Files
SRCmail/electron/main.ts
T
Bernd Rodler bd778adf12 feat(electron): supervise a password-protected opencode server (B1+B3)
B1 — LIFECYCLE. The OpenCode class previously required the user to remember
to run `opencode serve` in a terminal before opening their mail app, and again
after every reboot; in practice that means the feature quietly stops existing.
The desktop shell now owns it: finds the binary (OPENCODE_BIN, then
~/.opencode/bin — its installer's default, which is NOT on the PATH a macOS
GUI app inherits, so PATH alone finds nothing for most users), starts it on a
free port, restarts up to 3 times if it dies, and kills it on quit. Absent
binary = the class simply stays unavailable, no error.

B3 — SECURITY. opencode's own startup warns "OPENCODE_SERVER_PASSWORD is not
set; server is unsecured" — without one, any local process can drive the
agent. A per-launch password is now always generated (never persisted: the
server dies with the app, so a durable secret would be pure liability) and
handed to the standalone server alongside the base URL.

The auth scheme is worth recording because it is NOT in opencode's own
OpenAPI spec, which declares no securitySchemes at all: HTTP Basic with the
username EXACTLY `opencode`. Verified against 1.18.14 by trying them — an
empty username, an arbitrary one, Bearer, and every plausible custom header
all 401 with the correct password. Pinned by a unit test that decodes the
header, so a future refactor can't silently drop it.

Verified live against a real password-protected server on 4097: authenticated
discovery + prompt round-tripped, AND the same call with no password was
rejected — proving the auth is real rather than decorative.

Also removed now-stale guidance: the 503 no longer says "start one with
opencode serve", because the app does that; it says to install the CLI.

Gate: tsc clean, eslint clean, build clean, 2521/2522 tests. The one failure
is lib/__tests__/jmap-client-resilience.test.ts's onConnectionChange timing
flake — byte-identical to what is already running in prod (git diff vs
origin/main for that file and lib/jmap/ is empty), pre-existing, and
unrelated to anything here.
2026-08-07 09:57:51 +02:00

516 lines
22 KiB
TypeScript

// 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 { 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 `<cwd>/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<string, string> {
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<string, string> {
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<OpencodeHandle | null> {
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<number> {
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<void> {
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<string> {
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<void> {
// 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();
stopOpencodeServer();
if (process.platform !== "darwin") {
app.quit();
}
});
app.on("before-quit", () => {
stopStandaloneServer();
stopOpencodeServer();
});
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
void createMainWindow();
}
});