// Electron main process for the VNCmail+ (Bulwark) desktop shell. // // Boots the exact same Next.js "standalone" server artifact the Dockerfile // already produces for production (see next.config.ts's `output: // "standalone"` and the Dockerfile's builder stage) as a child process on a // random localhost port, then opens a BrowserWindow pointed at it. This is // deliberately the same server, not a reimplementation - lib/jmap/client.ts // and every app/api/** route behave identically to the web deployment. import { app, BrowserWindow, ipcMain, Notification } from "electron"; import { spawn, type ChildProcess } from "node:child_process"; import { createServer } from "node:net"; import { get as httpGet } from "node:http"; import path from "node:path"; import fs from "node:fs"; let serverProcess: ChildProcess | null = null; let mainWindow: BrowserWindow | null = null; /** * Locates the standalone server's entrypoint. Packaged builds ship it as an * extraResource (see electron-builder.config.js) because .next/standalone * isn't inside the app.asar; dev runs read it straight out of the repo via * `npm run build:standalone`. */ function getStandaloneServerEntry(): string { if (app.isPackaged) { return path.join(process.resourcesPath, "standalone", "server.js"); } return path.join(app.getAppPath(), ".next", "standalone", "server.js"); } function getFreePort(): Promise { return new Promise((resolve, reject) => { const server = createServer(); server.unref(); server.on("error", reject); server.listen(0, "127.0.0.1", () => { const address = server.address(); if (address && typeof address === "object") { const { port } = address; server.close(() => resolve(port)); } else { server.close(() => reject(new Error("Could not allocate a free localhost port"))); } }); }); } function waitForServerReady(url: string, timeoutMs = 20000): Promise { const deadline = Date.now() + timeoutMs; return new Promise((resolve, reject) => { const attempt = () => { const req = httpGet(url, (res) => { res.resume(); resolve(); }); req.on("error", () => { if (Date.now() > deadline) { reject(new Error(`Standalone server never became reachable at ${url}`)); return; } setTimeout(attempt, 200); }); }; attempt(); }); } async function startStandaloneServer(): Promise { const serverEntry = getStandaloneServerEntry(); if (!fs.existsSync(serverEntry)) { throw new Error( `Standalone Next.js server not found at ${serverEntry}. Run "npm run build:standalone" first.`, ); } const port = await getFreePort(); const url = `http://127.0.0.1:${port}`; // Spawn the Electron binary itself as a plain Node process // (ELECTRON_RUN_AS_NODE) instead of depending on a system Node install - // the packaged app can't assume Node exists on the target machine, and // this keeps dev/packaged behavior identical. serverProcess = spawn(process.execPath, [serverEntry], { env: { ...process.env, ELECTRON_RUN_AS_NODE: "1", PORT: String(port), HOSTNAME: "127.0.0.1", NODE_ENV: process.env.NODE_ENV || "production", }, stdio: "inherit", }); serverProcess.on("exit", (code, signal) => { if (code !== 0 && code !== null) { console.error(`[electron] standalone server exited early (code=${code}, signal=${signal})`); } serverProcess = null; }); await waitForServerReady(url); return url; } function stopStandaloneServer(): void { if (serverProcess && !serverProcess.killed) { serverProcess.kill(); } serverProcess = null; } async function createMainWindow(): Promise { const url = await startStandaloneServer(); mainWindow = new BrowserWindow({ width: 1280, height: 860, webPreferences: { preload: path.join(__dirname, "preload.js"), contextIsolation: true, nodeIntegration: false, sandbox: true, }, }); mainWindow.on("closed", () => { mainWindow = null; }); await mainWindow.loadURL(url); } // --- Native notification bridge -------------------------------------------- // Called from the preload's `window.vnc.showNotification` (electron/preload.ts). // Electron's own Notification API is the desktop shell's notification path - // it sits alongside, not in place of, the browser/PWA's service-worker push // path (public/sw.js's `push`/`notificationclick` handlers + lib/web-push.ts). // Which of the two actually gets wired up to real mail-delivery events is a // separate decision (VNCprodbuild Phase 1 steps 4-6); this handler is just // the plumbing that lets the renderer trigger a native OS notification at // all, so it can be exercised end-to-end from a smoke test now instead of // bolted on untested later. ipcMain.handle( "vnc:show-notification", (_event, title: string, options?: { body?: string; tag?: string }) => { if (!Notification.isSupported()) { return { shown: false }; } const notification = new Notification({ title, body: options?.body ?? "", }); notification.show(); return { shown: true }; }, ); app.whenReady().then(() => { void createMainWindow(); }); app.on("window-all-closed", () => { stopStandaloneServer(); if (process.platform !== "darwin") { app.quit(); } }); app.on("before-quit", () => { stopStandaloneServer(); }); app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) { void createMainWindow(); } });