235 lines
12 KiB
Markdown
235 lines
12 KiB
Markdown
# 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/<path>` → 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.
|