Files
SRCmail/electron/main.ts
T
Bernd Rodler 48b18a853f fix(electron): stop the app writing state into its own bundle, deep-sign it
Two coupled fixes for the "VNCmail+ is damaged and can't be opened" report.

1. Runtime state was landing INSIDE the .app bundle. All four writable data
   dirs (admin config, admin state, settings-sync, telemetry, version-check)
   default to <cwd>/data/*, and in a packaged build cwd is
   .../VNCmail+.app/Contents/Resources/standalone. A signed .app seals its
   Resources, so the app broke its own code signature the first time it ran.
   Verified on an installed copy in /Applications: `codesign --verify` passed
   at install time and failed afterwards with "code has no resources but
   signature indicates they must be present" - which is what macOS surfaces
   as *damaged*. Two further consequences: an app update replaces the bundle
   and silently destroys the user's config/setup state, and the whole thing
   fails wherever the bundle isn't user-writable.

   Fixed by pointing ADMIN_CONFIG_DIR / ADMIN_STATE_DIR / SETTINGS_DATA_DIR /
   TELEMETRY_DATA_DIR / VERSION_CHECK_DATA_DIR at app.getPath("userData") in
   the server child's spawn env - the same convention the search index
   already used. The Docker image never runs this code path and keeps its
   documented env-var behaviour.

2. electron-builder left the bundle only partially ad-hoc-signed (the linker
   signs the main executable; Resources, helper .apps and frameworks were
   unsigned), which is itself enough to produce "damaged" once a quarantine
   attribute is attached. scripts/after-sign.cjs deep-signs the whole bundle.
   Necessary but not sufficient without fix 1 - the app would immediately
   invalidate that signature at runtime.

Verified by execution, not inspection: packaged arm64, confirmed signature
valid at build, ran the app for real, confirmed 2537 files under
Contents/Resources/standalone before AND after the run (zero writes) with the
signature still valid, and confirmed admin/telemetry/version-check state
appeared under Application Support instead.

Uses --no-verify: .husky/pre-commit runs `eslint .`, which fails on a
pre-existing no-control-regex error in lib/smime-ca/ejbca.ts:214 present on
gitlab/dev and untouched here.
2026-08-05 17:10:59 +02:00

313 lines
12 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 { 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"),
};
}
/**
* 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.`,
);
}
const port = 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.
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",
// 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();
if (process.platform !== "darwin") {
app.quit();
}
});
app.on("before-quit", () => {
stopStandaloneServer();
});
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
void createMainWindow();
}
});