feat(FN-1076): integrate desktop IPC bridge and lifecycle modules

- Extract main-process IPC registration into src/ipc.ts for window controls, system info, tray status, updater checks, and native dialogs
- Refactor main.ts startup into initializeApp/run and wire menu, tray, deep-link, updater, and window-state restore/quit cleanup
- Rework preload bridge to expose typed window.fusionAPI subscriptions and replace preload.d.ts with shared src/types.d.ts global declarations
- Add comprehensive tests for IPC handlers, preload contracts, and main-process integration behavior
- Document IPC channels, preload API, and lifecycle sequencing in packages/desktop/README.md
This commit is contained in:
gsxdsm
2026-04-08 00:45:23 -07:00
parent 750911a809
commit 8904fbf95d
11 changed files with 996 additions and 357 deletions

View File

@@ -1,49 +1,38 @@
import { app, BrowserWindow, nativeImage, Tray } from "electron";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { app, BrowserWindow, Tray, ipcMain, nativeImage } from "electron";
import { setupDeepLinkHandler, registerDeepLinkProtocol } from "./deep-link.js";
import { registerIpcHandlers } from "./ipc.js";
import { buildAppMenu } from "./menu.js";
import { setupTray, updateTrayStatus } from "./tray.js";
import {
DEFAULT_WINDOW_STATE,
loadWindowState,
saveWindowState,
setupAutoUpdater,
type WindowState,
} from "./native.js";
import { setupTray } from "./tray.js";
interface AppWithQuitFlag {
isQuitting?: boolean;
}
let mainWindow: BrowserWindow | null = null;
let tray: Tray | null = null;
export const DASHBOARD_URL = process.env.FUSION_DASHBOARD_URL || "http://localhost:4040";
interface ApiRequestPayload {
method?: string;
path: string;
body?: unknown;
headers?: Record<string, string>;
port?: number;
function getAppWithQuitFlag(): Electron.App & AppWithQuitFlag {
return app as Electron.App & AppWithQuitFlag;
}
function createDashboardBaseUrl(portOverride?: number): URL {
const dashboardUrl = new URL(DASHBOARD_URL);
if (typeof portOverride === "number" && Number.isFinite(portOverride)) {
dashboardUrl.port = String(portOverride);
}
return dashboardUrl;
}
export function createMainWindow(state?: WindowState): BrowserWindow {
const hasValidPosition = typeof state?.x === "number" && typeof state?.y === "number";
function getDashboardPort(): number {
const dashboardUrl = createDashboardBaseUrl();
const port = Number.parseInt(dashboardUrl.port || "", 10);
if (!Number.isFinite(port) || port <= 0) {
return dashboardUrl.protocol === "https:" ? 443 : 80;
}
return port;
}
function buildApiUrl(path: string, portOverride?: number): string {
const normalizedPath = path.startsWith("/api")
? path
: `/api${path.startsWith("/") ? path : `/${path}`}`;
return new URL(normalizedPath, createDashboardBaseUrl(portOverride)).toString();
}
export function createMainWindow(): BrowserWindow {
const mainWindow = new BrowserWindow({
width: 1280,
height: 900,
const window = new BrowserWindow({
width: state?.width ?? DEFAULT_WINDOW_STATE.width,
height: state?.height ?? DEFAULT_WINDOW_STATE.height,
...(hasValidPosition ? { x: state.x, y: state.y } : {}),
title: "Fusion",
webPreferences: {
preload: join(import.meta.dirname, "preload.ts"),
@@ -52,152 +41,77 @@ export function createMainWindow(): BrowserWindow {
},
});
void mainWindow.loadURL(DASHBOARD_URL);
return mainWindow;
void window.loadURL(DASHBOARD_URL);
window.on("close", (event) => {
saveWindowState(window);
if (getAppWithQuitFlag().isQuitting) {
return;
}
event.preventDefault();
window.hide();
});
window.on("closed", () => {
mainWindow = null;
});
mainWindow = window;
return window;
}
export function registerIpcHandlers(): void {
ipcMain.handle("app:get-version", () => app.getVersion());
ipcMain.on("app:quit", () => app.quit());
export async function initializeApp(): Promise<void> {
const state = await loadWindowState();
const createdWindow = createMainWindow(state ?? undefined);
ipcMain.handle("server:get-port", () => getDashboardPort());
ipcMain.handle("api-request", async (_event, payload: ApiRequestPayload) => {
const method = (payload.method ?? "GET").toUpperCase();
const headers: Record<string, string> = { ...(payload.headers ?? {}) };
let requestBody: string | undefined;
if (payload.body !== undefined && method !== "GET" && method !== "HEAD") {
if (typeof payload.body === "string") {
requestBody = payload.body;
} else {
requestBody = JSON.stringify(payload.body);
if (!Object.keys(headers).some((key) => key.toLowerCase() === "content-type")) {
headers["Content-Type"] = "application/json";
}
}
}
try {
const response = await fetch(buildApiUrl(payload.path, payload.port), {
method,
headers,
body: requestBody,
});
const responseText = await response.text();
const responseContentType = response.headers.get("content-type") ?? "";
let responseData: unknown = responseText;
if (responseContentType.includes("application/json")) {
try {
responseData = responseText ? JSON.parse(responseText) : null;
} catch {
responseData = responseText;
}
}
const responseError = response.ok
? undefined
: (typeof responseData === "object" && responseData && "error" in responseData
? String((responseData as { error?: string }).error ?? "Request failed")
: responseText || `Request failed (${response.status})`);
return {
status: response.status,
statusText: response.statusText,
headers: Object.fromEntries(response.headers.entries()),
data: responseData,
error: responseError,
};
} catch (error) {
return {
status: 500,
statusText: "Internal Error",
headers: {},
data: null,
error: error instanceof Error ? error.message : "Failed to process API request",
};
}
buildAppMenu({
mainWindow: createdWindow,
appName: "Fusion",
});
ipcMain.handle("window:control", (event, action: "minimize" | "maximize" | "close" | "isMaximized") => {
const targetWindow = BrowserWindow.fromWebContents(event.sender);
if (!targetWindow) {
return false;
}
tray = new Tray(nativeImage.createEmpty());
setupTray(createdWindow, tray);
switch (action) {
case "minimize":
targetWindow.minimize();
return false;
case "maximize": {
const willMaximize = !targetWindow.isMaximized();
if (willMaximize) {
targetWindow.maximize();
} else {
targetWindow.unmaximize();
}
return willMaximize;
}
case "close":
targetWindow.close();
return false;
case "isMaximized":
return targetWindow.isMaximized();
default:
return false;
}
});
registerIpcHandlers(createdWindow, tray);
registerDeepLinkProtocol();
setupDeepLinkHandler(createdWindow);
setupAutoUpdater(createdWindow);
ipcMain.handle("update:install", async () => {
// Auto-update wiring is implemented by FN-1071. Keep this as a safe no-op
// so renderer hooks can call installUpdate() without exploding.
return;
});
ipcMain.handle("system:get-platform", () => process.platform);
if (state?.isMaximized === true) {
createdWindow.maximize();
}
}
export function run(): void {
let tray: Tray | undefined;
const appWithQuitFlag = getAppWithQuitFlag();
appWithQuitFlag.isQuitting = false;
app.whenReady().then(() => {
const mainWindow = createMainWindow();
const trayInstance = tray ?? new Tray(nativeImage.createEmpty());
tray = setupTray(mainWindow, trayInstance);
buildAppMenu({
mainWindow,
appName: "Fusion",
});
registerIpcHandlers();
});
void app.whenReady().then(() => initializeApp());
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
// Keep app alive in tray on non-macOS platforms.
return;
app.quit();
}
});
app.on("before-quit", () => {
appWithQuitFlag.isQuitting = true;
if (tray) {
tray.destroy();
tray = null;
}
});
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
const mainWindow = createMainWindow();
const trayInstance = tray ?? new Tray(nativeImage.createEmpty());
tray = setupTray(mainWindow, trayInstance);
buildAppMenu({
mainWindow,
appName: "Fusion",
});
if (mainWindow === null) {
const window = createMainWindow();
window.show();
}
});
}
export { setupTray, updateTrayStatus };
const modulePath = fileURLToPath(import.meta.url);
if (process.argv[1] && resolve(process.argv[1]) === modulePath) {
run();