diff --git a/packages/coding-agent/src/utils/clipboard-image.ts b/packages/coding-agent/src/utils/clipboard-image.ts index 58cd5913..6bddeb30 100644 --- a/packages/coding-agent/src/utils/clipboard-image.ts +++ b/packages/coding-agent/src/utils/clipboard-image.ts @@ -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 { 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 { + 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) {