fix(clipboard): use .NET Clipboard.GetImage() for Windows raw bitmap paste (#1855)

* fix(clipboard): use .NET Clipboard.GetImage() for Windows raw bitmap paste

Fixes #1844

Replace Get-Clipboard -Format Image with System.Windows.Forms
Clipboard.GetImage() / ContainsImage() on Windows. The old PowerShell
cmdlet does not properly convert raw DIB/CF_BITMAP data placed on the
clipboard by PrintScreen and Win+Shift+S (Snipping Tool). The .NET API
natively handles all clipboard image formats.

Also enables hasImageInClipboard() on Windows so the 'Image in
clipboard' hint notification fires when the terminal regains focus.

Changes:
- Extract WIN32_CLIPBOARD_HAS_IMAGE_CMD shared constant
- Update getClipboardCommands() win32 checkImage and saveImage
- Add Windows path to hasImageInClipboard()
- Add clarifying comment on checkImage exit code behavior
- Add unit tests for exported imagePaste functions

* test(clipboard): make Windows path assertion platform-aware

* fix(clipboard): address Windows image paste review findings

* test(clipboard): cover Windows image paste success path

* test(clipboard): expect Windows image dimensions

* test(clipboard): allow optional image dimensions

* test(clipboard): assert mocked image dimensions
This commit is contained in:
JATMN
2026-07-05 13:29:33 +08:00
committed by GitHub
parent 68fcb91930
commit 2ac20c759b
3 changed files with 355 additions and 4 deletions
+115
View File
@@ -0,0 +1,115 @@
import { describe, expect, test } from 'bun:test'
import {
buildLinuxClipboardCheckCommand,
buildLinuxClipboardSaveCommand,
IMAGE_EXTENSION_REGEX,
isImageFilePath,
asImageFilePath,
LINUX_CLIPBOARD_IMAGE_MIME_TYPES,
PASTE_THRESHOLD,
} from './imagePaste.js'
describe('LINUX_CLIPBOARD_IMAGE_MIME_TYPES', () => {
test('includes standard image MIME types', () => {
expect(LINUX_CLIPBOARD_IMAGE_MIME_TYPES).toContain('image/png')
expect(LINUX_CLIPBOARD_IMAGE_MIME_TYPES).toContain('image/jpeg')
expect(LINUX_CLIPBOARD_IMAGE_MIME_TYPES).toContain('image/gif')
expect(LINUX_CLIPBOARD_IMAGE_MIME_TYPES).toContain('image/webp')
expect(LINUX_CLIPBOARD_IMAGE_MIME_TYPES).toContain('image/bmp')
})
})
describe('buildLinuxClipboardCheckCommand', () => {
test('includes xclip and wl-paste', () => {
const cmd = buildLinuxClipboardCheckCommand()
expect(cmd).toContain('xclip')
expect(cmd).toContain('wl-paste')
})
test('includes all MIME types from LINUX_CLIPBOARD_IMAGE_MIME_TYPES', () => {
const cmd = buildLinuxClipboardCheckCommand()
for (const mimeType of LINUX_CLIPBOARD_IMAGE_MIME_TYPES) {
// MIME types are escaped with \/ for grep
expect(cmd).toContain(mimeType.replace('/', '\\/'))
}
})
})
describe('buildLinuxClipboardSaveCommand', () => {
test('includes xclip and wl-paste with screenshot path', () => {
const cmd = buildLinuxClipboardSaveCommand('/tmp/test.png')
expect(cmd).toContain('xclip')
expect(cmd).toContain('wl-paste')
expect(cmd).toContain('/tmp/test.png')
})
})
describe('IMAGE_EXTENSION_REGEX', () => {
test('matches image extensions case-insensitively', () => {
expect(IMAGE_EXTENSION_REGEX.test('photo.png')).toBe(true)
expect(IMAGE_EXTENSION_REGEX.test('photo.PNG')).toBe(true)
expect(IMAGE_EXTENSION_REGEX.test('photo.jpg')).toBe(true)
expect(IMAGE_EXTENSION_REGEX.test('photo.jpeg')).toBe(true)
expect(IMAGE_EXTENSION_REGEX.test('photo.JPEG')).toBe(true)
expect(IMAGE_EXTENSION_REGEX.test('photo.gif')).toBe(true)
expect(IMAGE_EXTENSION_REGEX.test('photo.webp')).toBe(true)
})
test('does not match non-image extensions', () => {
expect(IMAGE_EXTENSION_REGEX.test('file.txt')).toBe(false)
expect(IMAGE_EXTENSION_REGEX.test('file.bmp')).toBe(false)
expect(IMAGE_EXTENSION_REGEX.test('file.tiff')).toBe(false)
})
})
describe('PASTE_THRESHOLD', () => {
test('is a positive number', () => {
expect(PASTE_THRESHOLD).toBeGreaterThan(0)
})
})
describe('isImageFilePath', () => {
test('returns true for image file paths', () => {
expect(isImageFilePath('/Users/foo/photo.png')).toBe(true)
expect(isImageFilePath('C:\\Users\\foo\\photo.jpg')).toBe(true)
expect(isImageFilePath('photo.jpeg')).toBe(true)
expect(isImageFilePath('image.GIF')).toBe(true)
})
test('returns false for non-image file paths', () => {
expect(isImageFilePath('/Users/foo/document.txt')).toBe(false)
expect(isImageFilePath('C:\\Users\\foo\\readme.md')).toBe(false)
expect(isImageFilePath('plain text')).toBe(false)
})
test('handles quoted paths', () => {
expect(isImageFilePath('"/Users/foo/photo.png"')).toBe(true)
expect(isImageFilePath("'/Users/foo/photo.png'")).toBe(true)
})
test('handles paths with spaces', () => {
expect(isImageFilePath('/Users/foo/my photo.png')).toBe(true)
})
})
describe('asImageFilePath', () => {
test('returns cleaned path for valid image paths', () => {
expect(asImageFilePath('/Users/foo/photo.png')).toBe('/Users/foo/photo.png')
expect(asImageFilePath('"C:\\Users\\foo\\photo.jpg"')).toBe(
process.platform === 'win32'
? 'C:\\Users\\foo\\photo.jpg'
: 'C:Usersfoophoto.jpg',
)
})
test('returns null for non-image paths', () => {
expect(asImageFilePath('/Users/foo/document.txt')).toBeNull()
expect(asImageFilePath('plain text')).toBeNull()
})
test('trims whitespace', () => {
expect(asImageFilePath(' /Users/foo/photo.png ')).toBe(
'/Users/foo/photo.png',
)
})
})
+33 -4
View File
@@ -38,6 +38,18 @@ export const LINUX_CLIPBOARD_IMAGE_MIME_TYPES = [
'image/bmp',
]
// Shared PowerShell command to check if the Windows clipboard contains an image.
// Uses System.Windows.Forms.Clipboard.ContainsImage() which properly detects
// raw bitmap data (CF_DIB/CF_BITMAP) from screenshot tools like PrintScreen
// and Win+Shift+S, unlike Get-Clipboard -Format Image which only works with
// file references copied from Explorer.
const WIN32_CLIPBOARD_HAS_IMAGE_CMD =
'Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.Clipboard]::ContainsImage()'
function escapePowerShellSingleQuotedString(value: string): string {
return value.replace(/'/g, "''")
}
export function buildLinuxClipboardCheckCommand(): string {
const mimePattern = LINUX_CLIPBOARD_IMAGE_MIME_TYPES.map(mimeType =>
mimeType.replace('/', '\\/'),
@@ -94,9 +106,8 @@ function getClipboardCommands() {
deleteFile: `rm -f "${screenshotPath}"`,
},
win32: {
checkImage:
'powershell -NoProfile -Command "(Get-Clipboard -Format Image) -ne $null"',
saveImage: `powershell -NoProfile -Command "$img = Get-Clipboard -Format Image; if ($img) { $img.Save('${screenshotPath.replace(/\\/g, '\\\\')}', [System.Drawing.Imaging.ImageFormat]::Png) }"`,
checkImage: `powershell -NoProfile -Command "${WIN32_CLIPBOARD_HAS_IMAGE_CMD}"`,
saveImage: `powershell -NoProfile -Command "Add-Type -AssemblyName System.Windows.Forms; $img = [System.Windows.Forms.Clipboard]::GetImage(); if ($img) { $img.Save('${escapePowerShellSingleQuotedString(screenshotPath)}', [System.Drawing.Imaging.ImageFormat]::Png) }"`,
getPath: 'powershell -NoProfile -Command "Get-Clipboard"',
deleteFile: `del /f "${screenshotPath}"`,
},
@@ -118,6 +129,18 @@ export type ImageWithDimensions = {
* Check if clipboard contains an image without retrieving it.
*/
export async function hasImageInClipboard(): Promise<boolean> {
// Windows: use .NET Clipboard.ContainsImage() which properly detects
// raw bitmap data (CF_DIB/CF_BITMAP) from screenshot tools like
// PrintScreen and Win+Shift+S.
if (process.platform === 'win32') {
const result = await execFileNoThrowWithCwd('powershell', [
'-NoProfile',
'-Command',
WIN32_CLIPBOARD_HAS_IMAGE_CMD,
])
return result.code === 0 && result.stdout.trim() === 'True'
}
if (process.platform !== 'darwin') {
return false
}
@@ -209,7 +232,7 @@ export async function getImageFromClipboard(): Promise<ImageWithDimensions | nul
const { commands, screenshotPath } = getClipboardCommands()
try {
// Check if clipboard has image
// Check if clipboard has image.
const checkResult = await execa(commands.checkImage, {
shell: true,
reject: false,
@@ -217,6 +240,12 @@ export async function getImageFromClipboard(): Promise<ImageWithDimensions | nul
if (checkResult.exitCode !== 0) {
return null
}
if (
process.platform === 'win32' &&
checkResult.stdout.trim() !== 'True'
) {
return null
}
// Save the image
const saveResult = await execa(commands.saveImage, {
+207
View File
@@ -0,0 +1,207 @@
import { afterEach, describe, expect, mock, test } from 'bun:test'
import { mkdtempSync, rmSync, writeFileSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
type ImagePasteModule = typeof import('./imagePaste.js')
type ExecFileModule = typeof import('./execFileNoThrow.js')
type ExecaModule = typeof import('execa')
type ImageResizerModule = typeof import('./imageResizer.js')
type ExecaCall = [string, ...unknown[]]
const originalPlatform = process.platform
const originalTemp = process.env.TEMP
const originalClaudeCodeTmpdir = process.env.CLAUDE_CODE_TMPDIR
let actualExecFileModule: ExecFileModule | undefined
let actualExecaModule: ExecaModule | undefined
let actualImageResizerModule: ImageResizerModule | undefined
let tempDirs: string[] = []
function setPlatform(platform: NodeJS.Platform): void {
Object.defineProperty(process, 'platform', {
value: platform,
})
}
async function restoreMocks(): Promise<void> {
actualExecFileModule ??= await import(
`./execFileNoThrow.js?actual=${Date.now()}-${Math.random()}`
)
actualExecaModule ??= await import(
`execa?actual=${Date.now()}-${Math.random()}`
)
actualImageResizerModule ??= await import(
`./imageResizer.js?actual=${Date.now()}-${Math.random()}`
)
mock.module('./execFileNoThrow.js', () => actualExecFileModule!)
mock.module('execa', () => actualExecaModule!)
mock.module('./imageResizer.js', () => actualImageResizerModule!)
}
async function importImagePaste(): Promise<ImagePasteModule> {
return import(`./imagePaste.js?win32=${Date.now()}-${Math.random()}`)
}
afterEach(async () => {
setPlatform(originalPlatform)
if (originalTemp === undefined) {
delete process.env.TEMP
} else {
process.env.TEMP = originalTemp
}
if (originalClaudeCodeTmpdir === undefined) {
delete process.env.CLAUDE_CODE_TMPDIR
} else {
process.env.CLAUDE_CODE_TMPDIR = originalClaudeCodeTmpdir
}
for (const tempDir of tempDirs) {
rmSync(tempDir, { recursive: true, force: true })
}
tempDirs = []
await restoreMocks()
mock.restore()
})
describe('Windows clipboard image handling', () => {
test('hasImageInClipboard maps PowerShell True and False stdout', async () => {
setPlatform('win32')
const execFileNoThrowWithCwd = mock(async () => ({
code: 0,
stdout: 'True\r\n',
stderr: '',
}))
mock.module('./execFileNoThrow.js', () => ({
execFileNoThrowWithCwd,
}))
let imagePaste = await importImagePaste()
expect(await imagePaste.hasImageInClipboard()).toBe(true)
expect(execFileNoThrowWithCwd).toHaveBeenCalledWith('powershell', [
'-NoProfile',
'-Command',
'Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.Clipboard]::ContainsImage()',
])
execFileNoThrowWithCwd.mockResolvedValueOnce({
code: 0,
stdout: 'False\r\n',
stderr: '',
})
imagePaste = await importImagePaste()
expect(await imagePaste.hasImageInClipboard()).toBe(false)
})
test('getImageFromClipboard returns null before saving when Windows reports no image', async () => {
setPlatform('win32')
const execa = mock(async () => ({
exitCode: 0,
stdout: 'False\r\n',
stderr: '',
}))
mock.module('execa', () => ({ execa }))
const { getImageFromClipboard } = await importImagePaste()
expect(await getImageFromClipboard()).toBeNull()
expect(execa).toHaveBeenCalledTimes(1)
const checkCall = execa.mock.calls[0] as unknown as ExecaCall | undefined
expect(checkCall?.[0]).toContain('Clipboard]::ContainsImage()')
})
test('getImageFromClipboard keeps Windows backslashes and escapes apostrophes in the save path', async () => {
setPlatform('win32')
process.env.TEMP = "C:\\Temp\\O'Brien"
const execa = mock(async () => ({
exitCode: 0,
stdout: 'True\r\n',
stderr: '',
}))
execa.mockResolvedValueOnce({
exitCode: 0,
stdout: 'True\r\n',
stderr: '',
})
execa.mockResolvedValueOnce({
exitCode: 1,
stdout: '',
stderr: '',
})
mock.module('execa', () => ({ execa }))
const { getImageFromClipboard } = await importImagePaste()
expect(await getImageFromClipboard()).toBeNull()
const saveCall = execa.mock.calls[1] as unknown as ExecaCall | undefined
const saveCommand = String(saveCall?.[0] ?? '')
expect(saveCommand).toContain("C:\\Temp\\O''Brien")
expect(saveCommand).not.toContain('C:\\\\Temp')
expect(saveCommand).toContain(
'[System.Windows.Forms.Clipboard]::GetImage()',
)
})
test('getImageFromClipboard returns image data when Windows check and save succeed', async () => {
setPlatform('win32')
const tempDir = mkdtempSync(join(tmpdir(), 'openclaude-image-paste-'))
tempDirs.push(tempDir)
process.env.CLAUDE_CODE_TMPDIR = tempDir
const screenshotPath = join(tempDir, 'claude_cli_latest_screenshot.png')
const imageBuffer = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
)
const execa = mock(async (command: string) => {
if (command.includes('Clipboard]::GetImage()')) {
writeFileSync(screenshotPath, imageBuffer)
}
return {
exitCode: 0,
stdout: 'True\r\n',
stderr: '',
}
})
actualImageResizerModule ??= await import(
`./imageResizer.js?actual=${Date.now()}-${Math.random()}`
)
const maybeResizeAndDownsampleImageBuffer = mock(async () => ({
buffer: imageBuffer,
mediaType: 'png',
dimensions: {
originalWidth: 1,
originalHeight: 1,
displayWidth: 1,
displayHeight: 1,
},
}))
mock.module('execa', () => ({ execa }))
mock.module('./imageResizer.js', () => ({
...actualImageResizerModule!,
maybeResizeAndDownsampleImageBuffer,
}))
const { getImageFromClipboard } = await importImagePaste()
const image = await getImageFromClipboard()
expect(image?.base64).toEqual(expect.any(String))
expect(image?.mediaType).toBe('image/png')
expect(image?.dimensions).toEqual({
originalWidth: 1,
originalHeight: 1,
displayWidth: 1,
displayHeight: 1,
})
expect(maybeResizeAndDownsampleImageBuffer).toHaveBeenCalledWith(
imageBuffer,
imageBuffer.length,
'png',
)
expect(image?.base64.length).toBeGreaterThan(0)
expect(execa).toHaveBeenCalledTimes(3)
const saveCall = execa.mock.calls[1] as unknown as ExecaCall | undefined
expect(String(saveCall?.[0] ?? '')).toContain(screenshotPath)
const deleteCall = execa.mock.calls[2] as unknown as ExecaCall | undefined
expect(deleteCall?.[0]).toContain('del /f')
})
})