Spaces:
Runtime error
Runtime error
| title: "Electron Desktop Guide" | |
| version: 3.8.2 | |
| lastUpdated: 2026-05-13 | |
| # Electron Desktop Guide | |
| > **Source of truth:** `electron/` workspace | |
| > **Last updated:** 2026-05-13 β v3.8.0 | |
| OmniRoute ships a cross-platform desktop app (Windows / macOS / Linux) built on | |
| **Electron 41** + **electron-builder 26.10**. The desktop app spawns the Next.js | |
| standalone server as a child process, points a `BrowserWindow` at it, and adds a | |
| system tray, auto-updater, IPC bridge, and zero-config secret bootstrap. | |
| ## Architecture | |
| ``` | |
| ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| β Electron main process (electron/main.js) β | |
| β ββ Single-instance lock β | |
| β ββ Child process: Next.js standalone server β | |
| β β (spawned with Electron's Node runtime) β | |
| β ββ BrowserWindow β http://localhost:PORT β | |
| β ββ System tray + context menu β | |
| β ββ Auto-update via electron-updater β | |
| β ββ Content Security Policy (session headers) β | |
| β ββ Secret bootstrap (JWT / API_KEY_SECRET) β | |
| ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| β IPC bridge (electron/preload.js) | |
| ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| β Renderer (Next.js dashboard) β | |
| β window.electronAPI.* (contextIsolation) β | |
| ββββββββββββββββββββββββββββββββββββββββββββββββ | |
| ``` | |
| ## Versions | |
| Confirmed from `electron/package.json`: | |
| | Package | Version | | |
| | ------------------ | -------------------------- | | |
| | `electron` | `^41.5.1` | | |
| | `electron-builder` | `^26.10.0` | | |
| | `electron-updater` | `^6.8.5` | | |
| | `better-sqlite3` | `^12.9.0` | | |
| | App version | `3.8.0` | | |
| | App id | `online.omniroute.desktop` | | |
| | Product name | `OmniRoute` | | |
| ## Scripts (root `package.json`) | |
| | Script | Purpose | | |
| | --------------------------------- | -------------------------------------------------------------------------- | | |
| | `npm run electron:dev` | Starts `npm run dev` + waits for `localhost:20128` + launches Electron | | |
| | `npm run electron:build` | Builds Next.js then runs `electron-builder` for the current OS | | |
| | `npm run electron:build:win` | Builds Windows NSIS installer + portable (x64) | | |
| | `npm run electron:build:mac` | Builds macOS DMG (Intel + Apple Silicon) | | |
| | `npm run electron:build:linux` | Builds Linux AppImage + DEB (x64 + arm64) | | |
| | `npm run electron:smoke:packaged` | Launches packaged binary and probes `/login` for HTTP 200, then shuts down | | |
| The `electron/` workspace also exposes: | |
| - `npm run prepare:bundle` β runs `scripts/build/prepare-electron-standalone.mjs` | |
| - `npm run build:mac-x64` / `build:mac-arm64` β single-arch macOS builds | |
| - `npm run pack` β directory-only build for local testing (no installer) | |
| ## Directory Layout | |
| ``` | |
| electron/ | |
| βββ package.json # Electron deps + electron-builder config | |
| βββ main.js # Main process (24 KB β see annotations below) | |
| βββ preload.js # contextBridge IPC bridge | |
| βββ types.d.ts # AppInfo / ServerStatus / ElectronAPI types | |
| βββ README.md # In-workspace notes | |
| βββ assets/ # icon.png, icon.ico, icon.icns, tray-icon.png | |
| βββ dist-electron/ # electron-builder output (gitignored) | |
| scripts/ | |
| βββ build/ | |
| β βββ prepare-electron-standalone.mjs # Stages .next/electron-standalone bundle | |
| βββ dev/ | |
| βββ smoke-electron-packaged.mjs # Post-build smoke test | |
| ``` | |
| Both `main.js` and `preload.js` are **CommonJS `.js` files**, not TypeScript. The | |
| renderer-side typings live in `electron/types.d.ts`. | |
| ## IPC Bridge (`preload.js`) | |
| The preload exposes a whitelisted API on `window.electronAPI` using `contextBridge` | |
| with `contextIsolation: true` and `nodeIntegration: false`. | |
| ```javascript | |
| const VALID_CHANNELS = { | |
| invoke: [ | |
| "get-app-info", | |
| "open-external", | |
| "get-data-dir", | |
| "restart-server", | |
| "check-for-updates", | |
| "download-update", | |
| "install-update", | |
| "get-app-version", | |
| ], | |
| send: ["window-minimize", "window-maximize", "window-close"], | |
| receive: ["server-status", "port-changed", "update-status"], | |
| }; | |
| ``` | |
| Exposed methods: | |
| | Renderer call | Type | | |
| | ----------------------------------------------------------------- | -------------------------- | | |
| | `getAppInfo()` β `{ name, version, platform, isDev, port }` | invoke | | |
| | `openExternal(url)` | invoke | | |
| | `getDataDir()` | invoke | | |
| | `restartServer()` | invoke | | |
| | `getAppVersion()` | invoke | | |
| | `checkForUpdates()` / `downloadUpdate()` / `installUpdate()` | invoke | | |
| | `minimizeWindow()` / `maximizeWindow()` / `closeWindow()` | send | | |
| | `onServerStatus(cb)` / `onPortChanged(cb)` / `onUpdateStatus(cb)` | receive (returns disposer) | | |
| The receive helpers return a **disposer function** rather than relying on | |
| `removeAllListeners` β this prevents listener accumulation when React components | |
| remount. | |
| ## Server Lifecycle | |
| `main.js` spawns the Next.js standalone bundle directly with the Electron Node | |
| runtime to avoid native-module ABI mismatch with system Node: | |
| ```js | |
| spawn(process.execPath, [serverScript], { | |
| cwd: NEXT_SERVER_PATH, | |
| env: { ...serverEnv, PORT, NODE_ENV: "production", ELECTRON_RUN_AS_NODE: "1", NODE_PATH }, | |
| stdio: "pipe", | |
| }); | |
| ``` | |
| Highlights: | |
| - `waitForServer()` polls the URL up to 30 s before showing the window (no blank screen on cold start). | |
| - `stdio: "pipe"` captures stdout/stderr; ready phrases (`Ready` / `listening`) emit `server-status: running` over IPC. | |
| - `before-quit` waits up to 5 s for graceful SIGTERM (WAL checkpoint) then sends SIGKILL. | |
| - Port switcher in the tray (`20128`, `3000`, `8080`) stops and restarts the server, then reloads the BrowserWindow. | |
| ## Zero-config Secret Bootstrap | |
| On first launch, the main process auto-generates and persists missing secrets: | |
| | Secret | Source | | |
| | ------------------------ | ----------------------------------------------------------------------------------- | | |
| | `JWT_SECRET` | `crypto.randomBytes(64).toString("hex")` | | |
| | `STORAGE_ENCRYPTION_KEY` | `crypto.randomBytes(32).toString("hex")` (refuses if encrypted creds already exist) | | |
| | `API_KEY_SECRET` | `crypto.randomBytes(32).toString("hex")` | | |
| Persisted to `<DATA_DIR>/server.env`. `DATA_DIR` resolves to: | |
| - Windows: `%APPDATA%\omniroute` | |
| - Linux: `$XDG_CONFIG_HOME/omniroute` or `~/.omniroute` | |
| - macOS: `~/.omniroute` | |
| ## Window & Tray | |
| - `BrowserWindow`: 1400Γ900 (min 1024Γ700), `backgroundColor: "#0a0a0a"`. | |
| - macOS: `titleBarStyle: "hiddenInset"`, traffic-light at `{ x: 16, y: 16 }`. | |
| - Windows/Linux: native title bar. | |
| - Close button minimizes to tray; the tray menu has **Open OmniRoute**, **Open Dashboard** (external browser), **Server Port** submenu, **Check for Updates**, **Quit**. | |
| ## Content Security Policy | |
| Set via `session.defaultSession.webRequest.onHeadersReceived`. Notable directives: | |
| - `frame-ancestors 'none'`, `object-src 'none'`, `child-src 'none'` | |
| - `connect-src 'self' http://localhost:* http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:* https://*.omniroute.online https://*.omniroute.dev` | |
| - Dev mode adds `'unsafe-eval'` to `script-src` only | |
| ## Auto-update | |
| Uses `electron-updater` with the GitHub provider (`diegosouzapw/OmniRoute`). | |
| - `autoDownload = false`, `autoInstallOnAppQuit = true` | |
| - Events forwarded to renderer via `update-status` IPC: | |
| `checking`, `available`, `not-available`, `downloading` (with `percent`), `downloaded`, `error` | |
| - `installUpdate()` kills the server then calls `autoUpdater.quitAndInstall()` | |
| - Skipped in dev mode (`!app.isPackaged`) | |
| ## Build Pipeline | |
| 1. `npm run build` β Next.js standalone in `.next/standalone`. | |
| 2. `prepare-electron-standalone.mjs` β re-stages into `.next/electron-standalone` and rewrites absolute paths inside `server.js` + `required-server-files.json` so the bundle is relocatable. | |
| 3. `electron-builder` packages `main.js`, `preload.js`, `node_modules`, and `extraResources: { ../.next/electron-standalone β app }`. | |
| ### Build targets | |
| | OS | Targets | | |
| | ------- | ----------------------------------------- | | |
| | Windows | NSIS installer + portable (x64) | | |
| | macOS | DMG (Intel + arm64, drag-to-Applications) | | |
| | Linux | AppImage + DEB (x64 + arm64) | | |
| NSIS settings: `oneClick: false`, lets the user choose the install directory, creates Desktop and Start-Menu shortcuts. | |
| ## Smoke Testing Packaged Build | |
| ```bash | |
| npm run electron:smoke:packaged | |
| ``` | |
| `scripts/dev/smoke-electron-packaged.mjs`: | |
| - Auto-discovers the packaged binary in `electron/dist-electron/` for the current platform. | |
| - Launches with isolated `HOME`/`APPDATA`/`XDG_*` directories so it doesn't touch developer data. | |
| - Polls `http://127.0.0.1:20128/login` for HTTP 200 within 45 s. | |
| - Watches stderr/stdout for fatal patterns (`Cannot find module`, `MODULE_NOT_FOUND`, `ERR_DLOPEN_FAILED`, `Failed to start server`, etc.). | |
| - Waits 2 s of stable runtime after readiness, then issues SIGTERM and waits for the port to free. | |
| - In CI, automatically passes `--no-sandbox --disable-gpu` (and `--disable-dev-shm-usage` on Linux). | |
| Env overrides: `ELECTRON_SMOKE_APP_EXECUTABLE`, `ELECTRON_SMOKE_URL`, `ELECTRON_SMOKE_TIMEOUT_MS`, `ELECTRON_SMOKE_SETTLE_MS`, `ELECTRON_SMOKE_DATA_DIR`, `ELECTRON_SMOKE_KEEP_DATA`, `ELECTRON_SMOKE_STREAM_LOGS`. | |
| ## Code Signing | |
| `electron/package.json` does **not** wire signing credentials directly. Pass them via env vars to `electron-builder`: | |
| ### macOS | |
| ```bash | |
| export APPLE_ID=<email> | |
| export APPLE_APP_SPECIFIC_PASSWORD=<password> | |
| export APPLE_TEAM_ID=<id> | |
| export CSC_LINK=path/to/cert.p12 | |
| export CSC_KEY_PASSWORD=<cert-password> | |
| npm run electron:build:mac | |
| ``` | |
| ### Windows | |
| ```bash | |
| export CSC_LINK=path/to/cert.pfx | |
| export CSC_KEY_PASSWORD=<cert-password> | |
| npm run electron:build:win | |
| ``` | |
| ### Linux | |
| AppImage signing is optional β set `LINUX_GPG_KEY` if signing. | |
| ## Distribution | |
| Artifacts land in `electron/dist-electron/`: | |
| - `OmniRoute Setup X.Y.Z.exe`, `OmniRoute-X.Y.Z-portable.exe` (Windows) | |
| - `OmniRoute-X.Y.Z-mac.dmg`, `OmniRoute-X.Y.Z-arm64-mac.dmg` (macOS) | |
| - `OmniRoute-X.Y.Z.AppImage`, `omniroute-desktop_X.Y.Z_amd64.deb` (Linux) | |
| Releases are published to GitHub Releases (`diegosouzapw/OmniRoute`), which is also where `electron-updater` checks for new versions. | |
| ## Troubleshooting | |
| | Symptom | Fix | | |
| | --------------------------------------------------------------- | --------------------------------------------------------------------------- | | |
| | `Cannot find module 'better-sqlite3'` after Electron major bump | `cd electron && npm rebuild` | | |
| | `ERR_DLOPEN_FAILED` for native module | Re-run `prepare:bundle` and verify ABI matches Electron's Node | | |
| | Window appears blank on Linux | Confirm Next.js server actually bound to PORT (check `[Server]` logs) | | |
| | macOS notarization stalls | Ensure `APPLE_*` vars are exported, not just in `.env` | | |
| | Windows SmartScreen warning | Sign with EV cert, or users right-click β "Run anyway" | | |
| | Smoke test fails with port-in-use | Stop any local dev server on 20128 before running `electron:smoke:packaged` | | |
| ## See Also | |
| - [SETUP_GUIDE.md](./SETUP_GUIDE.md) | |
| - [RELEASE_CHECKLIST.md](../ops/RELEASE_CHECKLIST.md) | |
| - Source: `electron/main.js`, `electron/preload.js`, `electron/package.json` | |
| - Helpers: `scripts/build/prepare-electron-standalone.mjs`, `scripts/dev/smoke-electron-packaged.mjs` | |