Every prior distributable DMG this session was built with plain `npx electron-builder`, never `--config electron-builder.config.js`. electron- builder does not auto-detect a file named electron-builder.config.js (its search list is .yml/.yaml/.json/.json5/.js/.cjs/.mjs/.ts, not .config.js), so the config - correct productName/appId/icon and all - was silently ignored on every build. Caught only by actually launching the packaged .app: it booted to "Bulwark Webmail Setup" demanding a token from container logs, default Electron atom icon, output in dist/ instead of dist-electron-builds/. Fixes, each verified against the packaged .app (Playwright _electron.launch, not the build log): - Add dist:mac/win/linux/dir scripts that pass --config explicitly, so this can't recur. - Dedicated 1024x1024 app icon (build-resources/app-icon.png, SRC symbol on #09090b) instead of reusing the web PWA manifest icon. Verified: icns ships at 1024x1024, pixel-identical to the source (mean diff 0.0/255). - electron/main.ts: getDesktopDefaults() sets JMAP_SERVER_URL to the sandbox (the ONLY thing that puts the server into "env-managed" mode and skips the setup wizard - see lib/setup/state.ts), plus APP_NAME/login logo/ favicon/company-name env vars, spread before ...process.env so a real deployment still overrides. Verified: packaged app now opens straight to a login screen with the JMAP endpoint field pre-filled https://stalwart.sandbox.vnc.de, title "VNCmail+", SRC logo. - LOGIN_SHOW_SUBTITLE=false: the subtitle falls back to the login.title i18n string ("Webmail") whenever it differs from APP_NAME - a check written for the original Bulwark pairing where they matched. Hiding it avoids touching that shared string for every other deployment.
362 lines
15 KiB
TypeScript
362 lines
15 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"),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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",
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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: {
|
|
// 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(),
|
|
...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();
|
|
}
|
|
});
|