Electron Kiosk Shell¶
scripts/start-kiosk.sh launches the React UI inside Electron
(ui/electron/main.js) rather than shelling out to whatever browser
happens to be installed on the Pi. This document explains why that's an
improvement, and sketches how it could support self-updating later. It does
not describe anything implemented yet beyond the shell itself — see
Auto-Updates (Future Work).
Why Electron Instead Of A System Browser¶
The old launch_kiosk_browser tried chromium-browser, then chromium,
then google-chrome, then firefox — whichever the OS image happened to
have, with --kiosk flags tuned mostly for Chromium. That worked, but it
carried a few risks an Electron shell removes:
| Concern | System browser | Electron shell |
|---|---|---|
| Rendering engine version | Whatever apt installed/upgraded on that Pi — can silently drift between units or after an OS update |
Pinned in ui/package-lock.json (electron@44.1.0 today), identical across every Pi until deliberately bumped |
| Kiosk lockdown | --kiosk behaves differently across Chromium, Chrome, and Firefox; Firefox's kiosk mode in particular is looser (menu/shortcuts still reachable) |
One BrowserWindow with kiosk: true, no application menu, and setWindowOpenHandler denying any popup — the same guarantees everywhere |
| Startup noise | Chromium's "restore previous session" / crash bubbles needed extra flags (--disable-session-crashed-bubble) to suppress |
Electron has no Chromium session-restore prompt to suppress. Its default session still persists under the app userData directory (~/.config/openflight-ui on Linux) — Session, app.getPath('userData'). That is a different profile from system Chromium (~/.config/chromium / chromium-browser) |
| Maintenance surface | A 4-branch if/elif detection ladder to keep working across Raspberry Pi OS Bookworm/Bullseye, Lite/Desktop images |
One binary, one launch path; npm ci makes the exact runtime reproducible in CI the same way any other dependency is |
| Extensibility | A browser tab is sandboxed from the OS — no filesystem, process, or native API access | The Electron main process is a regular Node.js process with full OS access, which is what makes self-updating possible at all |
The old detection ladder is kept as a fallback (launch_kiosk_browser still
tries chromium-browser/chromium if ui/node_modules/.bin/electron is
missing), so a Pi that hasn't installed Electron doesn't lose its kiosk
entirely — it just loses the guarantees above until Electron is installed.
start-kiosk.sh builds ui/dist only when that directory is missing. If the
UI is already built but Electron is not installed, it tries npm install
when Node.js is 22.12+. Old Node, an offline Pi, or a failed install logs a
warning and continues to the Chromium fallback instead of aborting startup.
Browser-local state (breaking on first Electron launch)¶
Electron does not reuse the system Chromium profile. The first time a unit
switches from Chromium to Electron, browser-local localStorage looks empty:
| Data | Storage | Survives the switch? |
|---|---|---|
| Profiles and shot logs | Server (~/.config/openflight/profiles.json, session JSONL) |
Yes |
| Units, theme, language, pinned Live metric | Chromium localStorage |
No — re-set in the footer / Live grid |
| Validation annotations (comparator device, speed, notes) | localStorage key openflight-validation-entries |
No |
Before switching a validation unit to Electron, export the Shots CSV (Export CSV on the Shots tab) while still on Chromium. After the switch, re-enter units, theme, language, and the pinned metric once.
This is an accepted one-time reset, not a silent migration. Chromium's LevelDB
profile is not copied into Electron userData.
What Didn't Change¶
Electron here is a shell, not a rewrite: ui/electron/main.js opens a
BrowserWindow and points it at the same URL the browser used to load
(http://localhost:8080, served by Flask from ui/dist). The React app,
the WebSocket connection (socketService.ts), and the Flask server are
untouched — getServerOrigin() still resolves to window.location.origin,
which is the Electron window's origin now instead of a browser tab's.
Process Ownership¶
scripts/kiosk-browser.sh launches Electron (or the Chromium fallback) with
setsid, so the whole browser tree, including the zygote, GPU, network and
renderer helpers Chromium forks, lives in one process group that nothing
else on the Pi belongs to. Shutdown signals that group and nothing else. The
earlier cleanup matched the Electron binary path with pkill -f, which also
killed kiosks started by other launcher instances; see the changelog for
the boot-service crash loop that exposed it. start-kiosk.sh additionally
holds /tmp/openflight-kiosk-<port>.lock for its lifetime and exits with
status 3 if another instance already holds it.
Auto-Updates (Future Work)¶
Nothing below is implemented. It's worth writing down now because "Electron shell" and "auto-update" are usually mentioned in the same breath, and because OpenFlight's deployment shape (a small fleet of Pis you personally maintain, not a public app store release) points toward a different design than the default Electron answer.
There are two separate things that could be "updated," and they call for different mechanisms.
1. UI content (the React build) — already effectively live¶
Electron loads a URL, not a bundled copy of ui/dist. Whatever Flask is
currently serving is what the window shows. So once a Pi has pulled a new
ui/dist (via the existing git pull && npm run build flow in
splash-screen.md) and the
service restarts, the Electron window shows the new UI on its next launch —
no Electron-specific update logic needed for this layer. This is already
true today.
2. The Electron shell itself¶
electron is a normal devDependency in ui/package.json. Bumping its
version is a normal dependency bump: change the version, npm install,
commit the updated lockfile, git pull on each Pi. No runtime auto-update
machinery is needed for this either, as long as updates continue to arrive
through git pull + reinstall rather than an out-of-band download.
Installing that package (not running the Electron binary) needs Node.js
22.12+ on the Pi. Node 20 prints npm WARN EBADENGINE for electron@44
and its @electron/get helper. See the Node install step in
Raspberry Pi setup.
3. The interesting case: OpenFlight self-updating without an SSH session¶
The capability an Electron main process adds that a browser tab never had
is the kiosk can update itself, because main.js runs as a full
Node.js process on the Pi rather than inside a sandboxed tab. Two designs,
in increasing order of complexity:
A. Main-process-driven git pull (recommended starting point)
The main process periodically (or on a UI-triggered "Check for Updates"
action, via a contextBridge preload script) does the same thing an
operator does by hand today:
git fetchand compareHEADagainstorigin/<branch>.- If behind:
git pull,uv sync,npm run build(inui/). - Decide how to apply it:
- Content-only change (
ui/touched,ui/electron/andui/package.json'selectronversion untouched) →win.loadURL()again, or just wait for the operator's next launch. - Shell change (Electron itself bumped, or
main.jschanged) →app.relaunch(); app.exit(0), or restart the systemd unit (systemctl --user restart openflight/sudo systemctl restart openflight, perscripts/setup/openflight.service) so the newmain.jsis picked up.
This reuses the exact update path already documented for manual updates — it just runs it from inside the app instead of over SSH. It also keeps using GitHub as the source of truth, so no new release infrastructure, signing, or hosting is required.
Things to get right if this is built:
- Trust boundary: whatever triggers the pull (a timer or a UI button)
must not be reachable by anything the Flask server exposes over the
network — this must stay a main-process-only action, not a socket event
or HTTP endpoint, so a device on the same LAN can't trigger arbitrary
git pull/uv sync execution on the Pi.
- Partial-failure safety: a git pull that succeeds but an npm run
build that fails should not leave the Pi worse off than before — keep
the previous ui/dist until the new build succeeds (e.g. build to a
temp directory and swap), and skip the restart on build failure.
- Mid-round updates: don't apply an update (especially the
shell-restart kind) while a shot/session is in progress; gate it on
session/idle state the same way the splash screen gates on startup state.
- Network dependence: the Pi may be on a golf-sim LAN with no general
internet access even when it can reach GitHub, or vice versa — the check
should fail closed (skip silently) rather than block startup.
B. electron-updater + a packaged build
The conventional Electron answer — electron-builder packages the app,
electron-updater's autoUpdater.checkForUpdatesAndNotify() polls a feed
(GitHub Releases, S3, or a self-hosted static server) and swaps the
installed build. This is the right model for shipping to users you don't
operate the hardware for.
It's a bigger lift than option A here, for two reasons specific to this
project:
- It requires the packaging step this shell deliberately skipped (see the
original Electron-shell decision: "just run from source, no installers").
ui/dist would need to be bundled into the package rather than loaded
live from Flask, which reintroduces the "which layer updates independently"
question this doc just resolved for the source-checkout model.
- electron-updater's Linux auto-update support is limited to the AppImage
format. That's buildable for arm64 (Raspberry Pi OS 64-bit, which this
fleet already requires), but it's a new build target, a new artifact to
test on real hardware, and a release/signing pipeline to stand up — none
of which exists for this project today.
Recommendation: start with (A) if/when self-updating is prioritized. It
matches the fleet's actual shape (Pis you git pull on, not an app store
audience), reuses infrastructure that already exists (uv sync, npm run
build, the systemd unit), and doesn't require adopting a packaging and
release pipeline before there's a concrete need for one. Revisit (B) only if
OpenFlight starts distributing prebuilt images to people who don't run git
pull themselves.