diff --git a/src/main/window/clipboard-file-copy.test.ts b/src/main/window/clipboard-file-copy.test.ts index f1d8dfffbcd..0f00d04ccc9 100644 --- a/src/main/window/clipboard-file-copy.test.ts +++ b/src/main/window/clipboard-file-copy.test.ts @@ -1,6 +1,10 @@ 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' +import { + CLIPBOARD_FILE_RESOLUTION_TIMEOUT_MS, + writeFileToClipboard, + type ClipboardFileDeps +} from './clipboard-file-copy' function exited(code: number | null): ProcessResult { return { code, signal: null, stdout: '', stderr: '', timedOut: false } @@ -20,6 +24,7 @@ function makeDeps(overrides: Partial = {}): ClipboardFileDeps describe('writeFileToClipboard', () => { afterEach(() => { vi.unstubAllEnvs() + vi.useRealTimers() }) it('rejects relative paths without touching the clipboard', async () => { @@ -49,6 +54,15 @@ describe('writeFileToClipboard', () => { ).toEqual({ ok: false, reason: 'access-denied' }) }) + it('fails without publishing when path resolution never settles', async () => { + vi.useFakeTimers() + const deps = makeDeps({ resolveFilePath: () => new Promise(() => undefined) }) + const result = writeFileToClipboard('/mnt/stale/file.txt', deps) + await vi.advanceTimersByTimeAsync(CLIPBOARD_FILE_RESOLUTION_TIMEOUT_MS) + await expect(result).resolves.toEqual({ ok: false, reason: 'clipboard-write-failed' }) + expect(deps.writeBuffer).not.toHaveBeenCalled() + }) + it('writes a public.file-url buffer on macOS', async () => { const writeBuffer = vi.fn() const result = await writeFileToClipboard( diff --git a/src/main/window/clipboard-file-copy.ts b/src/main/window/clipboard-file-copy.ts index 80d226d389a..43886735ac2 100644 --- a/src/main/window/clipboard-file-copy.ts +++ b/src/main/window/clipboard-file-copy.ts @@ -2,6 +2,7 @@ import { isAbsolute } from 'node:path' import { pathToFileURL } from 'node:url' import type { ProcessResult, ProcessSpec } from '@orca/process-host/process-spec' import { windowsPowerShellPath } from '@orca/process-host/windows-system-binary' +import { withTimeout } from '../../shared/promise-timeout-fallback' export type ClipboardFileResult = { ok: boolean; reason?: string } @@ -18,6 +19,7 @@ export type ClipboardFileDeps = { } const WINDOWS_CLIPBOARD_FILE_ENV = 'ORCA_CLIPBOARD_FILE' +export const CLIPBOARD_FILE_RESOLUTION_TIMEOUT_MS = 5_000 // Publish local OS file references for file-manager paste; remote files are unavailable here. Return clipboard failures to the renderer. export async function writeFileToClipboard( @@ -27,7 +29,12 @@ export async function writeFileToClipboard( if (typeof filePath !== 'string' || !isAbsolute(filePath)) { return { ok: false, reason: 'invalid-path' } } - const resolvedFile = await deps.resolveFilePath(filePath) + // Copies share one ordered queue, so a hung mount must not block every later clipboard write. + const resolvedFile = await withTimeout( + deps.resolveFilePath(filePath), + CLIPBOARD_FILE_RESOLUTION_TIMEOUT_MS, + { ok: false, reason: 'clipboard-write-failed' } + ) if (!resolvedFile.ok) { return { ok: false, reason: resolvedFile.reason } } diff --git a/src/main/window/clipboard-ipc-handlers.test.ts b/src/main/window/clipboard-ipc-handlers.test.ts index 10736fe64ed..72c3b64bc2a 100644 --- a/src/main/window/clipboard-ipc-handlers.test.ts +++ b/src/main/window/clipboard-ipc-handlers.test.ts @@ -1,6 +1,7 @@ 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_TEXT_MEASURE_YIELD_CODE_UNITS } from '../../shared/clipboard-text' import { CLIPBOARD_IMAGE_MAX_BASE64_CHARS, CLIPBOARD_IMAGE_MAX_PIXELS, @@ -127,6 +128,12 @@ function getRegisteredHandlers(): Map unknown> { return handlers } +function registerMockClipboardHandlers(): Map unknown> { + // SAFETY: Path authorization is mocked; these clipboard handlers do not read Store state. + registerClipboardHandlers({} as never) + return getRegisteredHandlers() +} + function makeClipboardEvent(senderOverrides: Record = {}): { sender: Record } { @@ -249,6 +256,160 @@ describe('registerClipboardHandlers', () => { expect(clipboardWriteTextMock).toHaveBeenCalledWith('primary text', 'selection') }) + it.each(['clipboard:writeText', 'clipboard:writeTerminalText'])( + 'keeps a newer image after %s yields without blocking primary selection', + async (channel) => { + vi.useFakeTimers() + const publications: string[] = [] + let textValue = '' + clipboardReadTextMock.mockImplementation(() => textValue) + clipboardWriteTextMock.mockImplementation((text: string, selection?: string) => { + if (selection === 'selection') { + publications.push('selection') + } else { + textValue = text + publications.push('text') + } + }) + nativeImageCreateFromBufferMock.mockReturnValue({ + getSize: () => ({ width: 2, height: 2 }), + isEmpty: () => false + }) + clipboardWriteImageMock.mockImplementation(() => publications.push('image')) + const handlers = registerMockClipboardHandlers() + const large = 'x'.repeat(CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS * 2) + const text = handlers.get(channel)?.(makeClipboardEvent(), large) + const image = handlers.get('clipboard:writeImage')?.( + makeClipboardEvent(), + 'data:image/png;base64,AAAA' + ) + await handlers.get('clipboard:writeSelectionText')?.(makeClipboardEvent(), 'primary') + const whileTextPending = [...publications] + const decodedWhileTextPending = nativeImageCreateFromBufferMock.mock.calls.length + await vi.runAllTimersAsync() + await Promise.all([text, image]) + expect(whileTextPending).toEqual(['selection']) + expect(decodedWhileTextPending).toBe(0) + expect(publications).toEqual(['selection', 'text', 'image']) + } + ) + + it.each(['path resolution', 'helper publication'])( + 'keeps newer text after an older file stalls at %s', + async (stage) => { + vi.useFakeTimers() + vi.spyOn(process, 'platform', 'get').mockReturnValue('win32') + let release: () => void = () => undefined + let onEntered: () => void = () => undefined + const gate = new Promise((resolve) => { + release = resolve + }) + const entered = new Promise((resolve) => { + onEntered = resolve + }) + const publications: string[] = [] + if (stage === 'path resolution') { + resolveAuthorizedPathMock.mockImplementationOnce(async (path: string) => { + onEntered() + await gate + return path + }) + } + runProcessMock.mockImplementationOnce(async () => { + if (stage === 'helper publication') { + onEntered() + await gate + } + publications.push('file') + return { code: 0, signal: null, stdout: '', stderr: '', timedOut: false } + }) + clipboardWriteTextMock.mockImplementation(() => publications.push('text')) + const handlers = registerMockClipboardHandlers() + const file = handlers.get('clipboard:writeFile')?.( + makeClipboardEvent(), + '/tmp/copied-file.txt' + ) + await entered + const text = handlers.get('clipboard:writeText')?.(makeClipboardEvent(), 'newer') + await vi.advanceTimersByTimeAsync(1_000) + const whileFilePending = [...publications] + release() + await expect(file).resolves.toEqual({ ok: true }) + await text + expect(whileFilePending).toEqual([]) + expect(publications).toEqual(['file', 'text']) + } + ) + + it('keeps an older valid text copy when newer image and file requests are rejected', async () => { + vi.useFakeTimers() + nativeImageCreateFromBufferMock.mockReturnValue({ isEmpty: () => true }) + resolveAuthorizedPathMock.mockRejectedValueOnce( + new Error( + 'Access denied: path resolves outside allowed directories. If this blocks a legitimate workflow, please file a GitHub issue.' + ) + ) + const handlers = registerMockClipboardHandlers() + const large = 'x'.repeat(CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS * 2) + const text = handlers.get('clipboard:writeText')?.(makeClipboardEvent(), large) + const image = handlers.get('clipboard:writeImage')?.( + makeClipboardEvent(), + 'data:image/png;base64,AAAA' + ) + const file = handlers.get('clipboard:writeFile')?.(makeClipboardEvent(), '/tmp/denied.txt') + await vi.runAllTimersAsync() + await Promise.all([text, image]) + await expect(file).resolves.toEqual({ ok: false, reason: 'access-denied' }) + expect(clipboardWriteTextMock).toHaveBeenCalledWith(large) + expect(clipboardWriteImageMock).not.toHaveBeenCalled() + expect(runProcessMock).not.toHaveBeenCalled() + await handlers.get('clipboard:writeText')?.(makeClipboardEvent(), 'after rejection') + expect(clipboardWriteTextMock).toHaveBeenLastCalledWith('after rejection') + }) + + it('rejects a full image/file queue before decoding or resolving another payload', async () => { + vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + let release: () => void = () => undefined + let onEntered: () => void = () => undefined + const gate = new Promise((resolve) => { + release = resolve + }) + const entered = new Promise((resolve) => { + onEntered = resolve + }) + resolveAuthorizedPathMock.mockImplementationOnce(async (path: string) => { + onEntered() + await gate + return path + }) + nativeImageCreateFromBufferMock.mockReturnValue({ + getSize: () => ({ width: 2, height: 2 }), + isEmpty: () => false + }) + const handlers = registerMockClipboardHandlers() + const file = handlers.get('clipboard:writeFile')?.(makeClipboardEvent(), '/tmp/copied-file.txt') + await entered + const images = Array.from({ length: 31 }, () => + handlers.get('clipboard:writeImage')?.(makeClipboardEvent(), 'data:image/png;base64,AAAA') + ) + await expect( + handlers.get('clipboard:writeImage')?.(makeClipboardEvent(), 'data:image/png;base64,AAAA') + ).rejects.toThrow('Too many pending clipboard writes') + await expect( + handlers.get('clipboard:writeFile')?.(makeClipboardEvent(), '/tmp/overflow.txt') + ).resolves.toEqual({ ok: false, reason: 'clipboard-busy' }) + const decodedWhileBlocked = nativeImageCreateFromBufferMock.mock.calls.length + const resolvedWhileBlocked = resolveAuthorizedPathMock.mock.calls.length + release() + await expect(file).resolves.toEqual({ ok: true }) + await Promise.all(images) + expect(decodedWhileBlocked).toBe(0) + expect(resolvedWhileBlocked).toBe(1) + expect(clipboardWriteImageMock).toHaveBeenCalledTimes(31) + await handlers.get('clipboard:writeImage')?.(makeClipboardEvent(), 'data:image/png;base64,AAAA') + expect(clipboardWriteImageMock).toHaveBeenCalledTimes(32) + }) + it('rejects clipboard IPC from senders outside the current main renderer', async () => { setTrustedClipboardRendererWebContentsId(17) registerClipboardHandlers({} as never) @@ -694,7 +855,7 @@ describe('registerClipboardHandlers', () => { expect(clipboardWriteImageMock).not.toHaveBeenCalled() }) - it('ignores clipboard write images with oversized decoded dimensions', () => { + it('ignores clipboard write images with oversized decoded dimensions', async () => { nativeImageCreateFromBufferMock.mockReturnValue({ getSize: () => ({ height: 1, width: CLIPBOARD_IMAGE_MAX_PIXELS + 1 }), isEmpty: () => false @@ -703,7 +864,7 @@ describe('registerClipboardHandlers', () => { registerClipboardHandlers({} as never) const handlers = getRegisteredHandlers() - handlers.get('clipboard:writeImage')?.(makeClipboardEvent(), 'data:image/png;base64,AAAA') + await handlers.get('clipboard:writeImage')?.(makeClipboardEvent(), 'data:image/png;base64,AAAA') expect(nativeImageCreateFromBufferMock).toHaveBeenCalled() expect(clipboardWriteImageMock).not.toHaveBeenCalled() diff --git a/src/main/window/clipboard-ipc-handlers.ts b/src/main/window/clipboard-ipc-handlers.ts index 6fba50742f6..b94807dee67 100644 --- a/src/main/window/clipboard-ipc-handlers.ts +++ b/src/main/window/clipboard-ipc-handlers.ts @@ -46,7 +46,11 @@ import { readClipboardImageSource } from './clipboard-image-source' import { readClipboardCopiedFilePaths } from './clipboard-copied-file-paths' import { buildClipboardImageThumbnail } from './clipboard-image-thumbnail' import { readClipboardNativeImage } from './clipboard-native-image' -import { publishClipboardText } from './clipboard-text-publisher' +import { + ClipboardPublisherBusyError, + publishClipboardOperation, + publishClipboardText +} from './clipboard-text-publisher' import { isDashboardPopoutRenderer } from './dashboard-popout-window' import { restoreNativeChatPastes } from './native-chat-paste-files' import { savePastedTextAsFile, type SavePastedTextAsFileArgs } from './native-chat-pasted-text-file' @@ -213,12 +217,19 @@ export function registerClipboardHandlers( return { ok: false, reason: isENOENT(error) ? 'not-found' : 'invalid-path' } } } - return writeFileToClipboard(request.filePath, { - platform: process.platform, - desktop: process.env.XDG_CURRENT_DESKTOP, - resolveFilePath, - writeBuffer: (format, buffer) => clipboard.writeBuffer(format, buffer), - runProcess + return publishClipboardOperation(request.filePath.length * 2, () => + writeFileToClipboard(request.filePath, { + platform: process.platform, + desktop: process.env.XDG_CURRENT_DESKTOP, + resolveFilePath, + writeBuffer: (format, buffer) => clipboard.writeBuffer(format, buffer), + runProcess + }) + ).catch((error: unknown) => { + if (error instanceof ClipboardPublisherBusyError) { + return { ok: false, reason: 'clipboard-busy' } + } + throw error }) } ) @@ -257,23 +268,25 @@ export function registerClipboardHandlers( } catch { return } - // createFromDataURL silently fails on large images in some macOS/Electron versions; decode base64 then use createFromBuffer. - const buffer = Buffer.from(contentBase64, 'base64') - try { - assertClipboardImageByteLengthWithinLimit(buffer.byteLength) - } catch { - return - } - const image = nativeImage.createFromBuffer(buffer) - if (image.isEmpty()) { - return - } - try { - assertClipboardImageDimensionsWithinLimit(image.getSize()) - } catch { - return - } - clipboard.writeImage(image) + return publishClipboardOperation(contentBase64.length * 2, () => { + // createFromDataURL silently fails on large images in some macOS/Electron versions; decode base64 then use createFromBuffer. + const buffer = Buffer.from(contentBase64, 'base64') + try { + assertClipboardImageByteLengthWithinLimit(buffer.byteLength) + } catch { + return + } + const image = nativeImage.createFromBuffer(buffer) + if (image.isEmpty()) { + return + } + try { + assertClipboardImageDimensionsWithinLimit(image.getSize()) + } catch { + return + } + clipboard.writeImage(image) + }) }) } diff --git a/src/main/window/clipboard-text-publisher.test.ts b/src/main/window/clipboard-text-publisher.test.ts index e9ce5a06d05..f17b106cb2f 100644 --- a/src/main/window/clipboard-text-publisher.test.ts +++ b/src/main/window/clipboard-text-publisher.test.ts @@ -5,7 +5,8 @@ import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS, CLIPBOARD_TEXT_WRITE_MAX_BYTES } from '../../shared/clipboard-text' -import { createClipboardTextPublisher } from './clipboard-text-publisher' +import { CLIPBOARD_IMAGE_MAX_BASE64_CHARS } from '../../shared/clipboard-image' +import { createClipboardPublisher } from './clipboard-text-publisher' vi.mock('electron', () => ({ app: {}, clipboard: {} })) @@ -13,8 +14,8 @@ const ok: ProcessResult = { code: 0, signal: null, stdout: '', stderr: '', timed const run = vi.fn(async (_spec: ProcessSpec): Promise => ok) const write = vi.fn() const clearCache = vi.fn() -function writer(wayland = true, validate = assertClipboardTextWriteWithinLimitWithYield) { - return createClipboardTextPublisher({ +function publisher(wayland = true, validate = assertClipboardTextWriteWithinLimitWithYield) { + return createClipboardPublisher({ isWayland: () => wayland, helperPath: () => '/bundle/bin/orca-wayland-clipboard', run, @@ -23,6 +24,9 @@ function writer(wayland = true, validate = assertClipboardTextWriteWithinLimitWi validate }) } +function writer(wayland = true, validate = assertClipboardTextWriteWithinLimitWithYield) { + return publisher(wayland, validate).publishText +} describe('terminal clipboard writes', () => { beforeEach(() => { @@ -182,6 +186,45 @@ describe('terminal clipboard writes', () => { expect(clearCache).toHaveBeenCalledTimes(1) }) + it.each([ + { label: 'count', count: 32, bytes: 1 }, + { label: 'retained bytes', count: 1, bytes: CLIPBOARD_IMAGE_MAX_BASE64_CHARS * 2 } + ])( + 'bounds other clipboard operations by $label and releases capacity after failure', + async ({ count, bytes }) => { + const copy = publisher(false) + let release: () => void = () => undefined + const gate = new Promise((resolve) => { + release = resolve + }) + const publications: number[] = [] + const first = copy.publish(bytes, async () => { + await gate + throw new Error('file helper failed') + }) + const rejected = expect(first).rejects.toThrow('file helper failed') + const queued = Array.from({ length: count - 1 }, (_, index) => + copy.publish(bytes, () => { + publications.push(index) + return index + }) + ) + await expect(copy.publish(bytes, () => publications.push(-1))).rejects.toThrow( + 'Too many pending clipboard writes' + ) + expect(publications).toEqual([]) + const text = copy.publishText('after failed file', 'ordinary') + release() + await rejected + await Promise.all(queued) + await text + expect(publications).toHaveLength(count - 1) + expect(publications).not.toContain(-1) + expect(write).toHaveBeenLastCalledWith('after failed file', 'ordinary') + await expect(copy.publish(bytes, () => 'after flood')).resolves.toBe('after flood') + } + ) + it('bounds requests queued behind a stalled helper', async () => { let release: (value: typeof ok) => void = () => undefined run.mockImplementationOnce( diff --git a/src/main/window/clipboard-text-publisher.ts b/src/main/window/clipboard-text-publisher.ts index 99ccafbae96..9b0c7edaa4a 100644 --- a/src/main/window/clipboard-text-publisher.ts +++ b/src/main/window/clipboard-text-publisher.ts @@ -6,6 +6,7 @@ import { CLIPBOARD_TEXT_WRITE_MAX_BYTES, CLIPBOARD_TEXT_WRITE_TOO_LARGE_ERROR } from '../../shared/clipboard-text' +import { CLIPBOARD_IMAGE_MAX_BASE64_CHARS } from '../../shared/clipboard-image' import { isLinuxWaylandSession } from '../../shared/linux-wayland-session' import { writeClipboardTextAndVerify } from './clipboard-text-write-verify' import { stageClipboardPublisher } from './clipboard-publisher-executable' @@ -14,6 +15,13 @@ import { isLegacyAppImageRuntimeIdentity } from '../linux-update-package-type' const WAYLAND_CLIPBOARD_WRITE_FAILED_ERROR = 'Wayland clipboard write failed' const MAX_PENDING_CLIPBOARD_WRITES = 32 +const MAX_PENDING_CLIPBOARD_OPERATION_BYTES = CLIPBOARD_IMAGE_MAX_BASE64_CHARS * 2 + +export class ClipboardPublisherBusyError extends Error { + constructor() { + super('Too many pending clipboard writes') + } +} export type ClipboardTextWriteKind = 'terminal' | 'ordinary' @@ -27,13 +35,39 @@ type ClipboardTextPublisherDeps = { } // Workaround for https://github.com/electron/electron/issues/54645; removal checks are in the native README. -export function createClipboardTextPublisher( - deps: ClipboardTextPublisherDeps -): (text: unknown, kind?: ClipboardTextWriteKind) => Promise { +export function createClipboardPublisher(deps: ClipboardTextPublisherDeps) { let pending = Promise.resolve() let count = 0 let queuedCodeUnits = 0 - return (text, kind = 'terminal') => { + let operationCount = 0 + let operationBytes = 0 + function enqueue(operation: () => T | Promise): Promise { + const next = pending.then(operation) + pending = next.then( + () => undefined, + () => undefined + ) + return next + } + function publish(retainedBytes: number, operation: () => T | Promise): Promise { + if ( + operationCount >= MAX_PENDING_CLIPBOARD_WRITES || + retainedBytes > MAX_PENDING_CLIPBOARD_OPERATION_BYTES - operationBytes + ) { + return Promise.reject(new ClipboardPublisherBusyError()) + } + operationCount += 1 + operationBytes += retainedBytes + return enqueue(async () => { + try { + return await operation() + } finally { + operationCount -= 1 + operationBytes -= retainedBytes + } + }) + } + function publishText(text: unknown, kind: ClipboardTextWriteKind = 'terminal'): Promise { if (typeof text !== 'string') { return Promise.reject(new Error('Clipboard text must be a string')) } @@ -51,9 +85,8 @@ export function createClipboardTextPublisher( count += 1 queuedCodeUnits += text.length } - // Validate inside the queue: large copies yield and must not overwrite a later request. - const next = pending - .then(async () => { + return enqueue(async () => { + try { const safeText = await deps.validate(text) if (!wayland) { deps.write(safeText, kind) @@ -70,16 +103,15 @@ export function createClipboardTextPublisher( } // Discard any rejected Chromium source after the helper has established the actual owner. deps.clearCache() - }) - .finally(() => { + } finally { if (wayland) { count -= 1 queuedCodeUnits -= text.length } - }) - pending = next.catch(() => undefined) - return next + } + }) } + return { publish, publishText } } let stagedPublisher: Promise | undefined @@ -116,23 +148,24 @@ function publisherPath(): string | Promise { return stagedPublisher } -export const publishClipboardText = createClipboardTextPublisher({ - isWayland: () => - isLinuxWaylandSession({ - platform: process.platform, - env: process.env, - ozonePlatform: - process.platform === 'linux' ? app.commandLine.getSwitchValue('ozone-platform') : '' - }), - helperPath: publisherPath, - run: (spec) => runProcess({ ...spec, env: envWithoutAppImageRuntime(process.env) }), - clearCache: () => clipboard.clear(), - write: (text, kind) => { - if (kind === 'terminal') { - writeClipboardTextAndVerify(text) - } else { - clipboard.writeText(text) - } - }, - validate: assertClipboardTextWriteWithinLimitWithYield -}) +export const { publish: publishClipboardOperation, publishText: publishClipboardText } = + createClipboardPublisher({ + isWayland: () => + isLinuxWaylandSession({ + platform: process.platform, + env: process.env, + ozonePlatform: + process.platform === 'linux' ? app.commandLine.getSwitchValue('ozone-platform') : '' + }), + helperPath: publisherPath, + run: (spec) => runProcess({ ...spec, env: envWithoutAppImageRuntime(process.env) }), + clearCache: () => clipboard.clear(), + write: (text, kind) => { + if (kind === 'terminal') { + writeClipboardTextAndVerify(text) + } else { + clipboard.writeText(text) + } + }, + validate: assertClipboardTextWriteWithinLimitWithYield + })