chore(vendor): import pi 0.85.1
Imported as a linear vendor commit so forge rebases do not replay the upstream root tree. git-subtree-dir: third_party/pi git-subtree-split: d981de1229ef899957bbe968bc8dcda02a21f477
This commit is contained in:
300
third_party/pi/packages/coding-agent/src/utils/clipboard-image.ts
vendored
Normal file
300
third_party/pi/packages/coding-agent/src/utils/clipboard-image.ts
vendored
Normal file
@@ -0,0 +1,300 @@
|
||||
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.ts";
|
||||
import { loadPhoton } from "./photon.ts";
|
||||
|
||||
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_POWERSHELL_TIMEOUT_MS = 5000;
|
||||
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<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(
|
||||
command: string,
|
||||
args: string[],
|
||||
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;
|
||||
|
||||
const result = spawnSync(command, args, {
|
||||
timeout: timeoutMs,
|
||||
maxBuffer: maxBufferBytes,
|
||||
env: options?.env,
|
||||
});
|
||||
|
||||
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 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 psQuotedWinPath = winPath.replaceAll("'", "''");
|
||||
const psScript = [
|
||||
"Add-Type -AssemblyName System.Windows.Forms",
|
||||
"Add-Type -AssemblyName System.Drawing",
|
||||
`$path = '${psQuotedWinPath}'`,
|
||||
"$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,
|
||||
});
|
||||
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,
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}): Promise<ClipboardImage | null> {
|
||||
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") {
|
||||
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()) ?? readClipboardImageViaXclip();
|
||||
}
|
||||
} else {
|
||||
image = await readClipboardImageViaNativeClipboard();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user