Phase 1 step 7 of VNCprodbuild. electron/main.ts calls autoUpdater.checkForUpdatesAndNotify() once the app is ready, only for packaged builds (app.isPackaged) - dev/test runs have no latest.yml and would just log a noisy 404 on every launch. electron-builder.config.js gets a matching `publish` block pointing at this repo's own GitHub Releases (brvncde-dotcom/vncmail-plus) - the skill's recommendation over standing up a new distribution channel, since the repo is already private. Flagged as the "light decision" the skill calls it, not blocking. Deliberately defensive: no code signing yet (step 9), so update verification can fail on macOS in particular. Wrapped in try/catch + autoUpdater's "error" event so a failed check is logged and swallowed, never fatal - this is background maintenance, not something the user should be blocked on. Verified with a --dir packaged build: checkForUpdatesAndNotify() throws ENOENT for app-update.yml (expected - that file is only emitted by a full `electron-builder build`, not --dir) and the error handling swallows it cleanly; the standalone server still boots and serves the app normally. npm run test:electron still green (4/4) - autoUpdater is a no-op in the unpacked dev/test path this suite exercises.
208 lines
6.8 KiB
TypeScript
208 lines
6.8 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> {
|
|
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 };
|
|
},
|
|
);
|
|
|
|
// --- 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();
|
|
}
|
|
});
|