12 KiB
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 = falsealways - Unpackaged, dev:
electron:dev/electron:start→ loadshttp://localhost:8080 - Unpackaged, prod-mode test (
electron:start:prod) → loadsvojo://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 torelease/)
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:
- Host check: only
vojo://appis accepted; any other host returns 403. Otherwisevojo://evil/...would resolve into a separate (cookie/SW/IndexedDB-isolated) copy of the bundle with detached storage — same content but split-brain state. - 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_evilis a prefix-match for/a/dist).
IPC — security stance
contextIsolation: true,nodeIntegration: false— Electron defaults.preload.tsexposes 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 callingshell.openExternal. Don't widen the allowlist without thinking — e.g.file:would let the renderer ask the OS to open arbitrary files.setWindowOpenHandlerdenies allwindow.open()and routes safe URLs throughshell.openExternal.will-navigateintercepts top-frame navigation: onlyvojo://(prod) andhttp://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
__dirnamein CJS output points toelectron/dist-electron/— that's whydistDir = path.resolve(__dirname, '..', '..', 'dist')walks up two levels. If you changeoutDirinelectron/tsconfig.json, retune this path.sandbox: trueis on. Preload uses onlycontextBridgeandipcRenderer, both of which are sandbox-compatible per Electron sandbox docs. Don't addfs/path/child_processto preload — that requiressandbox: falseand 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-updaterrequires a signed build for differential updates; revisit when signing is in place.