mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
feat: copy a file from the explorer to the OS clipboard (#5990)
* feat: copy a file from the explorer to the OS clipboard Add a "Copy" action to the file explorer context menu that puts the actual file on the system clipboard, so pasting in Finder/Explorer/a file manager drops the file itself instead of its path as text. - macOS: write a public.file-url buffer; Finder synthesizes the legacy file types it needs for paste. - Windows: Set-Clipboard -LiteralPath populates the CF_HDROP file drop list that Explorer pastes as a file. - Linux: best-effort, picked by desktop — text/uri-list on KDE, x-special/gnome-copied-files on GNOME-family — via wl-copy or xclip. - Local files only; the action is hidden for remote/SSH files and the web client, where no OS clipboard reference is possible. The platform logic never throws: failures resolve to a structured result and the renderer surfaces an error toast. * Review copy-file clipboard safety Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com> Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
co-authored by
Orca
Jinwoo-H
parent
41add74a08
commit
b98fd2ff8b
@@ -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)
|
||||
|
||||
@@ -164,7 +164,7 @@ export function registerCoreHandlers(
|
||||
registerAiVaultHandlers({
|
||||
getAdditionalCodexHomePaths: lifecycleOptions.getAdditionalAiVaultCodexHomePaths
|
||||
})
|
||||
registerClipboardHandlers()
|
||||
registerClipboardHandlers(store)
|
||||
registerUpdaterHandlers(store)
|
||||
registerSpeechHandlers(store)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { writeFileToClipboard, type ClipboardFileDeps } from './clipboard-file-copy'
|
||||
|
||||
function makeDeps(overrides: Partial<ClipboardFileDeps> = {}): 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')
|
||||
})
|
||||
})
|
||||
@@ -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<void>
|
||||
}
|
||||
|
||||
// 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<ClipboardFileResult> {
|
||||
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' }
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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<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')
|
||||
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))
|
||||
|
||||
@@ -2479,6 +2479,7 @@ export type PreloadApi = {
|
||||
writeSelectionClipboardText: (text: string) => Promise<void>
|
||||
writeClipboardImage: (dataUrl: string) => Promise<void>
|
||||
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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<void> {
|
||||
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 (
|
||||
<ContextMenu
|
||||
@@ -592,6 +621,12 @@ export function FileExplorerRow({
|
||||
{translate('auto.components.right.sidebar.FileExplorerRow.f61af83316', 'New Folder')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
{showCopyFileAction && (
|
||||
<ContextMenuItem onSelect={handleCopyFile}>
|
||||
<Copy />
|
||||
{translate('auto.components.right.sidebar.FileExplorerRow.98a79948b3', 'Copy')}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuItem onSelect={() => onCopyPaths('absolute')}>
|
||||
<Copy />
|
||||
{selectionSize > 1
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -8349,6 +8349,8 @@
|
||||
"66a29dde82": "相対パスをコピー",
|
||||
"42e10cbf57": "相対パスのコピー",
|
||||
"b5d436aa30": "パスのコピー",
|
||||
"98a79948b3": "コピー",
|
||||
"b234ab25b4": "ファイルをクリップボードにコピーできませんでした",
|
||||
"f9d7ca753d": "パスのコピー",
|
||||
"3161c4e425": "フォルダ"
|
||||
},
|
||||
|
||||
@@ -8349,6 +8349,8 @@
|
||||
"66a29dde82": "상대 경로 복사",
|
||||
"42e10cbf57": "상대 경로 복사",
|
||||
"b5d436aa30": "경로 복사",
|
||||
"98a79948b3": "복사",
|
||||
"b234ab25b4": "파일을 클립보드에 복사하지 못했습니다",
|
||||
"f9d7ca753d": "경로 복사",
|
||||
"3161c4e425": "폴더"
|
||||
},
|
||||
|
||||
@@ -8349,6 +8349,8 @@
|
||||
"66a29dde82": "复制相对路径",
|
||||
"42e10cbf57": "复制相对路径",
|
||||
"b5d436aa30": "复制路径",
|
||||
"98a79948b3": "复制",
|
||||
"b234ab25b4": "无法将文件复制到剪贴板",
|
||||
"f9d7ca753d": "复制路径",
|
||||
"3161c4e425": "文件夹"
|
||||
},
|
||||
|
||||
@@ -2041,6 +2041,7 @@ function createWebUiApi(): NonNullable<Partial<PreloadApi>['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')
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user