diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ab26f5a0..2c6d4bdf 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -6,6 +6,10 @@ - Added `newSession`, `tree`, and `fork` keybinding actions for `/new`, `/tree`, and `/fork` commands. All unbound by default. ([#1114](https://github.com/badlogic/pi-mono/pull/1114) by [@juanibiapina](https://github.com/juanibiapina)) +### Fixed + +- Fixed clipboard image paste on WSL2/WSLg writing invalid PNG files when clipboard provides `image/bmp` format. BMP images are now converted to PNG before saving. ([#1112](https://github.com/badlogic/pi-mono/pull/1112) by [@lightningRalf](https://github.com/lightningRalf)) + ## [0.50.7] - 2026-01-31 ### Fixed diff --git a/packages/coding-agent/src/utils/clipboard-image.ts b/packages/coding-agent/src/utils/clipboard-image.ts index 6c0fdbe6..d0e0e274 100644 --- a/packages/coding-agent/src/utils/clipboard-image.ts +++ b/packages/coding-agent/src/utils/clipboard-image.ts @@ -1,12 +1,14 @@ import Clipboard from "@mariozechner/clipboard"; import { spawnSync } from "child_process"; +import { loadPhoton } from "./photon.js"; + export type ClipboardImage = { bytes: Uint8Array; mimeType: string; }; -const PREFERRED_IMAGE_MIME_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"] as const; +const SUPPORTED_IMAGE_MIME_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"] as const; const DEFAULT_LIST_TIMEOUT_MS = 1000; const DEFAULT_READ_TIMEOUT_MS = 3000; @@ -41,7 +43,7 @@ function selectPreferredImageMimeType(mimeTypes: string[]): string | null { .filter(Boolean) .map((t) => ({ raw: t, base: baseMimeType(t) })); - for (const preferred of PREFERRED_IMAGE_MIME_TYPES) { + for (const preferred of SUPPORTED_IMAGE_MIME_TYPES) { const match = normalized.find((t) => t.base === preferred); if (match) { return match.raw; @@ -52,6 +54,33 @@ function selectPreferredImageMimeType(mimeTypes: string[]): string | null { return anyImage?.raw ?? null; } +function isSupportedImageMimeType(mimeType: string): boolean { + const base = baseMimeType(mimeType); + return SUPPORTED_IMAGE_MIME_TYPES.some((t) => t === base); +} + +/** + * Convert unsupported image formats to PNG using Photon. + * Returns null if conversion is unavailable or fails. + */ +async function convertToPng(bytes: Uint8Array): Promise { + const photon = await loadPhoton(); + if (!photon) { + return null; + } + + try { + const image = photon.PhotonImage.new_from_byteslice(bytes); + try { + return image.get_bytes(); + } finally { + image.free(); + } + } catch { + return null; + } +} + function runCommand( command: string, args: string[], @@ -120,7 +149,7 @@ function readClipboardImageViaXclip(): ClipboardImage | null { } const preferred = candidateTypes.length > 0 ? selectPreferredImageMimeType(candidateTypes) : null; - const tryTypes = preferred ? [preferred, ...PREFERRED_IMAGE_MIME_TYPES] : [...PREFERRED_IMAGE_MIME_TYPES]; + const tryTypes = preferred ? [preferred, ...SUPPORTED_IMAGE_MIME_TYPES] : [...SUPPORTED_IMAGE_MIME_TYPES]; for (const mimeType of tryTypes) { const data = runCommand("xclip", ["-selection", "clipboard", "-t", mimeType, "-o"]); @@ -139,19 +168,36 @@ export async function readClipboardImage(options?: { const env = options?.env ?? process.env; const platform = options?.platform ?? process.platform; + let image: ClipboardImage | null = null; + if (platform === "linux" && isWaylandSession(env)) { - return readClipboardImageViaWlPaste() ?? readClipboardImageViaXclip(); + image = readClipboardImageViaWlPaste() ?? readClipboardImageViaXclip(); + } else { + if (!Clipboard.hasImage()) { + return null; + } + + const imageData = await Clipboard.getImageBinary(); + if (!imageData || imageData.length === 0) { + return null; + } + + const bytes = imageData instanceof Uint8Array ? imageData : Uint8Array.from(imageData); + image = { bytes, mimeType: "image/png" }; } - if (!Clipboard.hasImage()) { + if (!image) { return null; } - const imageData = await Clipboard.getImageBinary(); - if (!imageData || imageData.length === 0) { - return null; + // Convert unsupported formats (e.g., BMP from WSLg) to PNG + if (!isSupportedImageMimeType(image.mimeType)) { + const pngBytes = await convertToPng(image.bytes); + if (!pngBytes) { + return null; + } + return { bytes: pngBytes, mimeType: "image/png" }; } - const bytes = imageData instanceof Uint8Array ? imageData : Uint8Array.from(imageData); - return { bytes, mimeType: "image/png" }; + return image; } diff --git a/packages/coding-agent/test/clipboard-image-bmp-conversion.test.ts b/packages/coding-agent/test/clipboard-image-bmp-conversion.test.ts new file mode 100644 index 00000000..95107506 --- /dev/null +++ b/packages/coding-agent/test/clipboard-image-bmp-conversion.test.ts @@ -0,0 +1,88 @@ +/** + * Test for BMP to PNG conversion in clipboard image handling. + * Separate from clipboard-image.test.ts due to different mocking requirements. + * + * This tests the fix for WSL2/WSLg where clipboard often provides image/bmp + * instead of image/png. + */ +import { describe, expect, test, vi } from "vitest"; + +function createTinyBmp1x1Red24bpp(): Uint8Array { + // Minimal 1x1 24bpp BMP (BGR + row padding to 4 bytes) + // File size = 14 (BMP header) + 40 (DIB header) + 4 (pixel row) = 58 + const buffer = Buffer.alloc(58); + + // BITMAPFILEHEADER + buffer.write("BM", 0, "ascii"); + buffer.writeUInt32LE(buffer.length, 2); // file size + buffer.writeUInt16LE(0, 6); // reserved1 + buffer.writeUInt16LE(0, 8); // reserved2 + buffer.writeUInt32LE(54, 10); // pixel data offset + + // BITMAPINFOHEADER + buffer.writeUInt32LE(40, 14); // DIB header size + buffer.writeInt32LE(1, 18); // width + buffer.writeInt32LE(1, 22); // height (positive = bottom-up) + buffer.writeUInt16LE(1, 26); // planes + buffer.writeUInt16LE(24, 28); // bits per pixel + buffer.writeUInt32LE(0, 30); // compression (BI_RGB) + buffer.writeUInt32LE(4, 34); // image size (incl. padding) + buffer.writeInt32LE(0, 38); // x pixels per meter + buffer.writeInt32LE(0, 42); // y pixels per meter + buffer.writeUInt32LE(0, 46); // colors used + buffer.writeUInt32LE(0, 50); // important colors + + // Pixel data (B, G, R) + 1 byte padding + buffer[54] = 0x00; // B + buffer[55] = 0x00; // G + buffer[56] = 0xff; // R + buffer[57] = 0x00; // padding + + return new Uint8Array(buffer); +} + +// Mock wl-paste to return BMP +vi.mock("child_process", async () => { + const actual = await vi.importActual("child_process"); + return { + ...actual, + spawnSync: vi.fn((command: string, args: string[]) => { + if (command === "wl-paste" && args.includes("--list-types")) { + return { status: 0, stdout: Buffer.from("image/bmp\n"), error: null }; + } + if (command === "wl-paste" && args.includes("image/bmp")) { + return { status: 0, stdout: Buffer.from(createTinyBmp1x1Red24bpp()), error: null }; + } + return { status: 1, stdout: Buffer.alloc(0), error: null }; + }), + }; +}); + +// Mock the native clipboard (not used in Wayland path, but needs to be mocked) +vi.mock("@mariozechner/clipboard", () => ({ + default: { + hasImage: vi.fn(() => false), + getImageBinary: vi.fn(() => Promise.resolve(null)), + }, +})); + +describe("readClipboardImage BMP conversion", () => { + test("converts BMP to PNG on Wayland/WSLg", async () => { + const { readClipboardImage } = await import("../src/utils/clipboard-image.js"); + + // Simulate Wayland session (WSLg) + const image = await readClipboardImage({ + env: { WAYLAND_DISPLAY: "wayland-0" }, + platform: "linux", + }); + + expect(image).not.toBeNull(); + expect(image!.mimeType).toBe("image/png"); + + // Verify PNG magic bytes + expect(image!.bytes[0]).toBe(0x89); + expect(image!.bytes[1]).toBe(0x50); // P + expect(image!.bytes[2]).toBe(0x4e); // N + expect(image!.bytes[3]).toBe(0x47); // G + }); +});