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:
@@ -19,7 +19,11 @@ const projectRoot = path.resolve(__dirname, '..');
|
|||||||
|
|
||||||
test.describe('Electron desktop shell', () => {
|
test.describe('Electron desktop shell', () => {
|
||||||
let electronApp: ElectronApplication;
|
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[] = [];
|
const pageErrors: Error[] = [];
|
||||||
|
|
||||||
test.beforeAll(async () => {
|
test.beforeAll(async () => {
|
||||||
@@ -38,11 +42,11 @@ test.describe('Electron desktop shell', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
window = await electronApp.firstWindow();
|
appWindow = await electronApp.firstWindow();
|
||||||
window.on('pageerror', (error) => {
|
appWindow.on('pageerror', (error) => {
|
||||||
pageErrors.push(error);
|
pageErrors.push(error);
|
||||||
});
|
});
|
||||||
await window.waitForLoadState('domcontentloaded');
|
await appWindow.waitForLoadState('domcontentloaded');
|
||||||
});
|
});
|
||||||
|
|
||||||
test.afterAll(async () => {
|
test.afterAll(async () => {
|
||||||
@@ -52,11 +56,35 @@ test.describe('Electron desktop shell', () => {
|
|||||||
test('boots the standalone server and renders the login screen', async () => {
|
test('boots the standalone server and renders the login screen', async () => {
|
||||||
// Same selectors as e2e/login.spec.ts's browser-based check - the
|
// Same selectors as e2e/login.spec.ts's browser-based check - the
|
||||||
// shell should render the identical login form, not a different view.
|
// shell should render the identical login form, not a different view.
|
||||||
await expect(window.locator('input[type="text"]')).toBeVisible({ timeout: 20000 });
|
await expect(appWindow.locator('input[type="text"]')).toBeVisible({ timeout: 20000 });
|
||||||
await expect(window.locator('input[type="password"]')).toBeVisible();
|
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', () => {
|
test('produces zero uncaught page errors', () => {
|
||||||
expect(pageErrors).toEqual([]);
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+26
-1
@@ -6,7 +6,7 @@
|
|||||||
// random localhost port, then opens a BrowserWindow pointed at it. This is
|
// random localhost port, then opens a BrowserWindow pointed at it. This is
|
||||||
// deliberately the same server, not a reimplementation - lib/jmap/client.ts
|
// deliberately the same server, not a reimplementation - lib/jmap/client.ts
|
||||||
// and every app/api/** route behave identically to the web deployment.
|
// 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 { spawn, type ChildProcess } from "node:child_process";
|
||||||
import { createServer } from "node:net";
|
import { createServer } from "node:net";
|
||||||
import { get as httpGet } from "node:http";
|
import { get as httpGet } from "node:http";
|
||||||
@@ -131,6 +131,31 @@ async function createMainWindow(): Promise<void> {
|
|||||||
await mainWindow.loadURL(url);
|
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(() => {
|
app.whenReady().then(() => {
|
||||||
void createMainWindow();
|
void createMainWindow();
|
||||||
});
|
});
|
||||||
|
|||||||
+19
-5
@@ -2,12 +2,26 @@
|
|||||||
// context with access to Node APIs, and exposes a minimal, explicit surface
|
// context with access to Node APIs, and exposes a minimal, explicit surface
|
||||||
// to the renderer via contextBridge - the renderer never gets direct Node or
|
// to the renderer via contextBridge - the renderer never gets direct Node or
|
||||||
// Electron access (contextIsolation + nodeIntegration: false, see main.ts).
|
// Electron access (contextIsolation + nodeIntegration: false, see main.ts).
|
||||||
//
|
import { contextBridge, ipcRenderer } from "electron";
|
||||||
// Walking-skeleton stub for now: just `isElectron`, so renderer code can
|
|
||||||
// detect it's running inside the desktop shell. A real API surface (native
|
export interface ShowNotificationOptions {
|
||||||
// notifications, etc.) gets added on top of this bridge in a later step.
|
body?: string;
|
||||||
import { contextBridge } from "electron";
|
tag?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShowNotificationResult {
|
||||||
|
shown: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld("vnc", {
|
contextBridge.exposeInMainWorld("vnc", {
|
||||||
isElectron: true,
|
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),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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<ShowNotificationResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<boolean> {
|
||||||
|
if (!isElectronShell()) return false;
|
||||||
|
const result = await window.vnc!.showNotification(title, options);
|
||||||
|
return result.shown;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user