fix: route clipboard writes through Electron IPC to prevent NSPasteboard contention (#83)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jinjing 2026-03-24 09:15:38 -07:00 committed by GitHub
parent a4ef06b18f
commit a0d8ba1e18
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 35 additions and 13 deletions

View File

@ -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())

View File

@ -106,6 +106,7 @@ type UIApi = {
onOpenSettings: (callback: () => void) => () => void
onTerminalZoom: (callback: (direction: 'in' | 'out' | 'reset') => void) => () => void
readClipboardText: () => Promise<string>
writeClipboardText: (text: string) => Promise<void>
onFileDrop: (callback: (data: { path: string }) => void) => () => void
getZoomLevel: () => number
setZoomLevel: (level: number) => void

View File

@ -256,6 +256,8 @@ const api = {
return () => ipcRenderer.removeListener('terminal:zoom', listener)
},
readClipboardText: (): Promise<string> => ipcRenderer.invoke('clipboard:readText'),
writeClipboardText: (text: string): Promise<void> =>
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)

View File

@ -187,7 +187,7 @@ export default function MonacoEditor({
<DropdownMenuContent sideOffset={0} align="start">
<DropdownMenuItem
onSelect={() => {
navigator.clipboard.writeText(`${filePath}#L${gutterMenuLine}`)
window.api.ui.writeClipboardText(`${filePath}#L${gutterMenuLine}`)
}}
>
<Copy className="w-3.5 h-3.5 mr-1.5" />
@ -195,7 +195,7 @@ export default function MonacoEditor({
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
navigator.clipboard.writeText(`${relativePath}#L${gutterMenuLine}`)
window.api.ui.writeClipboardText(`${relativePath}#L${gutterMenuLine}`)
}}
>
<Copy className="w-3.5 h-3.5 mr-1.5" />

View File

@ -81,7 +81,9 @@ export function FileResultItem({
</button>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={() => navigator.clipboard.writeText(fileResult.relativePath)}>
<ContextMenuItem
onClick={() => window.api.ui.writeClipboardText(fileResult.relativePath)}
>
<Copy className="size-3.5" />
Copy Path
</ContextMenuItem>
@ -151,7 +153,7 @@ export function MatchItem({
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
onClick={() => navigator.clipboard.writeText(`${relativePath}#L${match.line}`)}
onClick={() => window.api.ui.writeClipboardText(`${relativePath}#L${match.line}`)}
>
<Copy className="size-3.5" />
Copy Line Path

View File

@ -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(() => {

View File

@ -115,7 +115,7 @@ export default function EditorFileTab({
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => {
navigator.clipboard.writeText(file.filePath)
window.api.ui.writeClipboardText(file.filePath)
}}
>
<Copy className="w-3.5 h-3.5 mr-1.5" />
@ -123,7 +123,7 @@ export default function EditorFileTab({
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
navigator.clipboard.writeText(file.relativePath)
window.api.ui.writeClipboardText(file.relativePath)
}}
>
<Copy className="w-3.5 h-3.5 mr-1.5" />
@ -134,7 +134,7 @@ export default function EditorFileTab({
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => {
navigator.clipboard.writeText(`${file.filePath}#L${cursorLine}`)
window.api.ui.writeClipboardText(`${file.filePath}#L${cursorLine}`)
}}
>
<MapPin className="w-3.5 h-3.5 mr-1.5" />
@ -142,7 +142,7 @@ export default function EditorFileTab({
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
navigator.clipboard.writeText(`${file.relativePath}#L${cursorLine}`)
window.api.ui.writeClipboardText(`${file.relativePath}#L${cursorLine}`)
}}
>
<MapPin className="w-3.5 h-3.5 mr-1.5" />

View File

@ -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)
}
}