Skip to content

Commit 16e5df9

Browse files
ChrisLiclaude
authored andcommitted
fix: replace navigator.clipboard with native arboard to prevent WebKitGTK hangs
- Use a persistent clipboard worker thread (arboard) for text writes so X11 keeps responding to SelectionRequest events after set_text returns - Add clipboard_read_text command for reading text back without WebKit - Ctrl+Shift+V now falls back to text paste when clipboard holds no image - Guard onMouseUp auto-copy to left-click only (button === 0) so middle-click no longer overwrites clipboard and breaks primary paste Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 01667aa commit 16e5df9

4 files changed

Lines changed: 106 additions & 15 deletions

File tree

‎src-tauri/src/clipboard.rs‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,65 @@
11
use std::fs;
22
use std::io::BufWriter;
3+
use std::sync::OnceLock;
4+
use std::sync::mpsc;
35

46
use arboard::{Clipboard, ImageData};
57
use uuid::Uuid;
68

9+
// Persistent clipboard worker: holds a Clipboard instance alive so X11 keeps
10+
// responding to SelectionRequest events. Without this, Clipboard drops at the
11+
// end of each command → X11 releases ownership → clipboard content is lost.
12+
struct ClipboardWorker {
13+
tx: mpsc::SyncSender<String>,
14+
}
15+
16+
static CLIPBOARD_WORKER: OnceLock<ClipboardWorker> = OnceLock::new();
17+
18+
fn get_clipboard_worker() -> &'static ClipboardWorker {
19+
CLIPBOARD_WORKER.get_or_init(|| {
20+
let (tx, rx) = mpsc::sync_channel::<String>(4);
21+
std::thread::Builder::new()
22+
.name("clipboard-writer".into())
23+
.spawn(move || {
24+
let mut cb = match Clipboard::new() {
25+
Ok(c) => c,
26+
Err(e) => {
27+
log::error!("clipboard worker init failed: {e}");
28+
return;
29+
}
30+
};
31+
while let Ok(text) = rx.recv() {
32+
if let Err(e) = cb.set_text(&text) {
33+
log::warn!("clipboard set_text failed: {e}");
34+
}
35+
}
36+
})
37+
.ok();
38+
ClipboardWorker { tx }
39+
})
40+
}
41+
42+
/// Write plain text to the system clipboard via a persistent arboard worker
43+
/// thread. Avoids WebKitGTK clipboard API hangs and X11 focus-stealing from
44+
/// repeated Clipboard::new() calls.
45+
#[tauri::command]
46+
pub fn clipboard_write_text(text: String) -> Result<(), String> {
47+
get_clipboard_worker()
48+
.tx
49+
.send(text)
50+
.map_err(|e| format!("clipboard write: {e}"))
51+
}
52+
53+
/// Read plain text from the system clipboard. Returns None when empty.
54+
#[tauri::command]
55+
pub fn clipboard_read_text() -> Result<Option<String>, String> {
56+
let mut cb = Clipboard::new().map_err(|e| format!("clipboard open: {e}"))?;
57+
match cb.get_text() {
58+
Ok(t) if !t.is_empty() => Ok(Some(t)),
59+
_ => Ok(None),
60+
}
61+
}
62+
763
/// Read the system clipboard. If it contains a usable image and no text takes
864
/// priority, encode it as PNG into the OS temp dir and return the absolute
965
/// path. If the clipboard holds text (even alongside an image), return None

