feat(FN-1073): add desktop CLI workflow and packaging support

- Add new `fn desktop` CLI command with argument handling and comprehensive command/bin tests
- Implement desktop build and hot-reload dev scripts and wire package scripts/dependencies for Electron workflows
- Add electron-builder configuration and desktop main-process/integration test coverage to stabilize packaging behavior
- Document desktop development and usage in README files and include a changeset for the published CLI package
This commit is contained in:
gsxdsm
2026-04-08 01:40:05 -07:00
parent 025b60164d
commit be19cb974c
19 changed files with 1006 additions and 50 deletions

View File

@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
const runTaskCreate = vi.fn();
const runTaskList = vi.fn();
const runDesktop = vi.fn();
const runTaskPlan = vi.fn();
const runTaskImportFromGitHub = vi.fn();
const runSettingsShow = vi.fn();
@@ -36,6 +37,10 @@ vi.mock("../commands/dashboard.js", () => ({
runDashboard: vi.fn(),
}));
vi.mock("../commands/desktop.js", () => ({
runDesktop,
}));
vi.mock("../commands/task.js", () => ({
runTaskCreate,
runTaskList,
@@ -272,6 +277,16 @@ describe("bin", () => {
expect(errorSpy).toHaveBeenCalledWith("Unknown subcommand: node wat");
});
it("routes desktop command flags", async () => {
await runBin(["desktop", "--dev", "--paused", "--interactive"]);
expect(runDesktop).toHaveBeenCalledWith({
paused: true,
dev: true,
interactive: true,
});
});
it("rejects duplicate --project flags", async () => {
await expect(runBin(["task", "list", "--project", "one", "-P", "two"]))
.rejects.toThrow("Duplicate --project flag. Specify a project only once.");

View File

@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const commandMocks = vi.hoisted(() => ({
runDashboard: vi.fn(),
runDesktop: vi.fn(),
runTaskCreate: vi.fn(),
runTaskList: vi.fn(),
runTaskMove: vi.fn(),
@@ -52,6 +53,7 @@ const commandMocks = vi.hoisted(() => ({
}));
vi.mock("./commands/dashboard.js", () => ({ runDashboard: commandMocks.runDashboard }));
vi.mock("./commands/desktop.js", () => ({ runDesktop: commandMocks.runDesktop }));
vi.mock("./commands/task.js", () => ({
runTaskCreate: commandMocks.runTaskCreate,
runTaskList: commandMocks.runTaskList,
@@ -129,8 +131,10 @@ async function runBin(args: string[]) {
await import("./bin.ts?test=3");
} else if (importCounter === 4) {
await import("./bin.ts?test=4");
} else {
} else if (importCounter === 5) {
await import("./bin.ts?test=5");
} else {
await import("./bin.ts?test=6");
}
}
@@ -182,4 +186,13 @@ describe("bin mission command integration", () => {
await runBin(["mission", "activate-slice", "SL-001"]);
expect(commandMocks.runMissionActivateSlice).toHaveBeenCalledWith("SL-001", undefined);
});
it("routes desktop flags to runDesktop", async () => {
await runBin(["desktop", "--dev", "--paused", "--interactive"]);
expect(commandMocks.runDesktop).toHaveBeenCalledWith({
paused: true,
dev: true,
interactive: true,
});
});
});

View File

@@ -39,6 +39,7 @@ if (isBunBinary) {
// Dynamic imports so the pi-coding-agent config module sees PI_PACKAGE_DIR
const { runDashboard } = await import("./commands/dashboard.js");
const { runDesktop } = await import("./commands/desktop.js");
const { runTaskCreate, runTaskList, runTaskMove, runTaskMerge, runTaskUpdate, runTaskLog, runTaskLogs, runTaskShow, runTaskAttach, runTaskPause, runTaskUnpause, runTaskImportFromGitHub, runTaskDuplicate, runTaskArchive, runTaskUnarchive, runTaskRefine, runTaskPlan, runTaskDelete, runTaskRetry, runTaskComment, runTaskComments, runTaskSteer, runTaskPrCreate } = await import("./commands/task.js");
const { runSettingsShow, runSettingsSet } = await import("./commands/settings.js");
const { runSettingsExport } = await import("./commands/settings-export.js");
@@ -62,6 +63,9 @@ Usage:
fn dashboard --paused Start with automation paused
fn dashboard --dev Start web UI only (no AI engine)
fn dashboard --interactive Start with interactive port selection
fn desktop Launch the Fusion desktop app (Electron)
fn desktop --dev Launch with hot-reload (connects to Vite dev server)
fn desktop --paused Launch with automation paused
fn task create [desc] [opts] Create a new task (goes to triage)
fn task plan [description] [opts] Create task via AI-guided planning
fn task list List all tasks
@@ -295,6 +299,14 @@ async function main() {
break;
}
case "desktop": {
const paused = args.includes("--paused");
const dev = args.includes("--dev");
const interactive = args.includes("--interactive");
await runDesktop({ paused, dev, interactive });
break;
}
case "project": {
const subcommand = args[1];
switch (subcommand) {

View File

@@ -0,0 +1,263 @@
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
type Listener = (...args: any[]) => void;
interface MockEmitter {
on(event: string, listener: Listener): MockEmitter;
once(event: string, listener: Listener): MockEmitter;
off(event: string, listener: Listener): MockEmitter;
emit(event: string, ...args: any[]): boolean;
}
interface MockChild extends MockEmitter {
kill: ReturnType<typeof vi.fn>;
killed: boolean;
}
const mocks = vi.hoisted(() => {
function createEmitter(): MockEmitter {
const listeners = new Map<string, Set<Listener>>();
const add = (event: string, listener: Listener) => {
const eventListeners = listeners.get(event) ?? new Set<Listener>();
eventListeners.add(listener);
listeners.set(event, eventListeners);
};
const remove = (event: string, listener: Listener) => {
const eventListeners = listeners.get(event);
if (!eventListeners) return;
eventListeners.delete(listener);
if (eventListeners.size === 0) {
listeners.delete(event);
}
};
return {
on(event: string, listener: Listener) {
add(event, listener);
return this;
},
once(event: string, listener: Listener) {
const wrapped: Listener = (...args: any[]) => {
remove(event, wrapped);
listener(...args);
};
add(event, wrapped);
return this;
},
off(event: string, listener: Listener) {
remove(event, listener);
return this;
},
emit(event: string, ...args: any[]) {
const eventListeners = listeners.get(event);
if (!eventListeners || eventListeners.size === 0) {
return false;
}
for (const listener of [...eventListeners]) {
listener(...args);
}
return true;
},
};
}
function createMockChild(): MockChild {
const emitter = createEmitter();
const child = emitter as MockChild;
child.killed = false;
child.kill = vi.fn((() => {
child.killed = true;
return true;
}) as unknown as MockChild["kill"]);
return child;
}
const state = {
buildChild: createMockChild(),
electronChild: createMockChild(),
};
const store = {
init: vi.fn().mockResolvedValue(undefined),
watch: vi.fn().mockResolvedValue(undefined),
updateSettings: vi.fn().mockResolvedValue(undefined),
close: vi.fn(),
};
const server = Object.assign(createEmitter(), {
address: vi.fn(() => ({ port: 4545 })),
close: vi.fn((callback?: () => void) => {
callback?.();
}),
});
const app = {
listen: vi.fn(() => {
queueMicrotask(() => {
server.emit("listening");
});
return server;
}),
};
const spawn = vi.fn((command: string) => {
if (command === "pnpm") {
queueMicrotask(() => {
state.buildChild.emit("exit", 0);
});
return state.buildChild;
}
return state.electronChild;
});
return {
state,
createMockChild,
store,
server,
app,
spawn,
taskStoreCtor: vi.fn(() => store),
createServer: vi.fn(() => app),
};
});
vi.mock("node:child_process", () => ({
spawn: mocks.spawn,
}));
vi.mock("@fusion/core", () => ({
TaskStore: mocks.taskStoreCtor,
}));
vi.mock("@fusion/dashboard", () => ({
createServer: mocks.createServer,
}));
import { runDesktop } from "./desktop.js";
describe("runDesktop", () => {
const originalCwd = process.cwd;
const originalExit = process.exit;
const originalElectronBinary = process.env.FUSION_ELECTRON_BINARY;
const originalDashboardUrl = process.env.FUSION_DASHBOARD_URL;
beforeEach(() => {
vi.clearAllMocks();
process.env.FUSION_ELECTRON_BINARY = "electron-bin";
delete process.env.FUSION_DASHBOARD_URL;
mocks.state.buildChild = mocks.createMockChild();
mocks.state.electronChild = mocks.createMockChild();
mocks.server.address.mockReturnValue({ port: 4545 });
mocks.app.listen.mockImplementation(() => {
queueMicrotask(() => {
mocks.server.emit("listening");
});
return mocks.server;
});
mocks.server.close.mockImplementation((callback?: () => void) => {
callback?.();
});
vi.spyOn(process, "cwd").mockReturnValue("/repo");
process.exit = vi.fn() as never;
});
afterEach(() => {
vi.restoreAllMocks();
process.cwd = originalCwd;
process.exit = originalExit;
if (originalElectronBinary === undefined) {
delete process.env.FUSION_ELECTRON_BINARY;
} else {
process.env.FUSION_ELECTRON_BINARY = originalElectronBinary;
}
if (originalDashboardUrl === undefined) {
delete process.env.FUSION_DASHBOARD_URL;
} else {
process.env.FUSION_DASHBOARD_URL = originalDashboardUrl;
}
});
it("builds desktop app, starts dashboard on random port, and launches Electron", async () => {
await runDesktop({ paused: true });
expect(mocks.spawn).toHaveBeenCalledWith(
"pnpm",
["--filter", "@fusion/desktop", "build"],
expect.objectContaining({ cwd: "/repo" }),
);
expect(mocks.taskStoreCtor).toHaveBeenCalledWith("/repo");
expect(mocks.store.updateSettings).toHaveBeenCalledWith({ enginePaused: true });
expect(mocks.app.listen).toHaveBeenCalledWith(0);
expect(mocks.spawn).toHaveBeenCalledWith(
"electron-bin",
["--enable-source-maps", "/repo/packages/desktop/dist/main.js"],
expect.objectContaining({
cwd: "/repo",
env: expect.objectContaining({
FUSION_DASHBOARD_URL: "http://localhost:4545",
FUSION_SERVER_PORT: "4545",
}),
}),
);
mocks.state.electronChild.emit("exit", 0);
await new Promise((resolve) => setTimeout(resolve, 0));
});
it("supports --dev mode by skipping build and pointing at Vite URL", async () => {
process.env.FUSION_DASHBOARD_URL = "http://localhost:5173";
await runDesktop({ dev: true });
const buildCalls = mocks.spawn.mock.calls.filter(([command]) => command === "pnpm");
expect(buildCalls).toHaveLength(0);
expect(mocks.spawn).toHaveBeenCalledWith(
"electron-bin",
["--enable-source-maps", "/repo/packages/desktop/dist/main.js", "--dev"],
expect.objectContaining({
env: expect.objectContaining({
NODE_ENV: "development",
FUSION_DASHBOARD_URL: "http://localhost:5173",
}),
}),
);
mocks.state.electronChild.emit("exit", 0);
await new Promise((resolve) => setTimeout(resolve, 0));
});
it("cleans up dashboard runtime when Electron exits", async () => {
await runDesktop();
mocks.state.electronChild.emit("exit", 7);
await new Promise((resolve) => setTimeout(resolve, 0));
expect(mocks.server.close).toHaveBeenCalledTimes(1);
expect(mocks.store.close).toHaveBeenCalledTimes(1);
expect(process.exit).toHaveBeenCalledWith(7);
});
it("handles SIGINT by terminating Electron and shutting down services", async () => {
await runDesktop();
process.emit("SIGINT");
await new Promise((resolve) => setTimeout(resolve, 0));
expect(mocks.state.electronChild.kill).toHaveBeenCalledWith("SIGTERM");
expect(mocks.server.close).toHaveBeenCalledTimes(1);
expect(mocks.store.close).toHaveBeenCalledTimes(1);
expect(process.exit).toHaveBeenCalledWith(0);
});
});

View File

@@ -0,0 +1,170 @@
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 { TaskStore } from "@fusion/core";
import { createServer } from "@fusion/dashboard";
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;
}
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);
await store.init();
await store.watch();
if (paused) {
await store.updateSettings({ enginePaused: true });
}
const app = createServer(store);
const server = app.listen(0);
try {
await Promise.race([
once(server, "listening"),
once(server, "error").then(([error]) => {
throw error;
}),
]);
} catch (error) {
store.close();
throw error;
}
const address = server.address() as AddressInfo | null;
if (!address?.port) {
server.close();
store.close();
throw new Error("Failed to determine dashboard server port");
}
return {
store,
server,
port: address.port,
};
}
async function closeDashboardRuntime(runtime: DashboardRuntime): Promise<void> {
await new Promise<void>((resolve) => {
runtime.server.close(() => resolve());
});
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 rendererUrl = options.dev
? process.env.FUSION_DASHBOARD_URL ?? "http://localhost:5173"
: `http://localhost:${runtime.port}`;
const electronBinary = resolveElectronBinary();
const desktopEntry = join(rootDir, "packages", "desktop", "dist", "main.js");
const electronArgs = ["--enable-source-maps", desktopEntry, ...(options.dev ? ["--dev"] : [])];
const electronProcess = spawn(electronBinary, electronArgs, {
cwd: rootDir,
stdio: "inherit",
env: {
...process.env,
FUSION_DASHBOARD_URL: rendererUrl,
FUSION_SERVER_PORT: String(runtime.port),
...(options.dev ? { NODE_ENV: "development" } : {}),
},
});
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);
});
}

View File

@@ -6,7 +6,9 @@ export default defineConfig({
test: {
include: ["src/**/*.test.ts"],
maxWorkers,
fileParallelism: true,
// build-exe and build-exe-cross suites both operate on packages/cli/dist/
// and can race when run in parallel workers.
fileParallelism: false,
coverage: {
enabled: false,
reporter: ["text", "html", "json"],

View File

@@ -4,20 +4,38 @@ Electron desktop shell for Fusion.
This package provides a native Electron wrapper around the existing Fusion dashboard web UI. The desktop shell connects to a running dashboard server and presents native desktop affordances including a system tray and application menu.
## Prerequisites
## Running the Desktop Shell
Start the Fusion dashboard server first:
### Hot-reload development workflow
```bash
fn dashboard
```
Then, in another terminal, start the desktop app:
Run a single command from the workspace root:
```bash
pnpm --filter @fusion/desktop dev
```
This command now orchestrates the full desktop dev loop:
1. Bundles Electron `main.ts` and `preload.ts` to `packages/desktop/dist`
2. Starts the dashboard Vite renderer dev server (`@fusion/dashboard dev:serve`)
3. Waits for renderer readiness
4. Launches Electron with `--dev` and live renderer reload
By default it uses `http://localhost:5173`. Override with `FUSION_DASHBOARD_URL`.
### Production-style desktop launch (from CLI)
```bash
fn desktop
```
`fn desktop` builds desktop artifacts, starts an embedded dashboard server on an ephemeral port, and launches Electron against that server.
Useful flags:
- `fn desktop --dev` — use dev renderer URL (`FUSION_DASHBOARD_URL` or `http://localhost:5173`)
- `fn desktop --paused` — start with engine paused
## IPC Channel Reference
`src/ipc.ts` registers the renderer ↔ main process bridge used by `window.fusionAPI`.
@@ -206,13 +224,24 @@ Tray icons are generated from `packages/dashboard/app/public/logo.svg`.
## Scripts
- `pnpm --filter @fusion/desktop dev`run the Electron main process in development
- `pnpm --filter @fusion/desktop build`compile TypeScript sources
- `pnpm --filter @fusion/desktop dev`hot-reload workflow (main/preload bundle + dashboard Vite dev server + Electron)
- `pnpm --filter @fusion/desktop build`production desktop build (dashboard client build + main/preload bundle + asset copy)
- `pnpm --filter @fusion/desktop test` — run Vitest suite
- `pnpm --filter @fusion/desktop typecheck` — run TypeScript checks without emitting files
- `pnpm --filter @fusion/desktop generate:icons` — regenerate tray icon PNG assets from the dashboard logo SVG
- `pnpm --filter @fusion/desktop pack`build distributable package via electron-builder
- `pnpm --filter @fusion/desktop dist`build distribution artifacts without publishing
- `pnpm --filter @fusion/desktop pack`generate unpacked artifacts via electron-builder (`--dir`)
- `pnpm --filter @fusion/desktop dist`generate installable desktop artifacts via electron-builder
## Packaging
Desktop packaging is configured in `electron-builder.yml`.
- Output directory: `packages/desktop/dist-electron`
- Targets: macOS (`dmg`, `zip`), Windows (`nsis`, `portable`), Linux (`AppImage`, `deb`, `tar.gz`)
- Deep link protocol: `fusion://`
- Publish provider: GitHub (`gsxdsm/fusion`)
Run `pnpm --filter @fusion/desktop build` before `pack`/`dist` to ensure `dist/` assets are up to date.
## Environment

View File

@@ -0,0 +1,62 @@
appId: com.gsxdsm.fusion.desktop
productName: Fusion
artifactName: "${productName}-${version}-${os}-${arch}.${ext}"
directories:
output: dist-electron
buildResources: src/icons
files:
- dist/**/*
- package.json
extraMetadata:
main: dist/main.js
extraResources:
- from: src/icons
to: icons
filter:
- "tray-*.png"
protocols:
- name: Fusion Deep Links
schemes:
- fusion
fileAssociations:
- ext: fusion
name: Fusion Task Link
description: Fusion desktop deep link
role: Viewer
mimeType: x-scheme-handler/fusion
publish:
provider: github
owner: gsxdsm
repo: fusion
mac:
category: public.app-category.developer-tools
minimumSystemVersion: "10.15"
target:
- target: dmg
- target: zip
win:
target:
- target: nsis
- target: portable
nsis:
oneClick: false
perMachine: false
allowElevation: false
allowToChangeInstallationDirectory: true
linux:
category: Development
target:
- target: AppImage
- target: deb
- target: tar.gz

View File

@@ -3,41 +3,18 @@
"version": "0.1.0",
"private": true,
"type": "module",
"main": "src/main.ts",
"main": "dist/main.js",
"engines": {
"node": ">=22.5.0"
},
"scripts": {
"dev": "tsx src/main.ts",
"build": "tsc",
"dev": "tsx scripts/dev.ts",
"build": "tsx scripts/build.ts",
"test": "vitest run",
"typecheck": "tsc --noEmit",
"generate:icons": "tsx scripts/generate-icons.ts",
"pack": "electron-builder",
"dist": "electron-builder --publish never"
},
"build": {
"appId": "com.fusion.desktop",
"productName": "Fusion",
"directories": {
"output": "dist-electron"
},
"mac": {
"category": "public.app-category.developer-tools",
"target": [
"dmg"
]
},
"win": {
"target": [
"nsis"
]
},
"linux": {
"target": [
"AppImage"
]
}
"pack": "electron-builder --dir",
"dist": "electron-builder"
},
"dependencies": {
"electron-updater": "^6.6.0"
@@ -50,6 +27,7 @@
"@vitest/coverage-v8": "^3.1.0",
"electron": "^35.0.0",
"electron-builder": "^26.0.0",
"esbuild": "^0.25.12",
"jsdom": "^29.0.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",

View File

@@ -0,0 +1,98 @@
import { build } from "esbuild";
import { cp, mkdir, rm, stat } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { spawn } from "node:child_process";
const __dirname = dirname(fileURLToPath(import.meta.url));
const packageRoot = resolve(__dirname, "..");
const workspaceRoot = resolve(packageRoot, "..", "..");
const dashboardClientDir = join(workspaceRoot, "packages", "dashboard", "dist", "client");
const desktopDistDir = join(packageRoot, "dist");
const desktopClientDistDir = join(desktopDistDir, "client");
function run(command: string, args: string[], cwd: string): Promise<void> {
return new Promise((resolvePromise, rejectPromise) => {
const child = spawn(command, args, {
cwd,
stdio: "inherit",
env: process.env,
});
child.on("error", (error) => {
rejectPromise(error);
});
child.on("exit", (code) => {
if (code === 0) {
resolvePromise();
return;
}
rejectPromise(new Error(`${command} ${args.join(" ")} exited with code ${code ?? "unknown"}`));
});
});
}
async function ensureDashboardBuild(): Promise<void> {
console.log("[desktop:build] Building dashboard client...");
await run("pnpm", ["--filter", "@fusion/dashboard", "build:client"], workspaceRoot);
try {
await stat(dashboardClientDir);
} catch {
throw new Error(`Dashboard client assets not found: ${dashboardClientDir}`);
}
}
async function buildElectronEntrypoints(): Promise<void> {
console.log("[desktop:build] Bundling Electron main/preload with esbuild...");
await Promise.all([
build({
entryPoints: [join(packageRoot, "src", "main.ts")],
outfile: join(desktopDistDir, "main.js"),
bundle: true,
format: "esm",
platform: "node",
target: "node22",
sourcemap: true,
packages: "external",
external: ["electron"],
logLevel: "info",
}),
build({
entryPoints: [join(packageRoot, "src", "preload.ts")],
outfile: join(desktopDistDir, "preload.js"),
bundle: true,
format: "esm",
platform: "node",
target: "node22",
sourcemap: true,
packages: "external",
external: ["electron"],
logLevel: "info",
}),
]);
}
async function copyDashboardClient(): Promise<void> {
console.log("[desktop:build] Copying dashboard client into desktop dist/client...");
await cp(dashboardClientDir, desktopClientDistDir, { recursive: true });
}
async function main(): Promise<void> {
await rm(desktopDistDir, { recursive: true, force: true });
await mkdir(desktopDistDir, { recursive: true });
await ensureDashboardBuild();
await buildElectronEntrypoints();
await copyDashboardClient();
console.log("[desktop:build] Desktop build complete");
}
void main().catch((error) => {
console.error("[desktop:build] Build failed", error);
process.exitCode = 1;
});

View File

@@ -0,0 +1,175 @@
import { build } from "esbuild";
import { spawn, type ChildProcess } from "node:child_process";
import { mkdir } from "node:fs/promises";
import { dirname, join, resolve } from "node:path";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const packageRoot = resolve(__dirname, "..");
const workspaceRoot = resolve(packageRoot, "..", "..");
const distDir = join(packageRoot, "dist");
const require = createRequire(import.meta.url);
const sleep = (ms: number): Promise<void> => new Promise((resolvePromise) => {
setTimeout(resolvePromise, ms);
});
function run(command: string, args: string[], cwd: string, env: NodeJS.ProcessEnv = process.env): ChildProcess {
return spawn(command, args, {
cwd,
env,
stdio: "inherit",
});
}
async function buildMainProcess(): Promise<void> {
await mkdir(distDir, { recursive: true });
await Promise.all([
build({
entryPoints: [join(packageRoot, "src", "main.ts")],
outfile: join(distDir, "main.js"),
bundle: true,
format: "esm",
platform: "node",
target: "node22",
sourcemap: true,
packages: "external",
external: ["electron"],
logLevel: "info",
}),
build({
entryPoints: [join(packageRoot, "src", "preload.ts")],
outfile: join(distDir, "preload.js"),
bundle: true,
format: "esm",
platform: "node",
target: "node22",
sourcemap: true,
packages: "external",
external: ["electron"],
logLevel: "info",
}),
]);
}
function resolveDashboardUrl(): URL {
const raw = process.env.FUSION_DASHBOARD_URL ?? "http://localhost:5173";
const parsed = new URL(raw);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error(`FUSION_DASHBOARD_URL must be http(s), received: ${raw}`);
}
return parsed;
}
async function waitForRenderer(url: string, timeoutMs: number = 60_000): Promise<void> {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
try {
const response = await fetch(url);
if (response.ok) {
return;
}
} catch {
// Keep polling until timeout.
}
await sleep(500);
}
throw new Error(`Renderer dev server did not become ready at ${url} within ${timeoutMs}ms`);
}
async function main(): Promise<void> {
console.log("[desktop:dev] Building main/preload entrypoints...");
await buildMainProcess();
const dashboardUrl = resolveDashboardUrl();
const dashboardHost = dashboardUrl.hostname;
const dashboardPort = dashboardUrl.port || (dashboardUrl.protocol === "https:" ? "443" : "80");
console.log(`[desktop:dev] Starting dashboard Vite dev server on ${dashboardUrl.origin}...`);
const viteProcess = run(
"pnpm",
[
"--filter",
"@fusion/dashboard",
"dev:serve",
"--host",
dashboardHost,
"--port",
dashboardPort,
"--strictPort",
],
workspaceRoot,
);
let isShuttingDown = false;
let electronProcess: ChildProcess | null = null;
const shutdown = (code: number): void => {
if (isShuttingDown) {
return;
}
isShuttingDown = true;
if (electronProcess && !electronProcess.killed) {
electronProcess.kill("SIGTERM");
}
if (!viteProcess.killed) {
viteProcess.kill("SIGTERM");
}
setTimeout(() => {
if (electronProcess && !electronProcess.killed) {
electronProcess.kill("SIGKILL");
}
if (!viteProcess.killed) {
viteProcess.kill("SIGKILL");
}
process.exit(code);
}, 200);
};
process.on("SIGINT", () => shutdown(0));
process.on("SIGTERM", () => shutdown(0));
viteProcess.on("exit", (code) => {
if (!isShuttingDown) {
const exitCode = code ?? 1;
console.error(`[desktop:dev] Dashboard dev server exited early with code ${exitCode}`);
shutdown(exitCode);
}
});
await waitForRenderer(dashboardUrl.toString());
console.log("[desktop:dev] Renderer dev server ready. Launching Electron...");
const electronBinary = require("electron") as string;
electronProcess = run(
electronBinary,
["--enable-source-maps", join(distDir, "main.js"), "--dev"],
packageRoot,
{
...process.env,
NODE_ENV: "development",
FUSION_DASHBOARD_URL: dashboardUrl.toString(),
},
);
electronProcess.on("exit", (code) => {
shutdown(code ?? 0);
});
}
void main().catch((error) => {
console.error("[desktop:dev] Failed to start development workflow", error);
process.exit(1);
});

View File

@@ -0,0 +1,100 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => {
const app = {
whenReady: vi.fn(() => Promise.resolve()),
on: vi.fn(),
quit: vi.fn(),
};
const browserWindow = {
loadURL: vi.fn(),
on: vi.fn(),
hide: vi.fn(),
show: vi.fn(),
maximize: vi.fn(),
};
return {
app,
BrowserWindow: vi.fn(() => browserWindow),
Tray: vi.fn(() => ({
destroy: vi.fn(),
setImage: vi.fn(),
setContextMenu: vi.fn(),
setToolTip: vi.fn(),
on: vi.fn(),
})),
nativeImage: {
createEmpty: vi.fn(() => ({ id: "empty-image" })),
},
browserWindow,
buildAppMenu: vi.fn(),
setupTray: vi.fn(),
registerIpcHandlers: vi.fn(),
registerDeepLinkProtocol: vi.fn(),
setupDeepLinkHandler: vi.fn(),
loadWindowState: vi.fn(async () => null),
saveWindowState: vi.fn(),
setupAutoUpdater: vi.fn(),
};
});
vi.mock("electron", () => ({
app: mocks.app,
BrowserWindow: mocks.BrowserWindow,
Tray: mocks.Tray,
nativeImage: mocks.nativeImage,
}));
vi.mock("../menu.js", () => ({
buildAppMenu: mocks.buildAppMenu,
}));
vi.mock("../tray.js", () => ({
setupTray: mocks.setupTray,
}));
vi.mock("../ipc.js", () => ({
registerIpcHandlers: mocks.registerIpcHandlers,
}));
vi.mock("../deep-link.js", () => ({
registerDeepLinkProtocol: mocks.registerDeepLinkProtocol,
setupDeepLinkHandler: mocks.setupDeepLinkHandler,
}));
vi.mock("../native.js", () => ({
DEFAULT_WINDOW_STATE: {
width: 1280,
height: 900,
isMaximized: false,
},
loadWindowState: mocks.loadWindowState,
saveWindowState: mocks.saveWindowState,
setupAutoUpdater: mocks.setupAutoUpdater,
}));
describe("main module integration", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.resetModules();
});
it("imports main module exports with a mocked electron runtime", async () => {
const mainModule = await import("../main.ts");
expect(mainModule.run).toBeTypeOf("function");
expect(mainModule.initializeApp).toBeTypeOf("function");
expect(mainModule.createMainWindow).toBeTypeOf("function");
});
it("initializes app lifecycle wiring without throwing", async () => {
const { initializeApp } = await import("../main.ts");
await expect(initializeApp()).resolves.toBeUndefined();
expect(mocks.BrowserWindow).toHaveBeenCalledTimes(1);
expect(mocks.registerIpcHandlers).toHaveBeenCalledTimes(1);
expect(mocks.setupTray).toHaveBeenCalledTimes(1);
});
});

View File

@@ -91,12 +91,13 @@ describe("main process", () => {
}
});
it("DASHBOARD_URL defaults to localhost:4040", async () => {
it("DASHBOARD_URL defaults to local file URL in production mode", async () => {
delete process.env.FUSION_DASHBOARD_URL;
const { DASHBOARD_URL } = await importMainModule();
expect(DASHBOARD_URL).toBe("http://localhost:4040");
expect(DASHBOARD_URL.startsWith("file://")).toBe(true);
expect(DASHBOARD_URL).toContain("/client/index.html");
});
it("DASHBOARD_URL uses env override", async () => {
@@ -125,7 +126,7 @@ describe("main process", () => {
expect(options.webPreferences.contextIsolation).toBe(true);
expect(options.webPreferences.nodeIntegration).toBe(false);
expect(options.webPreferences.preload).toContain("preload.ts");
expect(options.webPreferences.preload).toContain("preload.js");
});
it("createMainWindow loads the dashboard URL", async () => {

View File

@@ -1,6 +1,6 @@
import { app, BrowserWindow, nativeImage, Tray } from "electron";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { fileURLToPath, pathToFileURL } from "node:url";
import { setupDeepLinkHandler, registerDeepLinkProtocol } from "./deep-link.js";
import { registerIpcHandlers } from "./ipc.js";
import { buildAppMenu } from "./menu.js";
@@ -17,11 +17,33 @@ interface AppWithQuitFlag {
isQuitting?: boolean;
}
const DEFAULT_DEV_DASHBOARD_URL = "http://localhost:5173";
function isDevelopmentMode(): boolean {
return process.env.NODE_ENV === "development" || process.argv.includes("--dev");
}
const PRODUCTION_DASHBOARD_URL = pathToFileURL(
join(import.meta.dirname, "client", "index.html"),
).toString();
export const IS_DEVELOPMENT = isDevelopmentMode();
export const DASHBOARD_URL = process.env.FUSION_DASHBOARD_URL ?? (
IS_DEVELOPMENT ? DEFAULT_DEV_DASHBOARD_URL : PRODUCTION_DASHBOARD_URL
);
function enableSourceMaps(): void {
const processWithSourceMaps = process as NodeJS.Process & {
setSourceMapsEnabled?: (enabled: boolean) => void;
};
processWithSourceMaps.setSourceMapsEnabled?.(true);
}
enableSourceMaps();
let mainWindow: BrowserWindow | null = null;
let tray: Tray | null = null;
export const DASHBOARD_URL = process.env.FUSION_DASHBOARD_URL || "http://localhost:4040";
function getAppWithQuitFlag(): Electron.App & AppWithQuitFlag {
return app as Electron.App & AppWithQuitFlag;
}
@@ -35,7 +57,7 @@ export function createMainWindow(state?: WindowState): BrowserWindow {
...(hasValidPosition ? { x: state.x, y: state.y } : {}),
title: "Fusion",
webPreferences: {
preload: join(import.meta.dirname, "preload.ts"),
preload: join(import.meta.dirname, "preload.js"),
contextIsolation: true,
nodeIntegration: false,
},

View File

@@ -6,9 +6,11 @@ export default defineConfig({
test: {
include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
environmentMatchGlobs: [["src/renderer/**", "jsdom"]],
pool: "threads",
testTimeout: 30_000,
hookTimeout: 30_000,
maxWorkers,
fileParallelism: true,
pool: "threads",
passWithNoTests: true,
},
});