feat(desktop): port v0.11.4-v0.15.2 from Tauri to Electron + Discord-parity audio (v0.16.0)

Chronological port of every Tauri release commit (v0.11.4 -> v0.15.2)
into the Electron rebuild, plus Discord-style audio handling that goes
beyond the Tauri original.

Highlights:
  - All 17 Tauri release commits ported (audio fixes, custom notification
    sound, Discord-style chat UX, profile banner, changelog page,
    Discord-parity call UX, screen-share echo + re-watch UX, audio-loop
    fix).
  - Native napi-rs audio-loopback addon with WASAPI process-loopback:
      * EXCLUDE_TARGET_PROCESS_TREE for full-screen shares -> peers
        never hear themselves echoed back through the capture.
      * INCLUDE_TARGET_PROCESS_TREE for window shares -> only the
        picked window's audio is captured, not the whole OS mixer
        (Discord parity).
      * HWND -> PID resolution via Win32 GetWindowThreadProcessId.
  - Discord-style screen-source picker (thumbnail grid, screens vs
    apps tabs, live-refreshing thumbnails).
  - Hash routing fix for packaged builds (file:// can't resolve
    BrowserRouter paths).
  - Tauri sources removed (apps/desktop/src-tauri).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
byGalax
2026-05-06 23:35:01 +02:00
parent 72d02e385f
commit 825160ee46
504 changed files with 14217 additions and 14366 deletions
+35 -107
View File
@@ -1,150 +1,78 @@
// Frontend wrapper for the Rust `enumerate_screen_sources` command. Falls
// back to an empty list outside the Tauri runtime so a browser-only dev
// build (pnpm vite:dev in Chrome without Tauri) degrades gracefully to
// "nothing to show" rather than throwing.
// Frontend wrapper for the main-process screen-source enumerator.
// Pre-migration this called into a Rust command that captured JPEG
// thumbnails via xcap; Electron's desktopCapturer returns thumbnails
// inline as data URLs so there's no binary/base64 dual-path to juggle.
//
// The return shape here matches the ipc-types `ScreenSource` contract.
import { isTauriRuntime } from './globalShortcut';
export type ScreenSourceKind = 'screen' | 'window';
export interface ScreenSource {
/** Chromium-format source id ("screen:<id>:0" / "window:<hwnd>:0"). */
/** Chromium desktopCapturer id; feed unchanged to getUserMedia's
* chromeMediaSourceId constraint when capturing this source. */
id: string;
name: string;
kind: ScreenSourceKind;
/** Base64-encoded JPEG without a data-URL prefix. Null when capture failed.
* Kept under `thumbnailPng` key for rollout stability — the server-side
* format switched from PNG to JPEG for payload size, but the field name
* preserves the wire contract during the transition. */
thumbnailPng: string | null;
width: number;
height: number;
/** Thumbnail as a ready-to-use data URL (image/png). Null when
* desktopCapturer returned an empty buffer. */
thumbnailDataUrl: string | null;
/** App icon as a data URL for window sources; null for screens. */
iconDataUrl: string | null;
displayId: number | null;
}
// Fast, metadata-only list. The picker uses this first so names show up
// immediately; thumbnails stream in via captureScreenSourceThumbnail below.
export async function listScreenSources(): Promise<ScreenSource[]> {
if (!isTauriRuntime()) return [];
try {
const { invoke } = await import('@tauri-apps/api/core');
const raw = await invoke<ScreenSource[]>('list_screen_sources');
return raw ?? [];
return await window.electronAPI.getScreenSources();
} catch (err: unknown) {
console.warn('list_screen_sources failed', err);
console.warn('getScreenSources failed', err);
return [];
}
}
// Single-source thumbnail capture (legacy base64 variant). Callers should
// prefer `captureScreenSourceThumbnailBytes` below — it ships raw JPEG
// bytes over IPC so the main thread avoids both the base64 decode AND
// the JSON parse overhead of a long string result. Kept for fallback.
// Single-source high-res refresh. Re-queries desktopCapturer at 640x360
// so a detail view looks crisp without paying the full enumeration cost
// more than once per hover-debounce.
export async function captureScreenSourceThumbnail(
sourceId: string,
): Promise<string | null> {
if (!isTauriRuntime()) return null;
try {
const { invoke } = await import('@tauri-apps/api/core');
const result = await invoke<string | null>('capture_screen_source_thumbnail', {
sourceId,
});
return result ?? null;
return await window.electronAPI.getScreenThumbnail(sourceId);
} catch (err: unknown) {
console.warn('capture_screen_source_thumbnail failed', { sourceId, err });
console.warn('getScreenThumbnail failed', { sourceId, err });
return null;
}
}
// Thumbnail fetch with a two-tier fallback:
// 1. binary-IPC path (`..._bytes`) — ArrayBuffer over Tauri's raw channel
// 2. base64 path (legacy) — same command minus the ArrayBuffer wrapper
//
// The binary path can come through in several shapes depending on the
// Tauri / WebView2 version combo: a real ArrayBuffer, a Uint8Array, or
// occasionally a plain number[] when the response got re-serialised.
// We normalise all three into an ArrayBuffer before handing it to Blob.
// If the binary path returns nothing usable we retry once on the base64
// command — keeps thumbnails visible while the binary contract settles.
let warnedBinaryShape = false;
// Legacy name retained for call-sites that expected a Blob. desktopCapturer
// already gives us a data URL — callers that need a Blob can fetch() the
// URL. This helper keeps the old signature so nothing breaks during the
// migration.
export async function captureScreenSourceThumbnailBytes(
sourceId: string,
): Promise<Blob | null> {
if (!isTauriRuntime()) return null;
const { invoke } = await import('@tauri-apps/api/core');
// ---- Tier 1: binary IPC ---------------------------------------------
const url = await captureScreenSourceThumbnail(sourceId);
if (!url) return null;
try {
const result = await invoke<ArrayBuffer | Uint8Array | number[] | null>(
'capture_screen_source_thumbnail_bytes',
{ sourceId },
);
let bytes: Uint8Array | null = null;
if (result instanceof ArrayBuffer) {
bytes = new Uint8Array(result);
} else if (result instanceof Uint8Array) {
bytes = result;
} else if (Array.isArray(result) && result.length > 0) {
bytes = new Uint8Array(result);
} else if (result && typeof result === 'object') {
// One-time diagnostic so we can see the unexpected shape in the
// console if WebView2 de-serialises the Response body into a bag
// of properties instead of a transferable binary buffer.
if (!warnedBinaryShape) {
warnedBinaryShape = true;
console.warn(
'capture_screen_source_thumbnail_bytes: unexpected shape, falling back',
result,
);
}
}
if (bytes && bytes.byteLength > 0) {
const copy = new Uint8Array(bytes.byteLength);
copy.set(bytes);
return new Blob([copy.buffer], { type: 'image/jpeg' });
}
const res = await fetch(url);
return await res.blob();
} catch (err: unknown) {
console.warn('binary thumbnail path threw, trying base64 fallback', {
sourceId,
err,
});
}
// ---- Tier 2: base64 fallback ----------------------------------------
try {
const b64 = await invoke<string | null>('capture_screen_source_thumbnail', {
sourceId,
});
if (!b64) return null;
const bin = atob(b64);
const fallbackBytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) fallbackBytes[i] = bin.charCodeAt(i);
return new Blob([fallbackBytes.buffer], { type: 'image/jpeg' });
} catch (err: unknown) {
console.warn('base64 thumbnail fallback failed', { sourceId, err });
console.warn('thumbnail fetch failed', { sourceId, err });
return null;
}
}
// Legacy single-shot variant. Captures everything serially on the Rust side
// before returning. Prefer listScreenSources + captureScreenSourceThumbnail
// for user-facing flows — they feel 510× more responsive in practice.
// Legacy single-shot API — now just delegates to listScreenSources.
export async function enumerateScreenSources(): Promise<ScreenSource[]> {
if (!isTauriRuntime()) return [];
try {
const { invoke } = await import('@tauri-apps/api/core');
const raw = await invoke<ScreenSource[]>('enumerate_screen_sources');
return raw ?? [];
} catch (err: unknown) {
console.warn('enumerate_screen_sources failed', err);
return [];
}
return listScreenSources();
}
// Data-URL helper — picker tiles bind `src={thumbnailDataUrl(src)}` so the
// thumbnail bytes never leave the component's render pass. Rust encodes
// JPEG now (smaller payload, faster decode); the mime type here must
// match or the <img> element silently fails to paint.
// Picker tiles bind `src={thumbnailDataUrl(src)}`; we already receive a
// data URL so this is just a pass-through for API compatibility.
export function thumbnailDataUrl(src: ScreenSource): string | null {
if (!src.thumbnailPng) return null;
return 'data:image/jpeg;base64,' + src.thumbnailPng;
return src.thumbnailDataUrl;
}