import { spawn, execFile, type ChildProcess } from "node:child_process"; import { createRequire } from "node:module"; import { dirname, join, resolve } from "node:path"; import { promisify } from "node:util"; import type { Writable } from "node:stream"; import { Failure, type Data, type VideoOptions } from "./common.js"; export const NATIVE_VERSION = "0.2.0"; const run = promisify(execFile); const require = createRequire(import.meta.url); function nativeEnvironment(): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = {}; for (const key of [ "PATH", "HOME", "USERPROFILE", "SystemRoot", "SYSTEMROOT", "WINDIR", "TEMP", "TMP", "TMPDIR", "LANG", "LC_ALL", "SSL_CERT_FILE", "SSL_CERT_DIR", ]) if (process.env[key] !== undefined) env[key] = process.env[key]; return env; } export async function nativeBinary(override?: string): Promise { let binary: string; if (override) binary = resolve(override); else { const target = `${process.platform === "win32" ? "windows" : process.platform}-${process.arch}`; if ( ![ "darwin-arm64", "darwin-x64", "linux-arm64", "linux-x64", "windows-x64", ].includes(target) ) throw new Failure("PLATFORM_UNSUPPORTED"); if ( process.platform === "linux" && !(process.report.getReport() as Data).header.glibcVersionRuntime ) throw new Failure("GLIBC_REQUIRED"); try { const manifest = require.resolve( `@chunkdash/crossdesk-cli-${target}/package.json`, ); if (require(manifest).version !== NATIVE_VERSION) throw new Failure("NATIVE_VERSION_MISMATCH"); binary = join( dirname(manifest), "bin", process.platform === "win32" ? "crossdesk-cli.exe" : "crossdesk-cli", ); } catch (error) { throw error instanceof Failure ? error : new Failure("NATIVE_PACKAGE_MISSING"); } } try { const { stdout } = await run(binary, ["doctor", "--json"], { timeout: 5000, maxBuffer: 65536, windowsHide: true, env: nativeEnvironment(), }); const doctor = JSON.parse(stdout); if (doctor.automation_protocol !== "crossdesk-automation/1" || doctor.automation_terminal_protocol !== "ansi/1" || doctor.automation_video_transport !== "loopback-websocket/1" || !["h264-annexb/1", "nv12/1"].every((p) => doctor.automation_video_protocols?.includes(p)) || !["auto", "software"].every((p) => doctor.automation_video_decoders?.includes(p))) throw new Failure("NATIVE_PROTOCOL_MISMATCH"); } catch (error) { throw error instanceof Failure ? error : new Failure("NATIVE_START_FAILED"); } return binary; } type Pending = { resolve: (value: Data) => void; reject: (error: Failure) => void; clean: () => void; action: boolean; }; export class NativeClient { private child: ChildProcess; private pending = new Map(); private sequence = 0; private chunks: Buffer[] = []; private bufferedBytes = 0; private closed = false; private exited = false; private closing?: Promise; constructor(binary: string, server: string, port: number, logs: string, readonly videoOptions: VideoOptions = { decode_path: "browser", decoder: "auto" }) { this.child = spawn( binary, [ "agent", "--stdio", "--password-fd", "3", "--video-ws", "--server", server, "--port", String(port), "--log-dir", logs, ], { stdio: ["pipe", "pipe", "ignore", "pipe"], shell: false, windowsHide: true, env: nativeEnvironment(), }, ); this.child.stdout!.on("data", (part: Buffer) => this.receive(part)); this.child.stdin!.on("error", () => this.fail()); this.child.stdio[3]!.on("error", () => this.fail()); this.child.once("error", () => this.fail()); // Process exit can precede the final stdout data. Drain replies until EOF // or close before rejecting requests that have no response. this.child.once("close", () => { this.exited = true; this.fail(); }); this.child.stdout!.once("end", () => this.fail()); } async connect( device: string, password: string, mode: string, display: number, signal?: AbortSignal, ): Promise { if (Buffer.byteLength(password) > 256 || /[\0\r\n]/.test(password)) throw new Failure("INVALID_CREDENTIAL"); const hello = await this.request( "hello", { protocol: "crossdesk-automation/1" }, 5000, signal, ); if (hello.protocol !== "crossdesk-automation/1" || hello.terminal_protocol !== "ansi/1" || hello.video_transport !== "loopback-websocket/1" || !hello.video_protocols?.includes(this.videoOptions.decode_path === "minirtc" ? "nv12/1" : "h264-annexb/1") || !hello.video_decoders?.includes(this.videoOptions.decoder)) throw new Failure("NATIVE_PROTOCOL_MISMATCH"); const secret = Buffer.from(password + "\n"); password = ""; (this.child.stdio[3] as Writable).end(secret, () => secret.fill(0)); return this.request("connect", { device, mode, display, ...this.videoOptions }, 7000, signal); } request( method: string, args: Data = {}, timeout = 7000, signal?: AbortSignal, ): Promise { if (this.closed || (this.closing && method !== "disconnect")) return Promise.reject(new Failure("SESSION_CLOSED")); if (signal?.aborted) return Promise.reject(new Failure("CANCELLED")); if (this.pending.size >= 32 || this.child.stdin!.writableLength > 262144) return Promise.reject(new Failure("BUSY", false, true)); const id = ++this.sequence; const line = JSON.stringify({ ...args, id, method }) + "\n"; if (Buffer.byteLength(line) > 65536) return Promise.reject(new Failure("MESSAGE_TOO_LARGE")); return new Promise((resolve, reject) => { const cancel = (code: string) => { const p = this.pending.get(id); if (!p) return; this.pending.delete(id); p.clean(); // Cancellation is best effort; a timed-out action is never replayed. this.child.stdin?.write( JSON.stringify({ id: ++this.sequence, method: "cancel", request_id: id, }) + "\n", ); reject(new Failure(code, method === "action")); }; const abort = () => cancel("CANCELLED"); const timer = setTimeout(() => cancel("NATIVE_TIMEOUT"), timeout); signal?.addEventListener("abort", abort, { once: true }); this.pending.set(id, { resolve, reject, action: method === "action", clean: () => { clearTimeout(timer); signal?.removeEventListener("abort", abort); }, }); this.child.stdin!.write(line); }); } private receive(part: Buffer) { if (this.closed) return; for (let start = 0; start < part.length;) { const end = part.indexOf(10, start); const chunk = part.subarray(start, end < 0 ? part.length : end); this.bufferedBytes += chunk.length; if (this.bufferedBytes > 4 * 1024 * 1024) { this.fail(); this.child.kill(); return; } this.chunks.push(chunk); if (end < 0) return; // One allocation and UTF-8 decode per response, including split UTF-8 // codepoints. Do not repeatedly scan/copy a multi-megabyte PNG response. const line = this.chunks.length === 1 ? chunk : Buffer.concat(this.chunks, this.bufferedBytes); this.chunks = []; this.bufferedBytes = 0; start = end + 1; try { const reply = JSON.parse(line.toString("utf8")); if (!reply || !Number.isSafeInteger(reply.id) || reply.id < 0 || typeof reply.ok !== "boolean" || (reply.ok && (!reply.result || typeof reply.result !== "object" || Array.isArray(reply.result))) || (!reply.ok && (!reply.error || typeof reply.error.code !== "string"))) throw new Error(); const p = this.pending.get(reply.id); if (!p) continue; this.pending.delete(reply.id); p.clean(); if (reply.ok) p.resolve(reply.result); else p.reject( new Failure( /^[A-Z_]+$/.test(reply.error?.code) ? reply.error.code : "NATIVE_ERROR", !!reply.error?.side_effect_possible, !!reply.error?.retryable, ), ); } catch { this.fail(); this.child.kill(); return; } } } private fail() { this.closed = true; this.chunks = []; this.bufferedBytes = 0; for (const p of this.pending.values()) { p.clean(); p.reject(new Failure("SESSION_CLOSED", p.action)); } this.pending.clear(); } close(): Promise { if (this.closing) return this.closing; this.closing = (async () => { if (!this.closed) await this.request("disconnect", {}, 1000).catch(() => {}); this.child.stdin?.end(); if (!this.exited && this.child.exitCode === null && this.child.signalCode === null) await new Promise((resolve) => { const kill = setTimeout(() => { this.child.kill("SIGKILL"); }, 2000); // Keep the device lock until the child really exits. this.child.once("close", () => { clearTimeout(kill); resolve(); }); }); this.fail(); })(); return this.closing; } }