---
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`
