fix: make clipboard images pasteable in OpenClaude

Images in the clipboard could fail to become pasted image attachments in OpenClaude. User-facing symptom: paste would detect that an image existed, but nothing would appear in the prompt, and bundled builds could also fail while converting BMP clipboard images into a format OpenClaude can send to the model.

Linux clipboard image paste had drifted between detection and extraction. checkImage accepted png/jpeg/jpg/gif/webp/bmp, but saveImage only tried image/png and image/bmp. When the clipboard advertised a JPEG, GIF, or WebP image, OpenClaude concluded that an image was present and then failed to write the temp screenshot file, so the paste path returned null and nothing was inserted into the prompt.

Bundled OpenClaude builds had a second failure mode. The build replaces image-processor-napi and sharp with explicit stub modules in bundled mode. getImageProcessor() treated those stubs as real processors, so BMP clipboard images reached sharp(imageBuffer).png() and then failed before they could be converted into a pasteable PNG for OpenClaude.

Keep the Linux clipboard commands generated from one MIME type list and reject __stub-marked image processors up front instead of failing in the middle of image paste.
This commit is contained in:
jmt
2026-04-02 15:32:25 -07:00
parent 145c99b297
commit f5b20fc517
2 changed files with 57 additions and 10 deletions

View File

@@ -34,6 +34,17 @@ type SharpCreator = (options: SharpCreatorOptions) => SharpInstance
let imageProcessorModule: { default: SharpFunction } | null = null
let imageCreatorModule: { default: SharpCreator } | null = null
/**
* Error thrown when no image processor is available (e.g., in the open build
* where sharp and image-processor-napi are stubbed out).
*/
export class ImageProcessorUnavailableError extends Error {
constructor() {
super('No image processor available (sharp is not installed)')
this.name = 'ImageProcessorUnavailableError'
}
}
export async function getImageProcessor(): Promise<SharpFunction> {
if (imageProcessorModule) {
return imageProcessorModule.default
@@ -44,10 +55,14 @@ export async function getImageProcessor(): Promise<SharpFunction> {
try {
// Use the native image processor module
const imageProcessor = await import('image-processor-napi')
if ((imageProcessor as { __stub?: boolean }).__stub) {
throw new ImageProcessorUnavailableError()
}
const sharp = imageProcessor.sharp || imageProcessor.default
imageProcessorModule = { default: sharp }
return sharp
} catch {
} catch (e) {
if (e instanceof ImageProcessorUnavailableError) throw e
// Fall back to sharp if native module is not available
// biome-ignore lint/suspicious/noConsole: intentional warning
console.warn(
@@ -58,12 +73,20 @@ export async function getImageProcessor(): Promise<SharpFunction> {
// Use sharp for non-bundled builds or as fallback.
// Single structural cast: our SharpFunction is a subset of sharp's actual type surface.
const imported = (await import(
'sharp'
)) as unknown as MaybeDefault<SharpFunction>
const sharp = unwrapDefault(imported)
imageProcessorModule = { default: sharp }
return sharp
try {
const imported = (await import(
'sharp'
)) as unknown as MaybeDefault<SharpFunction> & { __stub?: boolean }
if (imported && (imported as { __stub?: boolean }).__stub) {
throw new ImageProcessorUnavailableError()
}
const sharp = unwrapDefault(imported as MaybeDefault<SharpFunction>)
imageProcessorModule = { default: sharp }
return sharp
} catch (e) {
if (e instanceof ImageProcessorUnavailableError) throw e
throw new ImageProcessorUnavailableError()
}
}
/**

View File

@@ -28,6 +28,31 @@ type SupportedPlatform = 'darwin' | 'linux' | 'win32'
// Threshold in characters for when to consider text a "large paste"
export const PASTE_THRESHOLD = 800
export const LINUX_CLIPBOARD_IMAGE_MIME_TYPES = [
'image/png',
'image/jpeg',
'image/jpg',
'image/gif',
'image/webp',
'image/bmp',
]
export function buildLinuxClipboardCheckCommand(): string {
const mimePattern = LINUX_CLIPBOARD_IMAGE_MIME_TYPES.map(mimeType =>
mimeType.replace('/', '\\/'),
).join('|')
return `xclip -selection clipboard -t TARGETS -o 2>/dev/null | grep -E "${mimePattern}" || wl-paste -l 2>/dev/null | grep -E "${mimePattern}"`
}
export function buildLinuxClipboardSaveCommand(screenshotPath: string): string {
return LINUX_CLIPBOARD_IMAGE_MIME_TYPES.flatMap(mimeType => [
`xclip -selection clipboard -t ${mimeType} -o > "${screenshotPath}" 2>/dev/null`,
`wl-paste --type ${mimeType} > "${screenshotPath}" 2>/dev/null`,
]).join(' || ')
}
function getClipboardCommands() {
const platform = process.platform as SupportedPlatform
@@ -62,9 +87,8 @@ function getClipboardCommands() {
deleteFile: `rm -f "${screenshotPath}"`,
},
linux: {
checkImage:
'xclip -selection clipboard -t TARGETS -o 2>/dev/null | grep -E "image/(png|jpeg|jpg|gif|webp|bmp)" || wl-paste -l 2>/dev/null | grep -E "image/(png|jpeg|jpg|gif|webp|bmp)"',
saveImage: `xclip -selection clipboard -t image/png -o > "${screenshotPath}" 2>/dev/null || wl-paste --type image/png > "${screenshotPath}" 2>/dev/null || xclip -selection clipboard -t image/bmp -o > "${screenshotPath}" 2>/dev/null || wl-paste --type image/bmp > "${screenshotPath}"`,
checkImage: buildLinuxClipboardCheckCommand(),
saveImage: buildLinuxClipboardSaveCommand(screenshotPath),
getPath:
'xclip -selection clipboard -t text/plain -o 2>/dev/null || wl-paste 2>/dev/null',
deleteFile: `rm -f "${screenshotPath}"`,