mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 10:14:19 -05:00
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:
@@ -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
@@ -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, {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user