Files
fusion/packages/cli/src/commands/desktop.ts
2026-06-21 02:15:24 -07:00

216 lines
6.1 KiB
TypeScript

import { spawn, type ChildProcess } from "node:child_process";
import { once } from "node:events";
import { join } from "node:path";
import type { AddressInfo } from "node:net";
import { createRequire } from "node:module";
import { CentralCore, TaskStore } from "@fusion/core";
import { createServer } from "@fusion/dashboard";
import { ProjectEngineManager } from "@fusion/engine";
import { ensureCwdProjectRegistered } from "./ensure-project-registered.js";
const require = createRequire(import.meta.url);
export interface RunDesktopOptions {
dev?: boolean;
paused?: boolean;
interactive?: boolean;
}
interface DashboardRuntime {
store: TaskStore;
server: import("node:http").Server;
port: number;
engineManager?: ProjectEngineManager;
centralCore?: CentralCore;
}
function runCommand(command: string, args: string[], cwd: string): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd,
stdio: "inherit",
env: process.env,
});
child.on("error", (error) => reject(error));
child.on("exit", (code) => {
if (code === 0) {
resolve();
return;
}
reject(new Error(`${command} ${args.join(" ")} exited with code ${code ?? "unknown"}`));
});
});
}
async function buildDesktopArtifacts(rootDir: string): Promise<void> {
await runCommand("pnpm", ["--filter", "@fusion/desktop", "build"], rootDir);
}
async function startDashboardRuntime(rootDir: string, paused: boolean): Promise<DashboardRuntime> {
const store = new TaskStore(rootDir);
let server: import("node:http").Server | null = null;
let engineManager: ProjectEngineManager | undefined;
let centralCore: CentralCore | undefined;
try {
await store.init();
await store.watch();
if (paused) {
await store.updateSettings({ enginePaused: true });
}
/*
* FNXC:DesktopRuntime 2026-06-20-23:39:
* Desktop local mode must start the same project engine lifecycle as CLI dashboard mode; a desktop window without engines leaves users with a live dashboard that cannot execute tasks.
*/
centralCore = new CentralCore();
await centralCore.init();
const cwdRegistered = await ensureCwdProjectRegistered({
cwd: rootDir,
central: centralCore,
logPrefix: "desktop",
autoRegister: true,
});
engineManager = new ProjectEngineManager(centralCore);
await engineManager.startAll();
engineManager.startReconciliation();
const cwdEngine = cwdRegistered
? await engineManager.ensureEngine(cwdRegistered.id).catch((err) => {
console.warn(`[desktop] Failed to warm cwd project engine: ${err instanceof Error ? err.message : String(err)}`);
return undefined;
})
: undefined;
const app = createServer(store, {
engine: cwdEngine,
engineManager,
centralCore,
onProjectFirstAccessed: (projectId: string) => engineManager?.onProjectAccessed(projectId),
});
server = app.listen(0);
await Promise.race([
once(server, "listening"),
once(server, "error").then(([error]) => {
throw error;
}),
]);
const address = server.address() as AddressInfo | null;
if (!address?.port) {
throw new Error("Failed to determine dashboard server port");
}
return {
store,
server,
port: address.port,
engineManager,
centralCore,
};
} catch (error) {
if (server) {
await new Promise<void>((resolve) => server?.close(() => resolve()));
}
await engineManager?.stopAll().catch(() => undefined);
await centralCore?.close?.().catch(() => undefined);
store.close();
throw error;
}
}
async function closeDashboardRuntime(runtime: DashboardRuntime): Promise<void> {
await new Promise<void>((resolve) => {
runtime.server.close(() => resolve());
});
await runtime.engineManager?.stopAll().catch(() => undefined);
await runtime.centralCore?.close?.().catch(() => undefined);
runtime.store.close();
}
function resolveElectronBinary(): string {
if (process.env.FUSION_ELECTRON_BINARY) {
return process.env.FUSION_ELECTRON_BINARY;
}
return require("electron") as string;
}
function terminateProcess(child: ChildProcess | null, signal: NodeJS.Signals = "SIGTERM"): void {
if (!child || child.killed) {
return;
}
child.kill(signal);
}
export async function runDesktop(options: RunDesktopOptions = {}): Promise<void> {
const rootDir = process.cwd();
if (!options.dev) {
await buildDesktopArtifacts(rootDir);
}
const runtime = await startDashboardRuntime(rootDir, Boolean(options.paused));
const electronBinary = resolveElectronBinary();
const desktopEntry = join(rootDir, "packages", "desktop", "dist", "main.js");
const electronArgs = ["--enable-source-maps", desktopEntry, ...(options.dev ? ["--dev"] : [])];
// Build environment for Electron process
const electronEnv: NodeJS.ProcessEnv = {
...process.env,
FUSION_SERVER_PORT: String(runtime.port),
};
// In dev mode, set FUSION_DASHBOARD_URL to the dashboard runtime URL
// In production mode, renderer uses embedded assets (no FUSION_DASHBOARD_URL needed)
if (options.dev) {
electronEnv.FUSION_DASHBOARD_URL = process.env.FUSION_DASHBOARD_URL ?? "http://localhost:5173";
electronEnv.NODE_ENV = "development";
}
const electronProcess = spawn(electronBinary, electronArgs, {
cwd: rootDir,
stdio: "inherit",
env: electronEnv,
});
let isShuttingDown = false;
const shutdown = async (exitCode: number): Promise<void> => {
if (isShuttingDown) {
return;
}
isShuttingDown = true;
process.off("SIGINT", onSigint);
process.off("SIGTERM", onSigterm);
terminateProcess(electronProcess);
await closeDashboardRuntime(runtime);
process.exit(exitCode);
};
const onSigint = () => {
void shutdown(0);
};
const onSigterm = () => {
void shutdown(0);
};
process.on("SIGINT", onSigint);
process.on("SIGTERM", onSigterm);
electronProcess.on("error", (error) => {
console.error(`Failed to launch Electron: ${(error as Error).message}`);
void shutdown(1);
});
electronProcess.on("exit", (code) => {
void shutdown(code ?? 0);
});
}