You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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:
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.
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.
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 throttle — powerSaveBlocker 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 → room — split 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.
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.chin a BrowserWindow. Verified this costs us nothing on the web side:${scheme}://${location.host}/ws/...(live.svelte.ts:55, presence.svelte.ts:28)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.
api.github.com/repos/natrontech/wattroom-releases/releases/latestat launch and every 6h (unauthenticated, 60 req/h — plenty)app.getVersion()→ preload event → quiet banner in the app: "WattRoom 1.2 is out — Download" → opens the release pageBecause the app content is remote, there is no version-negotiation problem: the web app feature-detects
window.wattroom?.captureSystemAudioetc. and degrades to browser behaviour when absent.Distribution
Public
natrontech/wattroom-releasesrepo — source stays private, GitHub carries the bandwidth, and the publicreleases/latestJSON is the update feed.arm64dmg (the real target —macos-15-intelis the last x86_64 macOS runner and retires Aug 2027, so an Intel dmg is a bonus with an expiry date), winx64nsis, linux AppImage + deb.github/workflows/desktop.yml, triggered on tagdesktop-v*, matrix macos/windows/ubuntu →gh release createcross-repo with a PAT secretwattroom.ch/downloaddetects platform and links the right assetSigning: 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
liveand 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 bothentitlements.mac.plistand the.inherit.plistthat Electron's helper processes get — the renderer's mic lives in the helper.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
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, sorequestDevice()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 standardgetDisplayMedia; 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, plussetWindowOpenHandler→ openExternal and awill-navigateguard..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.wattroom://deep links (needs the single-instance lock, or every link opens a second window). Two caveats from §14.5:setLoginItemSettingsis macOS/Windows only, Linux needs us to write a~/.config/autostartdesktop 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.powerSaveBlockerfor the ride's duration, Chromium background-timer throttling off. Kills "screen slept mid-interval" and "hidden window froze my metrics" outright./hudroute, same session, watts/target/room only.setDisplayMediaRequestHandlerwith 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._electron: packaged app launches, window opens,window.wattroomhas 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.desktopto the commit scopes in AGENTS.md +.claude/rules/git.mdNon-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
YYYY.0M.MICRO, ADR-0019) and the VM converges on the newest one, sodesktop-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.