diff --git a/crates/gui/Cargo.toml b/crates/gui/Cargo.toml index 5cdfeaa..6f828e1 100644 --- a/crates/gui/Cargo.toml +++ b/crates/gui/Cargo.toml @@ -51,10 +51,14 @@ tracing = "0.1" [target.'cfg(unix)'.dependencies] libc = "0.2" -# PRIMARY selection (middle-click paste) for the terminal. `wayland-data-control` -# makes it work on Wayland sessions instead of X11 only. +# Native clipboard for the terminal's copy/paste and, on Linux, the PRIMARY +# selection (middle-click paste). `wayland-data-control` makes the Linux side +# work on Wayland sessions instead of X11 only. [target.'cfg(target_os = "linux")'.dependencies] arboard = { version = "3", features = ["wayland-data-control"] } +[target.'cfg(not(target_os = "linux"))'.dependencies] +arboard = "3" + [dev-dependencies] tempfile = "3" diff --git a/crates/gui/src/commands/pty.rs b/crates/gui/src/commands/pty.rs index bbc8483..73dacce 100644 --- a/crates/gui/src/commands/pty.rs +++ b/crates/gui/src/commands/pty.rs @@ -1163,17 +1163,50 @@ pub fn close_all_ptys(app: AppHandle, state: State<'_, PtyState>) -> Result> = Mutex::new(None); + +fn with_clipboard( + f: impl FnOnce(&mut arboard::Clipboard) -> Result, +) -> Result { + let mut guard = CLIPBOARD.lock().unwrap_or_else(|e| e.into_inner()); + if guard.is_none() { + *guard = Some(arboard::Clipboard::new().map_err(|e| e.to_string())?); + } + let clipboard = guard.as_mut().expect("clipboard initialised above"); + f(clipboard).map_err(|e| e.to_string()) +} + +/// Read the system clipboard as text. +/// +/// The terminal uses this instead of `navigator.clipboard`, which WebKit (the +/// macOS and Linux webviews) only allows behind a paste prompt or not at all. +/// `None` when the clipboard is empty, holds no text, or is unavailable. +#[tauri::command(async)] +pub fn read_clipboard_text() -> Option { + with_clipboard(|clipboard| clipboard.get_text()).ok() +} + +/// Put text on the system clipboard (see [`read_clipboard_text`]). +#[tauri::command(async)] +pub fn write_clipboard_text(text: String) -> Result<(), String> { + with_clipboard(|clipboard| clipboard.set_text(text)) +} + /// Read the PRIMARY selection on Linux, where middle-click paste uses it. #[cfg(target_os = "linux")] fn primary_selection() -> Option { - use arboard::{Clipboard, GetExtLinux, LinuxClipboardKind}; - - let mut clipboard = Clipboard::new().ok()?; - clipboard - .get() - .clipboard(LinuxClipboardKind::Primary) - .text() - .ok() + use arboard::{GetExtLinux, LinuxClipboardKind}; + + with_clipboard(|clipboard| { + clipboard + .get() + .clipboard(LinuxClipboardKind::Primary) + .text() + }) + .ok() } /// Only X11 and Wayland have a PRIMARY selection; elsewhere middle-click paste diff --git a/crates/gui/src/lib.rs b/crates/gui/src/lib.rs index 82dc91e..3ff68f0 100644 --- a/crates/gui/src/lib.rs +++ b/crates/gui/src/lib.rs @@ -12,10 +12,10 @@ use commands::{ get_agent_status, get_connections, get_history, get_stats, get_workspace_info, import_ssh_config, list_detached_sessions, list_environments, list_popout_sessions, load_desktop_settings, needs_onboarding, open_terminal_window, pick_key_file, - pick_ssh_config_file, pick_upload_files, pick_upload_folder, quit_app, read_primary_selection, - reattach_pty, redraw_pty, remove_connection, remove_environment, resize_pty, - save_desktop_settings, save_workspace_config, seal_session_ui, set_active_env, spawn_pty, - start_agent, start_upload, write_pty, PtyState, UploadState, + pick_ssh_config_file, pick_upload_files, pick_upload_folder, quit_app, read_clipboard_text, + read_primary_selection, reattach_pty, redraw_pty, remove_connection, remove_environment, + resize_pty, save_desktop_settings, save_workspace_config, seal_session_ui, set_active_env, + spawn_pty, start_agent, start_upload, write_clipboard_text, write_pty, PtyState, UploadState, }; use kerberos::{acquire_kerberos_ticket, get_kerberos_status, renew_kerberos_ticket}; use std::collections::HashMap; @@ -95,6 +95,8 @@ pub fn run() { ack_pty, redraw_pty, read_primary_selection, + read_clipboard_text, + write_clipboard_text, count_active_sessions, close_pty, close_all_ptys, diff --git a/desktop/e2e/fixtures/tauri-mock.js b/desktop/e2e/fixtures/tauri-mock.js index c362877..ec2a8b3 100644 --- a/desktop/e2e/fixtures/tauri-mock.js +++ b/desktop/e2e/fixtures/tauri-mock.js @@ -393,6 +393,8 @@ redraw_pty: () => null, attach_pty_output: (a) => ({ session_id: a && a.sessionId, connection_name: "gpu-trainer", buffered_output: "" }), read_primary_selection: () => null, + read_clipboard_text: () => null, + write_clipboard_text: () => { throw new Error("no native clipboard in the browser"); }, run_batch_command: async (a) => { await new Promise((r) => setTimeout(r, window.__BATCH_DELAY || 300)); const ids = (a && (a.connectionIds || a.connection_ids)) || []; diff --git a/desktop/src/lib/utils/terminal-paste.ts b/desktop/src/lib/utils/terminal-paste.ts index 2c1b298..3191d28 100644 --- a/desktop/src/lib/utils/terminal-paste.ts +++ b/desktop/src/lib/utils/terminal-paste.ts @@ -2,6 +2,7 @@ import type { Terminal } from "@xterm/xterm"; import { invoke } from "@tauri-apps/api/core"; import { mount, unmount } from "svelte"; import PasteConfirm from "$lib/components/terminal/PasteConfirm.svelte"; +import { readClipboardText } from "$lib/utils/terminal-xterm"; const IS_LINUX = typeof navigator !== "undefined" && @@ -47,7 +48,12 @@ export async function pasteIntoTerminal(term: Terminal, text: string): Promise(); + // Not Promise.withResolvers: older WebKit webviews (macOS < 14.4) lack it, + // and throwing here would leave activeConfirmDialog stuck on. + let resolve!: (pasted: boolean) => void; + const promise = new Promise((r) => { + resolve = r; + }); let settled = false; let component: Record | null = null; @@ -130,7 +136,7 @@ export function attachPasteGuard(container: HTMLElement, term: Terminal): () => /** Middle-click (auxclick button 1) paste. Linux: invoke("read_primary_selection"); if null, * fall back to the most recent selection made in ANY xterm of this window (track via - * term.onSelectionChange; register each term). Other OS: navigator.clipboard.readText(). + * term.onSelectionChange; register each term). Other OS: the system clipboard. * Routes through pasteIntoTerminal. Returns cleanup (also unregisters the selection tracker). */ export function attachMiddleClickPaste(container: HTMLElement, term: Terminal): () => void { // Track selections made in this terminal for window-wide fallback @@ -168,13 +174,9 @@ export function attachMiddleClickPaste(container: HTMLElement, term: Terminal): void pasteIntoTerminal(term, lastWindowSelection); } } else { - try { - const clipText = await navigator.clipboard.readText(); - if (clipText) { - void pasteIntoTerminal(term, clipText); - } - } catch { - // Swallow clipboard read errors + const clipText = await readClipboardText(); + if (clipText) { + void pasteIntoTerminal(term, clipText); } } }; diff --git a/desktop/src/lib/utils/terminal-xterm.ts b/desktop/src/lib/utils/terminal-xterm.ts index 7492ffe..ee10f3b 100644 --- a/desktop/src/lib/utils/terminal-xterm.ts +++ b/desktop/src/lib/utils/terminal-xterm.ts @@ -192,14 +192,33 @@ export function downloadTerminalScrollback(term: Terminal, connectionName: strin } /** - * Copy text to the clipboard with a WebKitGTK-safe fallback (Tauri's Linux - * webview can reject `navigator.clipboard` outside of user-gesture paths). + * Read the system clipboard as text ("" when empty or unreadable). + * + * Goes through the backend first: WebKit, the webview on macOS and Linux, + * gates `navigator.clipboard.readText` behind a paste prompt or refuses it. + */ +export async function readClipboardText(): Promise { + try { + const text = await invoke("read_clipboard_text"); + if (text) return text; + } catch { + // Backend clipboard unavailable — try the web API. + } + try { + return (await navigator.clipboard?.readText?.()) ?? ""; + } catch { + return ""; + } +} + +/** + * Copy text to the clipboard through the backend, falling back to the web + * clipboard API and then to `execCommand` (the only path on some WebKitGTK + * builds). */ export function copyTextWithFallback(text: string): void { if (!text) return; const legacyFallback = () => { - // Legacy fallback: hidden textarea + execCommand (still the only - // reliable path on some WebKitGTK builds). const textarea = document.createElement("textarea"); textarea.value = text; textarea.style.position = "fixed"; @@ -214,11 +233,14 @@ export function copyTextWithFallback(text: string): void { document.body.removeChild(textarea); } }; - if (typeof navigator.clipboard?.writeText === "function") { - navigator.clipboard.writeText(text).catch(legacyFallback); - return; - } - legacyFallback(); + const webFallback = () => { + if (typeof navigator.clipboard?.writeText === "function") { + navigator.clipboard.writeText(text).catch(legacyFallback); + return; + } + legacyFallback(); + }; + invoke("write_clipboard_text", { text }).catch(webFallback); } let smartCopy = false; @@ -315,14 +337,9 @@ export function attachXtermKeyHandler( }; const pasteText = () => { - const doPaste = (text: string) => { + void readClipboardText().then((text) => { if (text) void pasteIntoTerminal(term, text); - }; - if (typeof navigator.clipboard?.readText === "function") { - navigator.clipboard.readText().then(doPaste).catch((err) => { - console.error("Failed to read from clipboard", err); - }); - } + }); }; /** Run `action` once per key press and keep the browser from acting too. */ @@ -603,12 +620,9 @@ export function createXtermSession( const cleanupContextMenu = attachTerminalContextMenu(container, term, { paste: () => { - const doPaste = (text: string) => { + void readClipboardText().then((text) => { if (text) void pasteIntoTerminal(term, text); - }; - if (typeof navigator.clipboard?.readText === "function") { - navigator.clipboard.readText().then(doPaste).catch(() => {}); - } + }); }, find: () => { options?.onOpenSearch?.();