/** * Wayland clipboard image reader. * * wl-paste only. No xclip, no PowerShell, no pngpaste, no native addon — this * extension targets a strict Wayland session, and every extra backend is a * spawn that can hang on a machine that will never use it. */ import { spawnSync } from "node:child_process"; /** Formats the Kitty graphics protocol and pi both accept without conversion. */ const PREFERRED = ["image/png", "image/jpeg", "image/webp", "image/gif"] as const; export interface ClipboardImage { bytes: Buffer; mimeType: string; } function baseMime(mimeType: string): string { return mimeType.split(";")[0]?.trim().toLowerCase() ?? mimeType.toLowerCase(); } export function extensionForMime(mimeType: string): string { switch (baseMime(mimeType)) { case "image/png": return "png"; case "image/jpeg": return "jpg"; case "image/webp": return "webp"; case "image/gif": return "gif"; default: return "bin"; } } function run(args: string[], timeoutMs: number): Buffer | null { const result = spawnSync("wl-paste", args, { timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024, }); if (result.error || result.status !== 0) return null; return Buffer.isBuffer(result.stdout) ? result.stdout : null; } /** MIME types currently on the clipboard, lowercased. */ export function clipboardTypes(): string[] { const out = run(["--list-types"], 1_000); if (!out) return []; return out .toString("utf-8") .split(/\r?\n/) .map((t) => t.trim()) .filter(Boolean); } function pickType(types: string[]): string | null { const normalized = types.map((raw) => ({ raw, base: baseMime(raw) })); for (const preferred of PREFERRED) { const match = normalized.find((t) => t.base === preferred); if (match) return match.raw; } // Any other image/* is still worth trying; the terminal may cope, and if it // does not the caller gets a clear error rather than a silent no-op. return normalized.find((t) => t.base.startsWith("image/"))?.raw ?? null; } /** * Read an image off the Wayland clipboard. * * Returns null when the clipboard holds no image — that is the normal case * (text copied), not an error. Throws only when wl-paste itself is missing. */ export function readClipboardImage(): ClipboardImage | null { const types = clipboardTypes(); if (types.length === 0) { // Distinguish "wl-paste absent/broken" from "clipboard is empty". const probe = spawnSync("wl-paste", ["--version"], { timeout: 1_000 }); if (probe.error) { throw new Error("wl-paste not found. Install wl-clipboard."); } return null; } const selected = pickType(types); if (!selected) return null; // --no-newline: wl-paste appends one for text types and would corrupt binary. const bytes = run(["--type", selected, "--no-newline"], 5_000); if (!bytes || bytes.length === 0) return null; return { bytes, mimeType: baseMime(selected) }; }