Skip to content

feat(desktop): installable WattRoom app for macOS/Windows/Linux — Electron thin shell + in-app update nudge #296

Description

@janlauber

Implementation research: docs/RESEARCH.md §14 (run 2026-09-01) — toolchain, the handlers Electron makes mandatory, macOS TCC, CI.

One icon in the dock, launches at login, sits in the tray, opens wattroom:// links, and can do the things a browser tab can't: hold the machine awake through an interval, keep running when hidden, put a HUD over your fullscreen Netflix, and push your system audio into the room.

Why now — this amends ADR-0004

ADR-0004 parked all wrapper work until a trigger fired. Trigger 3 has fired ("OS capabilities the web can't grant"): system-audio capture is impossible in a browser, wake-lock and background-throttling are unreliable, ANT+ is off the table entirely. The product reason stands next to it: "download WattRoom" beats "install Chrome, then bookmark a URL".

First PR of this issue is an ADR amending 0004's "no wrapper work" clause. It does not touch the iOS line in WATTROOM.md (still web-only forever), and the browser stays a first-class surface — every share/spectator link must keep working without the app.

Shape: Electron thin shell, not a rewrite

Electron, not Tauri — 0004 already did the research and it still holds: WKWebView has no Web Bluetooth, so Tauri means rewriting the BLE transport plus WebRTC/YouTube-embed risk in WebKitGTK. Discord, Spotify, Slack and VS Code are all Chromium shells; "like Spotify/Discord" literally means shipping Chromium.

The shell loads https://wattroom.ch in a BrowserWindow. Verified this costs us nothing on the web side:

  • WS is origin-relative — ${scheme}://${location.host}/ws/... (live.svelte.ts:55, presence.svelte.ts:28)
  • session cookies are HttpOnly + Lax + Secure-on-https (auth.go:575) — same-origin from the shell, so login just works. No token plumbing, no CORS work.
  • the jukebox keeps a real https origin. YouTube embeds/RMF break on file:// and custom protocols — a bundled SPA would break the jukebox, which settles the bundle-vs-remote question on its own.

Consequence: web UI ships on every server deploy exactly as today. A desktop release is only needed when native code changes — which is why the update story below can stay dumb.

Updates: in-app nudge, no OS auto-update

Decided: no Squirrel, no electron-updater. A thin shell changes maybe a few times a year.

  • shell checks api.github.com/repos/natrontech/wattroom-releases/releases/latest at launch and every 6h (unauthenticated, 60 req/h — plenty)
  • newer tag than app.getVersion() → preload event → quiet banner in the app: "WattRoom 1.2 is out — Download" → opens the release page
  • never during a ride — deferred to ride end (ux.md: nothing interrupts a rider mid-interval)

ponytail: skipped electron-updater, deltas and staged rollouts. Add when shell releases get frequent, or the first time a security fix has to land the same day.

Because the app content is remote, there is no version-negotiation problem: the web app feature-detects window.wattroom?.captureSystemAudio etc. and degrades to browser behaviour when absent.

Distribution

Public natrontech/wattroom-releases repo — source stays private, GitHub carries the bandwidth, and the public releases/latest JSON is the update feed.

  • artifacts: mac arm64 dmg (the real target — macos-15-intel is the last x86_64 macOS runner and retires Aug 2027, so an Intel dmg is a bonus with an expiry date), win x64 nsis, linux AppImage + deb
  • .github/workflows/desktop.yml, triggered on tag desktop-v*, matrix macos/windows/ubuntu → gh release create cross-repo with a PAT secret
  • wattroom.ch/download detects platform and links the right asset

Signing: macOS signed from v1, Windows deferred

The Apple Developer membership is already held, so the macOS half is settled: sign with Developer ID + notarize from the first build. Research (§14.3) says this was never really optional on mac — unsigned/ad-hoc builds lose TCC attribution, and TCC gates Bluetooth and the microphone. electron-builder ≥ 26.0.13 has an open report of ad-hoc builds where mic/camera tracks report live and deliver silence, with no prompt and no error. Silent failure, on the two things a rider needs.

The trap on the signed path: notarization requires the Hardened Runtime, which denies resource access unless entitlements grant it. Info.plist usage strings are necessary and not sufficient — miss the entitlements and the app is signed, notarized, launches clean, and still can't see the trainer. Needs com.apple.security.cs.allow-jit (V8), .device.audio-input, .device.bluetooth, .device.camera, in both entitlements.mac.plist and the .inherit.plist that Electron's helper processes get — the renderer's mic lives in the helper.

plan cost
macOS Developer ID + notarize, from v1 already paid
Windows unsigned; SmartScreen "Windows protected your PC" until reputation accrues. Azure Trusted Signing (~$10/mo, needs a 3+ yr old legal entity — Natron qualifies) when it starts costing installs deferred
Linux nothing to sign; AppImage/deb install clean

