mirror of
https://github.com/kunkundi/crossdesk.git
synced 2026-11-05 13:42:38 +08:00
[feat] add CrossDesk MCP with remote desktop panels
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
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_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 buffer = "";
|
||||
private closed = 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!.setEncoding("utf8");
|
||||
this.child.stdout!.on("data", (part: string) => this.receive(part));
|
||||
this.child.stdin!.on("error", () => this.fail());
|
||||
this.child.stdio[3]!.on("error", () => this.fail());
|
||||
this.child.once("error", () => this.fail());
|
||||
this.child.once("exit", () => 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.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)
|
||||
return Promise.reject(new Failure("SESSION_CLOSED", method === "action"));
|
||||
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: string) {
|
||||
this.buffer += part;
|
||||
if (Buffer.byteLength(this.buffer) > 8 * 1024 * 1024) {
|
||||
this.fail();
|
||||
this.child.kill();
|
||||
return;
|
||||
}
|
||||
for (;;) {
|
||||
const end = this.buffer.indexOf("\n");
|
||||
if (end < 0) break;
|
||||
const line = this.buffer.slice(0, end);
|
||||
this.buffer = this.buffer.slice(end + 1);
|
||||
try {
|
||||
if (Buffer.byteLength(line) > 4 * 1024 * 1024) throw new Error();
|
||||
const reply = JSON.parse(line);
|
||||
if (!Number.isSafeInteger(reply.id) || typeof reply.ok !== "boolean")
|
||||
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;
|
||||
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.child.exitCode === null && this.child.signalCode === null)
|
||||
await new Promise<void>((resolve) => {
|
||||
const kill = setTimeout(() => {
|
||||
this.child.kill("SIGKILL");
|
||||
resolve();
|
||||
}, 2000);
|
||||
this.child.once("exit", () => {
|
||||
clearTimeout(kill);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
this.fail();
|
||||
})();
|
||||
return this.closing;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user