vojo/docs/ai/electron.md

235 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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
(weeksmonths). **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.