diff --git a/electron-builder.config.js b/electron-builder.config.js new file mode 100644 index 00000000..108bde32 --- /dev/null +++ b/electron-builder.config.js @@ -0,0 +1,23 @@ +// Base electron-builder config - Phase 1 step 1 of the VNCprodbuild rollout. +// No code signing and no platform targets configured yet; this exists only +// to prove the packaging concept end to end (the app runs, boots its own +// server, opens a window). Targets (dmg/zip/nsis/AppImage/deb), branding +// icons, and code signing are wired up in later steps - see +// ~/.claude/skills/VNCprodbuild/SKILL.md, Phase 1 steps 6-9. +module.exports = { + appId: "de.vnc.vncmailplus", + productName: "VNCmail+", + directories: { + output: "dist-electron-builds", + }, + files: ["dist-electron/**/*", "package.json"], + extraResources: [ + { + // Same artifact the Dockerfile bakes into the container image (see + // Dockerfile + scripts/assemble-standalone.mjs). electron/main.ts + // reads it from process.resourcesPath in packaged builds. + from: ".next/standalone", + to: "standalone", + }, + ], +}; diff --git a/electron/main.ts b/electron/main.ts new file mode 100644 index 00000000..5fd15918 --- /dev/null +++ b/electron/main.ts @@ -0,0 +1,153 @@ +// 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 } 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 { + 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 { + 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 { + 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 { + 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); +} + +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(); + } +}); diff --git a/electron/preload.ts b/electron/preload.ts new file mode 100644 index 00000000..af846ce0 --- /dev/null +++ b/electron/preload.ts @@ -0,0 +1,13 @@ +// Preload script for the VNCmail+ desktop shell. Runs in an isolated +// 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"; + +contextBridge.exposeInMainWorld("vnc", { + isElectron: true, +}); diff --git a/eslint.config.mjs b/eslint.config.mjs index b268a47f..073bb7e9 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -53,6 +53,18 @@ export default [ }, }, }, + { + // Plain Node scripts (electron bundling/packaging helpers) - not React/ + // browser code, so they get node globals only, no react/jsx parsing. + files: ["scripts/**/*.{mjs,cjs,js}"], + languageOptions: { + ecmaVersion: "latest", + sourceType: "module", + globals: { + ...globals.node, + }, + }, + }, { files: ["**/*.test.{ts,tsx}", "**/*.spec.{ts,tsx}"], languageOptions: { @@ -70,6 +82,8 @@ export default [ { ignores: [ ".next/**", + "dist-electron/**", + "dist-electron-builds/**", "node_modules/**", "repos/**", "data/admin/plugins/**", @@ -81,6 +95,12 @@ export default [ "benchmark/**", "examples/**", "integration/**", + // Independent sub-package with its own package.json/build (esbuild, + // browser-only globals) - same reasoning as repos/** and examples/** + // above. Pre-existing gap: this was blocking `npm run lint` (and thus + // the pre-commit hook) repo-wide before this Electron work even + // touched anything - see the electron-desktop branch's first commits. + "vnc/plugins/smime/**", ], }, ]; diff --git a/scripts/assemble-standalone.mjs b/scripts/assemble-standalone.mjs new file mode 100644 index 00000000..4422327f --- /dev/null +++ b/scripts/assemble-standalone.mjs @@ -0,0 +1,29 @@ +#!/usr/bin/env node +// `next build --webpack` (see next.config.ts's `output: "standalone"`) +// emits .next/standalone/server.js but - deliberately, per Next's own docs - +// leaves out public/ and .next/static/. The Dockerfile copies both in by +// hand for the container image; this does the same thing for local Electron +// dev and packaging, so every path boots the exact same artifact. +import { cpSync, existsSync, rmSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const rootDir = path.dirname(path.dirname(fileURLToPath(import.meta.url))); +const standaloneDir = path.join(rootDir, ".next", "standalone"); + +if (!existsSync(standaloneDir)) { + console.error(`Missing ${standaloneDir} - run "next build --webpack" first.`); + process.exit(1); +} + +const publicSrc = path.join(rootDir, "public"); +const publicDest = path.join(standaloneDir, "public"); +rmSync(publicDest, { recursive: true, force: true }); +cpSync(publicSrc, publicDest, { recursive: true }); + +const staticSrc = path.join(rootDir, ".next", "static"); +const staticDest = path.join(standaloneDir, ".next", "static"); +rmSync(staticDest, { recursive: true, force: true }); +cpSync(staticSrc, staticDest, { recursive: true }); + +console.log("Assembled standalone server at", standaloneDir); diff --git a/scripts/build-electron.mjs b/scripts/build-electron.mjs new file mode 100644 index 00000000..18c1d857 --- /dev/null +++ b/scripts/build-electron.mjs @@ -0,0 +1,36 @@ +#!/usr/bin/env node +// Bundles electron/main.ts and electron/preload.ts into dist-electron/*.js. +// Uses esbuild (already a devDependency for the admin plugin dev-bundler, +// lib/admin/plugin-dev.ts) rather than pulling in ts-node/tsx - the output +// is plain CommonJS, so the packaged app needs no separate TS runtime. +import { build } from "esbuild"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; + +const rootDir = path.dirname(path.dirname(fileURLToPath(import.meta.url))); + +const shared = { + bundle: true, + platform: "node", + target: "node22", + format: "cjs", + sourcemap: true, + // `electron` is provided by the Electron runtime itself; `electron-updater` + // stays external so electron-builder ships it from node_modules as a + // normal production dependency instead of us re-bundling its native-ish + // internals (see electron-builder.config.js's file collection). + external: ["electron", "electron-updater"], + logLevel: "info", +}; + +await build({ + ...shared, + entryPoints: [path.join(rootDir, "electron/main.ts")], + outfile: path.join(rootDir, "dist-electron/main.js"), +}); + +await build({ + ...shared, + entryPoints: [path.join(rootDir, "electron/preload.ts")], + outfile: path.join(rootDir, "dist-electron/preload.js"), +});