Files
oh-my-pi/packages/coding-agent/src/utils/clipboard.ts
T

194 lines
6.3 KiB
TypeScript

import { execSync } from "node:child_process";
import type { ClipboardImage } from "@oh-my-pi/pi-natives";
import * as native from "@oh-my-pi/pi-natives";
import { logger } from "@oh-my-pi/pi-utils";
function hasDisplay(): boolean {
return process.platform !== "linux" || Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
}
function isWsl(): boolean {
return process.platform === "linux" && Boolean(process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP);
}
/**
* Copy text to the system clipboard.
*
* Emits OSC 52 first when running in a real terminal (works over SSH/mosh),
* then attempts native clipboard copy as best-effort for local sessions.
* On Termux, tries `termux-clipboard-set` before native.
*
* @param text - UTF-8 text to place on the clipboard.
*/
export async function copyToClipboard(text: string): Promise<void> {
if (process.stdout.isTTY) {
const onError = (err: unknown) => {
process.stdout.off("error", onError);
// Prevent unhandled 'error' from crashing the process when stdout is a closed pipe.
if ((err as NodeJS.ErrnoException | null | undefined)?.code === "EPIPE") {
return;
}
};
try {
const encoded = Buffer.from(text).toString("base64");
const osc52 = `\x1b]52;c;${encoded}\x07`;
process.stdout.on("error", onError);
process.stdout.write(osc52, err => {
process.stdout.off("error", onError);
// If stdout is closed (e.g. piped to a process that exits early),
// ignore EPIPE and proceed with native clipboard best-effort.
if ((err as NodeJS.ErrnoException | null | undefined)?.code === "EPIPE") {
return;
}
});
} catch (err) {
process.stdout.off("error", onError);
if ((err as NodeJS.ErrnoException | null | undefined)?.code !== "EPIPE") {
// Ignore all write failures (OSC 52 is best-effort).
}
}
}
// Also try native tools (best effort for local sessions)
try {
if (process.env.TERMUX_VERSION) {
try {
execSync("termux-clipboard-set", { input: text, timeout: 5000 });
return;
} catch {
// Fall through to native
}
}
await native.copyToClipboard(text);
} catch {
// Ignore — clipboard copy is best-effort
}
}
// PowerShell one-liner that emits the clipboard image as base64-encoded PNG on
// stdout, or nothing when the clipboard does not hold image data. Used as the
// WSL bridge — arboard cannot read the Windows clipboard through WSLg.
const POWERSHELL_IMAGE_SCRIPT = `
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Drawing
$img = [System.Windows.Forms.Clipboard]::GetImage()
if ($img -ne $null) {
$ms = New-Object System.IO.MemoryStream
$img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)
[Console]::Out.Write([Convert]::ToBase64String($ms.ToArray()))
}
`;
const POWERSHELL_TIMEOUT_MS = 8000;
/**
* Read a clipboard image through the Windows host's PowerShell.
*
* WSLg exposes a Wayland socket but no native clipboard image transport, so
* `arboard` returns `ContentNotAvailable`. PowerShell, reached via WSL interop,
* can read the Windows clipboard directly and round-trip the bitmap as PNG.
*
* Returns null when no image is on the clipboard, the host PowerShell is
* missing, or the bridge times out.
*/
async function readImageViaPowerShell(): Promise<ClipboardImage | null> {
try {
const proc = Bun.spawn(["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", POWERSHELL_IMAGE_SCRIPT], {
stdout: "pipe",
stderr: "ignore",
stdin: "ignore",
});
const timer = setTimeout(() => proc.kill(), POWERSHELL_TIMEOUT_MS);
let stdout = "";
try {
stdout = await new Response(proc.stdout).text();
await proc.exited;
} catch (err) {
// powershell.exe is a Windows process reached over WSL interop; if it
// doesn't reap cleanly, swallow the error so the dispatcher can fall
// through to the native bridge instead of throwing.
logger.warn("clipboard: powershell read failed", { error: String(err) });
return null;
} finally {
clearTimeout(timer);
}
if (proc.exitCode !== 0) return null;
const b64 = stdout.trim();
if (!b64) return null;
const bytes = Buffer.from(b64, "base64");
if (bytes.byteLength === 0) return null;
return { data: bytes, mimeType: "image/png" };
} catch {
return null;
}
}
/**
* Read an image from the system clipboard.
*
* Returns null on Termux (no image clipboard support) or when no display
* server is available (headless/SSH without forwarding). Under WSL the
* Windows clipboard is reached through `powershell.exe`, since WSLg's
* Wayland clipboard does not carry image payloads through to `arboard`.
*
* @returns PNG payload or null when no image is available.
*/
export async function readImageFromClipboard(): Promise<ClipboardImage | null> {
if (process.env.TERMUX_VERSION) {
return null;
}
if (isWsl()) {
const image = await readImageViaPowerShell();
if (image) return image;
// Fall through: arboard may still succeed on a future WSLg release —
// but only when we actually have a display server. Headless WSL has
// no display, so arboard would reject anyway.
}
if (!hasDisplay()) {
return null;
}
return (await native.readImageFromClipboard()) ?? null;
}
/**
* Read plain text from the system clipboard.
*/
export async function readTextFromClipboard(): Promise<string> {
try {
const p = process.platform;
if (p === "darwin") {
return execSync("pbpaste", { encoding: "utf8", timeout: 2000 }).toString();
}
if (p === "win32") {
return execSync('powershell.exe -NoProfile -Command "Get-Clipboard"', {
encoding: "utf8",
timeout: 2000,
}).toString();
}
if (process.env.TERMUX_VERSION) {
return execSync("termux-clipboard-get", { encoding: "utf8", timeout: 2000 }).toString();
}
const hasWaylandDisplay = Boolean(process.env.WAYLAND_DISPLAY);
const hasX11Display = Boolean(process.env.DISPLAY);
if (hasWaylandDisplay) {
try {
return execSync("wl-paste --type text/plain --no-newline", { encoding: "utf8", timeout: 2000 }).toString();
} catch {
if (hasX11Display) {
return execSync("xclip -selection clipboard -o", { encoding: "utf8", timeout: 2000 }).toString();
}
}
} else if (hasX11Display) {
return execSync("xclip -selection clipboard -o", { encoding: "utf8", timeout: 2000 }).toString();
}
} catch (error) {
logger.warn("clipboard: failed to read clipboard text", { error: String(error) });
}
return "";
}