282 lines
9.1 KiB
TypeScript
282 lines
9.1 KiB
TypeScript
/*
|
|
* Veskslop daemon — Vesktop core process control.
|
|
*
|
|
* The daemon can start, stop, and restart the Electron client that hosts the
|
|
* core bridge. Only a process started by this daemon instance is managed.
|
|
*
|
|
* On Linux without a DISPLAY, the daemon starts Xvfb and points the client at
|
|
* it. Set VESKSLOP_XVFB=0 to disable that.
|
|
*/
|
|
|
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
import { existsSync, statSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const ROOT = fileURLToPath(new URL("../..", import.meta.url));
|
|
|
|
const DEFAULT_ARGS = "--no-sandbox --disable-gpu --disable-dev-shm-usage";
|
|
const XVFB_ARGS = "-screen 0 1280x720x24 -nolisten tcp";
|
|
const MAX_OUTPUT_LINES = 200;
|
|
|
|
/**
|
|
* Warn when the built client bundle predates the core sources. Without this, a
|
|
* new daemon paired with a stale client reports UNKNOWN_ACTION for new actions.
|
|
*/
|
|
export function staleBundleWarning(): string | null {
|
|
const sources = [
|
|
"src/renderer/puppet/actions.ts",
|
|
"src/renderer/puppet/events.ts",
|
|
"src/shared/puppetProtocol.ts",
|
|
"src/main/puppetBridge.ts"
|
|
];
|
|
const bundles = ["dist/js/renderer.js", "dist/js/main.js"];
|
|
|
|
try {
|
|
let newest = 0;
|
|
for (const source of sources) newest = Math.max(newest, statSync(join(ROOT, source)).mtimeMs);
|
|
|
|
let oldest = Number.POSITIVE_INFINITY;
|
|
for (const bundle of bundles) oldest = Math.min(oldest, statSync(join(ROOT, bundle)).mtimeMs);
|
|
|
|
if (oldest < newest) {
|
|
return "the built client bundle is older than the core sources; run `pnpm build` and restart the client";
|
|
}
|
|
} catch {
|
|
// A missing bundle is reported by the client start itself.
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export interface CoreExit {
|
|
code: number | null;
|
|
signal: string | null;
|
|
at: string;
|
|
}
|
|
|
|
export interface CoreProcessStatus {
|
|
managed: boolean;
|
|
running: boolean;
|
|
pid: number | null;
|
|
startedAt: string | null;
|
|
command: string | null;
|
|
lastExit: CoreExit | null;
|
|
output: string[];
|
|
}
|
|
|
|
export interface CoreProcessOptions {
|
|
tokenFile: string;
|
|
}
|
|
|
|
function prefixLines(prefix: string, chunk: Buffer): string {
|
|
return chunk
|
|
.toString()
|
|
.split("\n")
|
|
.map(line => (line ? `${prefix} ${line}` : line))
|
|
.join("\n");
|
|
}
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function findFreeDisplay(): string {
|
|
for (let number = 99; number < 110; number++) {
|
|
if (!existsSync(`/tmp/.X11-unix/X${number}`)) return `:${number}`;
|
|
}
|
|
return ":99";
|
|
}
|
|
|
|
export class CoreProcess {
|
|
private child?: ChildProcess;
|
|
private startedAt?: number;
|
|
private command?: string;
|
|
private xvfb?: ChildProcess;
|
|
private display?: string;
|
|
private lastExit: CoreExit | null = null;
|
|
private readonly output: string[] = [];
|
|
private readonly exitListeners = new Set<() => void>();
|
|
|
|
constructor(private readonly options: CoreProcessOptions) {}
|
|
|
|
status(): CoreProcessStatus {
|
|
const running = !!this.child && this.child.exitCode === null && !this.child.killed;
|
|
return {
|
|
managed: !!this.child,
|
|
running,
|
|
pid: this.child?.pid ?? null,
|
|
startedAt: this.startedAt ? new Date(this.startedAt).toISOString() : null,
|
|
command: this.command ?? null,
|
|
lastExit: this.lastExit,
|
|
output: [...this.output]
|
|
};
|
|
}
|
|
|
|
onExit(listener: () => void) {
|
|
this.exitListeners.add(listener);
|
|
return () => this.exitListeners.delete(listener);
|
|
}
|
|
|
|
async start(forceToken = false): Promise<CoreProcessStatus> {
|
|
if (this.child) return this.status();
|
|
|
|
const override = process.env.VESKSLOP_CORE_CMD;
|
|
const args = (process.env.VESKSLOP_CORE_ARGS ?? DEFAULT_ARGS).split(/\s+/).filter(Boolean);
|
|
const display = await this.ensureDisplay();
|
|
|
|
const env = {
|
|
...process.env,
|
|
...(display ? { DISPLAY: display } : {}),
|
|
VESKSLOP_HEADLESS: process.env.VESKSLOP_HEADLESS ?? "1",
|
|
VESKSLOP_TOKEN_FILE: this.options.tokenFile,
|
|
...(forceToken ? { VESKSLOP_FORCE_TOKEN: "1" } : {})
|
|
};
|
|
|
|
let command: string;
|
|
let child: ChildProcess;
|
|
|
|
if (override) {
|
|
command = override;
|
|
child = spawn(override, { cwd: ROOT, env, shell: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
} else {
|
|
const electron = join(
|
|
ROOT,
|
|
"node_modules",
|
|
".bin",
|
|
process.platform === "win32" ? "electron.cmd" : "electron"
|
|
);
|
|
if (!existsSync(electron)) {
|
|
throw new Error("electron is not installed; run pnpm install or set VESKSLOP_CORE_CMD");
|
|
}
|
|
command = `${electron} . ${args.join(" ")}`;
|
|
child = spawn(electron, [".", ...args], { cwd: ROOT, env, stdio: ["ignore", "pipe", "pipe"] });
|
|
}
|
|
|
|
this.child = child;
|
|
this.startedAt = Date.now();
|
|
this.command = command;
|
|
this.lastExit = null;
|
|
this.output.length = 0;
|
|
|
|
child.stdout?.on("data", chunk => {
|
|
this.appendOutput(chunk);
|
|
process.stdout.write(prefixLines("[core]", chunk));
|
|
});
|
|
child.stderr?.on("data", chunk => {
|
|
this.appendOutput(chunk);
|
|
process.stderr.write(prefixLines("[core]", chunk));
|
|
});
|
|
|
|
child.on("error", error => {
|
|
console.error("[core] failed to start:", error.message);
|
|
this.appendOutput(Buffer.from(error.message));
|
|
});
|
|
|
|
child.on("exit", (code, signal) => {
|
|
console.log(`[core] exited (code ${code ?? "null"}${signal ? `, signal ${signal}` : ""})`);
|
|
this.lastExit = { code, signal, at: new Date().toISOString() };
|
|
this.child = undefined;
|
|
this.startedAt = undefined;
|
|
this.command = undefined;
|
|
for (const listener of this.exitListeners) listener();
|
|
});
|
|
|
|
console.log(`[core] starting: ${command}${display ? ` (DISPLAY=${display})` : ""}`);
|
|
return this.status();
|
|
}
|
|
|
|
async stop(timeoutMs = 8000): Promise<CoreProcessStatus> {
|
|
const child = this.child;
|
|
if (!child) return this.status();
|
|
|
|
await new Promise<void>(resolve => {
|
|
const timer = setTimeout(() => {
|
|
try {
|
|
child.kill("SIGKILL");
|
|
} catch {
|
|
// already gone
|
|
}
|
|
}, timeoutMs);
|
|
|
|
child.once("exit", () => {
|
|
clearTimeout(timer);
|
|
resolve();
|
|
});
|
|
|
|
try {
|
|
child.kill("SIGTERM");
|
|
} catch {
|
|
clearTimeout(timer);
|
|
resolve();
|
|
}
|
|
});
|
|
|
|
return this.status();
|
|
}
|
|
|
|
async restart(forceToken = false): Promise<CoreProcessStatus> {
|
|
await this.stop();
|
|
return this.start(forceToken);
|
|
}
|
|
|
|
/** Stop the core and any Xvfb that this daemon started. */
|
|
async dispose(timeoutMs = 4000): Promise<void> {
|
|
await this.stop(timeoutMs);
|
|
this.stopXvfb();
|
|
}
|
|
|
|
private appendOutput(chunk: Buffer) {
|
|
for (const line of chunk.toString().split("\n")) {
|
|
if (!line.trim()) continue;
|
|
this.output.push(line);
|
|
if (this.output.length > MAX_OUTPUT_LINES) this.output.shift();
|
|
}
|
|
}
|
|
|
|
private async ensureDisplay(): Promise<string | undefined> {
|
|
if (process.env.DISPLAY) return process.env.DISPLAY;
|
|
if (process.platform !== "linux" || process.env.VESKSLOP_XVFB === "0") return undefined;
|
|
if (this.xvfb && this.xvfb.exitCode === null) return this.display;
|
|
|
|
const display = findFreeDisplay();
|
|
const child = spawn("Xvfb", [display, ...XVFB_ARGS.split(" ")], { stdio: ["ignore", "ignore", "pipe"] });
|
|
|
|
child.stderr?.on("data", chunk => process.stderr.write(prefixLines("[xvfb]", chunk)));
|
|
child.on("error", error => {
|
|
console.warn(`[core] Xvfb is not available: ${error.message}`);
|
|
this.xvfb = undefined;
|
|
this.display = undefined;
|
|
});
|
|
child.on("exit", () => {
|
|
this.xvfb = undefined;
|
|
this.display = undefined;
|
|
});
|
|
|
|
this.xvfb = child;
|
|
this.display = display;
|
|
|
|
// Wait for the X socket so Electron does not race the server startup.
|
|
const socket = `/tmp/.X11-unix/X${display.slice(1)}`;
|
|
for (let attempt = 0; attempt < 30 && !existsSync(socket); attempt++) await sleep(100);
|
|
if (!existsSync(socket)) console.warn(`[core] Xvfb socket ${socket} did not appear; continuing anyway`);
|
|
|
|
console.log(`[core] Xvfb on ${display} (pid ${child.pid})`);
|
|
return display;
|
|
}
|
|
|
|
private stopXvfb() {
|
|
const child = this.xvfb;
|
|
this.xvfb = undefined;
|
|
this.display = undefined;
|
|
|
|
if (child && child.exitCode === null) {
|
|
try {
|
|
child.kill("SIGTERM");
|
|
} catch {
|
|
// already gone
|
|
}
|
|
}
|
|
}
|
|
}
|