diff --git a/src/main/window/clipboard-ipc-handlers.ts b/src/main/window/clipboard-ipc-handlers.ts index fa035c1210a..08347ff7fe2 100644 --- a/src/main/window/clipboard-ipc-handlers.ts +++ b/src/main/window/clipboard-ipc-handlers.ts @@ -43,6 +43,7 @@ import { readWindowsClipboardImageFileAsPng } from './clipboard-windows-image-fi 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 { isDashboardPopoutRenderer } from './dashboard-popout-window' import { restoreNativeChatPastes, sweepExpiredNativeChatPastes } from './native-chat-paste-files' @@ -124,7 +125,12 @@ export function registerClipboardHandlers(store: Store): void { // the clipboard in memory before the (slower) save lands. ipcMain.handle('clipboard:readImageThumbnail', (event): ClipboardImageThumbnail | null => { assertTrustedClipboardSender(event) - return buildClipboardImageThumbnail(clipboard.readImage()) + try { + return buildClipboardImageThumbnail(readClipboardNativeImage()) + } catch { + // The save call reports rejected source data; the preview is best effort. + return null + } }) ipcMain.handle('clipboard:hasImage', (event): boolean => { assertTrustedClipboardSender(event) @@ -146,7 +152,7 @@ export function registerClipboardHandlers(store: Store): void { if (!source) { return null } - const image = clipboard.readImage() + const image = readClipboardNativeImage() if (image.isEmpty()) { if (!source.windowsFileFormats) { return null diff --git a/src/main/window/clipboard-macos-image-paste.test.ts b/src/main/window/clipboard-macos-image-paste.test.ts index 660d264030b..40d193571e2 100644 --- a/src/main/window/clipboard-macos-image-paste.test.ts +++ b/src/main/window/clipboard-macos-image-paste.test.ts @@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => ({ has: vi.fn(), readImage: vi.fn(), readBuffer: vi.fn(), + createFromBuffer: vi.fn(), toPNG: vi.fn(), writeFile: vi.fn() })) @@ -28,7 +29,7 @@ vi.mock('electron', () => ({ handle: (channel: string, handler: (...args: unknown[]) => unknown) => mocks.handlers.set(channel, handler) }, - nativeImage: { createFromBuffer: vi.fn() } + nativeImage: { createFromBuffer: mocks.createFromBuffer } })) vi.mock('node:fs/promises', async (importOriginal) => { const fs = await importOriginal() @@ -70,8 +71,15 @@ function invoke(channel: string, args?: unknown): unknown { return handler(event, args) } -function setImage(size = { width: 32, height: 24 }, empty = false): void { - mocks.readImage.mockReturnValue({ isEmpty: () => empty, getSize: () => size, toPNG: mocks.toPNG }) +function setImage(size = { width: 32, height: 24 }, empty = false) { + const image = { + isEmpty: () => empty, + getSize: () => size, + toPNG: mocks.toPNG, + toDataURL: () => 'data:image/png;base64,PREVIEW' + } + mocks.readImage.mockReturnValue(image) + return image } beforeEach(() => { @@ -80,6 +88,8 @@ beforeEach(() => { vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') installFakeAppEnvironment({ getPath: () => '/tmp' }) mocks.has.mockImplementation((type: string) => type === 'public.png') + mocks.readBuffer.mockReturnValue(Buffer.alloc(0)) + mocks.createFromBuffer.mockReturnValue({ isEmpty: () => true }) mocks.toPNG.mockReturnValue(PNG) mocks.writeFile.mockResolvedValue(undefined) setImage() @@ -117,20 +127,92 @@ describe('macOS clipboard image data beside a file URL', () => { it('does not save malformed native image data or read its accompanying file', async () => { setImage({ width: 0, height: 0 }, true) + mocks.readBuffer.mockReturnValue(Buffer.from('malformed image')) await expect(invoke('clipboard:saveImageAsTempFile')).resolves.toBeNull() + expect(invoke('clipboard:readImageThumbnail')).toBeNull() expect(mocks.toPNG).not.toHaveBeenCalled() - expect(mocks.readBuffer).not.toHaveBeenCalled() expect(mocks.writeFile).not.toHaveBeenCalled() }) + it('saves and previews JPEG bytes stored under the native PNG type', async () => { + const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0]) + setImage({ width: 0, height: 0 }, true) + mocks.readBuffer.mockImplementation((type: string) => + type === 'public.png' ? jpeg : Buffer.alloc(0) + ) + mocks.createFromBuffer.mockReturnValue({ + isEmpty: () => false, + getSize: () => ({ width: 32, height: 24 }), + toPNG: mocks.toPNG, + toDataURL: () => 'data:image/png;base64,PREVIEW' + }) + + expect(invoke('clipboard:hasImage')).toBe(true) + const savedPath = await invoke('clipboard:saveImageAsTempFile') + expect(savedPath).toMatch(/orca-paste-.+\.png$/) + expect(mocks.writeFile).toHaveBeenCalledWith(savedPath, PNG) + expect(invoke('clipboard:readImageThumbnail')).toEqual({ + dataUrl: 'data:image/png;base64,PREVIEW', + width: 32, + height: 24 + }) + expect(mocks.createFromBuffer).toHaveBeenCalledWith(jpeg) + }) + + it('rejects oversized raw image bytes before decoding and omits their preview', async () => { + setImage({ width: 0, height: 0 }, true) + mocks.readBuffer.mockReturnValue(Buffer.alloc(CLIPBOARD_IMAGE_MAX_SOURCE_BYTES + 1)) + + await expect(invoke('clipboard:saveImageAsTempFile')).rejects.toThrow( + 'Clipboard image is too large' + ) + expect(invoke('clipboard:readImageThumbnail')).toBeNull() + expect(mocks.createFromBuffer).not.toHaveBeenCalled() + expect(mocks.writeFile).not.toHaveBeenCalled() + }) + + it('rejects oversized declared JPEG dimensions before decoding and omits their preview', async () => { + const image = setImage() + setImage({ width: 0, height: 0 }, true) + // A small JPEG header can declare 6000 × 6000 pixels, above the clipboard limit. + mocks.readBuffer.mockReturnValue( + Buffer.from('ffd8ffc00011081770177003011100021100031100ffd9', 'hex') + ) + mocks.createFromBuffer.mockReturnValue(image) + + await expect(invoke('clipboard:saveImageAsTempFile')).rejects.toThrow( + 'Clipboard image is too large' + ) + expect(invoke('clipboard:readImageThumbnail')).toBeNull() + expect(mocks.createFromBuffer).not.toHaveBeenCalled() + expect(mocks.writeFile).not.toHaveBeenCalled() + }) + + it('checks the renderer before attempting raw image recovery', async () => { + setTrustedClipboardRendererWebContentsId(42) + await expect(invoke('clipboard:saveImageAsTempFile')).rejects.toThrow( + 'Unauthorized clipboard IPC sender' + ) + expect(() => invoke('clipboard:readImageThumbnail')).toThrow( + 'Unauthorized clipboard IPC sender' + ) + expect(mocks.readImage).not.toHaveBeenCalled() + expect(mocks.readBuffer).not.toHaveBeenCalled() + }) + it('checks the renderer before probing native image types', () => { setTrustedClipboardRendererWebContentsId(42) expect(() => invoke('clipboard:hasImage')).toThrow('Unauthorized clipboard IPC sender') expect(mocks.has).not.toHaveBeenCalled() }) - it('rejects oversized dimensions before encoding or writing a PNG', async () => { - setImage({ width: CLIPBOARD_IMAGE_MAX_PIXELS + 1, height: 1 }) + it.each([false, true])('rejects oversized dimensions with raw recovery: %s', async (recover) => { + const image = setImage({ width: CLIPBOARD_IMAGE_MAX_PIXELS + 1, height: 1 }) + if (recover) { + setImage({ width: 0, height: 0 }, true) + mocks.readBuffer.mockReturnValue(Buffer.from([0xff, 0xd8])) + mocks.createFromBuffer.mockReturnValue(image) + } await expect(invoke('clipboard:saveImageAsTempFile')).rejects.toThrow( 'Clipboard image is too large' ) @@ -138,7 +220,13 @@ describe('macOS clipboard image data beside a file URL', () => { expect(mocks.writeFile).not.toHaveBeenCalled() }) - it('rejects oversized PNG bytes before refusing an SSH target', async () => { + it.each([false, true])('rejects oversized PNG output with raw recovery: %s', async (recover) => { + if (recover) { + const image = setImage() + setImage({ width: 0, height: 0 }, true) + mocks.readBuffer.mockReturnValue(Buffer.from([0xff, 0xd8])) + mocks.createFromBuffer.mockReturnValue(image) + } mocks.toPNG.mockReturnValue(Buffer.alloc(CLIPBOARD_IMAGE_MAX_SOURCE_BYTES + 1)) await expect( invoke('clipboard:saveImageAsTempFile', { connectionId: 'ssh-1' }) diff --git a/src/main/window/clipboard-native-image.ts b/src/main/window/clipboard-native-image.ts new file mode 100644 index 00000000000..47570035afc --- /dev/null +++ b/src/main/window/clipboard-native-image.ts @@ -0,0 +1,24 @@ +import { clipboard, nativeImage, type NativeImage } from 'electron' +import { + assertClipboardImageByteLengthWithinLimit, + assertClipboardImageDimensionsWithinLimit +} from '../../shared/clipboard-image' +import { readRasterImageDimensions } from '../../shared/raster-image-dimensions' + +export function readClipboardNativeImage(): NativeImage { + const image = clipboard.readImage() + if (!image.isEmpty() || process.platform !== 'darwin') { + return image + } + // CleanShot can store JPEG bytes as public.png, which readImage decodes as PNG only. + const buffer = clipboard.readBuffer('public.png') + if (buffer.length === 0) { + return image + } + assertClipboardImageByteLengthWithinLimit(buffer.length) + const dimensions = readRasterImageDimensions(buffer) + if (dimensions) { + assertClipboardImageDimensionsWithinLimit(dimensions) + } + return nativeImage.createFromBuffer(buffer) +} diff --git a/src/shared/raster-image-dimensions.test.ts b/src/shared/raster-image-dimensions.test.ts index d6e5e3ad3b5..31bf2644852 100644 --- a/src/shared/raster-image-dimensions.test.ts +++ b/src/shared/raster-image-dimensions.test.ts @@ -105,4 +105,18 @@ describe('readRasterImageDimensions', () => { height: 480 }) }) + + it.each(['c00011081770177003011100021100031100', 'c0001108000c001003011100021100031100'])( + 'reads the real JPEG frame after stray bytes: %s', + (strayHex) => { + const jpeg = jpegWithMetadata(0, 640, 480) + const prefixed = Buffer.concat([ + jpeg.subarray(0, 2), + Buffer.from(strayHex, 'hex'), + jpeg.subarray(2) + ]) + + expect(readRasterImageDimensions(prefixed)).toEqual({ width: 640, height: 480 }) + } + ) }) diff --git a/src/shared/raster-image-dimensions.ts b/src/shared/raster-image-dimensions.ts index 45ccd01dc2d..8b69dd4d76a 100644 --- a/src/shared/raster-image-dimensions.ts +++ b/src/shared/raster-image-dimensions.ts @@ -102,6 +102,9 @@ function readJpegDimensions(bytes: Uint8Array): RasterImageDimensions | null { let offset = 2 const scanEnd = bytes.byteLength while (offset < scanEnd) { + while (offset < scanEnd && bytes[offset] !== 0xff) { + offset += 1 + } while (offset < scanEnd && bytes[offset] === 0xff) { offset += 1 } diff --git a/tests/e2e/clipboard-macos-image-paste.spec.ts b/tests/e2e/clipboard-macos-image-paste.spec.ts new file mode 100644 index 00000000000..5015c67005f --- /dev/null +++ b/tests/e2e/clipboard-macos-image-paste.spec.ts @@ -0,0 +1,59 @@ +import { readFile, rm } from 'node:fs/promises' +import { test, expect } from './helpers/orca-app' +import { waitForSessionReady } from './helpers/store' + +test('macOS JPEG clipboard bytes produce a PNG and a matching preview', async ({ + electronApp, + orcaPage +}) => { + test.skip(process.platform !== 'darwin', 'macOS native clipboard contract') + await waitForSessionReady(orcaPage) + // Reproduce the measured CleanShot API results without changing the user's clipboard. + await electronApp.evaluate(({ clipboard, nativeImage }) => { + const image = nativeImage.createFromBitmap(Buffer.alloc(16 * 12 * 4, 200), { + width: 16, + height: 12 + }) + const jpeg = image.toJPEG(80) + clipboard.availableFormats = () => ['text/uri-list'] + clipboard.has = (format) => format === 'public.png' + clipboard.readImage = () => nativeImage.createEmpty() + clipboard.readBuffer = (format) => (format === 'public.png' ? jpeg : Buffer.alloc(0)) + }) + + let savedPath: string | null = null + try { + expect(await orcaPage.evaluate(() => window.api.ui.clipboardHasImage())).toBe(true) + savedPath = await orcaPage.evaluate(() => window.api.ui.saveClipboardImageAsTempFile()) + expect(savedPath).toMatch(/orca-paste-.+\.png$/) + if (!savedPath) { + throw new Error('Clipboard image was not saved') + } + const png = await readFile(savedPath) + expect(png.subarray(0, 8).toString('hex')).toBe('89504e470d0a1a0a') + expect([png.readUInt32BE(16), png.readUInt32BE(20)]).toEqual([16, 12]) + + const thumbnail = await orcaPage.evaluate(() => window.api.ui.readClipboardImageThumbnail()) + expect(thumbnail).toMatchObject({ width: 16, height: 12 }) + expect(thumbnail?.dataUrl).toBe(`data:image/png;base64,${png.toString('base64')}`) + + await electronApp.evaluate(({ clipboard }) => { + clipboard.readBuffer = () => Buffer.from('malformed image') + }) + expect(await orcaPage.evaluate(() => window.api.ui.saveClipboardImageAsTempFile())).toBeNull() + expect(await orcaPage.evaluate(() => window.api.ui.readClipboardImageThumbnail())).toBeNull() + + // A copied image file alone remains outside the clipboard-image save path. + await electronApp.evaluate(({ clipboard, nativeImage }) => { + clipboard.has = () => false + clipboard.readImage = () => + nativeImage.createFromBitmap(Buffer.alloc(7 * 5 * 4, 200), { width: 7, height: 5 }) + }) + expect(await orcaPage.evaluate(() => window.api.ui.clipboardHasImage())).toBe(false) + expect(await orcaPage.evaluate(() => window.api.ui.saveClipboardImageAsTempFile())).toBeNull() + } finally { + if (savedPath) { + await rm(savedPath, { force: true }) + } + } +})