mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-17 08:00:59 +00:00
fix(coding-agent): convert clipboard BMP images to PNG on paste
WSL2/WSLg often provides clipboard images as image/bmp only. Previously this resulted in invalid PNG files being written. Now readClipboardImage() converts unsupported formats to PNG via Photon before returning. Closes #1109 Based on #1112 by @lightningRalf
This commit is contained in:
parent
6f2d066342
commit
38ed9e86f8
3 changed files with 148 additions and 10 deletions
|
|
@ -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))
|
- 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
|
## [0.50.7] - 2026-01-31
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
import Clipboard from "@mariozechner/clipboard";
|
import Clipboard from "@mariozechner/clipboard";
|
||||||
import { spawnSync } from "child_process";
|
import { spawnSync } from "child_process";
|
||||||
|
|
||||||
|
import { loadPhoton } from "./photon.js";
|
||||||
|
|
||||||
export type ClipboardImage = {
|
export type ClipboardImage = {
|
||||||
bytes: Uint8Array;
|
bytes: Uint8Array;
|
||||||
mimeType: string;
|
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_LIST_TIMEOUT_MS = 1000;
|
||||||
const DEFAULT_READ_TIMEOUT_MS = 3000;
|
const DEFAULT_READ_TIMEOUT_MS = 3000;
|
||||||
|
|
@ -41,7 +43,7 @@ function selectPreferredImageMimeType(mimeTypes: string[]): string | null {
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.map((t) => ({ raw: t, base: baseMimeType(t) }));
|
.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);
|
const match = normalized.find((t) => t.base === preferred);
|
||||||
if (match) {
|
if (match) {
|
||||||
return match.raw;
|
return match.raw;
|
||||||
|
|
@ -52,6 +54,33 @@ function selectPreferredImageMimeType(mimeTypes: string[]): string | null {
|
||||||
return anyImage?.raw ?? 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<Uint8Array | null> {
|
||||||
|
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(
|
function runCommand(
|
||||||
command: string,
|
command: string,
|
||||||
args: string[],
|
args: string[],
|
||||||
|
|
@ -120,7 +149,7 @@ function readClipboardImageViaXclip(): ClipboardImage | null {
|
||||||
}
|
}
|
||||||
|
|
||||||
const preferred = candidateTypes.length > 0 ? selectPreferredImageMimeType(candidateTypes) : 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) {
|
for (const mimeType of tryTypes) {
|
||||||
const data = runCommand("xclip", ["-selection", "clipboard", "-t", mimeType, "-o"]);
|
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 env = options?.env ?? process.env;
|
||||||
const platform = options?.platform ?? process.platform;
|
const platform = options?.platform ?? process.platform;
|
||||||
|
|
||||||
|
let image: ClipboardImage | null = null;
|
||||||
|
|
||||||
if (platform === "linux" && isWaylandSession(env)) {
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const imageData = await Clipboard.getImageBinary();
|
// Convert unsupported formats (e.g., BMP from WSLg) to PNG
|
||||||
if (!imageData || imageData.length === 0) {
|
if (!isSupportedImageMimeType(image.mimeType)) {
|
||||||
return null;
|
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 image;
|
||||||
return { bytes, mimeType: "image/png" };
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<typeof import("child_process")>("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
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue