feat(FN-3404): add local embedded runtime manager to desktop app
Adds a local runtime manager to the desktop app (FN-3404) — an embedded engine process managed via IPC with packaging for bundled dependencies and lifecycle documentation. Also includes project mapping onboarding UI with persistence and rollback (FN-3505), plus related dashboard API and hook updates Fusion-Task-Id: FN-3404
This commit is contained in:
@@ -89,7 +89,10 @@ Desktop boots through a shell-owned mode chooser before mounting the dashboard a
|
||||
| `window:isMaximized` | renderer → main | none | `Promise<boolean>` |
|
||||
| `app:getSystemInfo` | renderer → main | none | `Promise<{ platform; arch; electronVersion; nodeVersion; appVersion; }>` |
|
||||
| `app:checkForUpdates` | renderer → main | none | `Promise<{ status: "checking" } \| { status: "error"; error: string }>` |
|
||||
| `app:getServerPort` | renderer → main | none | `Promise<number \| undefined>` |
|
||||
| `app:getServerPort` | renderer → main | none | `Promise<number \| undefined>` (external CLI port when present; otherwise embedded local runtime port when running) |
|
||||
| `desktopRuntime:getStatus` | renderer → main | none | `Promise<DesktopRuntimeStatus>` |
|
||||
| `desktopRuntime:startLocal` | renderer → main | none | `Promise<DesktopRuntimeStatus>` |
|
||||
| `desktopRuntime:stopLocal` | renderer → main | none | `Promise<DesktopRuntimeStatus>` |
|
||||
| `tray:updateStatus` | renderer → main | `status: "running" \| "paused" \| "stopped"` | `Promise<void>` |
|
||||
| `native:showExportDialog` | renderer → main | none | `Promise<string \| null>` |
|
||||
| `native:showImportDialog` | renderer → main | none | `Promise<string \| null>` |
|
||||
@@ -104,13 +107,28 @@ Desktop boots through a shell-owned mode chooser before mounting the dashboard a
|
||||
|
||||
## Local Bundled Runtime Lifecycle
|
||||
|
||||
Desktop local mode uses an in-process runtime manager (`src/local-server.ts`) that mirrors the CLI desktop server pattern:
|
||||
Desktop local mode uses an in-process runtime manager (`src/local-runtime.ts`) that mirrors the CLI desktop server pattern:
|
||||
|
||||
- creates `TaskStore`, calls `init()` and `watch()`
|
||||
- creates the dashboard server with `createServer(store)`
|
||||
- listens on an ephemeral port (`0`, never `4040`)
|
||||
- reports `idle | starting | ready | error` local runtime state via `window.fusionShell`
|
||||
- starts automatically when desktop mode is `local`, stops on remote switch and app shutdown
|
||||
- reports runtime status as:
|
||||
- `source`: `"embedded-local" | "external-cli" | "none"`
|
||||
- `state`: `"stopped" | "starting" | "running" | "error"`
|
||||
- optional `port`, `baseUrl`, and `error`
|
||||
- keeps shutdown idempotent and exact-once for embedded server close and store close
|
||||
|
||||
### Runtime source rules
|
||||
|
||||
- **external-cli**: when `FUSION_SERVER_PORT` is provided (for example by `fn desktop`), Electron treats the server as CLI-owned and does **not** start an embedded server. `desktopRuntime:stopLocal` is a no-op in this state and never kills the CLI-owned server.
|
||||
- **embedded-local**: when started inside Electron via runtime IPC or startup env activation.
|
||||
- **none**: no active runtime.
|
||||
|
||||
### Activation rules
|
||||
|
||||
- Desktop does **not** auto-start embedded local runtime by default.
|
||||
- Embedded local runtime starts at launch only when `FUSION_DESKTOP_MODE=local` is set.
|
||||
- Future onboarding/connection flows can start/stop embedded local runtime explicitly over IPC.
|
||||
|
||||
## Main Process Lifecycle
|
||||
|
||||
@@ -148,6 +166,7 @@ Desktop local mode uses an in-process runtime manager (`src/local-server.ts`) th
|
||||
- `window.electronAPI`
|
||||
- Window control: `minimize()`, `maximize()`, `close()`, `isMaximized()`
|
||||
- App/system: `getSystemInfo()`, `checkForUpdates()`, `getServerPort()`
|
||||
- Desktop runtime: `getDesktopRuntimeStatus()`, `startDesktopLocalRuntime()`, `stopDesktopLocalRuntime()`
|
||||
- Tray: `updateTrayStatus(status)`
|
||||
- Native dialogs: `showExportDialog()`, `showImportDialog()`
|
||||
- Event subscriptions (return unsubscribe functions):
|
||||
|
||||
@@ -9,6 +9,12 @@ directories:
|
||||
files:
|
||||
- dist/**/*
|
||||
- package.json
|
||||
- node_modules/@fusion/core/**/*
|
||||
- node_modules/@fusion/dashboard/**/*
|
||||
- node_modules/better-sqlite3/**/*
|
||||
|
||||
asarUnpack:
|
||||
- node_modules/better-sqlite3/**/*
|
||||
|
||||
extraMetadata:
|
||||
main: dist/main.js
|
||||
|
||||
@@ -28,6 +28,8 @@
|
||||
"dist": "electron-builder"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fusion/core": "workspace:*",
|
||||
"@fusion/dashboard": "workspace:*",
|
||||
"electron-updater": "^6.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -10,6 +10,7 @@ const workspaceRoot = resolve(packageRoot, "..", "..");
|
||||
const dashboardClientDir = join(workspaceRoot, "packages", "dashboard", "dist", "client");
|
||||
const desktopDistDir = join(packageRoot, "dist");
|
||||
const desktopClientDistDir = join(desktopDistDir, "client");
|
||||
const externalMainProcessPackages = ["electron", "@fusion/core", "@fusion/dashboard"];
|
||||
|
||||
function run(command: string, args: string[], cwd: string): Promise<void> {
|
||||
return new Promise((resolvePromise, rejectPromise) => {
|
||||
@@ -58,7 +59,7 @@ async function buildElectronEntrypoints(): Promise<void> {
|
||||
target: "node22",
|
||||
sourcemap: true,
|
||||
packages: "external",
|
||||
external: ["electron"],
|
||||
external: externalMainProcessPackages,
|
||||
logLevel: "info",
|
||||
}),
|
||||
build({
|
||||
@@ -70,7 +71,7 @@ async function buildElectronEntrypoints(): Promise<void> {
|
||||
target: "node22",
|
||||
sourcemap: true,
|
||||
packages: "external",
|
||||
external: ["electron"],
|
||||
external: externalMainProcessPackages,
|
||||
logLevel: "info",
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -124,21 +124,25 @@ describe("ipc handlers", () => {
|
||||
expect(channels.has("shell:saveProfile")).toBe(true);
|
||||
expect(channels.has("shell:getDesktopModeState")).toBe(true);
|
||||
expect(channels.has("shell:setDesktopMode")).toBe(true);
|
||||
expect(channels.has("desktopRuntime:getStatus")).toBe(true);
|
||||
expect(channels.has("desktopRuntime:startLocal")).toBe(true);
|
||||
expect(channels.has("desktopRuntime:stopLocal")).toBe(true);
|
||||
expect(channels.has("platform:get")).toBe(true);
|
||||
});
|
||||
|
||||
it("shell:getState returns desktop shell state", async () => {
|
||||
await registerHandlers();
|
||||
await registerHandlers({ getRuntimeStatus: () => ({ source: "none", state: "stopped" }) });
|
||||
const handler = mocks.ipcHandlers.get("shell:getState");
|
||||
const result = await handler?.({});
|
||||
|
||||
expect(result).toMatchObject({ host: "desktop-shell", desktopMode: "remote" });
|
||||
expect(result).toMatchObject({ desktopModeState: { isFirstRun: false, desktopMode: "remote" } });
|
||||
expect(result).toMatchObject({ localRuntime: { source: "none", state: "stopped" } });
|
||||
});
|
||||
|
||||
it("shell:setDesktopMode persists mode and emits state", async () => {
|
||||
const onDesktopModeChange = vi.fn(async () => undefined);
|
||||
const { window } = await registerHandlers({ onDesktopModeChange });
|
||||
const { window } = await registerHandlers({ onDesktopModeChange, getRuntimeStatus: () => ({ source: "none", state: "stopped" }) });
|
||||
const handler = mocks.ipcHandlers.get("shell:setDesktopMode");
|
||||
await handler?.({}, "local");
|
||||
|
||||
@@ -149,6 +153,18 @@ describe("ipc handlers", () => {
|
||||
expect(window.webContents.send).toHaveBeenCalledWith("shell:state", expect.any(Object));
|
||||
});
|
||||
|
||||
it("desktopRuntime start/stop/getStatus handlers proxy runtime manager", async () => {
|
||||
const getRuntimeStatus = vi.fn(() => ({ source: "none", state: "stopped" }));
|
||||
const startLocalRuntime = vi.fn(async () => ({ source: "embedded-local", state: "running", port: 4510 }));
|
||||
const stopLocalRuntime = vi.fn(async () => ({ source: "embedded-local", state: "running", port: 9999 }));
|
||||
|
||||
await registerHandlers({ getRuntimeStatus, startLocalRuntime, stopLocalRuntime });
|
||||
|
||||
await expect(mocks.ipcHandlers.get("desktopRuntime:getStatus")?.({})).resolves.toEqual({ source: "none", state: "stopped" });
|
||||
await expect(mocks.ipcHandlers.get("desktopRuntime:startLocal")?.({})).resolves.toEqual({ source: "embedded-local", state: "running", port: 4510 });
|
||||
await expect(mocks.ipcHandlers.get("desktopRuntime:stopLocal")?.({})).resolves.toEqual({ source: "embedded-local", state: "running", port: 9999 });
|
||||
});
|
||||
|
||||
it("shell:saveProfile persists the helper-generated profile", async () => {
|
||||
await registerHandlers();
|
||||
const handler = mocks.ipcHandlers.get("shell:saveProfile");
|
||||
|
||||
157
packages/desktop/src/__tests__/local-runtime.test.ts
Normal file
157
packages/desktop/src/__tests__/local-runtime.test.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { Server } from "node:http";
|
||||
|
||||
class FakeServer {
|
||||
private listeners = new Map<string, Array<(...args: unknown[]) => void>>();
|
||||
constructor(private readonly port: number) {}
|
||||
|
||||
on(event: string, handler: (...args: unknown[]) => void): this {
|
||||
const current = this.listeners.get(event) ?? [];
|
||||
current.push(handler);
|
||||
this.listeners.set(event, current);
|
||||
return this;
|
||||
}
|
||||
|
||||
once(event: string, handler: (...args: unknown[]) => void): this {
|
||||
const wrapped = (...args: unknown[]) => {
|
||||
this.removeListener(event, wrapped);
|
||||
handler(...args);
|
||||
};
|
||||
return this.on(event, wrapped);
|
||||
}
|
||||
|
||||
removeListener(event: string, handler: (...args: unknown[]) => void): this {
|
||||
const current = this.listeners.get(event) ?? [];
|
||||
this.listeners.set(event, current.filter((item) => item !== handler));
|
||||
return this;
|
||||
}
|
||||
|
||||
emit(event: string, ...args: unknown[]): void {
|
||||
for (const handler of this.listeners.get(event) ?? []) {
|
||||
handler(...args);
|
||||
}
|
||||
}
|
||||
|
||||
address() {
|
||||
return { port: this.port };
|
||||
}
|
||||
|
||||
close(callback: () => void): void {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
|
||||
describe("LocalRuntimeManager", () => {
|
||||
const store = {
|
||||
init: vi.fn(async () => undefined),
|
||||
watch: vi.fn(async () => undefined),
|
||||
close: vi.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("starts embedded local runtime and reports status", async () => {
|
||||
const { LocalRuntimeManager } = await import("../local-runtime.ts");
|
||||
const server = new FakeServer(4545);
|
||||
const manager = new LocalRuntimeManager({
|
||||
rootDir: "/repo",
|
||||
createStore: async () => store,
|
||||
createDashboardServer: async () => {
|
||||
setTimeout(() => server.emit("listening"), 0);
|
||||
return server as unknown as Server;
|
||||
},
|
||||
});
|
||||
|
||||
const status = await manager.startLocal();
|
||||
|
||||
expect(store.init).toHaveBeenCalledTimes(1);
|
||||
expect(store.watch).toHaveBeenCalledTimes(1);
|
||||
expect(status).toMatchObject({
|
||||
source: "embedded-local",
|
||||
state: "running",
|
||||
port: 4545,
|
||||
baseUrl: "http://127.0.0.1:4545",
|
||||
});
|
||||
expect(manager.getServerPort()).toBe(4545);
|
||||
});
|
||||
|
||||
it("returns external-cli status without starting embedded runtime", async () => {
|
||||
const { LocalRuntimeManager } = await import("../local-runtime.ts");
|
||||
const manager = new LocalRuntimeManager({
|
||||
rootDir: "/repo",
|
||||
getExternalPort: () => 7777,
|
||||
createStore: async () => store,
|
||||
createDashboardServer: async () => new FakeServer(4545) as unknown as Server,
|
||||
});
|
||||
|
||||
const status = await manager.startLocal();
|
||||
|
||||
expect(status).toMatchObject({
|
||||
source: "external-cli",
|
||||
state: "running",
|
||||
port: 7777,
|
||||
baseUrl: "http://127.0.0.1:7777",
|
||||
});
|
||||
expect(store.init).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rolls back and exposes error status when startup fails", async () => {
|
||||
const { LocalRuntimeManager } = await import("../local-runtime.ts");
|
||||
store.init.mockRejectedValueOnce(new Error("init failed"));
|
||||
const manager = new LocalRuntimeManager({
|
||||
rootDir: "/repo",
|
||||
createStore: async () => store,
|
||||
});
|
||||
|
||||
await expect(manager.startLocal()).rejects.toThrow("init failed");
|
||||
expect(store.close).toHaveBeenCalledTimes(1);
|
||||
expect(manager.getStatus()).toMatchObject({
|
||||
source: "embedded-local",
|
||||
state: "error",
|
||||
error: "init failed",
|
||||
});
|
||||
});
|
||||
|
||||
it("stopLocal is idempotent and no-op when inactive", async () => {
|
||||
const { LocalRuntimeManager } = await import("../local-runtime.ts");
|
||||
const server = new FakeServer(4545);
|
||||
const closeSpy = vi.spyOn(server, "close");
|
||||
const manager = new LocalRuntimeManager({
|
||||
rootDir: "/repo",
|
||||
createStore: async () => store,
|
||||
createDashboardServer: async () => {
|
||||
setTimeout(() => server.emit("listening"), 0);
|
||||
return server as unknown as Server;
|
||||
},
|
||||
});
|
||||
|
||||
await manager.startLocal();
|
||||
await manager.stopLocal();
|
||||
await manager.stopLocal();
|
||||
|
||||
expect(closeSpy).toHaveBeenCalledTimes(1);
|
||||
expect(store.close).toHaveBeenCalledTimes(1);
|
||||
expect(manager.getStatus()).toEqual({ source: "none", state: "stopped" });
|
||||
});
|
||||
|
||||
it("startLocal while already running returns current status", async () => {
|
||||
const { LocalRuntimeManager } = await import("../local-runtime.ts");
|
||||
const server = new FakeServer(4545);
|
||||
const manager = new LocalRuntimeManager({
|
||||
rootDir: "/repo",
|
||||
createStore: async () => store,
|
||||
createDashboardServer: async () => {
|
||||
setTimeout(() => server.emit("listening"), 0);
|
||||
return server as unknown as Server;
|
||||
},
|
||||
});
|
||||
|
||||
const first = await manager.startLocal();
|
||||
const second = await manager.startLocal();
|
||||
|
||||
expect(first).toEqual(second);
|
||||
expect(store.init).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -26,14 +26,14 @@ const mocks = vi.hoisted(() => {
|
||||
const BrowserWindow = vi.fn(() => browserWindow);
|
||||
const Tray = vi.fn(() => ({ destroy: vi.fn() }));
|
||||
|
||||
const localServerManager = {
|
||||
start: vi.fn(async () => undefined),
|
||||
stop: vi.fn(async () => undefined),
|
||||
getState: vi.fn(() => ({ status: "idle", error: null })),
|
||||
getPort: vi.fn(() => undefined),
|
||||
const localRuntimeManager = {
|
||||
startLocal: vi.fn(async () => ({ source: "embedded-local", state: "running", port: 4041 })),
|
||||
stopLocal: vi.fn(async () => ({ source: "none", state: "stopped" })),
|
||||
getStatus: vi.fn(() => ({ source: "none", state: "stopped" })),
|
||||
getServerPort: vi.fn(() => undefined),
|
||||
};
|
||||
|
||||
return { app, appHandlers, BrowserWindow, Tray, browserWindow, localServerManager };
|
||||
return { app, appHandlers, BrowserWindow, Tray, browserWindow, localRuntimeManager };
|
||||
});
|
||||
|
||||
vi.mock("electron", () => ({
|
||||
@@ -49,28 +49,22 @@ vi.mock("../tray.js", () => ({ setupTray: vi.fn() }));
|
||||
vi.mock("../ipc.js", () => ({ registerIpcHandlers: vi.fn() }));
|
||||
vi.mock("../native.js", () => ({ DEFAULT_WINDOW_STATE: { width: 1000, height: 800 }, loadWindowState: vi.fn(async () => null), saveWindowState: vi.fn(), setupAutoUpdater: vi.fn() }));
|
||||
vi.mock("../deep-link.js", () => ({ registerDeepLinkProtocol: vi.fn(), setupDeepLinkHandler: vi.fn() }));
|
||||
vi.mock("../shell-settings.js", () => ({
|
||||
readShellSettings: vi.fn(async () => ({
|
||||
desktopMode: "local",
|
||||
hasCompletedModeSelection: true,
|
||||
activeProfileId: null,
|
||||
profiles: [],
|
||||
})),
|
||||
getDesktopShellModeState: () => ({ isFirstRun: false, desktopMode: "local" }),
|
||||
}));
|
||||
vi.mock("../local-server.js", () => ({ DesktopLocalServerManager: vi.fn(() => mocks.localServerManager) }));
|
||||
vi.mock("../local-runtime.js", () => ({ LocalRuntimeManager: vi.fn(() => mocks.localRuntimeManager) }));
|
||||
|
||||
describe("main local mode", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
mocks.appHandlers.clear();
|
||||
delete process.env.FUSION_DESKTOP_MODE;
|
||||
});
|
||||
|
||||
it("starts local server manager when restored desktop mode is local", async () => {
|
||||
it("starts local runtime manager when FUSION_DESKTOP_MODE is local", async () => {
|
||||
process.env.FUSION_DESKTOP_MODE = "local";
|
||||
const { initializeApp } = await import("../main.ts");
|
||||
await initializeApp();
|
||||
|
||||
expect(mocks.localServerManager.start).toHaveBeenCalled();
|
||||
expect(mocks.localRuntimeManager.startLocal).toHaveBeenCalled();
|
||||
delete process.env.FUSION_DESKTOP_MODE;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -101,10 +101,10 @@ vi.mock("electron", () => ({
|
||||
}));
|
||||
|
||||
const mainDeps = vi.hoisted(() => {
|
||||
const start = vi.fn(async () => undefined);
|
||||
const stop = vi.fn(async () => undefined);
|
||||
const getState = vi.fn(() => ({ status: "idle", error: null }));
|
||||
const getPort = vi.fn(() => 0);
|
||||
const startLocal = vi.fn(async () => ({ source: "embedded-local", state: "running", port: 4545 }));
|
||||
const stopLocal = vi.fn(async () => ({ source: "none", state: "stopped" }));
|
||||
const getStatus = vi.fn(() => ({ source: "none", state: "stopped" }));
|
||||
const getServerPort = vi.fn(() => 0);
|
||||
return {
|
||||
registerIpcHandlers: vi.fn(),
|
||||
buildAppMenu: vi.fn(),
|
||||
@@ -114,14 +114,8 @@ const mainDeps = vi.hoisted(() => {
|
||||
setupAutoUpdater: vi.fn(),
|
||||
loadWindowState: vi.fn(async () => null),
|
||||
saveWindowState: vi.fn(),
|
||||
readShellSettings: vi.fn(async () => ({
|
||||
desktopMode: null,
|
||||
hasCompletedModeSelection: false,
|
||||
activeProfileId: null,
|
||||
profiles: [],
|
||||
})),
|
||||
DesktopLocalServerManager: vi.fn(() => ({ start, stop, getState, getPort })),
|
||||
start,
|
||||
LocalRuntimeManager: vi.fn(() => ({ startLocal, stopLocal, getStatus, getServerPort })),
|
||||
startLocal,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -138,15 +132,8 @@ vi.mock("../native.js", () => ({
|
||||
saveWindowState: mainDeps.saveWindowState,
|
||||
setupAutoUpdater: mainDeps.setupAutoUpdater,
|
||||
}));
|
||||
vi.mock("../shell-settings.js", () => ({
|
||||
readShellSettings: mainDeps.readShellSettings,
|
||||
getDesktopShellModeState: (settings: { hasCompletedModeSelection: boolean; desktopMode: "local" | "remote" | null }) => ({
|
||||
isFirstRun: !settings.hasCompletedModeSelection || settings.desktopMode === null,
|
||||
desktopMode: settings.desktopMode,
|
||||
}),
|
||||
}));
|
||||
vi.mock("../local-server.js", () => ({
|
||||
DesktopLocalServerManager: mainDeps.DesktopLocalServerManager,
|
||||
vi.mock("../local-runtime.js", () => ({
|
||||
LocalRuntimeManager: mainDeps.LocalRuntimeManager,
|
||||
}));
|
||||
|
||||
async function importMainModule() {
|
||||
@@ -160,12 +147,7 @@ describe("main process", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
mainDeps.readShellSettings.mockResolvedValue({
|
||||
desktopMode: null,
|
||||
hasCompletedModeSelection: false,
|
||||
activeProfileId: null,
|
||||
profiles: [],
|
||||
});
|
||||
delete process.env.FUSION_DESKTOP_MODE;
|
||||
if (originalDashboardUrl === undefined) {
|
||||
delete process.env.FUSION_DASHBOARD_URL;
|
||||
} else {
|
||||
@@ -257,26 +239,21 @@ describe("main process", () => {
|
||||
expect(typeof mainModule.initializeApp).toBe("function");
|
||||
});
|
||||
|
||||
it("initializeApp starts local server only when persisted mode is local and not first run", async () => {
|
||||
mainDeps.readShellSettings.mockResolvedValueOnce({
|
||||
desktopMode: "local",
|
||||
hasCompletedModeSelection: true,
|
||||
activeProfileId: null,
|
||||
profiles: [],
|
||||
});
|
||||
it("initializeApp starts local runtime when FUSION_DESKTOP_MODE=local", async () => {
|
||||
process.env.FUSION_DESKTOP_MODE = "local";
|
||||
const { initializeApp } = await importMainModule();
|
||||
|
||||
await initializeApp();
|
||||
|
||||
expect(mainDeps.start).toHaveBeenCalledTimes(1);
|
||||
expect(mainDeps.startLocal).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("initializeApp does not start local server on first run without mode selection", async () => {
|
||||
it("initializeApp does not start local runtime when FUSION_DESKTOP_MODE is unset", async () => {
|
||||
const { initializeApp } = await importMainModule();
|
||||
|
||||
await initializeApp();
|
||||
|
||||
expect(mainDeps.start).not.toHaveBeenCalled();
|
||||
expect(mainDeps.startLocal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("createMainWindow registers close and closed handlers", async () => {
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
type DesktopShellMode,
|
||||
type ShellConnectionProfile,
|
||||
} from "./shell-settings.js";
|
||||
import type { DesktopLocalServerState } from "./local-server.js";
|
||||
import type { DesktopRuntimeStatus } from "./local-runtime.js";
|
||||
|
||||
interface ShellConnectionProfileInput {
|
||||
id?: string;
|
||||
@@ -29,19 +29,20 @@ interface ShellConnectionState {
|
||||
desktopMode?: DesktopShellMode;
|
||||
activeProfileId: string | null;
|
||||
profiles: ShellConnectionProfile[];
|
||||
localServer?: DesktopLocalServerState;
|
||||
localRuntime?: DesktopRuntimeStatus;
|
||||
}
|
||||
|
||||
interface RegisterIpcOptions {
|
||||
onDesktopModeChange?: (mode: DesktopShellMode) => Promise<void>;
|
||||
getLocalServerState?: () => DesktopLocalServerState;
|
||||
getRuntimeStatus?: () => DesktopRuntimeStatus;
|
||||
startLocalRuntime?: () => Promise<DesktopRuntimeStatus>;
|
||||
stopLocalRuntime?: () => Promise<DesktopRuntimeStatus>;
|
||||
getServerPort?: () => number | undefined;
|
||||
}
|
||||
|
||||
|
||||
function toShellState(
|
||||
settings: Awaited<ReturnType<typeof readShellSettings>>,
|
||||
localServerState?: DesktopLocalServerState,
|
||||
runtimeStatus?: DesktopRuntimeStatus,
|
||||
): ShellConnectionState {
|
||||
return {
|
||||
host: "desktop-shell",
|
||||
@@ -49,15 +50,15 @@ function toShellState(
|
||||
desktopMode: settings.desktopMode ?? undefined,
|
||||
activeProfileId: settings.activeProfileId,
|
||||
profiles: settings.profiles,
|
||||
localServer: localServerState ?? { status: "idle", error: null },
|
||||
localRuntime: runtimeStatus ?? { source: "none", state: "stopped" },
|
||||
};
|
||||
}
|
||||
|
||||
async function emitShellState(
|
||||
mainWindow: BrowserWindow,
|
||||
getLocalServerState?: () => DesktopLocalServerState,
|
||||
getRuntimeStatus?: () => DesktopRuntimeStatus,
|
||||
): Promise<ShellConnectionState> {
|
||||
const state = toShellState(await readShellSettings(), getLocalServerState?.());
|
||||
const state = toShellState(await readShellSettings(), getRuntimeStatus?.());
|
||||
mainWindow.webContents.send("shell:state", state);
|
||||
return state;
|
||||
}
|
||||
@@ -99,7 +100,11 @@ export function registerIpcHandlers(mainWindow: BrowserWindow, tray: Tray, optio
|
||||
ipcMain.handle("native:showImportDialog", () => showImportSettingsDialog(mainWindow));
|
||||
ipcMain.handle("app:getServerPort", () => options.getServerPort?.());
|
||||
|
||||
ipcMain.handle("shell:getState", () => readShellSettings().then((settings) => toShellState(settings, options.getLocalServerState?.())));
|
||||
ipcMain.handle("desktopRuntime:getStatus", async () => options.getRuntimeStatus?.() ?? { source: "none", state: "stopped" });
|
||||
ipcMain.handle("desktopRuntime:startLocal", async () => options.startLocalRuntime?.() ?? { source: "none", state: "stopped" });
|
||||
ipcMain.handle("desktopRuntime:stopLocal", async () => options.stopLocalRuntime?.() ?? { source: "none", state: "stopped" });
|
||||
|
||||
ipcMain.handle("shell:getState", () => readShellSettings().then((settings) => toShellState(settings, options.getRuntimeStatus?.())));
|
||||
ipcMain.handle("shell:listProfiles", async () => (await readShellSettings()).profiles);
|
||||
|
||||
ipcMain.handle("shell:saveProfile", async (_event, profile: ShellConnectionProfileInput) => {
|
||||
@@ -111,20 +116,20 @@ export function registerIpcHandlers(mainWindow: BrowserWindow, tray: Tray, optio
|
||||
? settings.profiles.map((item) => (item.id === existing.id ? nextProfile : item))
|
||||
: [...settings.profiles, nextProfile];
|
||||
await writeShellSettings(settings);
|
||||
await emitShellState(mainWindow, options.getLocalServerState);
|
||||
await emitShellState(mainWindow, options.getRuntimeStatus);
|
||||
return nextProfile;
|
||||
});
|
||||
|
||||
ipcMain.handle("shell:deleteProfile", async (_event, profileId: string) => {
|
||||
const settings = applyDeleteProfile(await readShellSettings(), profileId);
|
||||
await writeShellSettings(settings);
|
||||
await emitShellState(mainWindow, options.getLocalServerState);
|
||||
await emitShellState(mainWindow, options.getRuntimeStatus);
|
||||
});
|
||||
|
||||
ipcMain.handle("shell:setActiveProfile", async (_event, profileId: string | null) => {
|
||||
const settings = applySetActiveProfile(await readShellSettings(), profileId);
|
||||
await writeShellSettings(settings);
|
||||
return emitShellState(mainWindow, options.getLocalServerState);
|
||||
return emitShellState(mainWindow, options.getRuntimeStatus);
|
||||
});
|
||||
|
||||
ipcMain.handle("shell:getDesktopModeState", async () => {
|
||||
@@ -138,7 +143,7 @@ export function registerIpcHandlers(mainWindow: BrowserWindow, tray: Tray, optio
|
||||
settings.hasCompletedModeSelection = true;
|
||||
await writeShellSettings(settings);
|
||||
await options.onDesktopModeChange?.(mode);
|
||||
return emitShellState(mainWindow, options.getLocalServerState);
|
||||
return emitShellState(mainWindow, options.getRuntimeStatus);
|
||||
});
|
||||
|
||||
ipcMain.handle("shell:startQrScan", async () => {
|
||||
|
||||
212
packages/desktop/src/local-runtime.ts
Normal file
212
packages/desktop/src/local-runtime.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import { once } from "node:events";
|
||||
import type { Server } from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
|
||||
export type RuntimeSource = "embedded-local" | "external-cli" | "none";
|
||||
export type RuntimeState = "stopped" | "starting" | "running" | "error";
|
||||
|
||||
export interface DesktopRuntimeStatus {
|
||||
source: RuntimeSource;
|
||||
state: RuntimeState;
|
||||
port?: number;
|
||||
baseUrl?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
type TaskStoreLike = {
|
||||
init(): Promise<void>;
|
||||
watch(): Promise<void>;
|
||||
close(): void;
|
||||
};
|
||||
|
||||
type RuntimeInstance = {
|
||||
store: TaskStoreLike;
|
||||
server: Server;
|
||||
port: number;
|
||||
baseUrl: string;
|
||||
};
|
||||
|
||||
export interface LocalRuntimeManagerOptions {
|
||||
rootDir: string;
|
||||
getExternalPort?: () => number | undefined;
|
||||
createStore?: (rootDir: string) => Promise<TaskStoreLike>;
|
||||
createDashboardServer?: (store: TaskStoreLike) => Promise<Server>;
|
||||
}
|
||||
|
||||
async function createStoreDefault(rootDir: string): Promise<TaskStoreLike> {
|
||||
const { TaskStore } = await import("@fusion/core");
|
||||
return new TaskStore(rootDir) as TaskStoreLike;
|
||||
}
|
||||
|
||||
async function createDashboardServerDefault(store: TaskStoreLike): Promise<Server> {
|
||||
const { createServer } = await import("@fusion/dashboard");
|
||||
return createServer(store).listen(0);
|
||||
}
|
||||
|
||||
function parsePort(raw: string | undefined): number | undefined {
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const value = Number(raw);
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function getAddressPort(server: Server): number {
|
||||
const address = server.address() as AddressInfo | null;
|
||||
const port = address?.port;
|
||||
if (!port) {
|
||||
throw new Error("Failed to resolve local server port");
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
export class LocalRuntimeManager {
|
||||
private runtime: RuntimeInstance | null = null;
|
||||
private startupPromise: Promise<DesktopRuntimeStatus> | null = null;
|
||||
private stopPromise: Promise<DesktopRuntimeStatus> | null = null;
|
||||
private status: DesktopRuntimeStatus = { source: "none", state: "stopped" };
|
||||
|
||||
private readonly getExternalPort: () => number | undefined;
|
||||
private readonly createStore: (rootDir: string) => Promise<TaskStoreLike>;
|
||||
private readonly createDashboardServer: (store: TaskStoreLike) => Promise<Server>;
|
||||
|
||||
constructor(private readonly options: LocalRuntimeManagerOptions) {
|
||||
this.getExternalPort = options.getExternalPort ?? (() => parsePort(process.env.FUSION_SERVER_PORT));
|
||||
this.createStore = options.createStore ?? createStoreDefault;
|
||||
this.createDashboardServer = options.createDashboardServer ?? createDashboardServerDefault;
|
||||
}
|
||||
|
||||
getStatus(): DesktopRuntimeStatus {
|
||||
if (this.runtime) {
|
||||
return {
|
||||
source: "embedded-local",
|
||||
state: "running",
|
||||
port: this.runtime.port,
|
||||
baseUrl: this.runtime.baseUrl,
|
||||
};
|
||||
}
|
||||
|
||||
const externalPort = this.getExternalPort();
|
||||
if (externalPort) {
|
||||
this.status = {
|
||||
source: "external-cli",
|
||||
state: "running",
|
||||
port: externalPort,
|
||||
baseUrl: `http://127.0.0.1:${externalPort}`,
|
||||
};
|
||||
}
|
||||
|
||||
return this.status;
|
||||
}
|
||||
|
||||
getServerPort(): number | undefined {
|
||||
return this.getStatus().port;
|
||||
}
|
||||
|
||||
async startLocal(): Promise<DesktopRuntimeStatus> {
|
||||
const externalPort = this.getExternalPort();
|
||||
if (externalPort) {
|
||||
this.status = {
|
||||
source: "external-cli",
|
||||
state: "running",
|
||||
port: externalPort,
|
||||
baseUrl: `http://127.0.0.1:${externalPort}`,
|
||||
};
|
||||
return this.status;
|
||||
}
|
||||
|
||||
if (this.runtime) {
|
||||
return this.getStatus();
|
||||
}
|
||||
|
||||
if (this.startupPromise) {
|
||||
return this.startupPromise;
|
||||
}
|
||||
|
||||
this.status = { source: "embedded-local", state: "starting" };
|
||||
this.startupPromise = this.startEmbedded();
|
||||
|
||||
try {
|
||||
return await this.startupPromise;
|
||||
} finally {
|
||||
this.startupPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async startEmbedded(): Promise<DesktopRuntimeStatus> {
|
||||
let store: TaskStoreLike | null = null;
|
||||
let server: Server | null = null;
|
||||
|
||||
try {
|
||||
store = await this.createStore(this.options.rootDir);
|
||||
await store.init();
|
||||
await store.watch();
|
||||
|
||||
server = await this.createDashboardServer(store);
|
||||
await Promise.race([
|
||||
once(server, "listening"),
|
||||
once(server, "error").then(([error]) => {
|
||||
throw error;
|
||||
}),
|
||||
]);
|
||||
|
||||
const port = getAddressPort(server);
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
this.runtime = { store, server, port, baseUrl };
|
||||
this.status = { source: "embedded-local", state: "running", port, baseUrl };
|
||||
return this.status;
|
||||
} catch (error) {
|
||||
if (server) {
|
||||
await new Promise<void>((resolve) => {
|
||||
server!.close(() => resolve());
|
||||
});
|
||||
}
|
||||
if (store) {
|
||||
store.close();
|
||||
}
|
||||
this.runtime = null;
|
||||
this.status = {
|
||||
source: "embedded-local",
|
||||
state: "error",
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async stopLocal(): Promise<DesktopRuntimeStatus> {
|
||||
if (this.stopPromise) {
|
||||
return this.stopPromise;
|
||||
}
|
||||
|
||||
this.stopPromise = this.stopInternal();
|
||||
try {
|
||||
return await this.stopPromise;
|
||||
} finally {
|
||||
this.stopPromise = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async stopInternal(): Promise<DesktopRuntimeStatus> {
|
||||
if (this.runtime) {
|
||||
const runtime = this.runtime;
|
||||
this.runtime = null;
|
||||
await new Promise<void>((resolve) => runtime.server.close(() => resolve()));
|
||||
runtime.store.close();
|
||||
this.status = { source: "none", state: "stopped" };
|
||||
return this.status;
|
||||
}
|
||||
|
||||
if (this.getStatus().source === "external-cli") {
|
||||
return this.status;
|
||||
}
|
||||
|
||||
this.status = { source: "none", state: "stopped" };
|
||||
return this.status;
|
||||
}
|
||||
}
|
||||
@@ -13,8 +13,7 @@ import {
|
||||
} from "./native.js";
|
||||
import { setupTray } from "./tray.js";
|
||||
import { getRendererUrl, getRendererFilePath, isUrlRenderer } from "./renderer.js";
|
||||
import { DesktopLocalServerManager } from "./local-server.js";
|
||||
import { getDesktopShellModeState, readShellSettings } from "./shell-settings.js";
|
||||
import { LocalRuntimeManager } from "./local-runtime.js";
|
||||
|
||||
// Re-export for backward compatibility
|
||||
export { IS_DEVELOPMENT } from "./renderer.js";
|
||||
@@ -35,7 +34,7 @@ enableSourceMaps();
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
let tray: Tray | null = null;
|
||||
let localServerManager: DesktopLocalServerManager | null = null;
|
||||
let localRuntimeManager: LocalRuntimeManager | null = null;
|
||||
|
||||
function getAppWithQuitFlag(): Electron.App & AppWithQuitFlag {
|
||||
return app as Electron.App & AppWithQuitFlag;
|
||||
@@ -91,33 +90,33 @@ export async function initializeApp(): Promise<void> {
|
||||
appName: "Fusion",
|
||||
});
|
||||
|
||||
localServerManager = new DesktopLocalServerManager(process.cwd());
|
||||
localRuntimeManager = new LocalRuntimeManager({ rootDir: process.cwd() });
|
||||
|
||||
tray = new Tray(nativeImage.createEmpty());
|
||||
setupTray(createdWindow, tray);
|
||||
|
||||
registerIpcHandlers(createdWindow, tray, {
|
||||
onDesktopModeChange: async (mode) => {
|
||||
if (!localServerManager) {
|
||||
if (!localRuntimeManager) {
|
||||
return;
|
||||
}
|
||||
if (mode === "local") {
|
||||
await localServerManager.start();
|
||||
await localRuntimeManager.startLocal();
|
||||
} else {
|
||||
await localServerManager.stop();
|
||||
await localRuntimeManager.stopLocal();
|
||||
}
|
||||
},
|
||||
getLocalServerState: () => localServerManager?.getState() ?? { status: "idle", error: null },
|
||||
getServerPort: () => localServerManager?.getPort(),
|
||||
getRuntimeStatus: () => localRuntimeManager?.getStatus() ?? { source: "none", state: "stopped" },
|
||||
startLocalRuntime: () => localRuntimeManager?.startLocal() ?? Promise.resolve({ source: "none", state: "stopped" }),
|
||||
stopLocalRuntime: () => localRuntimeManager?.stopLocal() ?? Promise.resolve({ source: "none", state: "stopped" }),
|
||||
getServerPort: () => localRuntimeManager?.getServerPort(),
|
||||
});
|
||||
registerDeepLinkProtocol();
|
||||
setupDeepLinkHandler(createdWindow);
|
||||
setupAutoUpdater(createdWindow);
|
||||
|
||||
const shellSettings = await readShellSettings();
|
||||
const desktopModeState = getDesktopShellModeState(shellSettings);
|
||||
if (!desktopModeState.isFirstRun && desktopModeState.desktopMode === "local") {
|
||||
await localServerManager.start();
|
||||
if (process.env.FUSION_DESKTOP_MODE === "local") {
|
||||
await localRuntimeManager.startLocal();
|
||||
}
|
||||
|
||||
if (state?.isMaximized === true) {
|
||||
@@ -144,8 +143,8 @@ export function run(): void {
|
||||
tray = null;
|
||||
}
|
||||
|
||||
if (localServerManager) {
|
||||
void localServerManager.stop();
|
||||
if (localRuntimeManager) {
|
||||
void localRuntimeManager.stopLocal();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -27,10 +27,12 @@ interface ShellConnectionState {
|
||||
desktopMode?: "local" | "remote";
|
||||
activeProfileId: string | null;
|
||||
profiles: ShellConnectionProfile[];
|
||||
localServer?: {
|
||||
status: "idle" | "starting" | "ready" | "error";
|
||||
localRuntime?: {
|
||||
source: "embedded-local" | "external-cli" | "none";
|
||||
state: "stopped" | "starting" | "running" | "error";
|
||||
port?: number;
|
||||
error?: string | null;
|
||||
baseUrl?: string;
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -49,6 +51,9 @@ const electronApi = {
|
||||
getSystemInfo: (): Promise<SystemInfo> => ipcRenderer.invoke("app:getSystemInfo"),
|
||||
checkForUpdates: (): Promise<UpdateCheckResult> => ipcRenderer.invoke("app:checkForUpdates"),
|
||||
getServerPort: (): Promise<number | undefined> => ipcRenderer.invoke("app:getServerPort"),
|
||||
getDesktopRuntimeStatus: (): Promise<ShellConnectionState["localRuntime"]> => ipcRenderer.invoke("desktopRuntime:getStatus"),
|
||||
startDesktopLocalRuntime: (): Promise<ShellConnectionState["localRuntime"]> => ipcRenderer.invoke("desktopRuntime:startLocal"),
|
||||
stopDesktopLocalRuntime: (): Promise<ShellConnectionState["localRuntime"]> => ipcRenderer.invoke("desktopRuntime:stopLocal"),
|
||||
|
||||
// Tray status
|
||||
updateTrayStatus: (status: string): Promise<void> => ipcRenderer.invoke("tray:updateStatus", status),
|
||||
|
||||
11
packages/desktop/src/types.d.ts
vendored
11
packages/desktop/src/types.d.ts
vendored
@@ -31,6 +31,9 @@ export interface FusionAPI {
|
||||
getSystemInfo(): Promise<SystemInfo>;
|
||||
checkForUpdates(): Promise<UpdateCheckResult>;
|
||||
getServerPort(): Promise<number | undefined>;
|
||||
getDesktopRuntimeStatus(): Promise<ShellConnectionState["localRuntime"]>;
|
||||
startDesktopLocalRuntime(): Promise<ShellConnectionState["localRuntime"]>;
|
||||
stopDesktopLocalRuntime(): Promise<ShellConnectionState["localRuntime"]>;
|
||||
|
||||
// Tray status
|
||||
updateTrayStatus(status: string): Promise<void>;
|
||||
@@ -78,10 +81,12 @@ export interface ShellConnectionState {
|
||||
desktopMode?: "local" | "remote";
|
||||
activeProfileId: string | null;
|
||||
profiles: ShellConnectionProfile[];
|
||||
localServer?: {
|
||||
status: "idle" | "starting" | "ready" | "error";
|
||||
localRuntime?: {
|
||||
source: "embedded-local" | "external-cli" | "none";
|
||||
state: "stopped" | "starting" | "running" | "error";
|
||||
port?: number;
|
||||
error?: string | null;
|
||||
baseUrl?: string;
|
||||
error?: string;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user