278 lines
13 KiB
Markdown
278 lines
13 KiB
Markdown
---
|
||
title: "Electron Desktop Guide"
|
||
version: 3.8.40
|
||
lastUpdated: 2026-06-28
|
||
---
|
||
|
||
# Electron Desktop Guide
|
||
|
||
> **Source of truth:** `electron/` workspace
|
||
> **Last updated:** 2026-06-28 — v3.8.40
|
||
|
||
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`
|