Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ Thank you for your interest in contributing to OpenFlight! This document provide
### Prerequisites

- Python 3.10 or higher
- Node.js 20+ (for UI development)
- Node.js 22.12 or newer (for UI development; CI uses the version in `.node-version`)
- Git
- [uv](https://github.com/astral-sh/uv) package manager (required)

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,7 @@ uv run pytest tests/ -v
- **[Parts List](docs/PARTS.md)** — What to buy
- **[Sound Trigger Wiring](docs/sound-trigger-wiring.md)** — How to wire the sound trigger
- **[Raspberry Pi Setup](docs/raspberry-pi-setup.md)** — Full setup guide
- **[Electron Kiosk Shell](docs/electron-kiosk-shell.md)** — Why the kiosk runs in Electron and how self-updating could work later
- **[Battery Monitoring](docs/battery/README.md)** — Provider architecture, UI states, and shared Pi support
- **[Geekworm X1202/X1206 Operator Guide](docs/battery/geekworm.md)** — Batteries, Pi setup, native telemetry, and warnings
- **[IWR6843 Operator Guide](docs/iwr6843/README.md)** — Wire, flash, mount, aim, and calibrate the angle radar
Expand Down
5 changes: 5 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
full horizontal speed, overstating attack angle on any shot with club path.

### Added
- **Electron kiosk shell.** `scripts/start-kiosk.sh` now opens the UI in a pinned
Electron window (`electron@44`) instead of whichever system browser happens to
be installed. Chromium remains a fallback if Electron is not installed. This
needs **Node.js 22.12 or newer** (`npm WARN EBADENGINE` on Node 20). See
[Electron Kiosk Shell](electron-kiosk-shell.md).
- **Profiles replace players.** Shots are now attributed to a server-owned profile
(a person *or* a place) with a stable id, persisted to
`~/.config/openflight/profiles.json` (override with `OPENFLIGHT_PROFILES_PATH`
Expand Down
147 changes: 147 additions & 0 deletions docs/electron-kiosk-shell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
# 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)](#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 starts a fresh profile each launch; there's no session-restore prompt to suppress |
| 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](#auto-updates-future-work) 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 run `npm install` yet doesn't lose its kiosk
entirely — it just loses the guarantees above until Electron is installed.

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

## 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](splash-screen.md#updating-an-existing-pi)) 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.md](raspberry-pi-setup.md).

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

1. `git fetch` and compare `HEAD` against `origin/<branch>`.
2. If behind: `git pull`, `uv sync`, `npm run build` (in `ui/`).
3. Decide how to apply it:
- Content-only change (`ui/` touched, `ui/electron/` and
`ui/package.json`'s `electron` version untouched) → `win.loadURL()`
again, or just wait for the operator's next launch.
- Shell change (Electron itself bumped, or `main.js` changed) →
`app.relaunch(); app.exit(0)`, or restart the systemd unit
(`systemctl --user restart openflight` / `sudo systemctl restart
openflight`, per `scripts/setup/openflight.service`) so the new
`main.js` is 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.
10 changes: 10 additions & 0 deletions docs/raspberry-pi-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,16 @@ Run the following command:
sudo apt update && sudo apt install -y swig liblgpio-dev python3-dev
```

The UI/Electron kiosk shell needs **Node.js 22.12 or newer**. Raspberry Pi OS
`apt` Node is often 18 or 20 and will print `EBADENGINE` (or fail) on first
build. Install Node 22 LTS before setup:

```bash
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt-get install -y nodejs
node -v # should report v22.12.0 or later
```

If `./scripts/setup/setup.sh` updates `~/.bashrc`, you may need to run `source ~/.bashrc` (or open a new terminal) so your current shell picks up the new environment variables immediately without needing to reboot or re-login.

### 2. Run the setup script
Expand Down
45 changes: 45 additions & 0 deletions scripts/require-node.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Sourced by setup and kiosk scripts. Electron 44's npm installer requires
# Node 22.12+ (see ui/package.json engines and electron's own engines field).
OPENFLIGHT_MIN_NODE="22.12.0"

openflight_node_version() {
command -v node >/dev/null 2>&1 || return 1
local v
v="$(node -v 2>/dev/null || true)"
v="${v#v}"
printf '%s' "${v%%[-+]*}"
}

openflight_node_meets_min() {
local current cmaj cmin cpat mmaj mmin mpat
current="$(openflight_node_version)" || return 1
[ -n "$current" ] || return 1

IFS=. read -r cmaj cmin cpat <<<"$current"
IFS=. read -r mmaj mmin mpat <<<"$OPENFLIGHT_MIN_NODE"

cmaj=${cmaj:-0}; cmin=${cmin:-0}; cpat=${cpat:-0}
mmaj=${mmaj:-0}; mmin=${mmin:-0}; mpat=${mpat:-0}

if (( cmaj > mmaj )); then return 0; fi
if (( cmaj < mmaj )); then return 1; fi
if (( cmin > mmin )); then return 0; fi
if (( cmin < mmin )); then return 1; fi
(( cpat >= mpat ))
}

openflight_node_install_hint() {
cat <<'EOF'
OpenFlight needs Node.js 22.12 or newer to install the Electron kiosk shell.
Raspberry Pi OS / Debian apt Node is often older than that (Node 18 or 20).

Raspberry Pi (64-bit):
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt-get install -y nodejs

macOS:
brew install node

Then confirm with: node -v
EOF
}
17 changes: 7 additions & 10 deletions scripts/setup/setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -134,18 +134,15 @@ else
exit 1
fi

# Check for Node.js
# Check for Node.js (Electron 44's npm installer requires 22.12+)
# shellcheck source=../require-node.sh
source "$SCRIPT_DIR/../require-node.sh"
log "Checking Node.js..."
if command -v node &> /dev/null; then
NODE_VERSION=$(node --version)
log "Node.js $NODE_VERSION found ✓"
if openflight_node_meets_min; then
log "Node.js $(openflight_node_version) found ✓"
else
error "Node.js not found. Please install Node.js 18+"
if [ "$PLATFORM" == "pi" ]; then
info "On Raspberry Pi, run: sudo apt install nodejs npm"
elif [ "$PLATFORM" == "macos" ]; then
info "On macOS, run: brew install node"
fi
error "Node.js $OPENFLIGHT_MIN_NODE+ required, found $(openflight_node_version 2>/dev/null || echo none)"
openflight_node_install_hint
exit 1
fi

Expand Down
43 changes: 26 additions & 17 deletions scripts/start-kiosk.sh
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#!/bin/bash
#
# OpenFlight Kiosk Startup Script
# Starts the radar server and launches Chromium in kiosk mode
# Starts the radar server and launches the Electron kiosk shell
#

set -e
Expand Down Expand Up @@ -507,20 +507,19 @@ error() {

launch_kiosk_browser() {
local url="$1"
local chrome_flags="--kiosk --noerrdialogs --disable-infobars --disable-session-crashed-bubble --password-store=basic"

log "Launching kiosk browser..."
if command -v chromium-browser &> /dev/null; then
DISPLAY=:0 chromium-browser $chrome_flags "$url" &
local electron_bin="$PROJECT_DIR/ui/node_modules/.bin/electron"

log "Launching kiosk shell (Electron)..."
if [ -x "$electron_bin" ]; then
DISPLAY=:0 OPENFLIGHT_URL="$url" "$electron_bin" "$PROJECT_DIR/ui" &
elif command -v chromium-browser &> /dev/null; then
warn "Electron kiosk shell not installed (run 'npm install' in ui/); falling back to chromium-browser"
DISPLAY=:0 chromium-browser --kiosk --noerrdialogs --disable-infobars --disable-session-crashed-bubble --password-store=basic "$url" &
elif command -v chromium &> /dev/null; then
DISPLAY=:0 chromium $chrome_flags "$url" &
elif command -v google-chrome &> /dev/null; then
DISPLAY=:0 google-chrome $chrome_flags "$url" &
elif command -v firefox &> /dev/null; then
DISPLAY=:0 firefox --kiosk "$url" &
warn "Electron kiosk shell not installed (run 'npm install' in ui/); falling back to chromium"
DISPLAY=:0 chromium --kiosk --noerrdialogs --disable-infobars --disable-session-crashed-bubble --password-store=basic "$url" &
else
warn "No supported browser found. Open $url manually."
warn "Supported browsers: chromium-browser, chromium, google-chrome, firefox"
warn "No Electron kiosk shell and no fallback browser found. Open $url manually."
return 1
fi

Expand Down Expand Up @@ -703,7 +702,8 @@ cleanup() {
if [ -n "$BROWSER_PID" ]; then
kill "$BROWSER_PID" 2>/dev/null || true
fi
# Chromium forks child processes that survive kill — clean them all
# Electron/Chromium fork child processes that survive kill — clean them all
pkill -f "ui/node_modules/electron/dist/electron" 2>/dev/null || true
pkill -f "chromium.*--kiosk" 2>/dev/null || true
pkill -f "chrome.*--kiosk" 2>/dev/null || true
exit "$exit_code"
Expand Down Expand Up @@ -1020,9 +1020,18 @@ fi

configure_kld7_latency

# Check if UI is built
if [ ! -d "ui/dist" ]; then
warn "UI not built. Building now..."
# Check if UI is built and the Electron kiosk shell is installed
if [ ! -d "ui/dist" ] || [ ! -x "ui/node_modules/.bin/electron" ]; then
warn "UI not built or Electron shell missing. Building now..."
# shellcheck source=require-node.sh
source "$SCRIPT_DIR/require-node.sh"
if ! openflight_node_meets_min; then
openflight_node_install_hint
show_startup_failure \
"server" \
"Node.js is too old to build the UI" \
"OpenFlight needs Node.js ${OPENFLIGHT_MIN_NODE} or newer (found $(openflight_node_version 2>/dev/null || echo none)). Upgrade Node, then relaunch."
fi
cd ui
if ! npm install || ! npm run build; then
cd ..
Expand Down
49 changes: 49 additions & 0 deletions tests/test_start_kiosk.py
Original file line number Diff line number Diff line change
Expand Up @@ -552,3 +552,52 @@ def test_iwr6843_horizontal_phase_reference_is_forwarded():
def test_iwr6843_horizontal_phase_reference_is_omitted_by_default():
command = _dry_run("--iwr6843").stdout.strip()
assert "--iwr6843-horizontal-phase-reference-rad" not in command


def _read_script() -> str:
return (Path(__file__).resolve().parents[1] / "scripts/start-kiosk.sh").read_text(
encoding="utf-8"
)


def test_launch_kiosk_browser_prefers_the_electron_shell():
"""The pinned Electron runtime must be tried before any system browser."""
script = _read_script()
launcher = script[
script.index("launch_kiosk_browser() {") : script.index("stop_startup_splash_server() {")
]

electron_idx = launcher.index('if [ -x "$electron_bin" ]; then')
chromium_browser_idx = launcher.index("command -v chromium-browser")
chromium_idx = launcher.index("command -v chromium &> /dev/null")

assert electron_idx < chromium_browser_idx < chromium_idx
assert 'local electron_bin="$PROJECT_DIR/ui/node_modules/.bin/electron"' in launcher
assert '"$electron_bin" "$PROJECT_DIR/ui"' in launcher


def test_launch_kiosk_browser_still_falls_back_without_electron():
"""A Pi that hasn't run `npm install` yet must not lose its kiosk entirely."""
script = _read_script()
launcher = script[
script.index("launch_kiosk_browser() {") : script.index("stop_startup_splash_server() {")
]

assert "chromium-browser --kiosk" in launcher
assert "chromium --kiosk" in launcher
assert "No Electron kiosk shell and no fallback browser found" in launcher


def test_cleanup_kills_the_electron_process_tree():
"""Electron, like Chromium, forks children that survive a signal to the launcher PID."""
script = _read_script()
cleanup_fn = script[script.index("cleanup() {") : script.index("configure_kld7_latency() {")]

assert 'pkill -f "ui/node_modules/electron/dist/electron"' in cleanup_fn


def test_ui_build_check_also_requires_the_electron_shell():
"""Rebuilding the UI must also install Electron if a checkout predates it."""
script = _read_script()

assert 'if [ ! -d "ui/dist" ] || [ ! -x "ui/node_modules/.bin/electron" ]; then' in script
Loading
Loading