fix(call): Response return-type can't be wrapped in Result

Previous commit had the command typed as Result<tauri::ipc::Response,
String>. Turns out that forces Tauri to JSON-serialise the variant
wrapper around the Response body — the frontend gets a JSON object
instead of the raw ArrayBuffer, the runtime check for byteLength fails,
and every thumbnail comes back as null.

Changed the return type to `tauri::ipc::Response` directly. Bad source
ids and capture failures now funnel into an empty byte buffer; the JS
side still detects "no thumbnail" via `byteLength === 0` so the
contract stays the same.

Frontend also widens the invoke-result typing to ArrayBuffer |
Uint8Array | number[] so an older WebView2 that happens to deserialise
as an array still works, and normalises into a plain ArrayBuffer
before constructing the Blob to sidestep a TS SharedArrayBuffer
incompatibility.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
byGalax
2026-04-22 22:21:39 +02:00
parent 12e91c0bbe
commit 16d179f8e8
2 changed files with 36 additions and 12 deletions
+10 -9
View File
@@ -83,22 +83,23 @@ pub fn capture_screen_source_thumbnail(source_id: String) -> Result<Option<Strin
// expose via `URL.createObjectURL` — skips the base64-decode step // expose via `URL.createObjectURL` — skips the base64-decode step
// entirely and keeps the main thread responsive during fan-in. // entirely and keeps the main thread responsive during fan-in.
// //
// A capture failure (source vanished, permission denied) returns an empty // The return type MUST be `Response` directly (not `Result<Response, E>`):
// byte buffer rather than an error so the JS side gets a uniform contract // a Result wrapper forces Tauri to JSON-serialise the variant so the
// (ArrayBuffer always). Caller checks `byteLength === 0` to detect the // frontend gets a JSON object instead of raw bytes. Failures — bad id
// no-thumbnail case. // format, capture errors, source vanished — all funnel into an empty
// byte buffer; the caller treats `byteLength === 0` as the "no thumbnail"
// signal.
#[tauri::command] #[tauri::command]
pub fn capture_screen_source_thumbnail_bytes( pub fn capture_screen_source_thumbnail_bytes(source_id: String) -> tauri::ipc::Response {
source_id: String,
) -> Result<tauri::ipc::Response, String> {
let bytes = if let Some(raw) = source_id.strip_prefix("screen:").and_then(strip_zero_suffix) { let bytes = if let Some(raw) = source_id.strip_prefix("screen:").and_then(strip_zero_suffix) {
capture_monitor_bytes_by_id(raw).unwrap_or_default() capture_monitor_bytes_by_id(raw).unwrap_or_default()
} else if let Some(raw) = source_id.strip_prefix("window:").and_then(strip_zero_suffix) { } else if let Some(raw) = source_id.strip_prefix("window:").and_then(strip_zero_suffix) {
capture_window_bytes_by_id(raw).unwrap_or_default() capture_window_bytes_by_id(raw).unwrap_or_default()
} else { } else {
return Err(format!("unknown source id format: {source_id}")); eprintln!("capture_screen_source_thumbnail_bytes: unknown id format: {source_id}");
Vec::new()
}; };
Ok(tauri::ipc::Response::new(bytes)) tauri::ipc::Response::new(bytes)
} }
fn strip_zero_suffix(s: &str) -> Option<&str> { fn strip_zero_suffix(s: &str) -> Option<&str> {
+26 -3
View File
@@ -60,18 +60,41 @@ export async function captureScreenSourceThumbnail(
// in a Blob so callers can hand it straight to `URL.createObjectURL` — // in a Blob so callers can hand it straight to `URL.createObjectURL` —
// never touches base64 on either side. Returns null when the Rust side // never touches base64 on either side. Returns null when the Rust side
// produced zero bytes (capture failed, source vanished). // produced zero bytes (capture failed, source vanished).
//
// 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.
export async function captureScreenSourceThumbnailBytes( export async function captureScreenSourceThumbnailBytes(
sourceId: string, sourceId: string,
): Promise<Blob | null> { ): Promise<Blob | null> {
if (!isTauriRuntime()) return null; if (!isTauriRuntime()) return null;
try { try {
const { invoke } = await import('@tauri-apps/api/core'); const { invoke } = await import('@tauri-apps/api/core');
const result = await invoke<ArrayBuffer>( const result = await invoke<ArrayBuffer | Uint8Array | number[]>(
'capture_screen_source_thumbnail_bytes', 'capture_screen_source_thumbnail_bytes',
{ sourceId }, { sourceId },
); );
if (!result || result.byteLength === 0) return null; let bytes: ArrayBuffer | Uint8Array | null = null;
return new Blob([result], { type: 'image/jpeg' }); if (result instanceof ArrayBuffer) {
bytes = result;
} else if (result instanceof Uint8Array) {
bytes = result;
} else if (Array.isArray(result) && result.length > 0) {
bytes = new Uint8Array(result);
}
if (!bytes || bytes.byteLength === 0) {
return null;
}
// 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) { } catch (err: unknown) {
console.warn('capture_screen_source_thumbnail_bytes failed', { sourceId, err }); console.warn('capture_screen_source_thumbnail_bytes failed', { sourceId, err });
return null; return null;