// 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 { autoUpdater } from "electron-updater"; 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 { // Test-only escape hatch: when set, skip spawning the standalone server // entirely and load this URL instead. Used by // integration/tests/11-electron-notification.spec.ts, which needs a // dev-mode Next.js server (proxy.ts's CSP only widens connect-src to // allow plain-HTTP/ws JMAP in dev - see that file's comments) to reach // the integration fixture's deliberately-plaintext local Stalwart, // exactly the same trade-off integration/webmail.Dockerfile already makes // for the browser-based integration suite. Never set by real users or by // any of the packaging/CI paths - those always go through // startStandaloneServer() below. const url = process.env.ELECTRON_LOAD_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), // itself called from lib/electron-bridge.ts's showElectronNotification(), // itself called from app/(main)/[locale]/page.tsx's "new mail arrived" // effect whenever lib/jmap/client.ts's push pipeline (WebSocket, or its SSE/ // polling fallback - see that file's circuit breaker) reports a genuine new // message. 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). ipcMain.handle( "vnc:show-notification", (_event, title: string, options?: { body?: string; tag?: string }) => { // Test-only observability hook, read via Playwright's // electronApp.evaluate(({ app }) => ...) - see // integration/tests/11-electron-notification.spec.ts. Not gated behind // NODE_ENV: it's an inert counter with no behavioral effect, cheaper // than maintaining a second code path just for tests. const counters = app as unknown as { __notificationCallCount?: number }; counters.__notificationCallCount = (counters.__notificationCallCount ?? 0) + 1; if (!Notification.isSupported()) { return { shown: false }; } const notification = new Notification({ title, body: options?.body ?? "", }); notification.show(); return { shown: true }; }, ); // --- Auto-update ------------------------------------------------------- // GitHub Releases as the update feed (electron-builder.config.js's // `publish` block) - the skill's recommendation over standing up a new // distribution channel, since the repo is already private. "Light // decision" per VNCprodbuild step 7, not re-litigated here. // // Deliberately best-effort: there's no code signing yet (step 9), so on // macOS in particular an update download/install can fail signature // verification. A failed check must never take the app down - it's // background maintenance, not something the user is blocked on. function setupAutoUpdater(): void { if (!app.isPackaged) { // Unpacked dev/test runs (npm run electron:dev, the Playwright smoke // test) have no latest.yml alongside them - checking would just log a // noisy 404 against GitHub Releases for every dev run. return; } autoUpdater.autoDownload = true; autoUpdater.autoInstallOnAppQuit = true; autoUpdater.on("error", (error) => { console.error("[electron] auto-update error:", error); }); autoUpdater.checkForUpdatesAndNotify().catch((error) => { console.error("[electron] checkForUpdatesAndNotify failed:", error); }); } app.whenReady().then(() => { void createMainWindow(); setupAutoUpdater(); }); 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(); } });