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 1c17cfaa67
commit ecf0eb6d84
19 changed files with 1006 additions and 50 deletions

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,
},
});