From af2439683a0ef67a18eb89b004bfd45998f86527 Mon Sep 17 00:00:00 2001 From: Wasim Yousef Said Date: Tue, 28 Jul 2026 13:46:51 +0200 Subject: [PATCH] Fix image and file paste in Studio desktop (#7543) * Fix Studio desktop clipboard paste * Address clipboard paste review findings --- .../src/components/assistant-ui/thread.tsx | 22 + studio/frontend/src/features/chat/index.ts | 1 + .../src/features/chat/shared-composer.tsx | 28 +- .../features/chat/utils/clipboard-files.ts | 248 ++++++++++ studio/src-tauri/Cargo.lock | 5 + studio/src-tauri/Cargo.toml | 5 + studio/src-tauri/capabilities/default.json | 1 + studio/src-tauri/src/main.rs | 3 + studio/src-tauri/src/native_clipboard.rs | 442 ++++++++++++++++++ ...t_desktop_reliability_frontend_contract.py | 72 +++ 10 files changed, 826 insertions(+), 1 deletion(-) create mode 100644 studio/frontend/src/features/chat/utils/clipboard-files.ts create mode 100644 studio/src-tauri/src/native_clipboard.rs diff --git a/studio/frontend/src/components/assistant-ui/thread.tsx b/studio/frontend/src/components/assistant-ui/thread.tsx index a1a270b834..6da8126421 100644 --- a/studio/frontend/src/components/assistant-ui/thread.tsx +++ b/studio/frontend/src/components/assistant-ui/thread.tsx @@ -36,6 +36,7 @@ import { TerminalToolUI } from "@/components/assistant-ui/tool-ui-terminal"; import { WebSearchToolUI } from "@/components/assistant-ui/tool-ui-web-search"; import { ChatDictationBar } from "@/components/assistant-ui/chat-dictation-bar"; import { + pasteClipboardFiles, isStudioDictationAvailable, notifyStudioDictationUnavailable, } from "@/features/chat"; @@ -177,6 +178,7 @@ import { type ChangeEvent, type ComponentProps, type CompositionEvent, + type ClipboardEvent, type FC, type KeyboardEvent, type DragEvent as ReactDragEvent, @@ -1528,6 +1530,24 @@ const Composer: FC<{ ); const { inputProps, isComposing, isComposingRef } = useImeComposerInputHandlers({ submitOnEnter: true }); + const handleFilePaste = useCallback( + (event: ClipboardEvent) => { + pasteClipboardFiles( + event, + async (files) => { + await Promise.all( + files.map((file) => aui.composer().addAttachment(file)), + ); + }, + () => + toast.error("Could not paste files.", { + description: "The clipboard item is unsupported, unreadable, or over 20 MB.", + }), + ); + }, + [aui], + ); + const composerText = useAuiState(({ composer }) => composer.text); // Expand only once the input wraps to a second line, not on first keystroke. // Latch until cleared so it can't flip-flop at the wrap boundary. @@ -2021,6 +2041,8 @@ const Composer: FC<{ // no effect on Latin / CJK / Devanagari. dir="auto" {...inputProps} + addAttachmentOnPaste={false} + onPaste={handleFilePaste} /> { + (files: FileList | readonly File[] | null) => { if (!files?.length) return; const next: PendingImage[] = []; let droppedImageForUnavailable = false; @@ -866,6 +868,29 @@ export function SharedComposer({ [setPendingAudioStore, attachUnavailableReason], ); + const handleFilePaste = useCallback( + (event: ClipboardEvent) => { + pasteClipboardFiles( + event, + async (files) => { + const supported = files.some( + (file) => + (file.type.match(/^audio\//i) && file.size <= MAX_AUDIO_SIZE) || + (file.type.match(/^image\/(jpeg|png|webp|gif)$/i) && + file.size <= MAX_IMAGE_SIZE), + ); + if (!supported) throw new Error("Unsupported compare attachment"); + addFiles(files); + }, + () => + toast.error("Could not paste files.", { + description: "Compare supports images and audio within the attachment size limits.", + }), + ); + }, + [addFiles], + ); + const removePendingImage = useCallback((id: string) => { setPendingImages((prev) => prev.filter((p) => p.id !== id)); }, []); @@ -1688,6 +1713,7 @@ export function SharedComposer({ setText(e.currentTarget.value); }} onKeyDown={onKeyDown} + onPaste={handleFilePaste} onBlur={() => { // Mac: switching input methods can fire compositionstart without a // matching compositionend, leaving composingRef pinned. The OS always diff --git a/studio/frontend/src/features/chat/utils/clipboard-files.ts b/studio/frontend/src/features/chat/utils/clipboard-files.ts new file mode 100644 index 0000000000..c3ced7765e --- /dev/null +++ b/studio/frontend/src/features/chat/utils/clipboard-files.ts @@ -0,0 +1,248 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +import { isTauri } from "@/lib/api-base"; + +const MAX_NATIVE_IMAGE_DIMENSION = 8192; +const MAX_NATIVE_IMAGE_RGBA_BYTES = 64 * 1024 * 1024; +const MAX_CLIPBOARD_BYTES = 20 * 1024 * 1024; +const MAX_CLIPBOARD_FILES = 8; + +type ClipboardPasteEvent = { + readonly clipboardData: DataTransfer | null; + readonly defaultPrevented: boolean; + readonly isTrusted: boolean; + preventDefault: () => void; +}; +type NativeClipboardFile = { + readonly name: string; + readonly mimeType: string; + readonly base64: string; +}; + +function browserClipboardFiles(clipboardData: DataTransfer): File[] { + const files = Array.from(clipboardData.files).filter((file) => file.size > 0); + if (files.length > 0) return files; + + return Array.from(clipboardData.items) + .filter((item) => item.kind === "file") + .map((item) => item.getAsFile()) + .filter((file): file is File => file !== null && file.size > 0); +} + +function clipboardTypes(clipboardData: DataTransfer): string[] { + return Array.from(clipboardData.types, (type) => type.toLowerCase()); +} + +function clipboardHasLocalFileUri( + clipboardData: DataTransfer, + types: readonly string[], +): boolean { + const uriTypes = types.filter( + (type) => type.includes("uri-list") || type.includes("urilist"), + ); + for (const type of uriTypes) { + try { + if ( + clipboardData + .getData(type) + .split(/\r?\n/) + .some((line) => line.trim().toLowerCase().startsWith("file:")) + ) { + return true; + } + } catch { + return false; + } + } + return false; +} + +function clipboardHasPlainText(clipboardData: DataTransfer): boolean { + try { + return clipboardData.getData("text/plain").length > 0; + } catch { + return true; + } +} + +function validDimension(value: number): boolean { + return ( + Number.isSafeInteger(value) && + value > 0 && + value <= MAX_NATIVE_IMAGE_DIMENSION + ); +} + +function canvasPng(canvas: HTMLCanvasElement): Promise { + return new Promise((resolve) => canvas.toBlob(resolve, "image/png")); +} + +function isLinuxDesktop(): boolean { + if (typeof navigator === "undefined") return false; + return `${navigator.platform} ${navigator.userAgent}`.toLowerCase().includes("linux"); +} + +async function readNativeClipboardFiles(): Promise { + const { invoke } = await import("@tauri-apps/api/core"); + const nativeFiles = await invoke( + "read_native_clipboard_files", + ); + if (nativeFiles.length > MAX_CLIPBOARD_FILES) return []; + + let totalBytes = 0; + const files: File[] = []; + for (const file of nativeFiles) { + if ( + !file.name || + file.name.length > 255 || + file.name.includes("/") || + file.name.includes("\0") || + file.base64.length > Math.ceil((MAX_CLIPBOARD_BYTES * 4) / 3) + 4 + ) { + return []; + } + const binary = globalThis.atob(file.base64); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + totalBytes += bytes.byteLength; + if (totalBytes > MAX_CLIPBOARD_BYTES) return []; + files.push( + new File([bytes], file.name, { + type: file.mimeType || "application/octet-stream", + lastModified: Date.now(), + }), + ); + } + return files; +} + +async function readLinuxClipboardImage(): Promise { + const { invoke } = await import("@tauri-apps/api/core"); + const raw = await invoke("read_native_clipboard_png"); + const png = Uint8Array.from(raw instanceof Uint8Array ? raw : new Uint8Array(raw)); + if (png.byteLength === 0 || png.byteLength > MAX_CLIPBOARD_BYTES) return null; + return new File([png], "pasted-image.png", { + type: "image/png", + lastModified: Date.now(), + }); +} + +async function readNativeClipboardImage(): Promise { + let image: Awaited> | null = null; + + try { + if (isLinuxDesktop()) return await readLinuxClipboardImage(); + const { readImage } = await import("@tauri-apps/plugin-clipboard-manager"); + image = await readImage(); + const { width, height } = await image.size(); + if (!validDimension(width) || !validDimension(height)) return null; + + const expectedRgbaBytes = width * height * 4; + if (expectedRgbaBytes > MAX_NATIVE_IMAGE_RGBA_BYTES) return null; + + const rgba = await image.rgba(); + if (rgba.byteLength !== expectedRgbaBytes) return null; + + const canvas = document.createElement("canvas"); + canvas.width = width; + canvas.height = height; + try { + const context = canvas.getContext("2d"); + if (!context) return null; + const pixels = new Uint8ClampedArray( + rgba.buffer as ArrayBuffer, + rgba.byteOffset, + rgba.byteLength, + ); + context.putImageData(new ImageData(pixels, width, height), 0, 0); + const blob = await canvasPng(canvas); + if (!blob || blob.size === 0 || blob.size > MAX_CLIPBOARD_BYTES) { + return null; + } + return new File([blob], "pasted-image.png", { + type: "image/png", + lastModified: Date.now(), + }); + } finally { + canvas.width = 0; + canvas.height = 0; + } + } catch { + return null; + } finally { + if (image) { + try { + await image.close(); + } catch { + // The native resource may already have been released after an invoke failure. + } + } + } +} + +function addClipboardFiles( + files: readonly File[], + addFiles: (files: readonly File[]) => void | Promise, + onError?: () => void, +): void { + void Promise.resolve(addFiles(files)).catch(() => onError?.()); +} + +function addNativeClipboardFiles( + addFiles: (files: readonly File[]) => void | Promise, + onError?: () => void, +): void { + void (async () => { + try { + const files = await readNativeClipboardFiles(); + if (files.length > 0) return files; + } catch { + // The clipboard may contain image pixels instead of file paths. + } + const image = await readNativeClipboardImage(); + return image ? [image] : []; + })().then((files) => { + if (files.length > 0) addClipboardFiles(files, addFiles, onError); + else onError?.(); + }); +} + +export function pasteClipboardFiles( + event: ClipboardPasteEvent, + addFiles: (files: readonly File[]) => void | Promise, + onError?: () => void, +): void { + const { clipboardData } = event; + if (clipboardData) { + const browserFiles = browserClipboardFiles(clipboardData); + if (browserFiles.length > 0) { + event.preventDefault(); + addClipboardFiles(browserFiles, addFiles, onError); + return; + } + } + + if (!isTauri || !event.isTrusted || event.defaultPrevented) return; + if (!clipboardData) { + addNativeClipboardFiles(addFiles, onError); + return; + } + + const types = clipboardTypes(clipboardData); + const advertisesImage = types.some((type) => type.startsWith("image/")); + const advertisesFile = + types.includes("files") || + types.some((type) => type.includes("copied-files")) || + clipboardHasLocalFileUri(clipboardData, types); + if (!advertisesImage && !advertisesFile && clipboardHasPlainText(clipboardData)) { + return; + } + + if (advertisesImage || advertisesFile) event.preventDefault(); + addNativeClipboardFiles(addFiles, onError); +} diff --git a/studio/src-tauri/Cargo.lock b/studio/src-tauri/Cargo.lock index 604bb01525..f5ca7e5cfb 100644 --- a/studio/src-tauri/Cargo.lock +++ b/studio/src-tauri/Cargo.lock @@ -5566,10 +5566,15 @@ checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" name = "unsloth-studio" version = "2026.4.8" dependencies = [ + "arboard", "base64 0.22.1", "dirs", "elevated-command", "fix-path-env", + "gdk", + "gdk-pixbuf", + "glib", + "gtk", "hmac", "libc", "log", diff --git a/studio/src-tauri/Cargo.toml b/studio/src-tauri/Cargo.toml index 826509ce9a..b3883d1afd 100644 --- a/studio/src-tauri/Cargo.toml +++ b/studio/src-tauri/Cargo.toml @@ -26,6 +26,7 @@ fix-path-env = { git = "https://github.com/tauri-apps/fix-path-env-rs" } tauri-plugin-opener = "2.5.4" tauri-plugin-updater = "2" tauri-plugin-clipboard-manager = "2" +arboard = "3.6.1" tauri-plugin-dialog = "2" rand = "0.10.0" tauri-plugin-notification = "2.3.3" @@ -36,6 +37,10 @@ tauri-plugin-window-state = "2" libc = "0.2" [target.'cfg(target_os = "linux")'.dependencies] +gdk = "0.18" +gdk-pixbuf = "0.18" +glib = "0.18" +gtk = "0.18" elevated-command = "1.1.2" [target.'cfg(windows)'.dependencies] diff --git a/studio/src-tauri/capabilities/default.json b/studio/src-tauri/capabilities/default.json index 232472d6db..456d3a15f9 100644 --- a/studio/src-tauri/capabilities/default.json +++ b/studio/src-tauri/capabilities/default.json @@ -29,6 +29,7 @@ }, "updater:default", "clipboard-manager:allow-write-text", + "clipboard-manager:allow-read-image", "window-state:default" ] } diff --git a/studio/src-tauri/src/main.rs b/studio/src-tauri/src/main.rs index 405b390177..2cc03f8c19 100644 --- a/studio/src-tauri/src/main.rs +++ b/studio/src-tauri/src/main.rs @@ -8,6 +8,7 @@ mod desktop_update_policy; mod diagnostics; mod install; mod native_backend_lease; +mod native_clipboard; mod native_file_dialogs; mod native_intents; mod native_path_policy; @@ -218,6 +219,8 @@ fn main() { desktop_update_policy::check_desktop_manual_update, desktop_update_policy::desktop_update_policy, diagnostics::collect_support_diagnostics, + native_clipboard::read_native_clipboard_files, + native_clipboard::read_native_clipboard_png, native_file_dialogs::save_native_file, native_file_dialogs::pick_native_chat_import, native_intents::drain_native_intents, diff --git a/studio/src-tauri/src/native_clipboard.rs b/studio/src-tauri/src/native_clipboard.rs new file mode 100644 index 0000000000..8d27da0222 --- /dev/null +++ b/studio/src-tauri/src/native_clipboard.rs @@ -0,0 +1,442 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; +use serde::Serialize; +use std::fs::File; +use std::io::Read; +use std::path::{Path, PathBuf}; + +const MAX_CLIPBOARD_IMAGE_DIMENSION: i32 = 8192; +const MAX_CLIPBOARD_RGBA_BYTES: u64 = 64 * 1024 * 1024; +const MAX_CLIPBOARD_PNG_BYTES: usize = 20 * 1024 * 1024; +const MAX_CLIPBOARD_SOURCE_BYTES: u64 = 20 * 1024 * 1024; +const MAX_CLIPBOARD_TOTAL_BYTES: u64 = 20 * 1024 * 1024; +const MAX_CLIPBOARD_FILES: usize = 8; +const MAX_CLIPBOARD_CANDIDATES: usize = 32; +#[cfg(target_os = "linux")] +const MAX_CLIPBOARD_URI_BYTES: usize = 64 * 1024; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct NativeClipboardFile { + name: String, + mime_type: String, + base64: String, +} + +fn validate_dimensions(width: i32, height: i32) -> Result<(), String> { + if width <= 0 + || height <= 0 + || width > MAX_CLIPBOARD_IMAGE_DIMENSION + || height > MAX_CLIPBOARD_IMAGE_DIMENSION + { + return Err("Clipboard image dimensions are invalid or too large.".to_string()); + } + let rgba_bytes = (width as u64) + .checked_mul(height as u64) + .and_then(|pixels| pixels.checked_mul(4)) + .ok_or_else(|| "Clipboard image dimensions overflow.".to_string())?; + if rgba_bytes > MAX_CLIPBOARD_RGBA_BYTES { + return Err("Clipboard image pixel data is too large.".to_string()); + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn validate_png_bytes(png: &[u8]) -> Result<(), String> { + const SIGNATURE: &[u8; 8] = b"\x89PNG\r\n\x1a\n"; + if png.len() < 24 || png.len() > MAX_CLIPBOARD_PNG_BYTES || &png[..8] != SIGNATURE { + return Err("Clipboard PNG data is invalid or too large.".to_string()); + } + if &png[12..16] != b"IHDR" { + return Err("Clipboard PNG header is invalid.".to_string()); + } + let width = u32::from_be_bytes(png[16..20].try_into().unwrap()); + let height = u32::from_be_bytes(png[20..24].try_into().unwrap()); + let width = i32::try_from(width).map_err(|_| "Clipboard PNG width is invalid.".to_string())?; + let height = + i32::try_from(height).map_err(|_| "Clipboard PNG height is invalid.".to_string())?; + validate_dimensions(width, height) +} + +fn clipboard_file_mime_type(path: &Path) -> Option<&'static str> { + let extension = path + .extension() + .and_then(|value| value.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + let mime_type = match extension.as_str() { + "json" | "jsonl" | "ndjson" => "application/json", + "md" | "markdown" | "mdx" => "text/markdown", + "csv" => "text/csv", + "html" | "htm" => "text/html", + "xml" => "application/xml", + "svg" => "image/svg+xml", + "jpg" | "jpeg" => "image/jpeg", + "png" => "image/png", + "webp" => "image/webp", + "gif" => "image/gif", + "pdf" => "application/pdf", + "docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "odt" => "application/vnd.oasis.opendocument.text", + "ods" => "application/vnd.oasis.opendocument.spreadsheet", + "mp3" => "audio/mpeg", + "wav" => "audio/wav", + "m4a" => "audio/mp4", + "ogg" | "oga" => "audio/ogg", + "flac" => "audio/flac", + "aac" => "audio/aac", + "txt" | "text" | "log" | "rst" | "tsv" | "yaml" | "yml" | "toml" | "ini" | "cfg" + | "conf" | "env" | "properties" | "css" | "scss" | "sass" | "less" | "js" | "jsx" + | "mjs" | "cjs" | "ts" | "tsx" | "py" | "pyi" | "ipynb" | "rb" | "php" | "go" | "rs" + | "java" | "kt" | "kts" | "scala" | "swift" | "c" | "h" | "cc" | "cpp" | "hpp" | "cxx" + | "cs" | "m" | "mm" | "sh" | "bash" | "zsh" | "fish" | "ps1" | "bat" | "lua" | "pl" + | "pm" | "r" | "jl" | "dart" | "vue" | "svelte" | "astro" | "sql" | "graphql" | "gql" + | "proto" | "tf" | "tfvars" | "gradle" | "dockerfile" | "makefile" | "cmake" | "diff" + | "patch" => "text/plain", + _ => return None, + }; + Some(mime_type) +} + +fn open_regular_clipboard_file(path: &Path) -> Option { + let metadata = std::fs::symlink_metadata(path).ok()?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return None; + } + + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + std::fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NONBLOCK | libc::O_NOFOLLOW) + .open(path) + .ok() + } + #[cfg(not(unix))] + { + File::open(path).ok() + } +} + +fn read_clipboard_files(paths: Vec) -> Result, String> { + let mut remaining = MAX_CLIPBOARD_TOTAL_BYTES; + let mut files = Vec::new(); + for path in paths.into_iter().take(MAX_CLIPBOARD_CANDIDATES) { + if remaining == 0 || files.len() >= MAX_CLIPBOARD_FILES { + break; + } + let Some(name) = path + .file_name() + .map(|value| value.to_string_lossy().into_owned()) + else { + continue; + }; + let Some(mime_type) = clipboard_file_mime_type(&path) else { + continue; + }; + let Some(source) = open_regular_clipboard_file(&path) else { + continue; + }; + let Ok(metadata) = source.metadata() else { + continue; + }; + let limit = MAX_CLIPBOARD_SOURCE_BYTES.min(remaining); + if !metadata.is_file() || metadata.len() > limit { + continue; + } + let mut bytes = Vec::with_capacity(metadata.len() as usize); + if source.take(limit + 1).read_to_end(&mut bytes).is_err() || bytes.len() as u64 > limit { + continue; + } + remaining -= bytes.len() as u64; + files.push(NativeClipboardFile { + name, + mime_type: mime_type.to_string(), + base64: BASE64.encode(bytes), + }); + } + if files.is_empty() { + return Err("Clipboard does not contain readable local files.".to_string()); + } + Ok(files) +} + +#[cfg(target_os = "linux")] +fn encode_clipboard_pixbuf(image: &gdk_pixbuf::Pixbuf) -> Result, String> { + validate_dimensions(image.width(), image.height())?; + let png = image + .save_to_bufferv("png", &[]) + .map_err(|error| format!("Could not encode clipboard image: {error}"))?; + if png.is_empty() || png.len() > MAX_CLIPBOARD_PNG_BYTES { + return Err("Clipboard image encoding is empty or too large.".to_string()); + } + Ok(png) +} + +#[cfg(target_os = "linux")] +fn local_clipboard_path(uri: &str) -> Option { + let (path, hostname) = glib::filename_from_uri(uri).ok()?; + hostname.is_none().then_some(path) +} + +#[cfg(target_os = "linux")] +fn local_clipboard_paths_from_bytes(data: &[u8]) -> Vec { + if data.len() > MAX_CLIPBOARD_URI_BYTES { + return Vec::new(); + } + let Ok(text) = std::str::from_utf8(data) else { + return Vec::new(); + }; + text.lines() + .map(|line| { + line.trim_matches(|character: char| character.is_whitespace() || character == '\0') + }) + .filter_map(local_clipboard_path) + .take(MAX_CLIPBOARD_CANDIDATES) + .collect() +} + +#[cfg(target_os = "linux")] +fn read_gtk_clipboard_paths() -> Vec { + let clipboard = gtk::Clipboard::get(&gdk::SELECTION_CLIPBOARD); + let mut paths: Vec = clipboard + .wait_for_uris() + .into_iter() + .filter_map(|uri| local_clipboard_path(uri.as_str())) + .take(MAX_CLIPBOARD_CANDIDATES) + .collect(); + + for target in clipboard.wait_for_targets().unwrap_or_default() { + if paths.len() >= MAX_CLIPBOARD_CANDIDATES { + break; + } + if !target.name().to_ascii_lowercase().contains("copied-files") { + continue; + } + let Some(data) = clipboard.wait_for_contents(&target) else { + continue; + }; + let length = data.length(); + if length <= 0 || length as usize > MAX_CLIPBOARD_URI_BYTES { + continue; + } + for path in local_clipboard_paths_from_bytes(&data.data()) { + if !paths.contains(&path) { + paths.push(path); + } + } + } + paths.truncate(MAX_CLIPBOARD_CANDIDATES); + paths +} + +#[cfg(target_os = "linux")] +async fn native_clipboard_paths() -> Result, String> { + let (tx, rx) = tokio::sync::oneshot::channel(); + glib::MainContext::default().invoke(move || { + let _ = tx.send(read_gtk_clipboard_paths()); + }); + rx.await + .map_err(|_| "Clipboard file reader stopped unexpectedly.".to_string()) +} + +#[cfg(not(target_os = "linux"))] +async fn native_clipboard_paths() -> Result, String> { + tokio::task::spawn_blocking(|| { + let mut clipboard = arboard::Clipboard::new().map_err(|error| error.to_string())?; + let mut paths = clipboard + .get() + .file_list() + .map_err(|error| error.to_string())?; + paths.truncate(MAX_CLIPBOARD_CANDIDATES); + Ok(paths) + }) + .await + .map_err(|_| "Clipboard file reader stopped unexpectedly.".to_string())? +} + +#[tauri::command] +pub async fn read_native_clipboard_files( + window: tauri::WebviewWindow, +) -> Result, String> { + crate::native_intents::ensure_main_window(&window)?; + let paths = native_clipboard_paths().await?; + tokio::task::spawn_blocking(move || read_clipboard_files(paths)) + .await + .map_err(|_| "Clipboard file loader stopped unexpectedly.".to_string())? +} + +#[cfg(target_os = "linux")] +fn read_gtk_clipboard_file_image() -> Result { + use std::os::fd::AsRawFd; + + for path in read_gtk_clipboard_paths() { + let Some(source) = open_regular_clipboard_file(&path) else { + continue; + }; + let Ok(metadata) = source.metadata() else { + continue; + }; + if !metadata.is_file() || metadata.len() > MAX_CLIPBOARD_SOURCE_BYTES { + continue; + } + let descriptor_path = PathBuf::from(format!("/proc/self/fd/{}", source.as_raw_fd())); + let Some((_, width, height)) = gdk_pixbuf::Pixbuf::file_info(&descriptor_path) else { + continue; + }; + if validate_dimensions(width, height).is_err() { + continue; + } + let Ok(image) = gdk_pixbuf::Pixbuf::from_file(&descriptor_path) else { + continue; + }; + if validate_dimensions(image.width(), image.height()).is_ok() { + return Ok(image); + } + } + Err("Clipboard does not contain a readable image or local image file.".to_string()) +} + +#[cfg(target_os = "linux")] +fn read_gtk_clipboard_png() -> Result, String> { + let clipboard = gtk::Clipboard::get(&gdk::SELECTION_CLIPBOARD); + let png_target = gdk::Atom::intern("image/png"); + if let Some(data) = clipboard.wait_for_contents(&png_target) { + let length = data.length(); + if length <= 0 || length as usize > MAX_CLIPBOARD_PNG_BYTES { + return Err("Clipboard PNG data is empty or too large.".to_string()); + } + let png = data.data(); + validate_png_bytes(&png)?; + return Ok(png); + } + + let image = match clipboard.wait_for_image() { + Some(image) => image, + None => read_gtk_clipboard_file_image()?, + }; + encode_clipboard_pixbuf(&image) +} + +#[cfg(target_os = "linux")] +#[tauri::command] +pub async fn read_native_clipboard_png( + window: tauri::WebviewWindow, +) -> Result { + crate::native_intents::ensure_main_window(&window)?; + let (tx, rx) = tokio::sync::oneshot::channel(); + glib::MainContext::default().invoke(move || { + let _ = tx.send(read_gtk_clipboard_png()); + }); + let png = rx + .await + .map_err(|_| "Clipboard image reader stopped unexpectedly.".to_string())??; + Ok(tauri::ipc::Response::new(png)) +} + +#[cfg(not(target_os = "linux"))] +#[tauri::command] +pub async fn read_native_clipboard_png( + window: tauri::WebviewWindow, +) -> Result { + crate::native_intents::ensure_main_window(&window)?; + Err("Native PNG clipboard fallback is only available on Linux.".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn clipboard_dimensions_are_bounded() { + assert!(validate_dimensions(3840, 2160).is_ok()); + assert!(validate_dimensions(0, 100).is_err()); + assert!(validate_dimensions(8193, 100).is_err()); + assert!(validate_dimensions(8192, 8192).is_err()); + } + + #[test] + fn clipboard_file_mime_types_cover_text_attachments() { + assert_eq!( + clipboard_file_mime_type(Path::new("data.json")), + Some("application/json") + ); + assert_eq!( + clipboard_file_mime_type(Path::new("notes.md")), + Some("text/markdown") + ); + assert_eq!(clipboard_file_mime_type(Path::new("unknown.bin")), None); + } + + #[cfg(target_os = "linux")] + #[test] + fn clipboard_png_headers_are_bounded_before_decode() { + let mut png = vec![0; 24]; + png[..8].copy_from_slice(b"\x89PNG\r\n\x1a\n"); + png[12..16].copy_from_slice(b"IHDR"); + png[16..20].copy_from_slice(&1920_u32.to_be_bytes()); + png[20..24].copy_from_slice(&1080_u32.to_be_bytes()); + assert!(validate_png_bytes(&png).is_ok()); + png[16..20].copy_from_slice(&9000_u32.to_be_bytes()); + assert!(validate_png_bytes(&png).is_err()); + } + + #[test] + fn clipboard_file_reads_are_bounded() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("notes.md"); + std::fs::write(&path, b"clipboard text").unwrap(); + let oversized = directory.path().join("oversized.md"); + File::create(&oversized) + .unwrap() + .set_len(MAX_CLIPBOARD_SOURCE_BYTES + 1) + .unwrap(); + + let files = + read_clipboard_files(vec![directory.path().to_path_buf(), oversized, path]).unwrap(); + assert_eq!(files.len(), 1); + assert_eq!(files[0].name, "notes.md"); + assert_eq!(files[0].mime_type, "text/markdown"); + + assert_eq!(BASE64.decode(&files[0].base64).unwrap(), b"clipboard text"); + } + + #[cfg(unix)] + #[test] + fn clipboard_file_reads_reject_symlinks() { + let directory = tempfile::tempdir().unwrap(); + let target = directory.path().join("target.md"); + let link = directory.path().join("link.md"); + std::fs::write(&target, b"clipboard text").unwrap(); + std::os::unix::fs::symlink(&target, &link).unwrap(); + + assert!(open_regular_clipboard_file(&link).is_none()); + assert!(read_clipboard_files(vec![link]).is_err()); + } + + #[cfg(target_os = "linux")] + #[test] + fn copied_file_targets_parse_local_uris() { + let paths = local_clipboard_paths_from_bytes( + b"copy\nfile:///tmp/pasted%20notes.md\nhttps://example.com/ignored.md\0", + ); + assert_eq!(paths, vec![PathBuf::from("/tmp/pasted notes.md")]); + assert!( + local_clipboard_paths_from_bytes(&vec![b'x'; MAX_CLIPBOARD_URI_BYTES + 1]).is_empty() + ); + } + + #[cfg(target_os = "linux")] + #[test] + fn clipboard_file_uris_must_be_local() { + assert_eq!( + local_clipboard_path("file:///tmp/pasted%20image.png"), + Some(PathBuf::from("/tmp/pasted image.png")) + ); + assert!(local_clipboard_path("file://remote/tmp/image.png").is_none()); + assert!(local_clipboard_path("https://example.com/image.png").is_none()); + } +} diff --git a/tests/studio/test_desktop_reliability_frontend_contract.py b/tests/studio/test_desktop_reliability_frontend_contract.py index b004218658..a576e3fe42 100644 --- a/tests/studio/test_desktop_reliability_frontend_contract.py +++ b/tests/studio/test_desktop_reliability_frontend_contract.py @@ -20,10 +20,15 @@ THREAD_SIDEBAR = FRONTEND / "features/chat/thread-sidebar.tsx" SHARED_COMPOSER = FRONTEND / "features/chat/shared-composer.tsx" TITLEBAR = FRONTEND / "components/tauri/window-titlebar.tsx" NATIVE_DIALOGS = REPO / "studio/src-tauri/src/native_file_dialogs.rs" +NATIVE_CLIPBOARD = REPO / "studio/src-tauri/src/native_clipboard.rs" +TAURI_MAIN = REPO / "studio/src-tauri/src/main.rs" APP_PROVIDER = FRONTEND / "app/provider.tsx" +CLIPBOARD_FILES = FRONTEND / "features/chat/utils/clipboard-files.ts" +TAURI_CAPABILITIES = REPO / "studio/src-tauri/capabilities/default.json" + def test_file_actions_route_through_native_commands_only_in_tauri(): helper = NATIVE_FILES.read_text(encoding = "utf-8") @@ -98,6 +103,73 @@ def test_chat_exports_await_native_saves_and_markdown_uses_shared_helper(): assert "downloadFile(" in thread +def test_clipboard_file_paste_is_bounded_and_wired_to_both_composers(): + helper = CLIPBOARD_FILES.read_text(encoding = "utf-8") + thread = THREAD.read_text(encoding = "utf-8") + shared_composer = SHARED_COMPOSER.read_text(encoding = "utf-8") + capabilities = TAURI_CAPABILITIES.read_text(encoding = "utf-8") + + for contract in ( + "clipboardData.files", + "clipboardData.items", + "item.getAsFile()", + "file.size > 0", + 'clipboardData.getData("text/plain")', + "event.isTrusted", + "event.defaultPrevented", + 'types.includes("files")', + 'type.includes("uri-list")', + '"read_native_clipboard_files"', + "globalThis.atob(file.base64)", + "new File([bytes], file.name", + "MAX_CLIPBOARD_BYTES", + 'import("@tauri-apps/plugin-clipboard-manager")', + "await readImage()", + "rgba.byteLength !== expectedRgbaBytes", + "await image.close()", + ): + assert contract in helper + + assert "addAttachmentOnPaste={false}" in thread + assert "onPaste={handleFilePaste}" in thread + assert "pasteClipboardFiles" in thread + assert "aui.composer().addAttachment(file)" in thread + assert "onPaste={handleFilePaste}" in shared_composer + assert "pasteClipboardFiles" in shared_composer + assert "addFiles(files)" in shared_composer + assert capabilities.count('"clipboard-manager:allow-read-image"') == 1 + assert '"clipboard-manager:allow-read-text"' not in capabilities + + +def test_native_clipboard_bridge_is_bounded_and_registered(): + native_clipboard = NATIVE_CLIPBOARD.read_text(encoding = "utf-8") + tauri_main = TAURI_MAIN.read_text(encoding = "utf-8") + + for contract in ( + "MAX_CLIPBOARD_FILES", + "MAX_CLIPBOARD_URI_BYTES", + "MAX_CLIPBOARD_TOTAL_BYTES", + "MAX_CLIPBOARD_SOURCE_BYTES", + "MAX_CLIPBOARD_RGBA_BYTES", + ".take(limit + 1)", + ".wait_for_uris()", + ".wait_for_targets()", + 'contains("copied-files")', + "open_regular_clipboard_file(&path)", + '"/proc/self/fd/{}"', + ".wait_for_image()", + "glib::filename_from_uri", + "glib::MainContext::default().invoke", + "arboard::Clipboard::new()", + "BASE64.encode(bytes)", + "tauri::ipc::Response::new(png)", + ): + assert contract in native_clipboard + + assert "native_clipboard::read_native_clipboard_files" in tauri_main + assert "native_clipboard::read_native_clipboard_png" in tauri_main + + def test_desktop_startup_waits_for_auth_without_intermediate_handoff(): source = APP_PROVIDER.read_text(encoding = "utf-8")