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:
@@ -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.");
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
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: {
|
||||
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"],
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
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",
|
||||
"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",
|
||||
|
||||
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;
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user