Files
radiantwave/README.md
T
2026-10-14 15:03:26 -05:00

726 lines
23 KiB
Markdown

# RadiantWave
Kiosk application for RadiantWave wellness technology. Displays scrolling
proclamation text with custom shaders, plays audio at solfeggio frequencies,
and manages WiFi/provisioning on a headless Linux kiosk.
**Language:** C++17
**Build:** CMake + Ninja (local) or Nix (flake)
**Graphics:** SDL3
**Audio:** SDL3_mixer
**Target:** Linux kiosk (Wayland); developed on macOS
---
## Version Deployment
> How to release a new version of RadiantWave, and what happens on the kiosks when you do.
### Two channels, two update paths
| Channel | App input | Attic cache | Manifest |
|---|---|---|---|
| `dev` | `radiantwave-dev` (`?ref=dev`) | `attic.../radiantwave-dev` | `files.../radiantwave-dev/manifest.json` |
| `main` | `radiantwave` (`?ref=refs/tags/1.8`) | `attic.../radiantwave` | `files.../radiantwave/manifest.json` |
A kiosk gets RadiantWave by two independent paths:
- **Live app update** -- CI publishes a manifest; the kiosk's
`radiantwave-updater.timer` fetches the closure from Attic and swaps
`/var/lib/radiantwave/current`. Takes effect in ~2 min, no rebuild.
- **NixOS rebuild** -- `system.autoUpgrade` runs every 10 min against the
`KioskConfig` repo with `--refresh` and rebuilds the whole system closure.
`KioskConfig` enables `services.radiantwaveUpdater` on all four configs on
**both** branches, so the live path is always available.
**An app-only feature only needs the live app update path. You do not commit,
push, or rebuild `../KioskConfig`.** Its `flake.lock` pin of `radiantwave-dev`
only determines the closure baked into the ISO; the updater overwrites that
pointer as soon as it sees a newer manifest.
### Test a feature on the dev kiosk -- manual steps
**1. Build and test locally.**
```sh
nix develop --command bash -c '
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug
cmake --build build
./build/test_assets
'
```
Run each `./build/test_<name>` binary you touched -- there is no ctest
integration and no aggregate target. If you added a test file, register it as
a new `add_executable` in `CMakeLists.txt` first.
**2. Land it on `dev`.**
```sh
git switch dev && git pull
git switch -c feature/<name>
# ... commit your work ...
git push -u origin feature/<name>
```
Open a PR against `dev` and merge. The kiosk only ever builds `dev`.
**3. Dispatch the build workflow** on the RadiantWave repo in Gitea Actions.
**4. Verify.**
The Home footer's Version row shows the new short commit hash, and:
```sh
ssh kiosk@<dev-kiosk> 'readlink /var/lib/radiantwave/current'
curl -fsSL https://files.radiantwavetech.com/radiantwave-dev/manifest.json | jq .storePath
```
Both must match.
### Test a feature on the dev kiosk -- automated by CI
> Everything between dispatching `build.yml` and the kiosk running the new
> binary. No human step is required for any of it.
>
> **Trigger.** `workflow_dispatch` force-clones the `dev` branch
> (`REF=dev`, hardcoded -- you cannot pick a branch). A tag push clones the tag.
> Either way `APP_VERSION` becomes the short commit hash on dispatch, or
> `git describe --tags --always` on a tag.
>
> **Build.** Native `x86_64-linux` package, then the offline suite via
> `checks.<system>.default`, then the live portal tests from `nix develop`
> (`RWT_LIVE_*` secrets), then the `aarch64-linux` package and checks. The
> aarch64 build is emulated via binfmt/qemu-user and is the long pole.
>
> **Publish.** `attic push radiantwave:radiantwave-dev` with both
> `result-pkg` and `result-pkg-aarch64`, using `ATTIC_TOKEN_DEV`. Then it
> writes `{"version","storePath"}` from `readlink -f` of each result into
> `manifest.json` (x86) and `aarch64-manifest.json` (arm), and `scp`s both to
> `157.245.124.103:/mnt/infrastructure_data/files/radiantwave-dev/`, which is
> served as `https://files.radiantwavetech.com/radiantwave-dev/`.
>
> **Kiosk side.** `radiantwave-updater.timer` (`OnBootSec=2min`,
> `OnUnitActiveSec=2min`, `Persistent`) runs the oneshot
> `radiantwave-updater.service`, which curls the manifest, `jq`s `.storePath`,
> and compares it against `readlink /var/lib/radiantwave/current`. On a new
> path it fetches the closure, never building:
>
> ```sh
> nix-store -r "$NEW_PATH" \
> --add-root /var/lib/radiantwave/.current-root \
> --option extra-substituters "<atticUrl> https://cache.nixos.org"
> ```
>
> Then it swaps atomically with `ln -sfn` plus `mv -T`. Both Attic caches and
> their public keys are already trusted in `configuration.nix`, so the kiosk
> needs no credentials.
>
> **Failure mode.** The Attic push and manifest publish are ordinary steps, not
> `always()`, so **any failing test blocks publication** and the kiosk keeps
> running the previous build. Check the run's log before assuming a deploy
> landed.
### Gotchas
- **Dispatch hardcodes `dev`.** `build.yml` sets `REF=dev` on
`workflow_dispatch`; you cannot select a branch.
- **Push before you dispatch.** CI clones `dev` at dispatch time, so dispatching
first publishes the previous commit and the feature appears not to deploy.
- **Reboot briefly reverts the app.** `radiantwave-updater.nix` seeds the
pointer with tmpfiles type `L`, which re-points `current` at the ISO's baked
baseline on every boot. The 2-minute timer restores the live build shortly
after, so the window is brief but real.
- **A reboot does not lose kiosk data.** `radiantwave-backup.service` rsyncs
`~/.local/radiantwave` (minus `audio/`) before every upgrade attempt.
- **Don't bump `channel.appVersion` for an app-only deploy.** It only labels
the baked ISO baseline. Bump it when rebuilding a dev ISO so it advertises
the new fingerprint.
- **No local Linux builder on macOS.** `extra-platforms = x86_64-darwin` only,
so `nix build .#packages.x86_64-linux.default` and
`nixos-rebuild switch --target-host` are not available from a Mac. The
`orion-lxc` runner is the only `x86_64-linux` builder.
### Releasing to production
1. Merge `dev` -> `main` in `radiantwave`.
2. Tag and push: `git tag 1.9 && git push origin 1.9`. The tag push triggers
`build.yml`, which publishes to the `radiantwave` Attic cache and
`radiantwave/manifest.json` using `ATTIC_TOKEN_PROD`.
3. In `KioskConfig`, on `main`: set `radiantwave.url` to
`?ref=refs/tags/1.9`, set `channel.appVersion = "1.9"`, and run
`nix flake update radiantwave`. Commit both -- the lock and the channel
block must agree.
4. Its CI fails fast unless the baked baseline exactly matches the published
manifest, so this step cannot drift silently.
---
## Project Layout
```
radiantwave/
├── CMakeLists.txt Build config
├── .vscode/ VS Code launch + task configs
├── assets/
│ ├── fonts/ Font files (Roboto-Regular.ttf)
│ └── images/ Logo and background images
├── src/
│ ├── main.cpp Entry point (SDL3 callbacks, global keybinds)
│ ├── app_state.h AppState struct (window, renderer, pages, keybinds)
│ ├── page.h Page base class (lifecycle interface)
│ ├── page_manager.h PageManager declaration
│ ├── page_manager.cpp PageManager implementation
│ ├── keybind_manager.h KeybindManager declaration
│ ├── keybind_manager.cpp KeybindManager implementation
│ ├── home.h Home page declaration
│ ├── home.cpp Home page implementation
│ ├── settings.h Settings page declaration
│ └── settings.cpp Settings page implementation
├── tests/
│ ├── test_harness.h Minimal test framework
│ ├── test_page.h TestPage mock
│ ├── test_page_manager.cpp PageManager unit tests
│ └── test_assets.cpp Asset existence checks
└── archive/ Previous Go implementation (reference only)
```
---
## Building
Two supported ways to build: **CMake + Ninja** (any machine with the
dependencies installed, or inside `nix develop`) and **Nix** (the flake builds
the package and runs the offline test suite in its `checkPhase`).
The Nix builds are really only important for testing builds compile, and
building the actual Nix deployment binary
### Prerequisites (CMake + Ninja)
- **CMake** >= 3.20
- **Ninja** build tool
- **C++17** compiler (Clang or GCC)
- **SDL3**, **SDL3_ttf**, **SDL3_image**, **SDL3_mixer**, **SDL3_net**
- **SQLite3**, **libcurl**, **OpenSSL**
- **GoogleTest** (hard requirement: the build fails without it)
- Linux only: `pkg-config` + systemd (`libsystemd`) for the NetworkManager
sd-bus code, plus glibc's `resolv`
Install the dependencies for your platform:
#### macOS (Homebrew)
```sh
brew install cmake ninja pkg-config \
sdl3 sdl3_ttf sdl3_image sdl3_mixer sdl3_net \
sqlite googletest
```
libcurl and OpenSSL headers ship with the macOS SDK. If your Xcode SDK's
OpenSSL is too old, `brew install openssl@3` and point CMake at it.
#### Debian / Ubuntu (trixie and newer ship SDL3)
```sh
sudo apt install cmake ninja-build g++ pkg-config \
libsdl3-dev libsdl3-ttf-dev libsdl3-image-dev libsdl3-mixer-dev libsdl3-net-dev \
libsqlite3-dev libcurl4-openssl-dev libssl-dev libgtest-dev libsystemd-dev
```
Older releases do not package SDL3 yet — use the Nix path below instead.
#### NixOS / any Nix system
The dev shell provides the compiler and every dependency:
```sh
nix develop
```
### Configure, build, run (CMake + Ninja)
```sh
# Configure (first time, or after CMakeLists.txt changes)
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug
# Build
cmake --build build
# Run
./build/radiantwave
```
Release build:
```sh
cmake -S . -B build-release -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build build-release
./build-release/radiantwave
```
Clean:
```sh
rm -rf build/
```
#### App version
The Home screen footer shows a **Version** row at the top of its status list.
The value is baked in at build time via the `APP_VERSION` CMake cache variable
and defaults to the project version (`0.1.0`). Pass your own value when
configuring:
```sh
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug -DAPP_VERSION="1.2.3"
cmake --build build
```
If `APP_VERSION` is left unset the footer falls back to the project version
from `CMakeLists.txt` (`project(... VERSION ...)`); a build that defines no
version at all shows `unknown`. The Nix build sets `APP_VERSION` automatically
to the flake package version, so a plain `nix build` needs no extra flags.
With `--impure` the flake instead reads the `APP_VERSION`
environment variable — this is how CI injects the version
(see the CI note under *Common Pitfalls*). Pure evals always get the package
defaults, because `builtins.getEnv` cannot see the environment in pure
evaluation mode.
#### Version in one configure
### Building with Nix
The flake supports `x86_64-linux` and `aarch64-linux` (the deployment
targets) plus `aarch64-darwin` (development):
```sh
nix build .#default # current system
nix build .#packages.x86_64-linux.default # needs a Linux machine/builders
nix build .#packages.aarch64-linux.default
```
To bake in a custom version, build impurely — the flake
reads `APP_VERSION` from the environment on impure evals:
```sh
APP_VERSION="1.2.3" nix build --impure .#default
```
A pure `nix build` always uses the package defaults (`0.1.0`)
even if those variables are set in your shell, because pure evaluation cannot
see the environment. CI uses `--impure` to inject them.
`nix build` runs the full offline test suite in its `checkPhase`; the
live-portal test binaries (`test_api_client`, `test_license_service`) are
excluded. `nix flake check` builds the package and therefore also runs the
tests. The app is marked `unfree`; the flake sets `allowUnfreePredicate` for
`radiantwave`, so no global `allowUnfree` setting is needed.
Note: building the Linux packages from a macOS machine requires remote
builders or emulation — the flake does not cross-compile.
### NixOS deployment
Import `radiantwave.nixosModules.default` into your `nixosSystem` `modules`
and enable the service:
```nix
# configuration.nix
{ ... }: {
services.radiantwave.enable = true;
# services.radiantwave.package = ...; # default: the flake's package
# services.radiantwave.user = "kiosk"; # default
}
```
The module only adds the package to the system path — the machine
configuration owns the display/audio session (`nixos/vm.nix` is a complete
working example).
Try the kiosk in a VM first (cage + PipeWire + NetworkManager + auto-login):
```sh
nix build .#packages.x86_64-linux.test-vm
./result/bin/run-radiantwave-test-vm
# or
nixos-rebuild build-vm --flake .#test-vm
./result/bin/run-radiantwave-test-vm
```
---
## VS Code
### Required extensions
- **C/C++** (`ms-vscode.cpptools`) — IntelliSense + launch configs
- **CodeLLDB** (`vadimcn.vscode-lldb`) — LLDB debugger frontend
Install from the Extensions sidebar, or:
```sh
code --install-extension ms-vscode.cpptools
code --install-extension vadimcn.vscode-lldb
```
### Launch configurations
`F5` with `Debug RadiantWave` selected builds and launches the app under LLDB.
`Debug Tests` builds and launches the test runner.
Tasks are preconfigured in `.vscode/tasks.json`:
| Task | Shortcut | What it does |
| ------ | ---------- | ------------- |
| `build-debug` | `Cmd+Shift+B` | Configure + build debug |
| `build-release` | — | Configure + build release |
| `build-tests` | — | Build test target only |
| `run-tests` | — | Build + run tests |
---
## Testing
Two frameworks coexist. `tests`, `test_assets`, and `test_provisioning` use
the minimal built-in harness (`tests/test_harness.h`); every other `test_*`
binary is GoogleTest. Each test file is its own CMake target — build them all
with `cmake --build build`, then run each binary from the repo root (the tests
resolve `assets/...` relative to the working directory):
```sh
cmake --build build
./build/test_assets
./build/tests
./build/test_network_unit
./build/test_config
# ... one ./build/test_<name> per target
```
Live-portal tests only run when their `RW_LIVE_*` env vars are set, except
`test_api_client` and `test_license_service`, which hit the portal
unconditionally — keep those off a hermetic/offline build.
Expected output:
```
# tests (PageManager)
push_single_page... ok
push_two_pages... ok
pop_single_page... ok
pop_resumes_previous... ok
replace_on_empty... ok
replace_on_existing... ok
pop_empty_stack... ok
depth_tracks_correctly... ok
push_pop_push_sequence... ok
9 passed, 0 failed
# test_assets (asset existence)
fonts_exist... ok
rd_title_image_exists... ok
rwt_circular_image_exists... ok
3 passed, 0 failed
```
### Writing new tests
Tests live in `tests/`. To add a test file:
1. Create `tests/test_yourthing.cpp`
2. Include the harness and the header under test:
```cpp
#include "test_harness.h"
#include "../src/your_header.h"
TEST(my_new_test)
{
// use ASSERT_TRUE, ASSERT_FALSE, ASSERT_EQ
}
RUN_ALL_TESTS()
```
1. Add the file to the `tests` target in `CMakeLists.txt`:
```cmake
add_executable(tests
tests/test_page_manager.cpp
tests/test_yourthing.cpp # <-- add here
src/page_manager.cpp
src/your_source.cpp
)
```
1. Rebuild and run:
```sh
cmake --build build --target tests
./build/tests
```
### Test conventions
- One `TEST()` per behavior.
- Name tests as `what_you_are_testing` (snake_case).
- Use `TestPage` mock from `test_page.h` to verify lifecycle calls.
- Call `clear_log()` before each test that checks lifecycle events.
- `AppState` is defined minimally in test files — pages must not depend on
fields beyond what `AppState` actually has.
---
## Architecture
### SDL3 Callback Entry Point
The app uses SDL3's callback system (`SDL_MAIN_USE_CALLBACKS`) instead of a
manual event loop. SDL calls these functions for you:
| Callback | Runs | Purpose |
| ---------- | ------ | --------- |
| `SDL_AppInit` | Once at startup | Init SDL, create window + renderer, push first page |
| `SDL_AppEvent` | Per event | Forward events to the active page |
| `SDL_AppIterate` | Per frame | Update + render the active page |
| `SDL_AppQuit` | Once at exit | Destroy window, renderer, clean up |
Application state (`AppState`) is passed between callbacks via `void*`.
### AppState
```cpp
struct AppState {
SDL_Window* window;
SDL_Renderer* renderer;
PageManager pages;
KeybindManager keybinds;
bool quitRequested;
int currentMenuItem;
};
```
All shared state lives here. Pages receive `AppState&` during lifecycle calls
so they can access the renderer, window, keybinds, or any future shared state.
### Page Lifecycle
Every page implements the `Page` interface:
```cpp
class Page {
virtual void onPush(AppState& state) = 0; // create textures, load state
virtual void onResume(AppState& state) = 0; // re-create textures after being paused
virtual void onPause(AppState& state) = 0; // destroy textures, save state
virtual void onPop(AppState& state) = 0; // final cleanup
virtual void handleEvent(const SDL_Event& event) = 0;
virtual void update(float dt) = 0;
virtual void render(SDL_Renderer* renderer) = 0;
};
```
Lifecycle rules:
- **Push:** current page gets `onPause()`, new page gets `onPush()`.
- **Pop:** top page gets `onPop()`, previous page gets `onResume()`.
- **Replace:** top page gets `onPop()`, new page gets `onPush()`.
At most one page has textures alive at a time. Inactive pages destroy their
textures in `onPause()` and re-create them in `onResume()`.
### PageManager
Stack-based navigation. Owned by `AppState`.
```cpp
PageManager pages;
// Navigate
pages.push(state, std::make_unique<SettingsPage>());
pages.pop(state);
pages.popToRoot(state); // pop all pages, resume root
pages.replace(state, std::make_unique<ScrollerPage>());
// Query
Page* current = pages.current();
std::size_t depth = pages.depth();
// Delegates to current page
pages.handleEvent(event);
pages.update(dt);
pages.render(renderer);
```
### Navigation Model
Pages replace each other rather than stacking deeply. Home uses `replace()`
to swap itself for Settings (so Settings is the only page on the stack).
TAB pops all pages and pushes a fresh Home. The page stack is primarily for
settings sub-pages (e.g., Settings → AudioOptions → DevicePicker).
---
## Pages
| Page | Purpose | Status |
| ------ | --------- | -------- |
| Home | Logo, menu selection (Play, Settings) | Done |
| Settings | Title-only settings stub | Done |
| ScrollerPage | Main screen: scrolling text with shaders + audio | — |
| AudioOptions | Audio device selection, volume | — |
| ProclamationOptions | Browse/search proclamations | — |
| WiFiSetupPage | WiFi scan, WPA connect, password entry | — |
| LicenseKeyPage | License key input | — |
| EmailAddressPage | Email input with validation | — |
| LogViewerPage | Scrollable log viewer | — |
| DownloadingAssets | Stub during download phase | — |
| Message | Generic message page | — |
---
## Keybinds
Global keybinds, active from any screen:
| Key | Action |
| ----- | -------- |
| TAB | Return to Home screen |
| UP Arrow | Move selection up (wraps) |
| DOWN Arrow | Move selection down (wraps) |
| ENTER | Select current menu item |
Keybinds are managed by `KeybindManager` and checked before events reach
the active page. Pages can also register their own keybinds via
`state.keybinds.bind()`.
---
## Rendering
### Virtual Framebuffer
Renders to a 1920x1080 FBO, then blits to native window resolution.
Decouples layout from display size.
### Shaders
| Shader | Purpose |
| -------- | --------- |
| scroller.vert/frag | Text with font texture; uniform `u_textColor` |
| text.vert/frag | Non-scrolling text elements |
| solid_color.vert/frag | Flat color (backgrounds, UI) |
---
## Audio
- SDL3_mixer for playback.
- PipeWire sink enumeration via `pactl list sinks short`.
- `.wav` files downloaded at runtime from `files.radiantwavetech.com`.
- 9 solfeggio frequency presets (174Hz - 963Hz).
---
## Database
SQLite at `~/.local/share/radiantwave/database.sqlite` (WAL mode).
| Table | Purpose |
| ------- | --------- |
| app_config | Key-value store: license, email, audio device, volume |
| logs | Application logs |
| proclamations | Synced proclamation text (category_id + group_id) |
| proclamation_categories | Flat category metadata |
| proclamation_groups | Tag-like groups that organize proclamations within a category |
| solfeggio_frequencies | 9 frequency presets |
| audio_files | Downloaded audio files |
---
## Network
D-Bus integration via sdbus-c++:
- Async WiFi scan
- WPA/WPA2 connection
- Captive portal detection
### Remote APIs
| Endpoint | Purpose |
| ---------- | --------- |
| `portal.radiantwavetech.com/api/v2/proclamations` | Sync proclamations |
| `files.radiantwavetech.com/files/` | Audio file downloads |
| HTTP API | License validation |
Request/response changes against the portal are tracked in `ApiUpdates.md`.
Changes #4 and #5 cover color palettes: the portal will expose them per
proclamation and via a standalone `/api/v2/color-palettes` route. The kiosk
side (`api::fetchColorPalettes`, `sync::syncColorPalettesFromApi`,
`db::replaceColorPalettes`) is implemented but **not wired into the app** until
those routes exist — palettes are meant to be updated by the portal, not code
releases.
---
## Style Guide
### C++
- **Standard:** C++17. No extensions, no GNU-isms.
- **Formatting:** 4 spaces, no tabs. Column limit: 100.
- **Naming:**
- `PascalCase` for types, classes, structs, enums.
- `camelCase` for functions, methods, variables.
- `snake_case` for files (`page_manager.cpp`).
- `UPPER_SNAKE_CASE` for constants and macros.
- **Includes:** SDL3 first, then stdlib, then project headers.
- **Ownership:** Use `std::unique_ptr` for heap allocation. No raw `new`/`delete`
outside of the SDL callback entry/exit points.
- **Callbacks:** Use `override` on all virtual method implementations.
- **Error handling:** Return `SDL_APP_FAILURE` on unrecoverable errors. Log with
`SDL_Log()`. No exceptions for control flow.
- **No globals.** Pass state through `AppState&` or function parameters.
### Files
- One class per header/source pair.
- Headers use `#pragma once`.
- Keep `src/` for production code, `tests/` for test code.
- `archive/` is read-only reference. Never modify files in it.
### Testing
- One `TEST()` per behavior.
- Use `TestPage` mock to verify lifecycle calls.
- Call `clear_log()` before tests that inspect lifecycle events.
- Prefer `ASSERT_EQ` over `ASSERT_TRUE(a == b)` for better error output.
---
## Provisioning State Machine
```
FIRST_RUN -> NEEDS_INTERNET -> NEEDS_LICENSE_KEY -> NEEDS_EMAIL_ADDRESS -> DOWNLOADING_ASSETS -> READY
```
Each state maps to a page. The state machine evaluates DB values and network
status to decide which page to show.
---
## Common Pitfalls
- Do not commit audio assets (`.wav` files) -- downloaded at runtime.
- Do not modify files in `archive/` -- it is reference only.
- `build/` is gitignored. Do not commit build artifacts.
- Use the Nix dev shell or ensure SDL3 is installed for building.
- CI uses Gitea Actions on a self-hosted host runner (`build-server`); the
workflow is `.gitea/workflows/build.yml`. It is **manual-only**
(`workflow_dispatch`) — pick the branch when dispatching. `main` builds bake
the current git tag as `APP_VERSION`; `dev` builds bake the current commit
hash. Both build with `nix build --impure` (which lets the flake's env
injection work). The offline test suite runs in the package's `checkPhase`;
the live portal tests run from `nix develop` when the `RWT_LIVE_*` secrets
are set.
- Asset tests (`test_assets`) must pass before committing -- they catch missing
fonts and images that would break the app at runtime.