fix(coding-agent): handle WSL clipboard image fallback\n\nCloses #1722

This commit is contained in:
Mario Zechner
2026-03-14 06:07:38 +01:00
parent 80ca61a1c4
commit 1feccfedcb

View File

@@ -1,4 +1,8 @@
import { spawnSync } from "child_process";
import { randomUUID } from "crypto";
import { readFileSync, unlinkSync } from "fs";
import { tmpdir } from "os";
import { join } from "path";
import { clipboard } from "./clipboard-native.js";
import { loadPhoton } from "./photon.js";
@@ -12,6 +16,7 @@ const SUPPORTED_IMAGE_MIME_TYPES = ["image/png", "image/jpeg", "image/webp", "im
const DEFAULT_LIST_TIMEOUT_MS = 1000;
const DEFAULT_READ_TIMEOUT_MS = 3000;
const DEFAULT_POWERSHELL_TIMEOUT_MS = 5000;
const DEFAULT_MAX_BUFFER_BYTES = 50 * 1024 * 1024;
export function isWaylandSession(env: NodeJS.ProcessEnv = process.env): boolean {
@@ -84,7 +89,7 @@ async function convertToPng(bytes: Uint8Array): Promise<Uint8Array | null> {
function runCommand(
command: string,
args: string[],
options?: { timeoutMs?: number; maxBufferBytes?: number },
options?: { timeoutMs?: number; maxBufferBytes?: number; env?: NodeJS.ProcessEnv },
): { stdout: Buffer; ok: boolean } {
const timeoutMs = options?.timeoutMs ?? DEFAULT_READ_TIMEOUT_MS;
const maxBufferBytes = options?.maxBufferBytes ?? DEFAULT_MAX_BUFFER_BYTES;
@@ -92,6 +97,7 @@ function runCommand(
const result = spawnSync(command, args, {
timeout: timeoutMs,
maxBuffer: maxBufferBytes,
env: options?.env,
});
if (result.error) {
@@ -134,6 +140,76 @@ function readClipboardImageViaWlPaste(): ClipboardImage | null {
return { bytes: data.stdout, mimeType: baseMimeType(selectedType) };
}
function isWSL(env: NodeJS.ProcessEnv = process.env): boolean {
if (env.WSL_DISTRO_NAME || env.WSLENV) {
return true;
}
try {
const release = readFileSync("/proc/version", "utf-8");
return /microsoft|wsl/i.test(release);
} catch {
return false;
}
}
/**
* On WSL, the Linux clipboard (Wayland/X11) does not receive image data from
* Windows screenshots (Win+Shift+S). PowerShell can access the Windows clipboard
* directly, so we use it as a fallback.
*/
function readClipboardImageViaPowerShell(): ClipboardImage | null {
const tmpFile = join(tmpdir(), `pi-wsl-clip-${randomUUID()}.png`);
try {
const winPathResult = runCommand("wslpath", ["-w", tmpFile], { timeoutMs: DEFAULT_LIST_TIMEOUT_MS });
if (!winPathResult.ok) {
return null;
}
const winPath = winPathResult.stdout.toString("utf-8").trim();
if (!winPath) {
return null;
}
const psScript = [
"Add-Type -AssemblyName System.Windows.Forms",
"Add-Type -AssemblyName System.Drawing",
"$path = $env:PI_WSL_CLIPBOARD_IMAGE_PATH",
"$img = [System.Windows.Forms.Clipboard]::GetImage()",
"if ($img) { $img.Save($path, [System.Drawing.Imaging.ImageFormat]::Png); Write-Output 'ok' } else { Write-Output 'empty' }",
].join("; ");
const result = runCommand("powershell.exe", ["-NoProfile", "-Command", psScript], {
timeoutMs: DEFAULT_POWERSHELL_TIMEOUT_MS,
env: { ...process.env, PI_WSL_CLIPBOARD_IMAGE_PATH: winPath },
});
if (!result.ok) {
return null;
}
const output = result.stdout.toString("utf-8").trim();
if (output !== "ok") {
return null;
}
const bytes = readFileSync(tmpFile);
if (bytes.length === 0) {
return null;
}
return { bytes: new Uint8Array(bytes), mimeType: "image/png" };
} catch {
return null;
} finally {
try {
unlinkSync(tmpFile);
} catch {
// Ignore cleanup errors.
}
}
}
function readClipboardImageViaXclip(): ClipboardImage | null {
const targets = runCommand("xclip", ["-selection", "clipboard", "-t", "TARGETS", "-o"], {
timeoutMs: DEFAULT_LIST_TIMEOUT_MS,
@@ -161,6 +237,20 @@ function readClipboardImageViaXclip(): ClipboardImage | null {
return null;
}
async function readClipboardImageViaNativeClipboard(): Promise<ClipboardImage | null> {
if (!clipboard || !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);
return { bytes, mimeType: "image/png" };
}
export async function readClipboardImage(options?: {
env?: NodeJS.ProcessEnv;
platform?: NodeJS.Platform;
@@ -174,20 +264,23 @@ export async function readClipboardImage(options?: {
let image: ClipboardImage | null = null;
if (platform === "linux" && isWaylandSession(env)) {
image = readClipboardImageViaWlPaste() ?? readClipboardImageViaXclip();
if (platform === "linux") {
const wsl = isWSL(env);
const wayland = isWaylandSession(env);
if (wayland || wsl) {
image = readClipboardImageViaWlPaste() ?? readClipboardImageViaXclip();
}
if (!image && wsl) {
image = readClipboardImageViaPowerShell();
}
if (!image && !wayland) {
image = await readClipboardImageViaNativeClipboard();
}
} else {
if (!clipboard || !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" };
image = await readClipboardImageViaNativeClipboard();
}
if (!image) {