/* * 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 { 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 { 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 { const child = this.child; if (!child) return this.status(); await new Promise(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 { await this.stop(); return this.start(forceToken); } /** Stop the core and any Xvfb that this daemon started. */ async dispose(timeoutMs = 4000): Promise { 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 { 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 } } } }