fix(clipboard): paste CleanShot JPEG screenshots stored under the macOS PNG type (#29685)

This commit is contained in:
Kelvin Amoaba
2026-10-16 21:44:15 +00:00
committed by GitHub
parent 74a90ebea7
commit fe41a9a5e8
6 changed files with 203 additions and 9 deletions
+8 -2
View File
@@ -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' })
+24
View File
@@ -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 })
}
)
})
+3
View File
@@ -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 })
}
}
})