From b8f668d25a41d8ac98a6ffce95ca64f284e7d4ea Mon Sep 17 00:00:00 2001 From: Bernd Rodler Date: Tue, 4 Aug 2026 12:48:30 +0200 Subject: [PATCH] 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. --- e2e/electron-smoke.spec.ts | 40 ++++++++++++++++++++++----- electron/main.ts | 27 ++++++++++++++++++- electron/preload.ts | 24 +++++++++++++---- lib/electron-bridge.ts | 55 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 134 insertions(+), 12 deletions(-) create mode 100644 lib/electron-bridge.ts diff --git a/e2e/electron-smoke.spec.ts b/e2e/electron-smoke.spec.ts index 8d93cc3b..92dcf65a 100644 --- a/e2e/electron-smoke.spec.ts +++ b/e2e/electron-smoke.spec.ts @@ -19,7 +19,11 @@ const projectRoot = path.resolve(__dirname, '..'); test.describe('Electron desktop shell', () => { let electronApp: ElectronApplication; - let window: Page; + // Named `appWindow`, not `window` - the latter would shadow the DOM + // global inside every `appWindow.evaluate(() => window...)` callback + // below, silently breaking their typing (evaluate() callbacks run in the + // browser context, where `window` must resolve to the DOM global). + let appWindow: Page; const pageErrors: Error[] = []; test.beforeAll(async () => { @@ -38,11 +42,11 @@ test.describe('Electron desktop shell', () => { }, }); - window = await electronApp.firstWindow(); - window.on('pageerror', (error) => { + appWindow = await electronApp.firstWindow(); + appWindow.on('pageerror', (error) => { pageErrors.push(error); }); - await window.waitForLoadState('domcontentloaded'); + await appWindow.waitForLoadState('domcontentloaded'); }); test.afterAll(async () => { @@ -52,11 +56,35 @@ test.describe('Electron desktop shell', () => { test('boots the standalone server and renders the login screen', async () => { // Same selectors as e2e/login.spec.ts's browser-based check - the // shell should render the identical login form, not a different view. - await expect(window.locator('input[type="text"]')).toBeVisible({ timeout: 20000 }); - await expect(window.locator('input[type="password"]')).toBeVisible(); + await expect(appWindow.locator('input[type="text"]')).toBeVisible({ timeout: 20000 }); + await expect(appWindow.locator('input[type="password"]')).toBeVisible(); + }); + + test('exposes the contextBridge API to the renderer', async () => { + const isElectron = await appWindow.evaluate(() => window.vnc?.isElectron); + expect(isElectron).toBe(true); }); test('produces zero uncaught page errors', () => { expect(pageErrors).toEqual([]); }); + + test('the native notification bridge round-trips through IPC', async () => { + // Not asserting a real OS toast appears - that isn't observable in CI + // (headless runners/CI accounts routinely have no notification + // permission, and Notification.isSupported() can legitimately be + // false). What matters is that window.vnc.showNotification (exposed by + // electron/preload.ts's contextBridge) actually reaches the main + // process's ipcMain.handle("vnc:show-notification", ...) and resolves - + // proving the renderer -> preload -> main -> Electron Notification API + // plumbing is wired, not just that `window.vnc` exists. + const result = await appWindow.evaluate(async () => { + return window.vnc?.showNotification('Electron smoke test', { + body: 'IPC round-trip check', + }); + }); + + expect(result).toBeDefined(); + expect(typeof result?.shown).toBe('boolean'); + }); }); diff --git a/electron/main.ts b/electron/main.ts index 5fd15918..a62a252d 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -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 { 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(); }); diff --git a/electron/preload.ts b/electron/preload.ts index af846ce0..867bcd0a 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -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 => + ipcRenderer.invoke("vnc:show-notification", title, options), }); diff --git a/lib/electron-bridge.ts b/lib/electron-bridge.ts new file mode 100644 index 00000000..fb214235 --- /dev/null +++ b/lib/electron-bridge.ts @@ -0,0 +1,55 @@ +// Detects whether the app is running inside the VNCmail+ (Bulwark) Electron +// desktop shell and wraps the native notification bridge that +// electron/preload.ts exposes via contextBridge. Mirrors how lib/web-push.ts +// mirrors the React Native push flow - same idea, different native API: +// PushManager/service-worker there, Electron's own Notification API here. +// +// Web/PWA deployments never get `window.vnc` at all (contextBridge only +// exists inside the Electron shell), so `isElectronShell()` is false there +// and callers should keep using the lib/web-push.ts + public/sw.js path. +// Wiring this bridge up to real mail-delivery events (JMAP WebSocket push +// vs. polling) is a separate, later decision - this module is only the +// plumbing. + +export interface ShowNotificationOptions { + body?: string; + tag?: string; +} + +export interface ShowNotificationResult { + shown: boolean; +} + +export interface VncElectronBridge { + isElectron: true; + showNotification: ( + title: string, + options?: ShowNotificationOptions, + ) => Promise; +} + +declare global { + interface Window { + vnc?: VncElectronBridge; + } +} + +export function isElectronShell(): boolean { + return typeof window !== "undefined" && window.vnc?.isElectron === true; +} + +/** + * Shows a notification via Electron's native Notification API when running + * inside the desktop shell. Resolves to false (never throws) when not + * running in Electron, or when the main process reports notifications + * unsupported on this OS/session - callers can fall back to the + * service-worker push path (lib/web-push.ts) in that case. + */ +export async function showElectronNotification( + title: string, + options?: ShowNotificationOptions, +): Promise { + if (!isElectronShell()) return false; + const result = await window.vnc!.showNotification(title, options); + return result.shown; +}