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:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user