vojo/docs/ai/electron.md

12 KiB
Raw Permalink Blame History

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

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 is:

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

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 mirrors the shape of capacitor.ts: isElectron(), openExternalUrl(url), setupExternalLinkHandler(). The handlers from both files are called in 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. 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.