Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions crates/gui/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
49 changes: 41 additions & 8 deletions crates/gui/src/commands/pty.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1163,17 +1163,50 @@ pub fn close_all_ptys(app: AppHandle, state: State<'_, PtyState>) -> Result<usiz
Ok(count)
}

/// The system clipboard, opened once and kept for the app's lifetime. On X11
/// and Wayland the process that copied must stay alive to serve the data, so a
/// handle dropped right after a write would lose it.
static CLIPBOARD: Mutex<Option<arboard::Clipboard>> = Mutex::new(None);

fn with_clipboard<T>(
f: impl FnOnce(&mut arboard::Clipboard) -> Result<T, arboard::Error>,
) -> Result<T, String> {
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<String> {
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<String> {
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
Expand Down
10 changes: 6 additions & 4 deletions crates/gui/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions desktop/e2e/fixtures/tauri-mock.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)) || [];
Expand Down
20 changes: 11 additions & 9 deletions desktop/src/lib/utils/terminal-paste.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" &&
Expand Down Expand Up @@ -47,7 +48,12 @@ export async function pasteIntoTerminal(term: Terminal, text: string): Promise<b
}
activeConfirmDialog = true;

const { promise, resolve } = Promise.withResolvers<boolean>();
// 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<boolean>((r) => {
resolve = r;
});
let settled = false;
let component: Record<string, any> | null = null;

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
}
};
Expand Down
56 changes: 35 additions & 21 deletions desktop/src/lib/utils/terminal-xterm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
try {
const text = await invoke<string | null>("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";
Expand All @@ -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;
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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?.();
Expand Down
Loading