Add Nocturne debug web UI, core process control, and web QR login
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
/*
|
||||
* 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 } 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 = 20;
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user