import { spawnSync } from "child_process"; import { clipboard } from "./clipboard-native.js"; import { loadPhoton } from "./photon.js"; export type ClipboardImage = { bytes: Uint8Array; mimeType: string; }; 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; const DEFAULT_MAX_BUFFER_BYTES = 50 * 1024 * 1024; export function isWaylandSession(env: NodeJS.ProcessEnv = process.env): boolean { return Boolean(env.WAYLAND_DISPLAY) || env.XDG_SESSION_TYPE === "wayland"; } function baseMimeType(mimeType: string): string { return mimeType.split(";")[0]?.trim().toLowerCase() ?? mimeType.toLowerCase(); } export function extensionForImageMimeType(mimeType: string): string | null { switch (baseMimeType(mimeType)) { case "image/png": return "png"; case "image/jpeg": return "jpg"; case "image/webp": return "webp"; case "image/gif": return "gif"; default: return null; } } function selectPreferredImageMimeType(mimeTypes: string[]): string | null { const normalized = mimeTypes .map((t) => t.trim()) .filter(Boolean) .map((t) => ({ raw: t, base: baseMimeType(t) })); for (const preferred of SUPPORTED_IMAGE_MIME_TYPES) { const match = normalized.find((t) => t.base === preferred); if (match) { return match.raw; } } const anyImage = normalized.find((t) => t.base.startsWith("image/")); 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[], options?: { timeoutMs?: number; maxBufferBytes?: number }, ): { stdout: Buffer; ok: boolean } { const timeoutMs = options?.timeoutMs ?? DEFAULT_READ_TIMEOUT_MS; const maxBufferBytes = options?.maxBufferBytes ?? DEFAULT_MAX_BUFFER_BYTES; const result = spawnSync(command, args, { timeout: timeoutMs, maxBuffer: maxBufferBytes, }); if (result.error) { return { ok: false, stdout: Buffer.alloc(0) }; } if (result.status !== 0) { return { ok: false, stdout: Buffer.alloc(0) }; } const stdout = Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.from(result.stdout ?? "", typeof result.stdout === "string" ? "utf-8" : undefined); return { ok: true, stdout }; } function readClipboardImageViaWlPaste(): ClipboardImage | null { const list = runCommand("wl-paste", ["--list-types"], { timeoutMs: DEFAULT_LIST_TIMEOUT_MS }); if (!list.ok) { return null; } const types = list.stdout .toString("utf-8") .split(/\r?\n/) .map((t) => t.trim()) .filter(Boolean); const selectedType = selectPreferredImageMimeType(types); if (!selectedType) { return null; } const data = runCommand("wl-paste", ["--type", selectedType, "--no-newline"]); if (!data.ok || data.stdout.length === 0) { return null; } return { bytes: data.stdout, mimeType: baseMimeType(selectedType) }; } function readClipboardImageViaXclip(): ClipboardImage | null { const targets = runCommand("xclip", ["-selection", "clipboard", "-t", "TARGETS", "-o"], { timeoutMs: DEFAULT_LIST_TIMEOUT_MS, }); let candidateTypes: string[] = []; if (targets.ok) { candidateTypes = targets.stdout .toString("utf-8") .split(/\r?\n/) .map((t) => t.trim()) .filter(Boolean); } const preferred = candidateTypes.length > 0 ? selectPreferredImageMimeType(candidateTypes) : null; 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"]); if (data.ok && data.stdout.length > 0) { return { bytes: data.stdout, mimeType: baseMimeType(mimeType) }; } } return null; } export async function readClipboardImage(options?: { env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform; }): Promise { const env = options?.env ?? process.env; const platform = options?.platform ?? process.platform; if (env.TERMUX_VERSION) { return null; } let image: ClipboardImage | null = null; if (platform === "linux" && isWaylandSession(env)) { image = readClipboardImageViaWlPaste() ?? readClipboardImageViaXclip(); } 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" }; } if (!image) { 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" }; } return image; }