diff --git a/src/main/ipc/register-core-handlers.test.ts b/src/main/ipc/register-core-handlers.test.ts index a2db899b682..72dd8bc8428 100644 --- a/src/main/ipc/register-core-handlers.test.ts +++ b/src/main/ipc/register-core-handlers.test.ts @@ -420,7 +420,7 @@ describe('registerCoreHandlers', () => { expect(registerCliHandlersMock).toHaveBeenCalled() expect(registerPreflightHandlersMock).toHaveBeenCalled() expect(registerShellHandlersMock).toHaveBeenCalled() - expect(registerClipboardHandlersMock).toHaveBeenCalled() + expect(registerClipboardHandlersMock).toHaveBeenCalledWith(store) expect(registerUpdaterHandlersMock).toHaveBeenCalled() expect(setTrustedBrowserRendererWebContentsIdMock).toHaveBeenCalledWith(null) expect(setTrustedClipboardRendererWebContentsIdMock).toHaveBeenCalledWith(null) diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index fd55b140421..7bcf9756c51 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -164,7 +164,7 @@ export function registerCoreHandlers( registerAiVaultHandlers({ getAdditionalCodexHomePaths: lifecycleOptions.getAdditionalAiVaultCodexHomePaths }) - registerClipboardHandlers() + registerClipboardHandlers(store) registerUpdaterHandlers(store) registerSpeechHandlers(store) } diff --git a/src/main/window/clipboard-file-copy.test.ts b/src/main/window/clipboard-file-copy.test.ts new file mode 100644 index 00000000000..d1142705a92 --- /dev/null +++ b/src/main/window/clipboard-file-copy.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it, vi } from 'vitest' +import { writeFileToClipboard, type ClipboardFileDeps } from './clipboard-file-copy' + +function makeDeps(overrides: Partial = {}): ClipboardFileDeps { + return { + platform: 'darwin', + desktop: undefined, + resolveFilePath: async (path) => ({ ok: true, path }), + writeBuffer: vi.fn(), + runCommand: vi.fn(async () => {}), + ...overrides + } +} + +describe('writeFileToClipboard', () => { + it('rejects relative paths without touching the clipboard', async () => { + const writeBuffer = vi.fn() + expect(await writeFileToClipboard('relative/file.png', makeDeps({ writeBuffer }))).toEqual({ + ok: false, + reason: 'invalid-path' + }) + expect(writeBuffer).not.toHaveBeenCalled() + }) + + it('rejects files that no longer exist', async () => { + expect( + await writeFileToClipboard( + '/repo/gone.png', + makeDeps({ resolveFilePath: async () => ({ ok: false, reason: 'not-found' }) }) + ) + ).toEqual({ ok: false, reason: 'not-found' }) + }) + + it('rejects files outside authorized local roots', async () => { + expect( + await writeFileToClipboard( + '/etc/passwd', + makeDeps({ resolveFilePath: async () => ({ ok: false, reason: 'access-denied' }) }) + ) + ).toEqual({ ok: false, reason: 'access-denied' }) + }) + + it('writes a public.file-url buffer on macOS', async () => { + const writeBuffer = vi.fn() + const result = await writeFileToClipboard( + '/repo/a b.png', + makeDeps({ platform: 'darwin', writeBuffer }) + ) + expect(result).toEqual({ ok: true }) + expect(writeBuffer).toHaveBeenCalledTimes(1) + const [format, buffer] = writeBuffer.mock.calls[0] + expect(format).toBe('public.file-url') + // spaces are percent-encoded into the file URL + expect(buffer.toString('utf8')).toBe('file:///repo/a%20b.png') + }) + + it('reports a failure when the macOS clipboard write throws', async () => { + const writeBuffer = vi.fn(() => { + throw new Error('clipboard unavailable') + }) + await expect( + writeFileToClipboard('/repo/a.png', makeDeps({ platform: 'darwin', writeBuffer })) + ).resolves.toEqual({ ok: false, reason: 'clipboard-write-failed' }) + }) + + it('uses the authorized resolved path for clipboard payloads', async () => { + const writeBuffer = vi.fn() + await writeFileToClipboard( + '/repo/link.png', + makeDeps({ + platform: 'darwin', + resolveFilePath: async () => ({ ok: true, path: '/repo/actual.png' }), + writeBuffer + }) + ) + expect(writeBuffer).toHaveBeenCalledWith( + 'public.file-url', + Buffer.from('file:///repo/actual.png', 'utf8') + ) + }) + + it('shells out to Set-Clipboard on Windows, escaping quotes', async () => { + const runCommand = vi.fn(async (_command: string, _args: string[]) => {}) + const result = await writeFileToClipboard( + "/repo/o'brien.png", + makeDeps({ platform: 'win32', runCommand }) + ) + expect(result).toEqual({ ok: true }) + const [command, args] = runCommand.mock.calls[0] + expect(command).toBe('powershell.exe') + expect(args.join(' ')).toContain("Set-Clipboard -LiteralPath '/repo/o''brien.png'") + }) + + it('reports a failure (never throws) when PowerShell rejects on Windows', async () => { + const runCommand = vi.fn(async (_command: string, _args: string[]) => { + throw new Error('powershell.exe not found') + }) + expect( + await writeFileToClipboard('/repo/a.png', makeDeps({ platform: 'win32', runCommand })) + ).toEqual({ ok: false, reason: 'clipboard-command-failed' }) + }) + + it('uses the KDE text/uri-list payload on a KDE desktop', async () => { + const runCommand = vi.fn(async (_command: string, _args: string[], _stdin?: string) => {}) + const result = await writeFileToClipboard( + '/repo/a b.png', + makeDeps({ platform: 'linux', desktop: 'KDE', runCommand }) + ) + expect(result).toEqual({ ok: true }) + const [command, args, stdin] = runCommand.mock.calls[0] + expect(command).toBe('wl-copy') + expect(args).toContain('text/uri-list') + expect(stdin).toBe('file:///repo/a%20b.png\r\n') + }) + + it('uses the GNOME copied-files payload on non-KDE desktops', async () => { + const runCommand = vi.fn(async (_command: string, _args: string[], _stdin?: string) => {}) + await writeFileToClipboard( + '/repo/a.png', + makeDeps({ platform: 'linux', desktop: 'GNOME', runCommand }) + ) + const [, args, stdin] = runCommand.mock.calls[0] + expect(args).toContain('x-special/gnome-copied-files') + expect(stdin).toBe('copy\nfile:///repo/a.png') + }) + + it('tries each Linux tool and reports unsupported when all fail', async () => { + const runCommand = vi.fn(async (_command: string, _args: string[]) => { + throw new Error('command not found') + }) + expect( + await writeFileToClipboard('/repo/a.png', makeDeps({ platform: 'linux', runCommand })) + ).toEqual({ ok: false, reason: 'unsupported-platform' }) + expect(runCommand).toHaveBeenCalledTimes(2) // wl-copy, then xclip + }) + + it('succeeds on Linux when a clipboard tool is available', async () => { + const runCommand = vi.fn(async (command: string, _args: string[]) => { + if (command === 'wl-copy') { + return + } + throw new Error('no xclip') + }) + expect( + await writeFileToClipboard('/repo/a.png', makeDeps({ platform: 'linux', runCommand })) + ).toEqual({ ok: true }) + expect(runCommand.mock.calls[0][0]).toBe('wl-copy') + }) +}) diff --git a/src/main/window/clipboard-file-copy.ts b/src/main/window/clipboard-file-copy.ts new file mode 100644 index 00000000000..63f5594f279 --- /dev/null +++ b/src/main/window/clipboard-file-copy.ts @@ -0,0 +1,85 @@ +import { isAbsolute } from 'node:path' +import { pathToFileURL } from 'node:url' + +export type ClipboardFileResult = { ok: boolean; reason?: string } + +// Injected so the platform branching is unit-testable without the real OS +// clipboard or spawning processes. +export type ClipboardFileDeps = { + platform: NodeJS.Platform + // Linux only: the active desktop ($XDG_CURRENT_DESKTOP). KDE and GNOME-family + // file managers disagree on the clipboard format, so it picks the payload. + desktop?: string + resolveFilePath: ( + path: string + ) => Promise<{ ok: true; path: string } | { ok: false; reason: string }> + writeBuffer: (format: string, buffer: Buffer) => void + runCommand: (command: string, args: string[], stdin?: string) => Promise +} + +// Put a real OS-level file reference on the clipboard so pasting in Finder / +// Explorer / a file manager drops the actual file (not its path as text). Only +// local files work — remote/SSH files don't exist on this machine. Always +// resolves a result and never throws, so the renderer can report failures. +export async function writeFileToClipboard( + filePath: string, + deps: ClipboardFileDeps +): Promise { + if (typeof filePath !== 'string' || !isAbsolute(filePath)) { + return { ok: false, reason: 'invalid-path' } + } + const resolvedFile = await deps.resolveFilePath(filePath) + if (!resolvedFile.ok) { + return { ok: false, reason: resolvedFile.reason } + } + const clipboardPath = resolvedFile.path + + if (deps.platform === 'darwin') { + // macOS reads `public.file-url` and synthesizes the legacy file types Finder + // needs, so a single buffer is enough. + try { + deps.writeBuffer('public.file-url', Buffer.from(pathToFileURL(clipboardPath).href, 'utf8')) + return { ok: true } + } catch { + return { ok: false, reason: 'clipboard-write-failed' } + } + } + + if (deps.platform === 'win32') { + // Set-Clipboard -LiteralPath populates CF_HDROP, which Explorer pastes as a + // file. Single-quote escaping for the PowerShell string literal. Guard the + // spawn so a missing/erroring PowerShell surfaces as a result, not a throw. + const escaped = clipboardPath.replace(/'/g, "''") + try { + await deps.runCommand('powershell.exe', [ + '-NoProfile', + '-NonInteractive', + '-Command', + `Set-Clipboard -LiteralPath '${escaped}'` + ]) + return { ok: true } + } catch { + return { ok: false, reason: 'clipboard-command-failed' } + } + } + + // Linux: best-effort and desktop-dependent. GNOME-family managers + // (Nautilus/Nemo/Caja) read the "copied-files" payload that carries the + // explicit copy verb; KDE/Qt managers (Dolphin) read text/uri-list instead. + const fileUrl = pathToFileURL(clipboardPath).href + const [mime, payload] = /kde/i.test(deps.desktop ?? '') + ? ['text/uri-list', `${fileUrl}\r\n`] + : ['x-special/gnome-copied-files', `copy\n${fileUrl}`] + for (const [command, args] of [ + ['wl-copy', ['--type', mime]], + ['xclip', ['-selection', 'clipboard', '-t', mime]] + ] as const) { + try { + await deps.runCommand(command, [...args], payload) + return { ok: true } + } catch { + // try the next tool + } + } + return { ok: false, reason: 'unsupported-platform' } +} diff --git a/src/main/window/clipboard-ipc-handlers.test.ts b/src/main/window/clipboard-ipc-handlers.test.ts index 6f8d308c75f..911e997d47a 100644 --- a/src/main/window/clipboard-ipc-handlers.test.ts +++ b/src/main/window/clipboard-ipc-handlers.test.ts @@ -9,33 +9,67 @@ import { const { removeHandlerMock, handleMock, + spawnMock, + childStdinEndMock, + resolveAuthorizedPathMock, fsWriteFileMock, + fsStatMock, clipboardReadTextMock, clipboardWriteTextMock, clipboardReadImageMock, clipboardWriteImageMock, + clipboardWriteBufferMock, nativeImageCreateFromBufferMock, randomUUIDMock, getSshFilesystemProviderMock } = vi.hoisted(() => ({ removeHandlerMock: vi.fn(), handleMock: vi.fn(), + childStdinEndMock: vi.fn(), + spawnMock: vi.fn(() => { + const child = { + stdin: { end: childStdinEndMock }, + on: vi.fn((event: string, callback: (code?: number) => void) => { + if (event === 'exit') { + queueMicrotask(() => callback(0)) + } + return child + }) + } + return child + }), + resolveAuthorizedPathMock: vi.fn(), fsWriteFileMock: vi.fn(), + fsStatMock: vi.fn(), clipboardReadTextMock: vi.fn(), clipboardWriteTextMock: vi.fn(), clipboardReadImageMock: vi.fn(), clipboardWriteImageMock: vi.fn(), + clipboardWriteBufferMock: vi.fn(), nativeImageCreateFromBufferMock: vi.fn(), randomUUIDMock: vi.fn(() => '00000000-0000-4000-8000-000000000000'), getSshFilesystemProviderMock: vi.fn() })) +vi.mock('node:child_process', () => ({ + spawn: spawnMock +})) + vi.mock('node:fs/promises', () => ({ + stat: fsStatMock, default: { writeFile: fsWriteFileMock } })) +vi.mock('../ipc/filesystem-auth', () => ({ + PATH_ACCESS_DENIED_MESSAGE: + 'Access denied: path resolves outside allowed directories. If this blocks a legitimate workflow, please file a GitHub issue.', + isENOENT: (error: unknown): boolean => + error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'ENOENT', + resolveAuthorizedPath: resolveAuthorizedPathMock +})) + vi.mock('node:crypto', () => ({ randomUUID: randomUUIDMock })) @@ -48,7 +82,8 @@ vi.mock('electron', () => ({ readText: clipboardReadTextMock, writeText: clipboardWriteTextMock, readImage: clipboardReadImageMock, - writeImage: clipboardWriteImageMock + writeImage: clipboardWriteImageMock, + writeBuffer: clipboardWriteBufferMock }, ipcMain: { removeHandler: removeHandlerMock, @@ -120,11 +155,18 @@ describe('registerClipboardHandlers', () => { vi.spyOn(Date, 'now').mockReturnValue(1760000000000) removeHandlerMock.mockReset() handleMock.mockReset() + spawnMock.mockClear() + childStdinEndMock.mockClear() + resolveAuthorizedPathMock.mockReset() + resolveAuthorizedPathMock.mockImplementation(async (path: string) => path) fsWriteFileMock.mockReset() + fsStatMock.mockReset() + fsStatMock.mockResolvedValue({}) clipboardReadTextMock.mockReset() clipboardWriteTextMock.mockReset() clipboardReadImageMock.mockReset() clipboardWriteImageMock.mockReset() + clipboardWriteBufferMock.mockReset() nativeImageCreateFromBufferMock.mockReset() randomUUIDMock.mockReset() randomUUIDMock.mockReturnValue('00000000-0000-4000-8000-000000000000') @@ -143,7 +185,7 @@ describe('registerClipboardHandlers', () => { clipboardType === 'selection' ? 'selection text' : 'standard text' ) - registerClipboardHandlers() + registerClipboardHandlers({} as never) const handlers = getRegisteredHandlers() await expect(handlers.get('clipboard:readText')?.(makeClipboardEvent())).resolves.toBe( @@ -163,7 +205,7 @@ describe('registerClipboardHandlers', () => { it('rejects clipboard IPC from senders outside the current main renderer', async () => { setTrustedClipboardRendererWebContentsId(17) - registerClipboardHandlers() + registerClipboardHandlers({} as never) const handlers = getRegisteredHandlers() const untrustedEvent = makeClipboardEvent({ id: 42 }) @@ -178,6 +220,9 @@ describe('registerClipboardHandlers', () => { connectionId: 'ssh-secret' }) ).rejects.toThrow('Unauthorized clipboard IPC sender') + expect(() => + handlers.get('clipboard:writeFile')?.(untrustedEvent, '/tmp/copied-file.txt') + ).toThrow('Unauthorized clipboard IPC sender') expect(() => handlers.get('clipboard:writeImage')?.(untrustedEvent, 'data:image/png;base64,AAAA') ).toThrow('Unauthorized clipboard IPC sender') @@ -187,11 +232,50 @@ describe('registerClipboardHandlers', () => { expect(clipboardReadImageMock).not.toHaveBeenCalled() expect(nativeImageCreateFromBufferMock).not.toHaveBeenCalled() expect(clipboardWriteImageMock).not.toHaveBeenCalled() + expect(clipboardWriteBufferMock).not.toHaveBeenCalled() expect(getSshFilesystemProviderMock).not.toHaveBeenCalled() }) + it('writes local files through the trusted clipboard IPC handler', async () => { + registerClipboardHandlers({} as never) + + const handlers = getRegisteredHandlers() + await expect( + handlers.get('clipboard:writeFile')?.(makeClipboardEvent(), '/tmp/copied-file.txt') + ).resolves.toEqual({ ok: true }) + + expect(fsStatMock).toHaveBeenCalledWith('/tmp/copied-file.txt') + expect(resolveAuthorizedPathMock).toHaveBeenCalledWith('/tmp/copied-file.txt', {}) + if (process.platform === 'darwin') { + expect(clipboardWriteBufferMock).toHaveBeenCalledWith( + 'public.file-url', + Buffer.from('file:///tmp/copied-file.txt', 'utf8') + ) + } else { + expect(spawnMock).toHaveBeenCalled() + } + }) + + it('rejects unauthorized local files before touching the OS clipboard', async () => { + resolveAuthorizedPathMock.mockRejectedValue( + new Error( + 'Access denied: path resolves outside allowed directories. If this blocks a legitimate workflow, please file a GitHub issue.' + ) + ) + registerClipboardHandlers({} as never) + + const handlers = getRegisteredHandlers() + await expect( + handlers.get('clipboard:writeFile')?.(makeClipboardEvent(), '/etc/passwd') + ).resolves.toEqual({ ok: false, reason: 'access-denied' }) + + expect(fsStatMock).not.toHaveBeenCalled() + expect(clipboardWriteBufferMock).not.toHaveBeenCalled() + expect(spawnMock).not.toHaveBeenCalled() + }) + it('rejects clipboard IPC from destroyed, browser, and mismatched dev-origin senders', async () => { - registerClipboardHandlers() + registerClipboardHandlers({} as never) const handlers = getRegisteredHandlers() await expect( @@ -220,7 +304,7 @@ describe('registerClipboardHandlers', () => { clipboardType === 'selection' ? 'selection secret' : 'standard secret' ) - registerClipboardHandlers() + registerClipboardHandlers({} as never) const handlers = getRegisteredHandlers() await expect( @@ -236,7 +320,7 @@ describe('registerClipboardHandlers', () => { const text = 'é'.repeat(300_000) clipboardReadTextMock.mockReturnValue(text) - registerClipboardHandlers() + registerClipboardHandlers({} as never) const handlers = getRegisteredHandlers() const result = handlers.get('clipboard:readText')?.(makeClipboardEvent(), { @@ -258,7 +342,7 @@ describe('registerClipboardHandlers', () => { vi.useFakeTimers() const text = 'é'.repeat(300_000) - registerClipboardHandlers() + registerClipboardHandlers({} as never) const handlers = getRegisteredHandlers() const result = handlers.get('clipboard:writeText')?.(makeClipboardEvent(), text) @@ -277,7 +361,7 @@ describe('registerClipboardHandlers', () => { }) it('rejects oversized text clipboard IPC writes before calling Electron clipboard', async () => { - registerClipboardHandlers() + registerClipboardHandlers({} as never) const handlers = getRegisteredHandlers() await expect( @@ -296,13 +380,14 @@ describe('registerClipboardHandlers', () => { }) it('removes stale clipboard IPC handlers before registering replacements', () => { - registerClipboardHandlers() + registerClipboardHandlers({} as never) expect(removeHandlerMock).toHaveBeenCalledWith('clipboard:readText') expect(removeHandlerMock).toHaveBeenCalledWith('clipboard:readSelectionText') expect(removeHandlerMock).toHaveBeenCalledWith('clipboard:writeText') expect(removeHandlerMock).toHaveBeenCalledWith('clipboard:writeSelectionText') expect(removeHandlerMock).toHaveBeenCalledWith('clipboard:writeImage') + expect(removeHandlerMock).toHaveBeenCalledWith('clipboard:writeFile') expect(removeHandlerMock).toHaveBeenCalledWith('clipboard:saveImageAsTempFile') }) @@ -318,7 +403,7 @@ describe('registerClipboardHandlers', () => { toPNG: () => png }) - registerClipboardHandlers() + registerClipboardHandlers({} as never) const handlers = getRegisteredHandlers() await expect( @@ -339,7 +424,7 @@ describe('registerClipboardHandlers', () => { }) getSshFilesystemProviderMock.mockReturnValue({ getTempDir, writeFileBase64 }) - registerClipboardHandlers() + registerClipboardHandlers({} as never) const handlers = getRegisteredHandlers() await expect( @@ -369,7 +454,7 @@ describe('registerClipboardHandlers', () => { writeFileBase64 }) - registerClipboardHandlers() + registerClipboardHandlers({} as never) const handlers = getRegisteredHandlers() await expect( @@ -393,7 +478,7 @@ describe('registerClipboardHandlers', () => { toPNG }) - registerClipboardHandlers() + registerClipboardHandlers({} as never) const handlers = getRegisteredHandlers() await expect( @@ -411,7 +496,7 @@ describe('registerClipboardHandlers', () => { toPNG: () => Buffer.alloc(CLIPBOARD_IMAGE_MAX_SOURCE_BYTES + 1) }) - registerClipboardHandlers() + registerClipboardHandlers({} as never) const handlers = getRegisteredHandlers() await expect( @@ -424,7 +509,7 @@ describe('registerClipboardHandlers', () => { }) it('ignores oversized clipboard write-image data before decoding base64', () => { - registerClipboardHandlers() + registerClipboardHandlers({} as never) const handlers = getRegisteredHandlers() const dataUrl = [ @@ -443,7 +528,7 @@ describe('registerClipboardHandlers', () => { isEmpty: () => false }) - registerClipboardHandlers() + registerClipboardHandlers({} as never) const handlers = getRegisteredHandlers() handlers.get('clipboard:writeImage')?.(makeClipboardEvent(), 'data:image/png;base64,AAAA') diff --git a/src/main/window/clipboard-ipc-handlers.ts b/src/main/window/clipboard-ipc-handlers.ts index 233c6870ed5..0d0570f9408 100644 --- a/src/main/window/clipboard-ipc-handlers.ts +++ b/src/main/window/clipboard-ipc-handlers.ts @@ -5,6 +5,10 @@ import { type IpcMainInvokeEvent, type WebContents } from 'electron' +import { spawn } from 'node:child_process' +import { stat } from 'node:fs/promises' +import type { Store } from '../persistence' +import { isENOENT, PATH_ACCESS_DENIED_MESSAGE, resolveAuthorizedPath } from '../ipc/filesystem-auth' import { assertClipboardTextWriteWithinLimitWithYield, assertClipboardTextWithinLimitWithYield, @@ -19,6 +23,7 @@ import { assertClipboardImageByteLengthWithinLimit, assertClipboardImageDimensionsWithinLimit } from '../../shared/clipboard-image' +import { writeFileToClipboard } from './clipboard-file-copy' let trustedClipboardRendererWebContentsId: number | null = null @@ -26,12 +31,26 @@ export function setTrustedClipboardRendererWebContentsId(webContentsId: number | trustedClipboardRendererWebContentsId = webContentsId } -export function registerClipboardHandlers(): void { +// Run a short-lived OS clipboard helper (PowerShell / wl-copy / xclip), feeding +// it stdin when provided; resolves only on a clean exit. +function runCommand(command: string, args: string[], stdin?: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { stdio: ['pipe', 'ignore', 'ignore'] }) + child.on('error', reject) + child.on('exit', (code) => + code === 0 ? resolve() : reject(new Error(`${command} exited with ${code}`)) + ) + child.stdin?.end(stdin ?? '') + }) +} + +export function registerClipboardHandlers(store: Store): void { ipcMain.removeHandler('clipboard:readText') ipcMain.removeHandler('clipboard:readSelectionText') ipcMain.removeHandler('clipboard:writeText') ipcMain.removeHandler('clipboard:writeSelectionText') ipcMain.removeHandler('clipboard:writeImage') + ipcMain.removeHandler('clipboard:writeFile') ipcMain.removeHandler('clipboard:saveImageAsTempFile') ipcMain.handle('clipboard:readText', async (event, options?: ReadClipboardTextOptions) => { @@ -60,6 +79,29 @@ export function registerClipboardHandlers(): void { return saveClipboardImageBufferAsTempFile(image.toPNG(), args) } ) + // Why: copy the actual file to the OS clipboard so pasting in Finder/Explorer + // drops the file itself, not its path as text. Local files only. + ipcMain.handle('clipboard:writeFile', (event, filePath: string) => { + assertTrustedClipboardSender(event) + return writeFileToClipboard(filePath, { + platform: process.platform, + desktop: process.env.XDG_CURRENT_DESKTOP, + resolveFilePath: async (path) => { + try { + const authorizedPath = await resolveAuthorizedPath(path, store) + await stat(authorizedPath) + return { ok: true, path: authorizedPath } + } catch (error) { + if (error instanceof Error && error.message === PATH_ACCESS_DENIED_MESSAGE) { + return { ok: false, reason: 'access-denied' } + } + return { ok: false, reason: isENOENT(error) ? 'not-found' : 'invalid-path' } + } + }, + writeBuffer: (format, buffer) => clipboard.writeBuffer(format, buffer), + runCommand + }) + }) ipcMain.handle('clipboard:writeText', async (event, text: string) => { assertTrustedClipboardSender(event) return clipboard.writeText(await assertClipboardTextWriteWithinLimitWithYield(text)) diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index de10206160e..5c053515666 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -2479,6 +2479,7 @@ export type PreloadApi = { writeSelectionClipboardText: (text: string) => Promise writeClipboardImage: (dataUrl: string) => Promise performNativePaste: (options?: { mode?: 'paste' | 'paste-and-match-style' }) => void + writeClipboardFile: (filePath: string) => Promise<{ ok: boolean; reason?: string }> onFileDrop: (callback: (data: NativeFileDropPayload) => void) => () => void getZoomLevel: () => number setZoomLevel: (level: number) => void diff --git a/src/preload/index.ts b/src/preload/index.ts index 63522990d4f..65738b8172f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -3270,6 +3270,8 @@ const api = { mode: options?.mode === 'paste-and-match-style' ? 'paste-and-match-style' : 'paste' }) }, + writeClipboardFile: (filePath: string): Promise<{ ok: boolean; reason?: string }> => + ipcRenderer.invoke('clipboard:writeFile', filePath), onFileDrop: (callback: (data: NativeFileDropPayload) => void): (() => void) => subscribeNativeFileDrop(callback), getZoomLevel: (): number => webFrame.getZoomLevel(), diff --git a/src/renderer/src/components/right-sidebar/FileExplorer.test.tsx b/src/renderer/src/components/right-sidebar/FileExplorer.test.tsx index 8b31de2c76e..c81dc9e64db 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorer.test.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorer.test.tsx @@ -16,6 +16,7 @@ import { FileExplorerRow, shouldShowCollapseFolderAction, shouldShowFindInFolderAction, + shouldShowCopyFileAction, shouldShowRemoteDownloadAction } from './FileExplorerRow' import { FileExplorerVirtualRows } from './FileExplorerVirtualRows' @@ -574,6 +575,16 @@ describe('FileExplorerRow collapse folder action', () => { expect(shouldShowRemoteDownloadAction(fileNode, 'ssh-1')).toBe(false) }) + it('shows OS file copy only for single local desktop selections', () => { + expect(shouldShowCopyFileAction(null, 1)).toBe(true) + expect(shouldShowCopyFileAction(undefined, 2)).toBe(false) + expect(shouldShowCopyFileAction('ssh-1', 1)).toBe(false) + + ;(globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ = true + + expect(shouldShowCopyFileAction(null, 1)).toBe(false) + }) + it('calls the preload download API and shows success only when not canceled', async () => { const downloadFile = vi .fn() diff --git a/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx b/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx index 56f4c31073e..757120d782a 100644 --- a/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx +++ b/src/renderer/src/components/right-sidebar/FileExplorerRow.tsx @@ -314,6 +314,16 @@ export function shouldShowRemoteDownloadAction( ) } +export function shouldShowCopyFileAction(connectionId?: string | null, selectionSize = 1): boolean { + // Why: the OS file clipboard only holds local files — remote (SSH) files + // don't exist on this machine, and the web client has no native clipboard. + return ( + !connectionId && + selectionSize === 1 && + (globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__ !== true + ) +} + export async function downloadRemoteFile(node: TreeNode, connectionId: string): Promise { try { const result = await window.api.fs.downloadFile({ filePath: node.path, connectionId }) @@ -393,6 +403,7 @@ export function FileExplorerRow({ const FileIcon = getFileTypeIcon(node.relativePath || node.name) const rowDropDir = node.isDirectory ? node.path : targetDir const showRemoteDownloadAction = shouldShowRemoteDownloadAction(node, connectionId) + const showCopyFileAction = shouldShowCopyFileAction(connectionId, selectionSize) const { setRowDragNode, handleDragOver, handleDragEnter, handleDragLeave, handleDrop } = useFileExplorerRowDrag({ rowDropDir, @@ -420,6 +431,24 @@ export function FileExplorerRow({ } void downloadRemoteFile(node, connectionId) }, [connectionId, node]) + const handleCopyFile = useCallback(() => { + const failureMessage = translate( + 'auto.components.right.sidebar.FileExplorerRow.b234ab25b4', + 'Could not copy the file to the clipboard' + ) + void window.api.ui + .writeClipboardFile(node.path) + .then((result) => { + if (!result.ok) { + toast.error(failureMessage) + } + }) + // A failure in the main process rejects the IPC promise; surface the same + // toast instead of leaving an unhandled rejection with no feedback. + .catch(() => { + toast.error(failureMessage) + }) + }, [node.path]) return ( + {showCopyFileAction && ( + + + {translate('auto.components.right.sidebar.FileExplorerRow.98a79948b3', 'Copy')} + + )} onCopyPaths('absolute')}> {selectionSize > 1 diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 0c6fdfd6a65..7b386f780ce 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -8349,6 +8349,8 @@ "66a29dde82": "Copy Relative Path", "42e10cbf57": "Copy Relative Paths", "b5d436aa30": "Copy Path", + "98a79948b3": "Copy", + "b234ab25b4": "Could not copy the file to the clipboard", "f9d7ca753d": "Copy Paths", "3161c4e425": "folder" }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index fea2357ab94..2e97c7df5a4 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -8349,6 +8349,8 @@ "66a29dde82": "Copiar ruta relativa", "42e10cbf57": "Copiar rutas relativas", "b5d436aa30": "Copiar ruta", + "98a79948b3": "Copiar", + "b234ab25b4": "No se pudo copiar el archivo al portapapeles", "f9d7ca753d": "Copiar rutas", "3161c4e425": "carpeta" }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 75555ae1548..07368bf8558 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -8349,6 +8349,8 @@ "66a29dde82": "相対パスをコピー", "42e10cbf57": "相対パスのコピー", "b5d436aa30": "パスのコピー", + "98a79948b3": "コピー", + "b234ab25b4": "ファイルをクリップボードにコピーできませんでした", "f9d7ca753d": "パスのコピー", "3161c4e425": "フォルダ" }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 8b6dc42fd9a..d2297f98aa5 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -8349,6 +8349,8 @@ "66a29dde82": "상대 경로 복사", "42e10cbf57": "상대 경로 복사", "b5d436aa30": "경로 복사", + "98a79948b3": "복사", + "b234ab25b4": "파일을 클립보드에 복사하지 못했습니다", "f9d7ca753d": "경로 복사", "3161c4e425": "폴더" }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 83e32e2934e..388e543e47f 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -8349,6 +8349,8 @@ "66a29dde82": "复制相对路径", "42e10cbf57": "复制相对路径", "b5d436aa30": "复制路径", + "98a79948b3": "复制", + "b234ab25b4": "无法将文件复制到剪贴板", "f9d7ca753d": "复制路径", "3161c4e425": "文件夹" }, diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 061ab0b12d5..4eaf43372f0 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -2041,6 +2041,7 @@ function createWebUiApi(): NonNullable['ui']> { writeSelectionClipboardText: () => Promise.reject(new Error('Selection clipboard is unavailable in the web client')), writeClipboardImage: () => Promise.resolve(), + writeClipboardFile: () => Promise.resolve({ ok: false, reason: 'unsupported-platform' }), performNativePaste: () => { document.execCommand?.('paste') },