Two real bugs in the previous WS-push commit, both found while building the
integration test for it (not theoretical - each reproduced and verified
before and after the fix):
1. proxy.ts's production CSP (`connect-src 'self' https:`) has no `wss:`
term, so `new WebSocket(...)` was blocked before any network attempt at
all - confirmed by listening for `securitypolicyviolation` against the
real reference server (stalwart.sandbox.vnc.de, HTTPS): the WS feature
was entirely inert in a production build, for every server, not just
ones with an incompatible auth model. Fixed by adding `wss:` alongside
`https:` in production - no new trust surface, since `https:` here
already allows fetch/XHR to any TLS host (needed for
ALLOW_CUSTOM_JMAP_ENDPOINT / multi-server setups), so extending that same
model to WebSocket is consistent, not a new precedent. Verified after the
fix: the same probe now reaches the network and gets a real (expected)
auth rejection from Stalwart instead of a CSP block.
2. lib/jmap/client.ts's circuit breaker (5 attempts, 1s/30s backoff) could
take up to ~31s to give up on WS and fall back to SSE. Against a server
that fails the handshake instantly and deterministically every time (the
auth-header limitation documented in the previous commit), that's ~31s
of NO live push at all - WS hasn't succeeded and hasn't given up yet, so
SSE never starts connecting, and any mail delivered in that window was
silently missed (SSE only streams changes from the moment it connects,
no catch-up). Reproduced directly: a real SMTP delivery sent during that
window never reached the notification bridge.
Fixed two ways:
- Tightened the ladder to a 200ms base / 5s cap / 3-attempt circuit
breaker (worst case ~1.75s instead of ~31s) - still genuine
exponential-with-jitter backoff, just tuned for a failure mode that's
fast and deterministic rather than slow and flaky. A slow/real
network issue is unaffected: a hanging attempt is still bounded by
the browser's own WebSocket connect timeout, not by these constants.
- setupPushNotifications() now primes a polling baseline
(fetchCurrentStates()) in parallel with the WS attempt, and
fallbackFromWebSocket() diffs against it (checkForStateChanges())
BEFORE connectSSE()/startPollingFallback() get a chance to erase that
opportunity. This is what actually closes the gap rather than just
shrinking it: it catches a change that happened to the primary
account during the (now much shorter) WS retry window.
electron/main.ts also gets a test-only escape hatch (ELECTRON_LOAD_URL): set
it to skip spawning the standalone server and load that URL instead. Real
users and every packaging/CI path never set it - added because verifying
the fixes above against this repo's own local Stalwart fixture (deliberately
plaintext HTTP - integration/webmail.Dockerfile makes the identical
trade-off for the browser-based suite) needs a dev-mode Next.js server
(proxy.ts only widens connect-src for plain http/ws in dev), not the
production standalone build electron/main.ts normally boots.
next.config.ts: added 127.0.0.1 to allowedDevOrigins alongside the existing
LAN entry - electron/main.ts always loads its window at 127.0.0.1, so a
dev-mode Electron run (only used by the escape hatch above) needs it in this
allowlist the same as any other cross-origin dev client would.
Verified: full lib/__tests__ JMAP suite still green (158/158); npm run
test:electron still green (4/4); the raw WebSocket probe against the real
sandbox now reaches the network post-fix instead of being CSP-blocked.
226 lines
7.9 KiB
TypeScript
226 lines
7.9 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";
|
|
|
|
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<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}`;
|
|
|
|
// 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<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();
|
|
}
|
|
});
|