mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(clipboard): paste CleanShot JPEG screenshots stored under the macOS PNG type (#29685)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<typeof FsPromises>()
|
||||
@@ -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' })
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user