From a0d8ba1e183bd5335f0ac034c4a720f4eabb3d9a Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Tue, 24 Mar 2026 09:15:38 -0700 Subject: [PATCH] fix: route clipboard writes through Electron IPC to prevent NSPasteboard contention (#83) Co-authored-by: Claude Opus 4.6 --- src/main/index.ts | 23 ++++++++++++++++--- src/preload/index.d.ts | 1 + src/preload/index.ts | 2 ++ .../src/components/editor/MonacoEditor.tsx | 4 ++-- .../right-sidebar/SearchResultItems.tsx | 6 +++-- .../sidebar/WorktreeContextMenu.tsx | 2 +- .../src/components/tab-bar/EditorFileTab.tsx | 8 +++---- .../use-terminal-pane-context-menu.ts | 2 +- 8 files changed, 35 insertions(+), 13 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index ec05396ba..a3301063c 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -272,10 +272,27 @@ app.whenReady().then(() => { warmSystemFontFamilies() setupAutoUpdater(mainWindow, { onBeforeQuit: () => store?.flush() }) - // Clipboard: read text via Electron's native clipboard module so the - // renderer can bypass Chromium's clipboard pipeline (which holds - // NSPasteboard references and causes contention with CLI tools like Codex). + // Clipboard: route all clipboard operations through Electron's native + // clipboard module so the renderer bypasses Chromium's clipboard pipeline. + // Chromium holds NSPasteboard references during format conversion and + // change-detection polling, which causes concurrent clipboard reads by CLI + // tools (e.g. Codex checking for images on Enter) to fail intermittently. ipcMain.handle('clipboard:readText', () => clipboard.readText()) + ipcMain.handle('clipboard:writeText', (_event, text: string) => clipboard.writeText(text)) + + // Deny clipboard-read permission to the renderer so Chromium does not + // autonomously poll NSPasteboard (e.g. for Edit-menu state or async + // Clipboard API calls). All clipboard access now goes through the IPC + // handlers above, eliminating contention with CLI subprocesses. + mainWindow.webContents.session.setPermissionRequestHandler( + (_webContents, permission, callback) => { + if (permission === 'clipboard-read' || permission === 'clipboard-sanitized-write') { + callback(false) + return + } + callback(true) + } + ) // Updater IPC ipcMain.handle('updater:getStatus', () => getUpdateStatus()) diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index f496385a7..845c94f01 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -106,6 +106,7 @@ type UIApi = { onOpenSettings: (callback: () => void) => () => void onTerminalZoom: (callback: (direction: 'in' | 'out' | 'reset') => void) => () => void readClipboardText: () => Promise + writeClipboardText: (text: string) => Promise onFileDrop: (callback: (data: { path: string }) => void) => () => void getZoomLevel: () => number setZoomLevel: (level: number) => void diff --git a/src/preload/index.ts b/src/preload/index.ts index 875deefeb..85f3a7a13 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -256,6 +256,8 @@ const api = { return () => ipcRenderer.removeListener('terminal:zoom', listener) }, readClipboardText: (): Promise => ipcRenderer.invoke('clipboard:readText'), + writeClipboardText: (text: string): Promise => + ipcRenderer.invoke('clipboard:writeText', text), onFileDrop: (callback: (data: { path: string }) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, data: { path: string }) => callback(data) ipcRenderer.on('terminal:file-drop', listener) diff --git a/src/renderer/src/components/editor/MonacoEditor.tsx b/src/renderer/src/components/editor/MonacoEditor.tsx index c1bd1cc2a..dbd975e00 100644 --- a/src/renderer/src/components/editor/MonacoEditor.tsx +++ b/src/renderer/src/components/editor/MonacoEditor.tsx @@ -187,7 +187,7 @@ export default function MonacoEditor({ { - navigator.clipboard.writeText(`${filePath}#L${gutterMenuLine}`) + window.api.ui.writeClipboardText(`${filePath}#L${gutterMenuLine}`) }} > @@ -195,7 +195,7 @@ export default function MonacoEditor({ { - navigator.clipboard.writeText(`${relativePath}#L${gutterMenuLine}`) + window.api.ui.writeClipboardText(`${relativePath}#L${gutterMenuLine}`) }} > diff --git a/src/renderer/src/components/right-sidebar/SearchResultItems.tsx b/src/renderer/src/components/right-sidebar/SearchResultItems.tsx index cc7aa4322..cfc1c2bf2 100644 --- a/src/renderer/src/components/right-sidebar/SearchResultItems.tsx +++ b/src/renderer/src/components/right-sidebar/SearchResultItems.tsx @@ -81,7 +81,9 @@ export function FileResultItem({ - navigator.clipboard.writeText(fileResult.relativePath)}> + window.api.ui.writeClipboardText(fileResult.relativePath)} + > Copy Path @@ -151,7 +153,7 @@ export function MatchItem({ navigator.clipboard.writeText(`${relativePath}#L${match.line}`)} + onClick={() => window.api.ui.writeClipboardText(`${relativePath}#L${match.line}`)} > Copy Line Path diff --git a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx index e319d0422..30773d8ed 100644 --- a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx +++ b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx @@ -40,7 +40,7 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({ worktree, }, [worktree.path]) const handleCopyPath = useCallback(() => { - navigator.clipboard.writeText(worktree.path) + window.api.ui.writeClipboardText(worktree.path) }, [worktree.path]) const handleToggleRead = useCallback(() => { diff --git a/src/renderer/src/components/tab-bar/EditorFileTab.tsx b/src/renderer/src/components/tab-bar/EditorFileTab.tsx index 57f3f39d7..23e2672ec 100644 --- a/src/renderer/src/components/tab-bar/EditorFileTab.tsx +++ b/src/renderer/src/components/tab-bar/EditorFileTab.tsx @@ -115,7 +115,7 @@ export default function EditorFileTab({ { - navigator.clipboard.writeText(file.filePath) + window.api.ui.writeClipboardText(file.filePath) }} > @@ -123,7 +123,7 @@ export default function EditorFileTab({ { - navigator.clipboard.writeText(file.relativePath) + window.api.ui.writeClipboardText(file.relativePath) }} > @@ -134,7 +134,7 @@ export default function EditorFileTab({ { - navigator.clipboard.writeText(`${file.filePath}#L${cursorLine}`) + window.api.ui.writeClipboardText(`${file.filePath}#L${cursorLine}`) }} > @@ -142,7 +142,7 @@ export default function EditorFileTab({ { - navigator.clipboard.writeText(`${file.relativePath}#L${cursorLine}`) + window.api.ui.writeClipboardText(`${file.relativePath}#L${cursorLine}`) }} > diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts index 63f0709cf..fb7c6301a 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts @@ -67,7 +67,7 @@ export function useTerminalPaneContextMenu({ } const selection = pane.terminal.getSelection() if (selection) { - await navigator.clipboard.writeText(selection) + await window.api.ui.writeClipboardText(selection) } }