From dbfb31a92682eab973bb91308060ccc357c2f070 Mon Sep 17 00:00:00 2001 From: ymajoros Date: Fri, 29 May 2026 13:39:45 +0200 Subject: [PATCH 1/2] fix: route terminal copy through main-process clipboard + handle OSC 52 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copying out of the terminal did nothing on Linux/Wayland. Two separate holes: - Ctrl+C copy used navigator.clipboard.writeText in the renderer. Chromium gates that on window focus + a user gesture, and it's effectively dead under Ozone/Wayland. The trailing .catch(() => {}) ate the rejection, so it failed in total silence. - OSC 52 (how Claude Code itself copies) wasn't wired up at all — xterm doesn't do it for you, so those copies just evaporated. Both now go through the main-process clipboard over IPC, which doesn't care about focus or gestures. Paste was never affected, so it's left alone. Co-Authored-By: Claude Opus 4.8 (1M context) --- main.js | 10 +++++++++- preload.js | 1 + public/terminal-manager.js | 20 +++++++++++++++++++- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/main.js b/main.js index 2c587b77..2b264311 100644 --- a/main.js +++ b/main.js @@ -1,4 +1,4 @@ -const { app, BrowserWindow, dialog, ipcMain, Menu, screen, shell } = require('electron'); +const { app, BrowserWindow, clipboard, dialog, ipcMain, Menu, screen, shell } = require('electron'); const { Worker } = require('worker_threads'); const path = require('path'); const fs = require('fs'); @@ -348,6 +348,14 @@ ipcMain.handle('open-external', (_event, url) => { if (/^https?:\/\//i.test(url)) return shell.openExternal(url); }); +// --- IPC: clipboard write --- +// The renderer's navigator.clipboard.writeText is gated on focus/user-activation and +// is flaky-to-dead on Linux/Wayland (Ozone). The main-process clipboard has no such +// strings attached, so all terminal copies go through here. +ipcMain.handle('clipboard-write-text', (_event, text) => { + if (typeof text === 'string') clipboard.writeText(text); +}); + // --- IPC: MCP bridge --- ipcMain.on('mcp-diff-response', (_event, sessionId, diffId, action, editedContent) => { resolvePendingDiff(sessionId, diffId, action, editedContent); diff --git a/preload.js b/preload.js index 91d8b5e5..68030ca7 100644 --- a/preload.js +++ b/preload.js @@ -36,6 +36,7 @@ contextBridge.exposeInMainWorld('api', { addProject: (projectPath) => ipcRenderer.invoke('add-project', projectPath), removeProject: (projectPath) => ipcRenderer.invoke('remove-project', projectPath), openExternal: (url) => ipcRenderer.invoke('open-external', url), + writeClipboard: (text) => ipcRenderer.invoke('clipboard-write-text', text), // Send (fire-and-forget) sendInput: (id, data) => ipcRenderer.send('terminal-input', id, data), diff --git a/public/terminal-manager.js b/public/terminal-manager.js index 6863b226..ea401cae 100644 --- a/public/terminal-manager.js +++ b/public/terminal-manager.js @@ -63,7 +63,7 @@ function setupTerminalKeyBindings(terminal, container, getSessionId, { onFind } if (!isMac && e.key === 'c' && e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey) { if (terminal.hasSelection()) { if (e.type === 'keydown') { - navigator.clipboard.writeText(terminal.getSelection()).catch(() => {}); + window.api.writeClipboard(terminal.getSelection()); } return false; } @@ -191,6 +191,24 @@ function createTerminalEntry(session) { }, }); + // OSC 52 — let the program inside the terminal set the system clipboard (this is how + // Claude Code copies). xterm doesn't wire this up itself, so we do. Payload is + // ";" (or ";?" for a read-back query, which we ignore). + // Route through the main process — see writeClipboard — because the renderer clipboard + // is unreliable on Wayland. + terminal.parser.registerOscHandler(52, (payload) => { + const sep = payload.indexOf(';'); + const b64 = sep === -1 ? payload : payload.slice(sep + 1); + if (!b64 || b64 === '?') return true; + try { + const bytes = Uint8Array.from(atob(b64), (ch) => ch.charCodeAt(0)); + window.api.writeClipboard(new TextDecoder().decode(bytes)); + } catch { + return false; + } + return true; + }); + const fitAddon = new FitAddon.FitAddon(); terminal.loadAddon(fitAddon); terminal.loadAddon(new WebLinksAddon.WebLinksAddon((_event, url) => { From 6368f2e5137749ab79fec93b5c92a12a569e1954 Mon Sep 17 00:00:00 2001 From: Ali Basiri Date: Fri, 31 Jul 2026 22:32:18 -0700 Subject: [PATCH 2/2] fix(clipboard): handle writeClipboard rejection + pin the OSC 52 read-back refusal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review follow-ups on the OSC 52 handler. writeClipboard is an ipcRenderer.invoke, so it returns a promise. The code it replaced ended in .catch(() => {}); without it an IPC failure surfaces as an unhandled rejection. Restored. The handler's security property — that a read-back query (";?") is consumed but never answered — lived in a bare `b64 === '?'` check that reads like an unimplemented case. Answering it would write the user's clipboard back into the terminal, letting any program in the session exfiltrate whatever was last copied. Extracted the parse into decodeOsc52Payload() so the refusal is documented and covered by tests that say why, rather than being one condition away from someone "completing" it. Pure refactor otherwise: verified the extracted helper produces identical (handled, written) results to the previous inline logic across write, query, empty, separator-less, multi-byte and malformed-base64 payloads. Co-Authored-By: Claude Opus 5 (1M context) --- public/terminal-manager.js | 35 ++++++++++++++++++++++++-------- test/clipboard-osc52.test.js | 39 ++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 8 deletions(-) create mode 100644 test/clipboard-osc52.test.js diff --git a/public/terminal-manager.js b/public/terminal-manager.js index 4f4b6953..e2375492 100644 --- a/public/terminal-manager.js +++ b/public/terminal-manager.js @@ -34,6 +34,25 @@ function shouldSendSpaceDirectly(e) { && !isImeComposing(e); } +// Decode an OSC 52 payload into the text the program wants on the clipboard. +// Payload is ";", e.g. "c;aGVsbG8=". +// +// Returns null when there is nothing to write — an empty payload, or a read-back +// query (";?"). The read-back case is a deliberate refusal, not a gap: +// answering it would write the user's clipboard contents back into the terminal, +// letting any program running in the session exfiltrate whatever they last +// copied. We consume the sequence and stay silent. Do not "finish" this by +// implementing the query response. +// +// Throws on malformed base64 (atob), which the caller reports as unhandled. +function decodeOsc52Payload(payload) { + const sep = payload.indexOf(';'); + const b64 = sep === -1 ? payload : payload.slice(sep + 1); + if (!b64 || b64 === '?') return null; + const bytes = Uint8Array.from(atob(b64), (ch) => ch.charCodeAt(0)); + return new TextDecoder().decode(bytes); +} + function setupTerminalKeyBindings(terminal, container, getSessionId, { onFind } = {}) { terminal.attachCustomKeyEventHandler((e) => { // Cmd/Ctrl+F → open terminal search bar @@ -222,20 +241,20 @@ function createTerminalEntry(session) { }); // OSC 52 — let the program inside the terminal set the system clipboard (this is how - // Claude Code copies). xterm doesn't wire this up itself, so we do. Payload is - // ";" (or ";?" for a read-back query, which we ignore). + // Claude Code copies). xterm doesn't wire this up itself, so we do. // Route through the main process — see writeClipboard — because the renderer clipboard // is unreliable on Wayland. terminal.parser.registerOscHandler(52, (payload) => { - const sep = payload.indexOf(';'); - const b64 = sep === -1 ? payload : payload.slice(sep + 1); - if (!b64 || b64 === '?') return true; + let text; try { - const bytes = Uint8Array.from(atob(b64), (ch) => ch.charCodeAt(0)); - window.api.writeClipboard(new TextDecoder().decode(bytes)); + text = decodeOsc52Payload(payload); } catch { return false; } + // null = read-back query or empty payload: consumed, and deliberately not + // answered. See decodeOsc52Payload. + if (text === null) return true; + window.api.writeClipboard(text).catch(() => {}); return true; }); @@ -418,5 +437,5 @@ function setupDragAndDrop(container, getSessionId) { // Expose pure key-handling predicates to Node for unit testing. No-op in the // browser, where this file is loaded as a plain