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:
5
.changeset/add-fn-desktop-command.md
Normal file
5
.changeset/add-fn-desktop-command.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@gsxdsm/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add a new `fn desktop` command that launches the Electron desktop app, optionally builds desktop artifacts, boots a dashboard server, and wires runtime environment flags for dev and paused modes.
|
||||||
@@ -212,6 +212,9 @@ fn dashboard # Start the web UI (default port 4040)
|
|||||||
fn dashboard --interactive # Start with interactive port selection
|
fn dashboard --interactive # Start with interactive port selection
|
||||||
fn dashboard --paused # Start with automation paused
|
fn dashboard --paused # Start with automation paused
|
||||||
fn dashboard --dev # Start web UI only (no AI engine)
|
fn dashboard --dev # Start web UI only (no AI engine)
|
||||||
|
fn desktop # Launch Electron desktop app with embedded dashboard server
|
||||||
|
fn desktop --dev # Launch desktop app against Vite dev renderer (hot-reload)
|
||||||
|
fn desktop --paused # Launch desktop app with automation paused
|
||||||
```
|
```
|
||||||
|
|
||||||
**Task Operations:**
|
**Task Operations:**
|
||||||
@@ -746,6 +749,8 @@ pnpm install
|
|||||||
pnpm dev:ui # Dashboard only; builds and typechecks first
|
pnpm dev:ui # Dashboard only; builds and typechecks first
|
||||||
pnpm dev dashboard # Board + AI engine
|
pnpm dev dashboard # Board + AI engine
|
||||||
pnpm dev task list # CLI commands
|
pnpm dev task list # CLI commands
|
||||||
|
pnpm --filter @fusion/desktop dev # Desktop hot-reload workflow (renderer + Electron)
|
||||||
|
pnpm build:desktop # Production desktop build pipeline
|
||||||
pnpm typecheck # Type-check all packages (no build required)
|
pnpm typecheck # Type-check all packages (no build required)
|
||||||
pnpm test # Run all tests
|
pnpm test # Run all tests
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -25,7 +25,8 @@
|
|||||||
"mobile:android": "pnpm mobile:build && pnpm --filter @fusion/mobile cap open android",
|
"mobile:android": "pnpm mobile:build && pnpm --filter @fusion/mobile cap open android",
|
||||||
"mobile:dev:ios": "pnpm --filter @fusion/mobile dev:ios",
|
"mobile:dev:ios": "pnpm --filter @fusion/mobile dev:ios",
|
||||||
"mobile:dev:android": "pnpm --filter @fusion/mobile dev:android",
|
"mobile:dev:android": "pnpm --filter @fusion/mobile dev:android",
|
||||||
"mobile:sync": "pnpm --filter @fusion/mobile cap sync"
|
"mobile:sync": "pnpm --filter @fusion/mobile cap sync",
|
||||||
|
"build:desktop": "pnpm --filter @fusion/desktop build"
|
||||||
},
|
},
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|||||||
|
|
||||||
const runTaskCreate = vi.fn();
|
const runTaskCreate = vi.fn();
|
||||||
const runTaskList = vi.fn();
|
const runTaskList = vi.fn();
|
||||||
|
const runDesktop = vi.fn();
|
||||||
const runTaskPlan = vi.fn();
|
const runTaskPlan = vi.fn();
|
||||||
const runTaskImportFromGitHub = vi.fn();
|
const runTaskImportFromGitHub = vi.fn();
|
||||||
const runSettingsShow = vi.fn();
|
const runSettingsShow = vi.fn();
|
||||||
@@ -36,6 +37,10 @@ vi.mock("../commands/dashboard.js", () => ({
|
|||||||
runDashboard: vi.fn(),
|
runDashboard: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../commands/desktop.js", () => ({
|
||||||
|
runDesktop,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("../commands/task.js", () => ({
|
vi.mock("../commands/task.js", () => ({
|
||||||
runTaskCreate,
|
runTaskCreate,
|
||||||
runTaskList,
|
runTaskList,
|
||||||
@@ -272,6 +277,16 @@ describe("bin", () => {
|
|||||||
expect(errorSpy).toHaveBeenCalledWith("Unknown subcommand: node wat");
|
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 () => {
|
it("rejects duplicate --project flags", async () => {
|
||||||
await expect(runBin(["task", "list", "--project", "one", "-P", "two"]))
|
await expect(runBin(["task", "list", "--project", "one", "-P", "two"]))
|
||||||
.rejects.toThrow("Duplicate --project flag. Specify a project only once.");
|
.rejects.toThrow("Duplicate --project flag. Specify a project only once.");
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
|
|
||||||
const commandMocks = vi.hoisted(() => ({
|
const commandMocks = vi.hoisted(() => ({
|
||||||
runDashboard: vi.fn(),
|
runDashboard: vi.fn(),
|
||||||
|
runDesktop: vi.fn(),
|
||||||
runTaskCreate: vi.fn(),
|
runTaskCreate: vi.fn(),
|
||||||
runTaskList: vi.fn(),
|
runTaskList: vi.fn(),
|
||||||
runTaskMove: vi.fn(),
|
runTaskMove: vi.fn(),
|
||||||
@@ -52,6 +53,7 @@ const commandMocks = vi.hoisted(() => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("./commands/dashboard.js", () => ({ runDashboard: commandMocks.runDashboard }));
|
vi.mock("./commands/dashboard.js", () => ({ runDashboard: commandMocks.runDashboard }));
|
||||||
|
vi.mock("./commands/desktop.js", () => ({ runDesktop: commandMocks.runDesktop }));
|
||||||
vi.mock("./commands/task.js", () => ({
|
vi.mock("./commands/task.js", () => ({
|
||||||
runTaskCreate: commandMocks.runTaskCreate,
|
runTaskCreate: commandMocks.runTaskCreate,
|
||||||
runTaskList: commandMocks.runTaskList,
|
runTaskList: commandMocks.runTaskList,
|
||||||
@@ -129,8 +131,10 @@ async function runBin(args: string[]) {
|
|||||||
await import("./bin.ts?test=3");
|
await import("./bin.ts?test=3");
|
||||||
} else if (importCounter === 4) {
|
} else if (importCounter === 4) {
|
||||||
await import("./bin.ts?test=4");
|
await import("./bin.ts?test=4");
|
||||||
} else {
|
} else if (importCounter === 5) {
|
||||||
await import("./bin.ts?test=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"]);
|
await runBin(["mission", "activate-slice", "SL-001"]);
|
||||||
expect(commandMocks.runMissionActivateSlice).toHaveBeenCalledWith("SL-001", undefined);
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ if (isBunBinary) {
|
|||||||
|
|
||||||
// Dynamic imports so the pi-coding-agent config module sees PI_PACKAGE_DIR
|
// Dynamic imports so the pi-coding-agent config module sees PI_PACKAGE_DIR
|
||||||
const { runDashboard } = await import("./commands/dashboard.js");
|
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 { 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 { runSettingsShow, runSettingsSet } = await import("./commands/settings.js");
|
||||||
const { runSettingsExport } = await import("./commands/settings-export.js");
|
const { runSettingsExport } = await import("./commands/settings-export.js");
|
||||||
@@ -62,6 +63,9 @@ Usage:
|
|||||||
fn dashboard --paused Start with automation paused
|
fn dashboard --paused Start with automation paused
|
||||||
fn dashboard --dev Start web UI only (no AI engine)
|
fn dashboard --dev Start web UI only (no AI engine)
|
||||||
fn dashboard --interactive Start with interactive port selection
|
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 create [desc] [opts] Create a new task (goes to triage)
|
||||||
fn task plan [description] [opts] Create task via AI-guided planning
|
fn task plan [description] [opts] Create task via AI-guided planning
|
||||||
fn task list List all tasks
|
fn task list List all tasks
|
||||||
@@ -295,6 +299,14 @@ async function main() {
|
|||||||
break;
|
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": {
|
case "project": {
|
||||||
const subcommand = args[1];
|
const subcommand = args[1];
|
||||||
switch (subcommand) {
|
switch (subcommand) {
|
||||||
|
|||||||
263
packages/cli/src/commands/desktop.test.ts
Normal file
263
packages/cli/src/commands/desktop.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
170
packages/cli/src/commands/desktop.ts
Normal file
170
packages/cli/src/commands/desktop.ts
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -6,7 +6,9 @@ export default defineConfig({
|
|||||||
test: {
|
test: {
|
||||||
include: ["src/**/*.test.ts"],
|
include: ["src/**/*.test.ts"],
|
||||||
maxWorkers,
|
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: {
|
coverage: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
reporter: ["text", "html", "json"],
|
reporter: ["text", "html", "json"],
|
||||||
|
|||||||
@@ -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.
|
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
|
Run a single command from the workspace root:
|
||||||
fn dashboard
|
|
||||||
```
|
|
||||||
|
|
||||||
Then, in another terminal, start the desktop app:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm --filter @fusion/desktop dev
|
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
|
## IPC Channel Reference
|
||||||
|
|
||||||
`src/ipc.ts` registers the renderer ↔ main process bridge used by `window.fusionAPI`.
|
`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
|
## Scripts
|
||||||
|
|
||||||
- `pnpm --filter @fusion/desktop dev` — run the Electron main process in development
|
- `pnpm --filter @fusion/desktop dev` — hot-reload workflow (main/preload bundle + dashboard Vite dev server + Electron)
|
||||||
- `pnpm --filter @fusion/desktop build` — compile TypeScript sources
|
- `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 test` — run Vitest suite
|
||||||
- `pnpm --filter @fusion/desktop typecheck` — run TypeScript checks without emitting files
|
- `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 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 pack` — generate unpacked artifacts via electron-builder (`--dir`)
|
||||||
- `pnpm --filter @fusion/desktop dist` — build distribution artifacts without publishing
|
- `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
|
## Environment
|
||||||
|
|
||||||
|
|||||||
62
packages/desktop/electron-builder.yml
Normal file
62
packages/desktop/electron-builder.yml
Normal 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
|
||||||
@@ -3,41 +3,18 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/main.ts",
|
"main": "dist/main.js",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.5.0"
|
"node": ">=22.5.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx src/main.ts",
|
"dev": "tsx scripts/dev.ts",
|
||||||
"build": "tsc",
|
"build": "tsx scripts/build.ts",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"generate:icons": "tsx scripts/generate-icons.ts",
|
"generate:icons": "tsx scripts/generate-icons.ts",
|
||||||
"pack": "electron-builder",
|
"pack": "electron-builder --dir",
|
||||||
"dist": "electron-builder --publish never"
|
"dist": "electron-builder"
|
||||||
},
|
|
||||||
"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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"electron-updater": "^6.6.0"
|
"electron-updater": "^6.6.0"
|
||||||
@@ -50,6 +27,7 @@
|
|||||||
"@vitest/coverage-v8": "^3.1.0",
|
"@vitest/coverage-v8": "^3.1.0",
|
||||||
"electron": "^35.0.0",
|
"electron": "^35.0.0",
|
||||||
"electron-builder": "^26.0.0",
|
"electron-builder": "^26.0.0",
|
||||||
|
"esbuild": "^0.25.12",
|
||||||
"jsdom": "^29.0.1",
|
"jsdom": "^29.0.1",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
|||||||
98
packages/desktop/scripts/build.ts
Normal file
98
packages/desktop/scripts/build.ts
Normal 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;
|
||||||
|
});
|
||||||
175
packages/desktop/scripts/dev.ts
Normal file
175
packages/desktop/scripts/dev.ts
Normal 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);
|
||||||
|
});
|
||||||
100
packages/desktop/src/__tests__/main.integration.test.ts
Normal file
100
packages/desktop/src/__tests__/main.integration.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
delete process.env.FUSION_DASHBOARD_URL;
|
||||||
|
|
||||||
const { DASHBOARD_URL } = await importMainModule();
|
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 () => {
|
it("DASHBOARD_URL uses env override", async () => {
|
||||||
@@ -125,7 +126,7 @@ describe("main process", () => {
|
|||||||
|
|
||||||
expect(options.webPreferences.contextIsolation).toBe(true);
|
expect(options.webPreferences.contextIsolation).toBe(true);
|
||||||
expect(options.webPreferences.nodeIntegration).toBe(false);
|
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 () => {
|
it("createMainWindow loads the dashboard URL", async () => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { app, BrowserWindow, nativeImage, Tray } from "electron";
|
import { app, BrowserWindow, nativeImage, Tray } from "electron";
|
||||||
import { join, resolve } from "node:path";
|
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 { setupDeepLinkHandler, registerDeepLinkProtocol } from "./deep-link.js";
|
||||||
import { registerIpcHandlers } from "./ipc.js";
|
import { registerIpcHandlers } from "./ipc.js";
|
||||||
import { buildAppMenu } from "./menu.js";
|
import { buildAppMenu } from "./menu.js";
|
||||||
@@ -17,11 +17,33 @@ interface AppWithQuitFlag {
|
|||||||
isQuitting?: boolean;
|
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 mainWindow: BrowserWindow | null = null;
|
||||||
let tray: Tray | null = null;
|
let tray: Tray | null = null;
|
||||||
|
|
||||||
export const DASHBOARD_URL = process.env.FUSION_DASHBOARD_URL || "http://localhost:4040";
|
|
||||||
|
|
||||||
function getAppWithQuitFlag(): Electron.App & AppWithQuitFlag {
|
function getAppWithQuitFlag(): Electron.App & AppWithQuitFlag {
|
||||||
return app as 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 } : {}),
|
...(hasValidPosition ? { x: state.x, y: state.y } : {}),
|
||||||
title: "Fusion",
|
title: "Fusion",
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: join(import.meta.dirname, "preload.ts"),
|
preload: join(import.meta.dirname, "preload.js"),
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ export default defineConfig({
|
|||||||
test: {
|
test: {
|
||||||
include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
|
include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
|
||||||
environmentMatchGlobs: [["src/renderer/**", "jsdom"]],
|
environmentMatchGlobs: [["src/renderer/**", "jsdom"]],
|
||||||
|
pool: "threads",
|
||||||
|
testTimeout: 30_000,
|
||||||
|
hookTimeout: 30_000,
|
||||||
maxWorkers,
|
maxWorkers,
|
||||||
fileParallelism: true,
|
fileParallelism: true,
|
||||||
pool: "threads",
|
|
||||||
passWithNoTests: true,
|
passWithNoTests: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -276,6 +276,9 @@ importers:
|
|||||||
electron-builder:
|
electron-builder:
|
||||||
specifier: ^26.0.0
|
specifier: ^26.0.0
|
||||||
version: 26.8.1(electron-builder-squirrel-windows@26.8.1)
|
version: 26.8.1(electron-builder-squirrel-windows@26.8.1)
|
||||||
|
esbuild:
|
||||||
|
specifier: ^0.25.12
|
||||||
|
version: 0.25.12
|
||||||
jsdom:
|
jsdom:
|
||||||
specifier: ^29.0.1
|
specifier: ^29.0.1
|
||||||
version: 29.0.1
|
version: 29.0.1
|
||||||
|
|||||||
Reference in New Issue
Block a user