‎src-tauri/src/lib.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ pub fn run() {
6464
buttons::buttons_delete,
6565
buttons::buttons_reorder,
6666
clipboard::clipboard_save_image,
67+
clipboard::clipboard_write_text,
68+
clipboard::clipboard_read_text,
6769
general::general_get,
6870
general::general_set,
6971
tabs::tabs_load_state,

‎src/components/Terminal.tsx‎

Lines changed: 42 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -194,24 +194,26 @@ export function TerminalView(props: Props) {
194194
//
195195
// In both cases: local tabs get the local /tmp path; SSH tabs have the
196196
// PNG uploaded to the remote /tmp first and get the remote path.
197-
const pasteImage = async () => {
198-
if (!props.active) return;
197+
// Returns true if an image was found and pasted, false if clipboard had no image.
198+
const pasteImage = async (): Promise<boolean> => {
199+
if (!props.active) return false;
199200
const conn = connections().find((c) => c.id === props.tab.connectionId);
200-
if (!conn) return;
201+
if (!conn) return false;
201202
const sid = props.tab.sessionId;
202203
const localPath = await api.clipboardSaveImage();
203-
if (!localPath) return;
204+
if (!localPath) return false;
204205
if (conn.kind === "local") {
205206
term?.paste(quoteShellPath(localPath) + " ");
206207
term?.focus();
207-
return;
208+
return true;
208209
}
209-
if (!sid) return;
210+
if (!sid) return false;
210211
setUploading(true);
211212
try {
212213
const remotePath = await api.sshUploadFile(sid, localPath);
213214
term?.paste(quoteShellPath(remotePath) + " ");
214215
term?.focus();
216+
return true;
215217
} finally {
216218
setUploading(false);
217219
}
@@ -240,14 +242,23 @@ export function TerminalView(props: Props) {
240242
}
241243
}
242244

243-
// Ctrl+Shift+V: explicit image paste shortcut, bypasses paste event.
244-
const onCtrlShiftV = (e: KeyboardEvent) => {
245+
// Ctrl+Shift+V: paste image if clipboard has one, otherwise paste text.
246+
const onCtrlShiftV = async (e: KeyboardEvent) => {
245247
if (!e.ctrlKey || !e.shiftKey || e.altKey || e.key !== "V") return;
246248
if (!props.active) return;
247249
e.preventDefault();
248-
pasteImage().catch((err) =>
249-
console.warn("clipboard image paste failed", err),
250-
);
250+
try {
251+
const didImage = await pasteImage();
252+
if (!didImage) {
253+
const text = await api.clipboardReadText();
254+
if (text) {
255+
term?.paste(text);
256+
term?.focus();
257+
}
258+
}
259+
} catch (err) {
260+
console.warn("clipboard paste failed", err);
261+
}
251262
};
252263
window.addEventListener("keydown", onCtrlShiftV);
253264
onCleanup(() => window.removeEventListener("keydown", onCtrlShiftV));
@@ -299,19 +310,35 @@ export function TerminalView(props: Props) {
299310
return out.join("\n");
300311
});
301312

302-
// Auto-copy on selection: when the user finishes a mouse drag and there is
303-
// a non-empty selection, push it to the OS clipboard.
304-
const onMouseUp = () => {
313+
// Auto-copy on selection: fires when the drag ends inside the terminal.
314+
const onMouseUp = (e: MouseEvent) => {
315+
if (e.button !== 0) return; // middle/right-click must not overwrite clipboard
305316
if (!term?.hasSelection()) return;
306317
const sel = term.getSelection();
307318
if (!sel) return;
308-
navigator.clipboard.writeText(sel).catch((e) =>
319+
api.clipboardWriteText(sel).catch((e) =>
309320
console.warn("clipboard write failed", e),
310321
);
311322
};
312323
host.addEventListener("mouseup", onMouseUp);
313324
onCleanup(() => host.removeEventListener("mouseup", onMouseUp));
314325

326+
// Ctrl+Shift+C: explicit copy shortcut. Covers the case where the drag
327+
// ends outside the terminal (tab bar, etc.) and mouseup didn't fire on
328+
// host. Uses native arboard to avoid WebKitGTK clipboard API hangs.
329+
const onCopyKey = (e: KeyboardEvent) => {
330+
if (!props.active || !e.ctrlKey || !e.shiftKey || e.altKey || e.key !== "C") return;
331+
if (!term?.hasSelection()) return;
332+
e.preventDefault();
333+
const sel = term.getSelection();
334+
if (!sel) return;
335+
api.clipboardWriteText(sel).catch((e) =>
336+
console.warn("clipboard write failed", e),
337+
);
338+
};
339+
window.addEventListener("keydown", onCopyKey);
340+
onCleanup(() => window.removeEventListener("keydown", onCopyKey));
341+
315342
// Middle-click: just suppress the browser's auto-scroll affordance.
316343
// WebKitGTK already does X11-style primary-selection paste into the
317344
// focused textarea, which xterm forwards via onData — doing our own

‎src/ipc/api.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,12 @@ export const api = {
9797
* image (text-only or empty). */
9898
clipboardSaveImage: () => invoke<string | null>("clipboard_save_image"),
9999

100+
/** Write plain text to the OS clipboard via arboard (avoids WebKitGTK clipboard API hangs). */
101+
clipboardWriteText: (text: string) => invoke<void>("clipboard_write_text", { text }),
102+
103+
/** Read plain text from the OS clipboard. Returns null when empty or image-only. */
104+
clipboardReadText: () => invoke<string | null>("clipboard_read_text"),
105+
100106
// Command buttons
101107
buttonsList: () => invoke<CommandButton[]>("buttons_list"),
102108
buttonsSave: (button: CommandButton) =>

0 commit comments

Comments
 (0)