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.
This commit is contained in:
Bernd Rodler
2026-08-04 12:48:30 +02:00
parent 9254a7fa20
commit b8f668d25a
4 changed files with 134 additions and 12 deletions
+26 -1
View File
@@ -6,7 +6,7 @@
// 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 } from "electron";
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";
@@ -131,6 +131,31 @@ async function createMainWindow(): Promise<void> {
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();
});
+19 -5
View File
@@ -2,12 +2,26 @@
// context with access to Node APIs, and exposes a minimal, explicit surface
// to the renderer via contextBridge - the renderer never gets direct Node or
// Electron access (contextIsolation + nodeIntegration: false, see main.ts).
//
// Walking-skeleton stub for now: just `isElectron`, so renderer code can
// detect it's running inside the desktop shell. A real API surface (native
// notifications, etc.) gets added on top of this bridge in a later step.
import { contextBridge } from "electron";
import { contextBridge, ipcRenderer } from "electron";
export interface ShowNotificationOptions {
body?: string;
tag?: string;
}
export interface ShowNotificationResult {
shown: boolean;
}
contextBridge.exposeInMainWorld("vnc", {
isElectron: true,
// Routes to Electron's own Notification API in main.ts (ipcMain.handle
// "vnc:show-notification"). This is the desktop shell's native
// notification path - it does not replace lib/web-push.ts's Web Push
// (VAPID) path, which is what the browser/PWA deployment still uses.
showNotification: (
title: string,
options?: ShowNotificationOptions,
): Promise<ShowNotificationResult> =>
ipcRenderer.invoke("vnc:show-notification", title, options),
});