mirror of
https://github.com/stablyai/orca.git
synced 2026-11-01 17:47:40 +00:00
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:
@@ -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(
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user