fix(clipboard): preserve copy order across text, images and files (#35332)

* fix(clipboard): order text, image and file IPC copies

* fix(clipboard): time out stalled file path resolution in the shared copy queue

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
OrcaWin
2026-10-28 17:36:22 -07:00
committed by GitHub
co-authored by OrcaWin
parent 828fe5fea3
commit 6f41a55b8b
6 changed files with 334 additions and 63 deletions
+15 -1
View File
@@ -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> = {}): 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(
+8 -1
View File
@@ -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 }
}
+163 -2
View File
@@ -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<string, (...args: unknown[]) => unknown> {
return handlers
}
function registerMockClipboardHandlers(): Map<string, (...args: unknown[]) => unknown> {
// SAFETY: Path authorization is mocked; these clipboard handlers do not read Store state.
registerClipboardHandlers({} as never)
return getRegisteredHandlers()
}
function makeClipboardEvent(senderOverrides: Record<string, unknown> = {}): {
sender: Record<string, unknown>
} {
@@ -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<void>((resolve) => {
release = resolve
})
const entered = new Promise<void>((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<void>((resolve) => {
release = resolve
})
const entered = new Promise<void>((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()
+37 -24
View File
@@ -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)
})
})
}
@@ -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<ProcessResult> => 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<void>((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(
+65 -32
View File
@@ -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<void> {
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<T>(operation: () => T | Promise<T>): Promise<T> {
const next = pending.then(operation)
pending = next.then(
() => undefined,
() => undefined
)
return next
}
function publish<T>(retainedBytes: number, operation: () => T | Promise<T>): Promise<T> {
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<void> {
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<string> | undefined
@@ -116,23 +148,24 @@ function publisherPath(): string | Promise<string> {
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
})