# Electron Desktop Vojo as a native desktop app (Windows .exe first, macOS/Linux later) via **Electron** wrapping the same Vite `dist/` that web/Capacitor consume. ## Why not Tauri Tauri 2 uses the system WebView (WebView2 on Windows). Service Worker registration on custom schemes is **«won't fix»** per Tauri's own maintainer ([tauri#13031](https://github.com/tauri-apps/tauri/issues/13031), Aug 2025). Vojo's SW is load-bearing for authenticated Matrix media (MSC3916). The official Tauri workaround (`tauri-plugin-localhost`) is itself flagged in Tauri's docs as «considerable security risks» — exposes a local HTTP port, any process on the user's machine can hit it. Unacceptable for a Matrix client storing E2EE keys. Electron bundles its own Chromium, so SW works as in Chrome after `protocol.registerSchemesAsPrivileged({ allowServiceWorkers: true, ... })`. Element Desktop uses the same **privileged-scheme** mechanism but with a different media-auth strategy: their scheme privileges set is just `{ standard, secure, supportFetchAPI }` (no `allowServiceWorkers`), and they inject the `Authorization` header for Matrix media via `session.defaultSession.webRequest.onBeforeSendHeaders` — Service Workers aren't load-bearing for them. Vojo keeps the SW because that's how the web build authenticates media; re-implementing the auth in a main-process hook just for desktop would diverge renderer code paths. Our privilege set is a superset of Element's by design (also Matrix, also AGPL — still our architectural reference for the wider Electron shell). ## Source layout ``` electron/ ├── main.ts # main process — window, privileged scheme, IPC ├── preload.ts # contextBridge: window.vojoElectron API ├── tsconfig.json # CJS output, Node target — separate from src/ └── dist-electron/ # tsc output (gitignored) └── main.js, preload.js # generated src/app/utils/electron.ts # renderer-side: isElectron(), openExternalUrl(), setupExternalLinkHandler() electron-builder.json # packaging config (NSIS for Windows) release/ # electron-builder output (gitignored) ``` ## Build chain ```bash npm run electron:typecheck # tsc --noEmit -p electron/tsconfig.json npm run electron:build # tsc → electron/dist-electron/*.js (+ package.json override) npm run electron:dev # vite + electron in parallel (concurrently + wait-on) npm run electron:start # electron only — DEV mode (loads localhost:8080) npm run electron:start:prod # electron only — PROD mode (loads vojo://, requires npm run build first) npm run build:electron:win # native build: vite build → electron:build → electron-builder --win # ONLY works on Windows host (or WSL with Wine installed) npm run build:electron:win:docker # cross-build from Linux/WSL via electronuserland/builder:wine # Docker image ~3GB on first run; output in release/ ``` ### M1 vs M2 mode toggle `isDev` in [`electron/main.ts`](../../electron/main.ts) is: ```ts const isDev = !app.isPackaged && process.env.VOJO_ELECTRON_PROD !== '1'; ``` - **Packaged binary** (`.exe`/`.dmg`/`.AppImage`) → `isDev = false` always - **Unpackaged, dev**: `electron:dev` / `electron:start` → loads `http://localhost:8080` - **Unpackaged, prod-mode test** (`electron:start:prod`) → loads `vojo://app/index.html` The prod-mode env override exists so M2 (verifying the privileged scheme + service worker actually register) can be tested locally **without** running `electron-builder` for every change. The packaged binary uses the same code path. ### Cross-building Windows .exe from Linux/WSL `build:electron:win:docker` runs the build inside `electronuserland/builder:wine-mono` — the official Wine-based image. **`electron-builder.json::win.signAndEditExecutable = false` is required** for this cross-build to finish. Without it, electron-builder invokes `rcedit.exe` through Wine to stamp `FileDescription`/`ProductName`/version metadata onto the bundled `Vojo.exe`. The Wine docker images (`:wine`, `:wine-mono`) ship **without Xvfb**, so rcedit hangs forever trying to create a Win32 window for COM apartment init — see [electron-userland/electron-builder#6191](https://github.com/electron-userland/electron-builder/issues/6191). Cost of the workaround: the `.exe` Properties dialog on Windows shows generic «Electron 42.1.0» metadata instead of «Vojo». Cosmetic only; the binary itself runs correctly. Revisit when CI moves to a real Windows runner (M3 GitHub Actions), where `signAndEditExecutable` can flip back to `true`. Three host caches are mounted in to speed up subsequent builds: - `~/.cache/electron` — Electron runtime download cache (~150MB) - `~/.cache/electron-builder` — NSIS / app-update binaries - `${PWD}` — project source (read-write, output goes to `release/`) This is the workflow we use locally on WSL because Wine isn't installed natively. The same artifact is produced as a native Windows build. CI (M3, future) will run `electron-builder --win` directly on `windows-latest`. `electron:build` writes `electron/dist-electron/package.json` with `{"type":"commonjs"}` to override the root `"type":"module"` for the compiled `.js` files. Required because Electron's main process loader expects CJS unless you opt into ESM (which has separate pitfalls). ## Custom protocol — load-bearing In production, the renderer is loaded from `vojo://app/` (trailing slash, NOT `vojo://app/index.html` — that was the original choice and produced a «Join index.html» screen because React Router parsed the `index.html` segment as a space alias). The `vojo` scheme is registered as privileged BEFORE `app.whenReady()` with `allowServiceWorkers: true`, `secure: true`, `standard: true`, `supportFetchAPI: true`, `corsEnabled: true`, `stream: true`, `codeCache: true`. This is the **one** thing that makes the Vojo SW work in the packaged build. **Do not change `loadURL(vojo://...)` to `loadFile(...)`** — SW will silently fail to register. `protocol.handle('vojo', ...)` maps `vojo://app/` → file lookup inside the packaged `dist/`. Two guards: 1. **Host check**: only `vojo://app` is accepted; any other host returns 403. Otherwise `vojo://evil/...` would resolve into a separate (cookie/SW/IndexedDB-isolated) copy of the bundle with detached storage — same content but split-brain state. 2. **Path-traversal guard**: `path.relative(distDir, filePath)` is checked for `..`-prefix or absolute output — the canonical Node check. `filePath.startsWith(distDir)` is **insufficient** (`/a/dist_evil` is a prefix-match for `/a/dist`). ## IPC — security stance - `contextIsolation: true`, `nodeIntegration: false` — Electron defaults. - `preload.ts` exposes a minimal API: `platform` (string), `openExternal(url)`. - `ipcMain.handle('vojo:open-external')` validates url scheme against an allowlist (`http:`, `https:`, `mailto:`) and length (≤ 8KB) before calling `shell.openExternal`. Don't widen the allowlist without thinking — e.g. `file:` would let the renderer ask the OS to open arbitrary files. - `setWindowOpenHandler` denies all `window.open()` and routes safe URLs through `shell.openExternal`. - `will-navigate` intercepts top-frame navigation: only `vojo://` (prod) and `http://localhost:8080` (dev) are internal; everything else opens externally. ## Push notifications — different model from Android Android uses FCM + foreground service + Sygnal. **Electron does NOT use this path.** Desktop model = «always running app»: matrix-js-sdk sync stays open while the app is launched (Discord/Slack/Element Desktop pattern), and timeline events fan out to `new Notification(...)` directly. Closed app = no notifications. This is intentional and matches user expectations for desktop Matrix clients. The existing [`usePushNotifications.ts`](../../src/app/hooks/usePushNotifications.ts) VAPID/Web-Push flow is web/PWA-only on this branch — Electron path is to be added in **M4** of [`docs/plans/electron_desktop.md`](../plans/electron_desktop.md). ## Window chrome — Windows Controls Overlay On Windows the `BrowserWindow` uses `titleBarStyle: 'hidden'` plus `titleBarOverlay` to remove the native title bar but **keep real OS min/max/close buttons** (drawn by Windows itself in the top-right corner, with their native hover/snap/accessibility behavior). The remaining bar area takes our Dawn-dark palette (`#0d0e11` / `#e0e0e8` symbol color) so it blends with the renderer rather than clashing with the system accent. Same pattern as Discord, Slack, VS Code, Element Desktop. macOS gets `titleBarStyle: 'hiddenInset'` (traffic lights inset over a clean dark area). Linux keeps the system frame to avoid breaking GTK/KWin window decorations. **Caveat**: the 32px overlay area floats ON TOP of the renderer's pixels (the renderer's content extends to `y=0`). If you put critical UI in the top-right 100px, it visually overlaps the buttons. The buttons remain clickable regardless, but content may look obscured. Vojo's current SidebarNav (66px wide on the left) and PageRoot content don't have anything load-bearing in the top-right strip, so this is fine — verify before adding new top-bar widgets there. **Drag region** — Electron does NOT make the hidden-titlebar area draggable automatically. `main.ts` injects CSS via `webContents.insertCSS` on `dom-ready` that adds a `body::before` overlay with `-webkit-app-region: drag` sized by the Window Controls Overlay API `env(titlebar-area-*)` variables. Body gets matching `padding-top: env(titlebar-area-height, 32px)` so renderer content shifts down instead of sitting under the drag strip. If new Vojo UI ever puts elements at `top: 0` with `position: fixed`, they'll need explicit `top: env(titlebar-area-height, 32px)` to clear the drag region — body padding doesn't shift fixed children. **Theme switching**: `titleBarOverlay.color` is currently hardcoded dark. When light theme is fully wired up (see `architecture.md` «Known follow-ups for light theme»), call `win.setTitleBarOverlay({color, symbolColor})` from a theme-change IPC to keep the bar in sync. Tracked as a follow-up, not implemented in M2. ## Renderer-side platform detection [`src/app/utils/electron.ts`](../../src/app/utils/electron.ts) mirrors the shape of [`capacitor.ts`](../../src/app/utils/capacitor.ts): `isElectron()`, `openExternalUrl(url)`, `setupExternalLinkHandler()`. The handlers from both files are called in [`src/index.tsx`](../../src/index.tsx) at boot — each is a no-op on the other platform. Do **not** unify them into a single `platform.ts` — Capacitor's wrapper imports `@capacitor/browser` at top level and runs in WebView even in dev; Electron's wrapper has no peer dependency and detects via `window.vojoElectron`. ## Build doesn't ship Android stuff `electron-builder.json` `files` field includes ONLY `dist/**`, `electron/dist-electron/**`, and `package.json`. The `android/` directory is NOT in the asar — keeps the .exe lean and avoids accidentally shipping keystore paths or build artifacts. ## Known caveats - **`__dirname` in CJS output points to `electron/dist-electron/`** — that's why `distDir = path.resolve(__dirname, '..', '..', 'dist')` walks up two levels. If you change `outDir` in `electron/tsconfig.json`, retune this path. - **`sandbox: true` is on.** Preload uses only `contextBridge` and `ipcRenderer`, both of which are sandbox-compatible per [Electron sandbox docs](https://www.electronjs.org/docs/latest/tutorial/sandbox). Don't add `fs`/`path`/`child_process` to preload — that requires `sandbox: false` and weakens isolation. If you ever do, document why. - **No code-signing in M0..M3.** SmartScreen on Windows shows «Windows protected your PC» dialog; user clicks «More info → Run anyway». Drop-off exists but acceptable for alpha. **OV cert (~$65/yr) does NOT bypass SmartScreen instantly** — reputation accrues over ~thousands of installs (weeks–months). **EV cert (~$250-400/yr + hardware token / cloud HSM) gives instant SmartScreen pass.** Plan accordingly: OV is a trap for app-launch UX; either accept unsigned + click-through, or budget EV. - **No auto-updater in M0..M3.** `electron-updater` requires a signed build for differential updates; revisit when signing is in place.