mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-11-05 05:22:38 +08:00
287 lines
9.5 KiB
TypeScript
287 lines
9.5 KiB
TypeScript
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<string> {
|
|
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<number, Pending>();
|
|
private sequence = 0;
|
|
private chunks: Buffer[] = [];
|
|
private bufferedBytes = 0;
|
|
private closed = false;
|
|
private exited = false;
|
|
private closing?: Promise<void>;
|
|
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<Data> {
|
|
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<Data> {
|
|
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<void> {
|
|
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<void>((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;
|
|
}
|
|
}
|