mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(windows): copy a file to the clipboard without a visible PowerShell window (#28781)
* Run clipboard file copy and TLS openssl through process-host * refactor(clipboard): inject runProcess, pass the Windows path as data, add an e2e - clipboard-file-copy builds the process spec itself, so the stdio policy sits next to the wl-copy/xclip knowledge it exists for; the IPC handler passes process-host's runProcess straight through and keeps no wrapper. - A helper's exit is read as data (clean exit or not) instead of being turned into a throw and caught again. - Windows runs a constant Set-Clipboard script and reads the path from the environment, so no path is ever parsed as PowerShell. - Drop the dead tls-certificate rewrite; nothing in the app imports it. - e2e: right-click Copy on Windows, then a real shell paste of the file. * ci(e2e): run the Windows explorer file-copy spec on the Windows runner Route it from the clipboard sources, keep it out of the Linux lanes, and run it in the existing Windows e2e job beside the other Windows-only specs. * refactor(clipboard): tighten the helper seam, tests and e2e after review - runClipboardHelper takes runProcess and a plain ProcessSpec. - One handler test pins the process-host wiring on every host; spec details stay in the unit tests, which now also pin the inherited environment. - e2e: native realpath so an 8.3 runner temp path still matches, paste via process-host, and no toast check that could never fail. - Route the renderer caller to the e2e too. --------- Co-authored-by: Orca Worker <orca-worker@localhost>
This commit is contained in:
co-authored by
Orca Worker
parent
76735edf31
commit
88bd5db42e
@@ -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 <userData>\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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}')`)
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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> = {}): 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<ProcessResult> => 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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<void>
|
||||
runProcess: (spec: ProcessSpec) => Promise<ProcessResult>
|
||||
}
|
||||
|
||||
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<boolean> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<typeof ProcessHost.runProcess>(),
|
||||
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<typeof ProcessHost>()),
|
||||
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 () => {
|
||||
|
||||
@@ -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<void> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user