// 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 { randomBytes } from "node:crypto"; import { createServer } from "node:net"; import { get as httpGet } from "node:http"; import path from "node:path"; import fs from "node:fs"; import type { Duplex } from "node:stream"; import { WebSocket } from "ws"; import { attachKeyService, checkEncryptionAvailable } from "./key-service"; let serverProcess: ChildProcess | null = null; let mainWindow: BrowserWindow | null = null; /** * Root for the encrypted local search index (lib/mail-index/**). Under * `userData`, so it is per-OS-user and removed with the app's data. * * Passing this to the server child process is what ACTIVATES the index: the * routes 404 without it. That matters because the standalone server is the same * artifact the production Dockerfile ships to multi-tenant deployments, where a * server-side index of every user's mail would be badly wrong. One variable * both enables the feature and supplies its path, so the two cannot drift apart. */ function getIndexStoreDir(): string { return path.join(app.getPath("userData"), "offline"); } /** * Every writable data dir the standalone server uses, redirected under * `userData`. * * WITHOUT this, all four default to `/data/*` (see lib/admin/paths.ts, * lib/settings-sync.ts, lib/telemetry/state.ts, lib/version-check/state.ts), * and in a packaged build cwd is `.../VNCmail+.app/Contents/Resources/standalone` * - i.e. the app writes its own runtime state INSIDE its own bundle. Three * separate failure modes, all observed rather than theorised: * * 1. It INVALIDATES THE CODE SIGNATURE. A signed .app seals its Resources; * writing there breaks the seal, so `codesign --verify` starts failing * ("code has no resources but signature indicates they must be present") * and macOS reports the app as *damaged* on a later launch. Verified on * an installed copy in /Applications: signature valid at install time, * exit 1 after the app had run once and written data/admin + data/telemetry. * Deep-signing the bundle at build time (scripts/after-sign.cjs) is * necessary but NOT sufficient on its own - the app immediately breaks * its own signature at runtime unless the writes go elsewhere. * 2. An app update replaces the bundle, silently destroying the user's admin * config, settings and setup state. * 3. It fails outright wherever the bundle isn't user-writable. * * `userData` is the correct home for per-user mutable state on every platform * and is where the search index already lives, so this keeps one convention. */ function getServerDataDirs(): Record { const root = app.getPath("userData"); return { ADMIN_CONFIG_DIR: path.join(root, "admin"), ADMIN_STATE_DIR: path.join(root, "admin-state"), SETTINGS_DATA_DIR: path.join(root, "settings"), TELEMETRY_DATA_DIR: path.join(root, "telemetry"), VERSION_CHECK_DATA_DIR: path.join(root, "version-check"), }; } /** * Desktop-shell defaults for a fresh, un-configured install. * * Setting JMAP_SERVER_URL puts the standalone server into "env-managed" * mode (see lib/setup/state.ts's detectSetupState()) - the ONLY thing that * disables the setup wizard short of an operator finishing it by hand. Every * distributable build of this desktop shell up to 2026-08-05 skipped this, * so handing someone the packaged app landed them on "Bulwark Webmail * Setup" asking for a token out of container logs they have no access to - * caught only by actually launching the packaged .app and looking, not by * reading the build log. * * The rest are CONFIG_ENV_MAP entries (lib/admin/types.ts) that only matter * while env-managed - once an admin completes the wizard, config.json wins * for everything except jmapServerUrl itself. allowCustomJmapEndpoint keeps * the server field on the login screen editable, so this is a starting * point for the sandbox, not a hard lock to it. * * `...process.env` in startStandaloneServer() below is spread AFTER this * object, so a real deployment env (the Dockerfile path, or a future * per-install override) still wins over these defaults. */ function getDesktopDefaults(): Record { return { JMAP_SERVER_URL: "https://stalwart.sandbox.vnc.de", APP_NAME: "VNCmail+", APP_SHORT_NAME: "VNCmail+", LOGIN_LOGO_LIGHT_URL: "/branding/SRC_Symbol.png", LOGIN_LOGO_DARK_URL: "/branding/SRC_Symbol.png", LOGIN_COMPANY_NAME: "VNC AG", FAVICON_URL: "/branding/SRC_Symbol.png", ALLOW_CUSTOM_JMAP_ENDPOINT: "true", // The login page's subtitle falls back to the login.title i18n string // whenever it differs from appName (app/(main)/[locale]/login/page.tsx) // - a check clearly written for the original Bulwark/"Webmail" pairing, // where they matched. With APP_NAME overridden to "VNCmail+" they no // longer match, so the raw translation ("Webmail") surfaces instead of // anything brand-appropriate. Hiding the subtitle avoids editing a // shared i18n string that every other deployment (incl. Bulwark // default) still uses - the SRC logo + "VNCmail+" heading is enough // context on its own. LOGIN_SHOW_SUBTITLE: "false", }; } /** * Per-install session secret for the standalone server. * * The server's cookie crypto (lib/auth/crypto.ts) refuses to mint the * `jmap_stalwart_ctx` auth-context cookie without a >=32-char SESSION_SECRET, * and every server-side-identity feature hangs off that cookie: the encrypted * local search index and offline replica (their routes 401 without it), * S/MIME enrolment, and the AI `server` class. A web deployment gets the * secret from an operator (env var or the setup wizard); the desktop shell * has NO operator, and up to 1.7.8 the packaged app simply shipped without * one — so every login's stalwart-context POST failed with 500, the index * stayed permanently empty, and the AI assistant answered "No local mail * index available in this session" against real accounts. Caught live on a * real mailbox, not by tests: every test run had injected its own * SESSION_SECRET into the child env, masking exactly this. * * Generated once per install (64 hex chars, comfortably over the minimum), * persisted 0600 under userData next to the rest of the per-user state, and * handed to the server as SESSION_SECRET_FILE rather than SESSION_SECRET so * the value itself stays out of the child's environment block. A * deployment-provided SESSION_SECRET env var still wins — getSessionSecret() * resolves the env var before the file. */ function ensureSessionSecretFile(): string | null { const secretPath = path.join(app.getPath("userData"), "session-secret"); try { const existing = fs.readFileSync(secretPath, "utf8").trim(); if (existing.length >= 32) return secretPath; } catch { /* first run — generate below */ } try { fs.mkdirSync(path.dirname(secretPath), { recursive: true }); fs.writeFileSync(secretPath, randomBytes(32).toString("hex"), { mode: 0o600 }); return secretPath; } catch (cause) { // Loud, because the downstream symptom is otherwise "index/AI features // return 401" with no hint of why — but never fatal: reading mail does // not depend on this cookie. console.error("[electron] could not persist a session secret:", cause); return null; } } // ── OpenCode agent server ──────────────────────────────────────────────── // // The `opencode` AI class talks to a locally-running `opencode serve`. Left to // the user that means "remember to start a terminal process before opening // your mail app, and again after every reboot" - which is to say the feature // quietly stops existing. So the desktop shell owns its lifecycle: start it if // the binary is installed, restart it if it dies, kill it on quit. // // SECURITY: opencode itself warns "OPENCODE_SERVER_PASSWORD is not set; server // is unsecured" - without one, any local process can drive the agent. We always // generate one. Auth is HTTP Basic with the username EXACTLY `opencode` // (verified against 1.18.14: an empty or arbitrary username 401s even with the // right password, and no bearer/custom-header form works) - undocumented in its // own OpenAPI spec, which declares no securitySchemes at all. let opencodeProcess: ChildProcess | null = null; let opencodeRestarts = 0; /** Set by stopOpencodeServer() so the exit handler can tell a deliberate * shutdown from a crash and not fight the quit by respawning. */ let opencodeStopping = false; const OPENCODE_MAX_RESTARTS = 3; /** Where the binary lives. `~/.opencode/bin` is its own installer's default and * is NOT on the PATH a GUI app inherits on macOS, so PATH alone finds nothing * for most users. */ function findOpencodeBinary(): string | null { const explicit = process.env.OPENCODE_BIN?.trim(); if (explicit && fs.existsSync(explicit)) return explicit; const candidates = [ path.join(app.getPath("home"), ".opencode", "bin", "opencode"), "/opt/homebrew/bin/opencode", "/usr/local/bin/opencode", "/usr/bin/opencode", ]; return candidates.find((c) => fs.existsSync(c)) ?? null; } interface OpencodeHandle { baseUrl: string; password: string; } async function startOpencodeServer(): Promise { const binary = findOpencodeBinary(); if (!binary) return null; // not installed - the class simply stays unavailable const port = await getFreePort(); // Per-launch, never persisted: the server dies with the app, so there is no // value in a durable secret and every reason not to leave one on disk. const password = randomBytes(24).toString("hex"); const baseUrl = `http://127.0.0.1:${port}`; const spawnOnce = () => { opencodeProcess = spawn(binary, ["serve", "--port", String(port), "--hostname", "127.0.0.1"], { env: { ...process.env, OPENCODE_SERVER_PASSWORD: password }, stdio: "ignore", }); opencodeProcess.on("exit", (code, signal) => { opencodeProcess = null; // A deliberate shutdown arrives as SIGTERM from stopOpencodeServer(). if (opencodeStopping || signal === "SIGTERM") return; if (opencodeRestarts >= OPENCODE_MAX_RESTARTS) { console.error(`[opencode] gave up restarting after ${OPENCODE_MAX_RESTARTS} attempts (last code=${code})`); return; } opencodeRestarts += 1; console.error(`[opencode] server exited (code=${code}); restart ${opencodeRestarts}/${OPENCODE_MAX_RESTARTS}`); setTimeout(spawnOnce, 1000 * opencodeRestarts); }); }; spawnOnce(); return { baseUrl, password }; } function stopOpencodeServer(): void { opencodeStopping = true; if (!opencodeProcess) return; const proc = opencodeProcess; opencodeProcess = null; proc.kill("SIGTERM"); } /** * 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.`, ); } // Normally a random free port, chosen fresh every launch - JMAP_SERVER_URL // never needs to reference it back (a real deployment's Stalwart lives at // its own fixed address). VNCMAIL_TEST_FIXED_PORT is a narrow escape hatch // for e2e tests that DO need to know the port ahead of time - specifically // to point DEV_MOCK_JMAP's JMAP_SERVER_URL at this same standalone server's // own /api/dev-jmap route, which is the only way to exercise the real // encrypted offline index (lib/mail-index/**) without a real Stalwart // fixture: that index's key channel only gets wired up in this function, // never when ELECTRON_LOAD_URL bypasses it for a plain `next dev` target. // Unset in every normal launch, so this changes nothing outside a test run. const fixedPort = process.env.VNCMAIL_TEST_FIXED_PORT ? Number(process.env.VNCMAIL_TEST_FIXED_PORT) : null; const port = fixedPort && Number.isInteger(fixedPort) ? fixedPort : await getFreePort(); const url = `http://127.0.0.1:${port}`; const storeDir = getIndexStoreDir(); const encryption = checkEncryptionAvailable(); if (!encryption.ok) { // Refuse rather than degrade. On Linux with no keyring, safeStorage // "succeeds" using a hardcoded public password, which would look like an // encrypted mailbox index while providing no protection. Leaving the env // vars unset makes every index route 404, so the app runs normally without // the feature. console.error(`[electron] local search index disabled: ${encryption.reason}`); } // 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. // // stdio gains a 4th entry: fd 3 is the key channel for the local index (see // electron/key-service.ts). libuv creates extra stdio "pipe" entries as // socketpairs, so it is duplex in both directions - verified by execution // before this was built on. Deliberately NOT an environment variable: env is // readable by any process running as the same OS user, which would defeat // using the OS keychain at all. The fd NUMBER below is not a secret; only // what travels over it is. const sessionSecretFile = ensureSessionSecretFile(); // Started before the app server so its address can be handed over as env; // null when opencode isn't installed, in which case the class stays absent. const opencode = await startOpencodeServer(); serverProcess = spawn(process.execPath, [serverEntry], { env: { // First, so any real deployment env (a future per-install override, // or this same binary run somewhere JMAP_SERVER_URL is already set) // wins over these desktop-shell defaults - see getDesktopDefaults(). ...getDesktopDefaults(), // Also before ...process.env: an operator-provided SESSION_SECRET or // SESSION_SECRET_FILE must win over the per-install default (and a // SESSION_SECRET env var outranks any file in getSessionSecret()'s // resolution order regardless). ...(sessionSecretFile ? { SESSION_SECRET_FILE: sessionSecretFile } : {}), ...(opencode ? { OPENCODE_BASE_URL: opencode.baseUrl, OPENCODE_SERVER_PASSWORD: opencode.password } : {}), ...process.env, ELECTRON_RUN_AS_NODE: "1", PORT: String(port), HOSTNAME: "127.0.0.1", NODE_ENV: process.env.NODE_ENV || "production", // Keep all mutable state out of the .app bundle - see // getServerDataDirs() for why that matters. Placed after // ...process.env so the desktop shell's paths win over any inherited // value; the same standalone server run outside Electron (the Docker // image) never executes this and keeps its documented env behaviour. ...getServerDataDirs(), ...(encryption.ok ? { VNCMAIL_DESKTOP_STORE_DIR: storeDir, VNCMAIL_DESKTOP_KEY_FD: "3" } : {}), }, stdio: encryption.ok ? ["inherit", "inherit", "inherit", "pipe"] : "inherit", }); if (encryption.ok) { attachKeyService(serverProcess.stdio[3] as Duplex | null, storeDir); } 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 }; }, ); // --- WebSocket bridge for renderer ---------------------------------------- // The browser WebSocket constructor cannot attach Authorization headers, so // JMAP-over-WebSocket (RFC 8887) push paths that require auth at the upgrade // handshake are unreachable from the renderer. This IPC bridge opens the // WebSocket from the main process (where we control headers) and forwards // messages to the renderer as 'vnc:ws-message' events. const wsConnections = new Map(); ipcMain.handle( "vnc:ws-connect", (event, { url, authHeader }: { url: string; authHeader: string }) => { const id = randomBytes(8).toString("hex"); const ws = new WebSocket(url, { headers: { Authorization: authHeader }, }); ws.on("open", () => { event.sender.send("vnc:ws-message", { id, type: "open" }); }); ws.on("message", (data: Buffer) => { event.sender.send("vnc:ws-message", { id, type: "message", data: data.toString(), }); }); ws.on("close", (code: number) => { wsConnections.delete(id); event.sender.send("vnc:ws-message", { id, type: "close", code }); }); ws.on("error", (err: Error) => { event.sender.send("vnc:ws-message", { id, type: "error", message: err.message, }); }); wsConnections.set(id, ws); return id; }, ); ipcMain.handle( "vnc:ws-send", (_event, { id, data }: { id: string; data: string }) => { const ws = wsConnections.get(id); if (!ws || ws.readyState !== WebSocket.OPEN) return false; ws.send(data); return true; }, ); ipcMain.handle("vnc:ws-close", (_event, { id }: { id: string }) => { const ws = wsConnections.get(id); if (!ws) return; ws.close(); wsConnections.delete(id); }); // --- 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(); stopOpencodeServer(); if (process.platform !== "darwin") { app.quit(); } }); app.on("before-quit", () => { stopStandaloneServer(); stopOpencodeServer(); }); app.on("activate", () => { if (BrowserWindow.getAllWindows().length === 0) { void createMainWindow(); } });