diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 82c8aa889ec..71df770da5b 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -331,6 +331,7 @@ jobs: . != "tests/e2e/orcad-serve-mode-switch.spec.ts" and . != "tests/e2e/workspace-layout-oracle-headless.spec.ts" and . != "tests/e2e/windows-missing-appdata-startup.spec.ts" and + . != "tests/e2e/file-explorer-copy-file-windows.spec.ts" and (startswith("tests/e2e/ssh-orcad-") | not) )' <<<"$TEST_FILES_JSON" > "$RUNNER_TEMP/general-e2e-specs" fi @@ -494,10 +495,10 @@ jobs: # D7 on Windows: both hosts share \daemon and so one daemon pipe. Built here rather than # from the Linux e2e build because the slot, template and addons are Windows-native. Also runs the - # missing-AppData startup check, which needs the same Windows e2e build. + # missing-AppData startup check and the explorer file-copy check, which need the same Windows e2e build. orcad-serve-mode-switch-windows: name: orca serve on Windows (D7 mode switch, missing AppData) - if: inputs.test_files == '' || contains(inputs.test_files, 'tests/e2e/orcad-serve-mode-switch.spec.ts') || contains(inputs.test_files, 'tests/e2e/windows-missing-appdata-startup.spec.ts') + if: inputs.test_files == '' || contains(inputs.test_files, 'tests/e2e/orcad-serve-mode-switch.spec.ts') || contains(inputs.test_files, 'tests/e2e/windows-missing-appdata-startup.spec.ts') || contains(inputs.test_files, 'tests/e2e/file-explorer-copy-file-windows.spec.ts') runs-on: windows-2022 timeout-minutes: 45 env: @@ -534,6 +535,13 @@ jobs: SKIP_BUILD: '1' ORCA_E2E_FORWARD_APP_LOGS: '1' run: pnpm exec playwright test --config tests/playwright.config.ts tests/e2e/windows-missing-appdata-startup.spec.ts --project=electron-headless --workers=1 + # Explorer pastes only a real CF_HDROP, which needs the Windows clipboard and shell. + - name: Copy a file from the explorer + if: ${{ !cancelled() && (inputs.test_files == '' || contains(inputs.test_files, 'tests/e2e/file-explorer-copy-file-windows.spec.ts')) }} + env: + SKIP_BUILD: '1' + ORCA_E2E_FORWARD_APP_LOGS: '1' + run: pnpm exec playwright test --config tests/playwright.config.ts tests/e2e/file-explorer-copy-file-windows.spec.ts --project=electron-headless --workers=1 - name: Switch serve hosts on one profile if: ${{ !cancelled() && (inputs.test_files == '' || contains(inputs.test_files, 'tests/e2e/orcad-serve-mode-switch.spec.ts')) }} env: diff --git a/config/process-host-direct-import-baseline.txt b/config/process-host-direct-import-baseline.txt index 1b51486ff35..baf9a9106a8 100644 --- a/config/process-host-direct-import-baseline.txt +++ b/config/process-host-direct-import-baseline.txt @@ -110,7 +110,6 @@ src/main/ssh/system-ssh-forward-process.ts src/main/ssh/system-ssh-operation-lifecycle.ts src/main/startup/ensure-virtual-display.ts src/main/startup/hydrate-shell-path.ts -src/main/window/clipboard-ipc-handlers.ts src/main/windows-process-tree-kill.ts src/main/workspace-space-analysis.ts src/main/zsh-startup-hook-pty-harness.ts diff --git a/config/scripts/ci-e2e-job-selection.mjs b/config/scripts/ci-e2e-job-selection.mjs index daf33c7b7a4..00be43ec1ad 100644 --- a/config/scripts/ci-e2e-job-selection.mjs +++ b/config/scripts/ci-e2e-job-selection.mjs @@ -7,6 +7,8 @@ export const NATIVE_IME_E2E_SPEC = 'tests/e2e/terminal-ibus-hangul-native.spec.t export const ORCAD_SERVE_MODE_SWITCH_E2E_SPEC = 'tests/e2e/orcad-serve-mode-switch.spec.ts' // Windows-only; its own job runs it on a Windows runner. export const WINDOWS_MISSING_APPDATA_E2E_SPEC = 'tests/e2e/windows-missing-appdata-startup.spec.ts' +// Windows-only; the same Windows job runs it. +export const WINDOWS_COPY_FILE_E2E_SPEC = 'tests/e2e/file-explorer-copy-file-windows.spec.ts' // Needs out/orcad, which only the mode-switch job builds; it runs there beside that spec. export const LAYOUT_ORACLE_HEADLESS_E2E_SPEC = 'tests/e2e/workspace-layout-oracle-headless.spec.ts' // One Docker job runs every managed-SSH spec; it builds the orcad template they need. @@ -16,14 +18,16 @@ export const DEDICATED_E2E_SPECS = [ NATIVE_IME_E2E_SPEC, ORCAD_SERVE_MODE_SWITCH_E2E_SPEC, LAYOUT_ORACLE_HEADLESS_E2E_SPEC, - WINDOWS_MISSING_APPDATA_E2E_SPEC + WINDOWS_MISSING_APPDATA_E2E_SPEC, + WINDOWS_COPY_FILE_E2E_SPEC ] const dedicatedSpecs = new Set(DEDICATED_E2E_SPECS) // Their jobs build their own app or none, so they never need the shared e2e build. const OWN_BUILD_E2E_SPECS = new Set([ NODE_NETWORK_E2E_SPEC, NATIVE_IME_E2E_SPEC, - WINDOWS_MISSING_APPDATA_E2E_SPEC + WINDOWS_MISSING_APPDATA_E2E_SPEC, + WINDOWS_COPY_FILE_E2E_SPEC ]) export function isDedicatedE2eSpec(spec) { diff --git a/config/scripts/pr-e2e-route-contracts.test.mjs b/config/scripts/pr-e2e-route-contracts.test.mjs index eeb9498aecf..9cbb60d4082 100644 --- a/config/scripts/pr-e2e-route-contracts.test.mjs +++ b/config/scripts/pr-e2e-route-contracts.test.mjs @@ -7,6 +7,7 @@ import { DEDICATED_E2E_SPECS, MANAGED_SSH_E2E_SPEC_PREFIX, ORCAD_SERVE_MODE_SWITCH_E2E_SPEC, + WINDOWS_COPY_FILE_E2E_SPEC, WINDOWS_MISSING_APPDATA_E2E_SPEC, selectGeneralE2eSpecs } from './ci-e2e-job-selection.mjs' @@ -359,6 +360,17 @@ describe('orcad-e2e-routing', () => { ) }) + it('routes the Windows file-copy spec from its clipboard sources', () => { + expectRouted( + [ + 'src/main/window/clipboard-file-copy.ts', + 'src/main/window/clipboard-ipc-handlers.ts', + 'src/renderer/src/components/right-sidebar/file-explorer-row-file-transfer.ts' + ], + WINDOWS_COPY_FILE_E2E_SPEC + ) + }) + it('routes the auto-convert spec from its conversion sources and harness', () => { expectRouted( [ @@ -424,10 +436,14 @@ describe('orcad-e2e-routing', () => { } }) - it('runs both Windows serve specs on one runner, each only when requested', () => { + it('runs the Windows-only specs on one runner, each only when requested', () => { expect(jobs['windows-missing-appdata-startup']).toBeUndefined() const job = jobs['orcad-serve-mode-switch-windows'] - for (const spec of [ORCAD_SERVE_MODE_SWITCH_E2E_SPEC, WINDOWS_MISSING_APPDATA_E2E_SPEC]) { + for (const spec of [ + ORCAD_SERVE_MODE_SWITCH_E2E_SPEC, + WINDOWS_MISSING_APPDATA_E2E_SPEC, + WINDOWS_COPY_FILE_E2E_SPEC + ]) { expect(job.if, spec).toContain(`contains(inputs.test_files, '${spec}')`) const step = job.steps.find((candidate) => candidate.run?.includes(spec)) expect(step.if, spec).toContain(`contains(inputs.test_files, '${spec}')`) diff --git a/config/scripts/pr-e2e-source-routing.mjs b/config/scripts/pr-e2e-source-routing.mjs index e833ede1714..d580c176a3a 100644 --- a/config/scripts/pr-e2e-source-routing.mjs +++ b/config/scripts/pr-e2e-source-routing.mjs @@ -110,6 +110,15 @@ export const PR_E2E_SOURCE_ROUTES = [ (isProductSource(file) && /^src\/main\/startup\/(?:windows-app-data-path|main-process-preflight)\.ts$/.test(file)) }, + { + id: 'clipboard.windows-copy-file', + specs: ['tests/e2e/file-explorer-copy-file-windows.spec.ts'], + matches: (file) => + isProductSource(file) && + /^src\/(?:main\/window\/clipboard-(?:file-copy|ipc-handlers)|renderer\/src\/components\/right-sidebar\/file-explorer-row-file-transfer)\.ts$/.test( + file + ) + }, { id: 'ssh.orcad-auto-convert', specs: ['tests/e2e/ssh-orcad-auto-convert.spec.ts'], diff --git a/src/main/window/clipboard-file-copy.test.ts b/src/main/window/clipboard-file-copy.test.ts index dfc58eb7e1c..2e69a1cbc41 100644 --- a/src/main/window/clipboard-file-copy.test.ts +++ b/src/main/window/clipboard-file-copy.test.ts @@ -1,18 +1,27 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { ProcessResult, ProcessSpec } from '@orca/process-host/process-spec' import { writeFileToClipboard, type ClipboardFileDeps } from './clipboard-file-copy' +function exited(code: number | null): ProcessResult { + return { code, signal: null, stdout: '', stderr: '', timedOut: false } +} + function makeDeps(overrides: Partial = {}): ClipboardFileDeps { return { platform: 'darwin', desktop: undefined, resolveFilePath: async (path) => ({ ok: true, path }), writeBuffer: vi.fn(), - runCommand: vi.fn(async () => {}), + runProcess: vi.fn(async () => exited(0)), ...overrides } } describe('writeFileToClipboard', () => { + afterEach(() => { + vi.unstubAllEnvs() + }) + it('rejects relative paths without touching the clipboard', async () => { const writeBuffer = vi.fn() expect(await writeFileToClipboard('relative/file.png', makeDeps({ writeBuffer }))).toEqual({ @@ -79,82 +88,94 @@ describe('writeFileToClipboard', () => { ) }) - it('shells out to Set-Clipboard on Windows, escaping quotes', async () => { - const runCommand = vi.fn(async (_command: string, _args: string[]) => {}) + it('copies on Windows with Set-Clipboard, passing the path as data, never as script', async () => { + vi.stubEnv('SystemRoot', 'D:\\Win') + vi.stubEnv('ORCA_TEST_INHERITED', 'kept') + const runProcess = vi.fn(async (_spec: ProcessSpec) => exited(0)) const result = await writeFileToClipboard( - "/repo/o'brien.png", - makeDeps({ platform: 'win32', runCommand }) + "/repo/o'brien $(calc).png", + makeDeps({ platform: 'win32', runProcess }) ) 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'") + const spec = runProcess.mock.calls[0][0] + // Absolute System32 path, never a bare name resolved on PATH. + expect(spec.program).toBe('D:\\Win\\System32\\WindowsPowerShell\\v1.0\\powershell.exe') + expect(spec.args).toEqual([ + '-NoProfile', + '-NonInteractive', + '-Command', + 'Set-Clipboard -LiteralPath $env:ORCA_CLIPBOARD_FILE' + ]) + expect(spec.env?.ORCA_CLIPBOARD_FILE).toBe("/repo/o'brien $(calc).png") + // PowerShell cannot start without the inherited environment. + expect(spec.env?.ORCA_TEST_INHERITED).toBe('kept') }) - it('doubles typographic single quotes, which PowerShell also treats as delimiters', async () => { - const runCommand = vi.fn(async (_command: string, _args: string[]) => {}) - await writeFileToClipboard( - '/repo/o\u2019brien.png', - makeDeps({ platform: 'win32', runCommand }) - ) - expect(runCommand.mock.calls[0][1].join(' ')).toContain( - "Set-Clipboard -LiteralPath '/repo/o\u2019\u2019brien.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') - }) + it.each([ + ['exits non-zero', async () => exited(1)], + ['times out', async () => ({ ...exited(null), timedOut: true })], + [ + 'cannot start', + async () => { + throw new Error('spawn ENOENT') + } + ] + ])('reports a Windows copy whose PowerShell %s as failed', async (_, run) => { expect( - await writeFileToClipboard('/repo/a.png', makeDeps({ platform: 'win32', runCommand })) + await writeFileToClipboard( + '/repo/a.png', + makeDeps({ platform: 'win32', runProcess: vi.fn(run) }) + ) ).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 runProcess = vi.fn(async (_spec: ProcessSpec) => exited(0)) const result = await writeFileToClipboard( '/repo/a b.png', - makeDeps({ platform: 'linux', desktop: 'KDE', runCommand }) + makeDeps({ platform: 'linux', desktop: 'KDE', runProcess }) ) 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') + const spec = runProcess.mock.calls[0][0] + expect(spec.program).toBe('wl-copy') + expect(spec.args).toContain('text/uri-list') + expect(spec.input).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) => {}) + const runProcess = vi.fn(async (_spec: ProcessSpec) => exited(0)) await writeFileToClipboard( '/repo/a.png', - makeDeps({ platform: 'linux', desktop: 'GNOME', runCommand }) + makeDeps({ platform: 'linux', desktop: 'GNOME', runProcess }) ) - const [, args, stdin] = runCommand.mock.calls[0] - expect(args).toContain('x-special/gnome-copied-files') - expect(stdin).toBe('copy\nfile:///repo/a.png') + const spec = runProcess.mock.calls[0][0] + expect(spec.args).toContain('x-special/gnome-copied-files') + expect(spec.input).toBe('copy\nfile:///repo/a.png') + }) + + it('ignores helper stdout/stderr so a forked selection owner cannot hold the run open', async () => { + const runProcess = vi.fn(async (_spec: ProcessSpec) => exited(0)) + await writeFileToClipboard('/repo/a.png', makeDeps({ platform: 'linux', runProcess })) + expect(runProcess.mock.calls[0][0].stdio).toEqual(['pipe', 'ignore', 'ignore']) }) 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') - }) + const runProcess = vi + .fn(async (_spec: ProcessSpec): Promise => exited(1)) + .mockRejectedValueOnce(new Error('spawn wl-copy ENOENT')) expect( - await writeFileToClipboard('/repo/a.png', makeDeps({ platform: 'linux', runCommand })) + await writeFileToClipboard('/repo/a.png', makeDeps({ platform: 'linux', runProcess })) ).toEqual({ ok: false, reason: 'unsupported-platform' }) - expect(runCommand).toHaveBeenCalledTimes(2) // wl-copy, then xclip + expect(runProcess.mock.calls.map(([spec]) => spec.program)).toEqual(['wl-copy', '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') - }) + it('falls back to xclip when wl-copy fails', async () => { + const runProcess = vi.fn(async (spec: ProcessSpec) => + spec.program === 'xclip' ? exited(0) : exited(1) + ) expect( - await writeFileToClipboard('/repo/a.png', makeDeps({ platform: 'linux', runCommand })) + await writeFileToClipboard('/repo/a.png', makeDeps({ platform: 'linux', runProcess })) ).toEqual({ ok: true }) - expect(runCommand.mock.calls[0][0]).toBe('wl-copy') + expect(runProcess).toHaveBeenCalledTimes(2) }) }) diff --git a/src/main/window/clipboard-file-copy.ts b/src/main/window/clipboard-file-copy.ts index bc09c27c19c..75fe6182fe4 100644 --- a/src/main/window/clipboard-file-copy.ts +++ b/src/main/window/clipboard-file-copy.ts @@ -1,6 +1,7 @@ import { isAbsolute } from 'node:path' import { pathToFileURL } from 'node:url' -import { quotePowerShellLiteral } from '../../shared/powershell-native-argument' +import type { ProcessResult, ProcessSpec } from '@orca/process-host/process-spec' +import { windowsPowerShellPath } from '@orca/process-host/windows-system-binary' export type ClipboardFileResult = { ok: boolean; reason?: string } @@ -15,9 +16,11 @@ export type ClipboardFileDeps = { 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 + runProcess: (spec: ProcessSpec) => Promise } +const WINDOWS_CLIPBOARD_FILE_ENV = 'ORCA_CLIPBOARD_FILE' + // 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 @@ -47,20 +50,19 @@ export async function writeFileToClipboard( } if (deps.platform === 'win32') { - // Set-Clipboard -LiteralPath populates CF_HDROP, which Explorer pastes as a - // file. Guard the spawn so a missing/erroring PowerShell surfaces as a - // result, not a throw. - try { - await deps.runCommand('powershell.exe', [ + // Set-Clipboard publishes CF_HDROP; Electron's writeBuffer only registers custom formats Explorer can't paste. + // The path travels in the environment so it is never parsed as script. + const copied = await runClipboardHelper(deps.runProcess, { + program: windowsPowerShellPath(), + args: [ '-NoProfile', '-NonInteractive', '-Command', - `Set-Clipboard -LiteralPath ${quotePowerShellLiteral(clipboardPath)}` - ]) - return { ok: true } - } catch { - return { ok: false, reason: 'clipboard-command-failed' } - } + `Set-Clipboard -LiteralPath $env:${WINDOWS_CLIPBOARD_FILE_ENV}` + ], + env: { ...process.env, [WINDOWS_CLIPBOARD_FILE_ENV]: clipboardPath } + }) + return copied ? { ok: true } : { ok: false, reason: 'clipboard-command-failed' } } // Linux: best-effort and desktop-dependent. GNOME-family managers @@ -70,16 +72,31 @@ export async function writeFileToClipboard( 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 [ + for (const [program, args] of [ ['wl-copy', ['--type', mime]], ['xclip', ['-selection', 'clipboard', '-t', mime]] ] as const) { - try { - await deps.runCommand(command, [...args], payload) + if (await runClipboardHelper(deps.runProcess, { program, args, input: payload })) { return { ok: true } - } catch { - // try the next tool } } return { ok: false, reason: 'unsupported-platform' } } + +// True only on a clean exit; a helper that cannot start, fails or times out is false. +async function runClipboardHelper( + runProcess: ClipboardFileDeps['runProcess'], + spec: ProcessSpec +): Promise { + try { + const result = await runProcess({ + ...spec, + // Why ignored: wl-copy and xclip fork a background selection owner, and an + // inherited output pipe would hold the run open until that owner exits. + stdio: ['pipe', 'ignore', 'ignore'] + }) + return result.code === 0 && !result.timedOut + } catch { + return false + } +} diff --git a/src/main/window/clipboard-ipc-handlers.test.ts b/src/main/window/clipboard-ipc-handlers.test.ts index 92b86d33336..7c4d0a13f0b 100644 --- a/src/main/window/clipboard-ipc-handlers.test.ts +++ b/src/main/window/clipboard-ipc-handlers.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as ProcessHost from '@orca/process-host' import { installFakeAppEnvironment } from '../../../config/scripts/vitest-host-ports-setup' import { CLIPBOARD_IMAGE_MAX_BASE64_CHARS, @@ -9,8 +10,7 @@ import { const { removeHandlerMock, handleMock, - spawnMock, - childStdinEndMock, + runProcessMock, resolveAuthorizedPathMock, fsAccessMock, fsLstatMock, @@ -33,19 +33,7 @@ const { } = 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 - }), + runProcessMock: vi.fn(), resolveAuthorizedPathMock: vi.fn(), fsAccessMock: vi.fn(), fsLstatMock: vi.fn(), @@ -67,8 +55,9 @@ const { callRuntimeEnvironmentMock: vi.fn() })) -vi.mock('node:child_process', () => ({ - spawn: spawnMock +vi.mock('@orca/process-host', async (importOriginal) => ({ + ...(await importOriginal()), + runProcess: runProcessMock })) vi.mock('node:fs/promises', () => ({ @@ -177,8 +166,14 @@ describe('registerClipboardHandlers', () => { vi.spyOn(Date, 'now').mockReturnValue(1760000000000) removeHandlerMock.mockReset() handleMock.mockReset() - spawnMock.mockClear() - childStdinEndMock.mockClear() + runProcessMock.mockReset() + runProcessMock.mockResolvedValue({ + code: 0, + signal: null, + stdout: '', + stderr: '', + timedOut: false + }) resolveAuthorizedPathMock.mockReset() resolveAuthorizedPathMock.mockImplementation(async (path: string) => path) fsLstatMock.mockReset() @@ -286,7 +281,9 @@ describe('registerClipboardHandlers', () => { expect(clipboardWriteBufferMock).not.toHaveBeenCalled() }) - it('writes local files through the trusted clipboard IPC handler', async () => { + it('writes local files through the trusted handler into process-host', async () => { + // Pinned so the process-host wiring runs on every CI host, macOS included. + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') registerClipboardHandlers({} as never) const handlers = getRegisteredHandlers() @@ -296,14 +293,7 @@ describe('registerClipboardHandlers', () => { expect(fsStatMock).toHaveBeenCalledWith('/tmp/copied-file.txt') expect(resolveAuthorizedPathMock.mock.calls[0]?.[0]).toBe('/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() - } + expect(runProcessMock).toHaveBeenCalledTimes(1) }) it('rejects unauthorized local files before touching the OS clipboard', async () => { @@ -321,7 +311,7 @@ describe('registerClipboardHandlers', () => { expect(fsStatMock).not.toHaveBeenCalled() expect(clipboardWriteBufferMock).not.toHaveBeenCalled() - expect(spawnMock).not.toHaveBeenCalled() + expect(runProcessMock).not.toHaveBeenCalled() }) it('rejects clipboard IPC from destroyed, browser, and mismatched dev-origin senders', async () => { diff --git a/src/main/window/clipboard-ipc-handlers.ts b/src/main/window/clipboard-ipc-handlers.ts index d960e62a6f9..fa035c1210a 100644 --- a/src/main/window/clipboard-ipc-handlers.ts +++ b/src/main/window/clipboard-ipc-handlers.ts @@ -6,8 +6,8 @@ import { type IpcMainInvokeEvent, type WebContents } from 'electron' -import { spawn } from 'node:child_process' import { open, stat } from 'node:fs/promises' +import { runProcess } from '@orca/process-host' import type { Store } from '../persistence' import { PATH_ACCESS_DENIED_MESSAGE } from '../ipc/filesystem-auth' import { resolveDesktopAuthorizedPath } from '../ipc/local-file-access-resolution' @@ -87,19 +87,6 @@ export function setTrustedClipboardRendererWebContentsId(webContentsId: number | trustedClipboardRendererWebContentsId = webContentsId } -// 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') @@ -288,7 +275,7 @@ function makeClipboardFileDeps( desktop: process.env.XDG_CURRENT_DESKTOP, resolveFilePath, writeBuffer: (format, buffer) => clipboard.writeBuffer(format, buffer), - runCommand + runProcess } } diff --git a/tests/e2e/file-explorer-copy-file-windows.spec.ts b/tests/e2e/file-explorer-copy-file-windows.spec.ts new file mode 100644 index 00000000000..27a622ab2c3 --- /dev/null +++ b/tests/e2e/file-explorer-copy-file-windows.spec.ts @@ -0,0 +1,72 @@ +import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { runProcess } from '@orca/process-host' +import { windowsPowerShellPath } from '@orca/process-host/windows-system-binary' +import { test, expect } from './helpers/orca-app' + +test.use({ seedTestRepo: false }) + +// Quote, subexpression and non-ASCII: a path that reached PowerShell as script +// text would be mangled or executed instead of copied. +const FILE_NAME = "o'brien $(exit 7) ü.txt" + +// Explorer pastes only a real CF_HDROP; FileNameW alone reads back fine but +// pastes nothing, so prove the copy with the shell's own paste verb. +async function shellPasteInto(folder: string): Promise { + const result = await runProcess({ + program: windowsPowerShellPath(), + args: [ + '-NoProfile', + '-NonInteractive', + '-Command', + '(New-Object -ComObject Shell.Application).NameSpace($env:PASTE_TARGET).Self.InvokeVerb("paste")' + ], + env: { ...process.env, PASTE_TARGET: folder }, + timeoutMs: 30_000 + }) + expect(result.code, result.stderr).toBe(0) +} + +test.describe('file explorer Copy on Windows', () => { + test.skip(process.platform !== 'win32', 'Exercises the Windows CF_HDROP clipboard path') + + test('puts a file Explorer can paste on the clipboard', async ({ + orcaPage: page, + electronApp, + registerPostElectronShutdownCleanup + }) => { + // Native realpath: runners hand out an 8.3 temp path, but the app copies the long one. + const folder = await realpath(await mkdtemp(path.join(os.tmpdir(), 'orca-copy-file-'))) + registerPostElectronShutdownCleanup(() => rm(folder, { recursive: true, force: true })) + const source = path.join(folder, 'workspace') + const pasteTarget = path.join(folder, 'paste-target') + await mkdir(source) + await mkdir(pasteTarget) + await writeFile(path.join(source, FILE_NAME), 'copied through the clipboard\n') + + await page.evaluate(async (source) => { + await window.__store!.getState().addRepoPath(source, 'folder') + }, source) + await page.getByRole('option').filter({ hasText: 'workspace' }).click() + await page + .locator('[data-orca-explorer-shell]') + .getByRole('button', { name: FILE_NAME, exact: true }) + .click({ button: 'right' }) + await page.getByRole('menuitem', { name: 'Copy', exact: true }).click() + + // Waits for the copy to land; the paste below is what proves it. + await expect + .poll(() => + electronApp.evaluate(({ clipboard }) => + clipboard.readBuffer('FileNameW').toString('utf16le').replace(/\0+$/, '') + ) + ) + .toBe(path.join(source, FILE_NAME)) + + await shellPasteInto(pasteTarget) + await expect + .poll(() => readFile(path.join(pasteTarget, FILE_NAME), 'utf8').catch(() => null)) + .toBe('copied through the clipboard\n') + }) +})