Files
SRCmail/electron/main.ts
T
Bernd Rodler b8f668d25a feat(electron): native notification bridge over contextBridge/IPC
Phase 1 step 3 of VNCprodbuild. electron/preload.ts's contextBridge now
exposes window.vnc.showNotification(title, options), routed via
ipcRenderer.invoke("vnc:show-notification") to a new ipcMain.handle in
electron/main.ts that calls Electron's own Notification API. This is the
desktop shell's native 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), which is untouched.

lib/electron-bridge.ts gives the renderer a `isElectronShell()` +
`showElectronNotification()` wrapper so app code can detect the shell and
use the native path instead of/alongside SW push - not wired to any real
mail-delivery trigger yet, that's Phase 1 steps 4-6 (JMAP realtime
capability investigation, the background/foreground strategy decision, and
implementing it).

Extended e2e/electron-smoke.spec.ts to prove the IPC plumbing actually
fires end-to-end: calls window.vnc.showNotification from the renderer and
asserts the round-trip resolves (not that a real OS toast appears - not
observable in CI). Verified locally: the call resolves {"shown":true} on
this machine, confirming it genuinely reaches Electron's Notification API
and back, not just that window.vnc exists.

Also fixes a real bug caught by this step's typecheck: the smoke test's
Playwright Page variable was named `window`, shadowing the DOM global
inside every evaluate() callback and silently breaking their types. Renamed
to `appWindow`.

All 4 smoke-test assertions green: npm run build:electron && npm run
test:electron.
2026-08-04 12:48:30 +02:00

179 lines
5.5 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 { 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> {
const 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).
// 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).
// Which of the two actually gets wired up to real mail-delivery events is a
// separate decision (VNCprodbuild Phase 1 steps 4-6); this handler is just
// the plumbing that lets the renderer trigger a native OS notification at
// all, so it can be exercised end-to-end from a smoke test now instead of
// bolted on untested later.
ipcMain.handle(
"vnc:show-notification",
(_event, title: string, options?: { body?: string; tag?: string }) => {
if (!Notification.isSupported()) {
return { shown: false };
}
const notification = new Notification({
title,
body: options?.body ?? "",
});
notification.show();
return { shown: true };
},
);
app.whenReady().then(() => {
void createMainWindow();
});
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();
}
});