Windows keeps the deferral cost: the download page carries the "More info → Run anyway" step, and the update banner sends people back through it each version. Revisit trigger: the first installer handed to someone outside the training circle, or the first person who bounces off it.

Plan — one PR each

  • ADR (number TBD) — desktop shell, amends 0004. Text only. 0018 is taken by one-music-surface and three files currently share 0019 — take the number docs: three ADRs are all numbered 0019 — the number no longer identifies a decision #379 leaves free.
  • desktop/ skeleton — electron-builder (not Forge/electron-vite: no renderer to bundle), plain main.js + preload.js, make desktop, single-instance lock, server-down screen with retry (errors.md: never blank). Plus the four handlers Electron makes mandatory (§14.2) — this is most of the work, not boilerplate:
    • select-bluetooth-device — Electron ships no chooser UI, so requestDevice() never resolves without it (and a missing handler is a known CVE). Our own picker can filter to FTMS/HR/CSC and remember the last trainer, which Chrome's can't.
    • setDisplayMediaRequestHandler — Electron doesn't implement standard getDisplayMedia; without it the stage (feat(web): Discord-style stage — many screenshares, pick + zoom, resizable panes, duck knob #280) silently breaks. Must throw on cancel or the renderer hangs.
    • setPermissionRequestHandler — Electron's default is allow; with remote content that hands camera+mic to anything that redirects the renderer. Allowlist to our origin, plus setWindowOpenHandler → openExternal and a will-navigate guard.
    • Info.plist usage-description keys (Bluetooth, mic, camera, audio-capture) — a missing audio-capture key yields a dead stream with no error.
  • macOS signing + notarization — Developer ID cert as base64 .p12 (CSC_LINK/CSC_KEY_PASSWORD), notarytool via an App Store Connect API key (ages better in CI than an Apple ID + app-specific password), hardened runtime, both entitlements files. First signed build gets the real check: pair a Kickr, open the mic, share a screen.
  • CI + releases repo — first unsigned pre-release the crew installs by hand.
  • Download page at wattroom.ch/download — platform detection + the first-launch unblock steps per OS (they are the price of going unsigned; write them like errors.md wants, what happens and what to click).
  • Update check + in-app banner — gated on not-riding.
  • App-ness — tray, launch-at-login, wattroom:// deep links (needs the single-instance lock, or every link opens a second window). Two caveats from §14.5: setLoginItemSettings is macOS/Windows only, Linux needs us to write a ~/.config/autostart desktop file; and media keys on macOS need Accessibility permission, lie about registration success, and get routed to whatever is playing audio (the YouTube iframe) — register them on Win/Linux, and spend the effort on a global push-to-talk instead.
  • Keep-awake + no background throttlepowerSaveBlocker for the ride's duration, Chromium background-timer throttling off. Kills "screen slept mid-interval" and "hidden window froze my metrics" outright.
  • HUD overlay — frameless always-on-top window loading a new /hud route, same session, watts/target/room only.
  • System audio → roomsplit into its own issue: setDisplayMediaRequestHandler with loopback audio, published as a second LiveKit track (av.svelte.ts:177) with its own fader (mixer.svelte.ts) and a look at ADR-0011. Windows loopback is solid; macOS goes through ScreenCaptureKit — verify against whichever Electron major we pin before promising it.
  • CI smoke, one per platform — Playwright _electron: packaged app launches, window opens, window.wattroom has the expected keys, navigation guard rejects an off-origin URL, clean quit. Linux under xvfb. Native dialogs/tray/menus are not Playwright-drivable and BLE/audio aren't CI-testable — those go to a hardware session per OS (§14.7). The existing e2e suite stays pointed at the browser build.
  • add desktop to the commit scopes in AGENTS.md + .claude/rules/git.md

Non-goals

No Windows code signing in v1 (deferred above). No offline mode (a multiplayer room is useless offline). No iOS/Android. No App Store / Microsoft Store / Flatpak / Snap in v1. No ANT+ yet — the shell makes it possible, it gets its own issue when a rider actually needs it.

Risks

  • ~120 MB per platform, and Electron ships a major every ~8 weeks. A stale Chromium rendering remote content is a real CVE surface — Renovate keeps the shell on the current major or this rots.
  • Repo grows a second toolchain (Node + electron-builder) and CI grows macOS/Windows runners.
  • The shell needs its own version line: the repo now cuts CalVer release tags (YYYY.0M.MICRO, ADR-0019) and the VM converges on the newest one, so desktop-v* is a second, deliberately uncoupled tag namespace — a desktop release only happens when native code changes, not on every server release.

Rough size: shell + CI + first installable build ≈ 1 day. Whole checklist ≈ 3–4 days spread out, with system-audio the only genuinely uncertain piece.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestinfraCI, deploy, tooling

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions