diff --git a/apps/desktop/src/lib/screenSources.ts b/apps/desktop/src/lib/screenSources.ts index 127acc5..5903faa 100644 --- a/apps/desktop/src/lib/screenSources.ts +++ b/apps/desktop/src/lib/screenSources.ts @@ -55,48 +55,72 @@ export async function captureScreenSourceThumbnail( } } -// Binary-IPC variant. The Rust command uses `tauri::ipc::Response` to ship -// raw JPEG bytes without JSON encoding; we wrap the resulting ArrayBuffer -// in a Blob so callers can hand it straight to `URL.createObjectURL` — -// never touches base64 on either side. Returns null when the Rust side -// produced zero bytes (capture failed, source vanished). +// 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 // -// Tauri's invoke may resolve with different shapes depending on runtime -// version — accept ArrayBuffer, Uint8Array, or a number[] fallback so a -// WebView2 variant that doesn't speak the binary channel still works. +// 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; export async function captureScreenSourceThumbnailBytes( sourceId: string, ): Promise { if (!isTauriRuntime()) return null; + const { invoke } = await import('@tauri-apps/api/core'); + + // ---- Tier 1: binary IPC --------------------------------------------- try { - const { invoke } = await import('@tauri-apps/api/core'); - const result = await invoke( + const result = await invoke( 'capture_screen_source_thumbnail_bytes', { sourceId }, ); - let bytes: ArrayBuffer | Uint8Array | null = null; + let bytes: Uint8Array | null = null; if (result instanceof ArrayBuffer) { - bytes = result; + 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) { - return null; + if (bytes && bytes.byteLength > 0) { + const copy = new Uint8Array(bytes.byteLength); + copy.set(bytes); + return new Blob([copy.buffer], { type: 'image/jpeg' }); } - // Normalise to ArrayBuffer so Blob() accepts the part without TS - // complaining about SharedArrayBuffer-backed Uint8Array flavours. - const buffer = - bytes instanceof ArrayBuffer - ? bytes - : (bytes.buffer.slice( - bytes.byteOffset, - bytes.byteOffset + bytes.byteLength, - ) as ArrayBuffer); - return new Blob([buffer], { type: 'image/jpeg' }); } catch (err: unknown) { - console.warn('capture_screen_source_thumbnail_bytes failed', { sourceId, err }); + console.warn('binary thumbnail path threw, trying base64 fallback', { + sourceId, + err, + }); + } + + // ---- Tier 2: base64 fallback ---------------------------------------- + try { + const b64 = await invoke('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 }); return null; } }