From 58eafd81e587d758d391c18b6b9b34de86f05962 Mon Sep 17 00:00:00 2001 From: QTom <22166516+DaydreamCoding@users.noreply.github.com> Date: Sun, 1 Nov 2026 01:01:27 +0800 Subject: [PATCH] fix(app): fall back to native terminal copy/paste without the Clipboard API (#8230) Browsers omit navigator.clipboard outside secure contexts, such as the daemon web UI served over plain http on a Tailscale or LAN address. The non-Mac Ctrl+C/Ctrl+V terminal shortcuts then threw synchronously, and Ctrl+V had already prevented the native paste, so terminal copy/paste broke. When the Clipboard API is unavailable, the shortcuts now leave the default action alone so xterm's native copy/paste event handlers do the work, and the browser clipboard rejects instead of throwing. Co-authored-by: Claude Opus 5.5 --- .../runtime/terminal-emulator-runtime.test.ts | 95 +++++++++++++++++++ .../runtime/terminal-emulator-runtime.ts | 64 +++++++++---- 2 files changed, 140 insertions(+), 19 deletions(-) diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts index a6939aaecb..8b90839154 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts @@ -82,6 +82,7 @@ import { createTerminalResizeEvent, encodeTerminalOutput, TerminalEmulatorRuntime, + type TerminalClipboard, } from "./terminal-emulator-runtime"; interface StubTerminal { @@ -638,3 +639,97 @@ describe("terminal-emulator-runtime", () => { expect(fitAndEmitResize).not.toHaveBeenCalled(); }); }); + +describe("terminal-emulator-runtime clipboard shortcuts", () => { + type KeyHandler = (event: KeyboardEvent) => boolean; + + function createClipboard(available: boolean) { + return { + isAvailable: () => available, + readText: vi.fn(async () => "clipboard text"), + read: vi.fn(async () => []), + writeText: vi.fn(async (_text: string) => undefined), + } satisfies TerminalClipboard; + } + + function attachKeyHandler(input: { clipboard: TerminalClipboard; selection: string }) { + const runtime = new TerminalEmulatorRuntime({ isMac: false }, input.clipboard); + let handler: KeyHandler | null = null; + const paste = vi.fn(); + runtime.attachKeyEventHandler({ + attachCustomKeyEventHandler: (next: KeyHandler) => { + handler = next; + }, + hasSelection: () => input.selection.length > 0, + getSelection: () => input.selection, + paste, + input: vi.fn(), + scrollToBottom: vi.fn(), + }); + if (!handler) throw new Error("key handler was not attached"); + return { handler: handler as KeyHandler, paste }; + } + + function ctrlKey(key: string) { + const preventDefault = vi.fn(); + const event = { + type: "keydown", + key, + isComposing: false, + ctrlKey: true, + shiftKey: false, + altKey: false, + metaKey: false, + preventDefault, + stopPropagation: vi.fn(), + } as unknown as KeyboardEvent; + return { event, preventDefault }; + } + + it("leaves Ctrl+C with a selection to the native copy event without the Clipboard API", () => { + const clipboard = createClipboard(false); + const { handler } = attachKeyHandler({ clipboard, selection: "selected text" }); + const { event, preventDefault } = ctrlKey("c"); + + expect(handler(event)).toBe(false); + expect(clipboard.writeText).not.toHaveBeenCalled(); + expect(preventDefault).not.toHaveBeenCalled(); + }); + + it("leaves Ctrl+V to the native paste event without the Clipboard API", () => { + const clipboard = createClipboard(false); + const { handler, paste } = attachKeyHandler({ clipboard, selection: "" }); + const { event, preventDefault } = ctrlKey("v"); + + expect(handler(event)).toBe(false); + expect(clipboard.readText).not.toHaveBeenCalled(); + expect(preventDefault).not.toHaveBeenCalled(); + expect(paste).not.toHaveBeenCalled(); + }); + + it("lets xterm send ^C for Ctrl+C without a selection when the Clipboard API is missing", () => { + const clipboard = createClipboard(false); + const { handler } = attachKeyHandler({ clipboard, selection: "" }); + const { event, preventDefault } = ctrlKey("c"); + + expect(handler(event)).toBe(true); + expect(clipboard.writeText).not.toHaveBeenCalled(); + expect(preventDefault).not.toHaveBeenCalled(); + }); + + it("copies and pastes through the Clipboard API when it is available", async () => { + const clipboard = createClipboard(true); + const { handler, paste } = attachKeyHandler({ clipboard, selection: "selected text" }); + + const copy = ctrlKey("c"); + expect(handler(copy.event)).toBe(false); + expect(clipboard.writeText).toHaveBeenCalledWith("selected text"); + expect(copy.preventDefault).not.toHaveBeenCalled(); + + const pasteKey = ctrlKey("v"); + expect(handler(pasteKey.event)).toBe(false); + expect(pasteKey.preventDefault).toHaveBeenCalledTimes(1); + expect(clipboard.readText).toHaveBeenCalledTimes(1); + await vi.waitFor(() => expect(paste).toHaveBeenCalledWith("clipboard text")); + }); +}); diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts index a7460f7f80..0e1f88dafb 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts @@ -182,15 +182,26 @@ function withOverviewRulerBorderHidden(theme: ITheme): ITheme { } export interface TerminalClipboard { + /** Whether the async Clipboard API is usable. Browsers omit it outside secure contexts. */ + isAvailable?(): boolean; readText(): Promise; read(): Promise[]>; writeText(text: string): Promise; } +function getBrowserClipboard(): Clipboard | undefined { + return typeof navigator !== "undefined" ? navigator.clipboard : undefined; +} + +function rejectUnavailableClipboard(): Promise { + return Promise.reject(new Error("Clipboard API is unavailable")); +} + const browserClipboard: TerminalClipboard = { - readText: () => navigator.clipboard.readText(), - read: () => navigator.clipboard.read(), - writeText: (text) => navigator.clipboard.writeText(text), + isAvailable: () => !!getBrowserClipboard(), + readText: () => getBrowserClipboard()?.readText() ?? rejectUnavailableClipboard(), + read: () => getBrowserClipboard()?.read() ?? rejectUnavailableClipboard(), + writeText: (text) => getBrowserClipboard()?.writeText(text) ?? rejectUnavailableClipboard(), }; async function readClipboardImage(clipboard: TerminalClipboard): Promise { @@ -368,22 +379,7 @@ export class TerminalEmulatorRuntime { !event.altKey && !event.metaKey ) { - const key = event.key.toLowerCase(); - - // Ctrl+C: copy selection to clipboard if text is selected, otherwise let xterm send SIGINT - if (key === "c" && terminal.hasSelection()) { - void this.clipboard.writeText(terminal.getSelection()); - return false; - } - - // Ctrl+V: paste clipboard text, or the clipboard image when there is no text - if (key === "v") { - event.preventDefault(); - this.pasteClipboard(terminal); - return false; - } - - return true; + return !this.handleCtrlClipboardShortcut(terminal, event); } if (this.maybeSendMacEditingShortcut(terminal, event)) { @@ -432,6 +428,36 @@ export class TerminalEmulatorRuntime { }); } + /** Returns true when a non-Mac Ctrl+C / Ctrl+V clipboard shortcut was handled. */ + private handleCtrlClipboardShortcut( + terminal: Pick, + event: KeyboardEvent, + ): boolean { + const key = event.key.toLowerCase(); + // Without the Clipboard API (e.g. plain http on a LAN address), the browser's native + // copy/paste events still fire and xterm handles them, so leave the default action. + const clipboardAvailable = this.clipboard.isAvailable?.() ?? true; + + // Ctrl+C: copy selection to clipboard if text is selected, otherwise let xterm send SIGINT + if (key === "c" && terminal.hasSelection()) { + if (clipboardAvailable) { + void this.clipboard.writeText(terminal.getSelection()); + } + return true; + } + + // Ctrl+V: paste clipboard text, or the clipboard image when there is no text + if (key === "v") { + if (clipboardAvailable) { + event.preventDefault(); + this.pasteClipboard(terminal); + } + return true; + } + + return false; + } + private handleFindShortcut(event: KeyboardEvent): boolean { if (!isFindShortcut(event, this.options) || !this.callbacks.onFindRequest) return false; event.preventDefault();