Files
NeilClack 9fead922c4
build-dev / nix-build (push) Successful in 32s
Move Proclamations From Sub-Categories To Groups
The portal's v2 API replaced nested categories (proclamation_categories.parent_id)
with flat categories plus tag-like groups: a proclamation now belongs to one
category and optionally one group. This lets the Proclamation Options page drop
its special-cased two-level tree.

Database:
- Add the proclamation_groups table (id, name, sort_order) and a
  proclamations.group_id column.
- Drop parent_id from the proclamation_categories DDL so fresh databases get a
  flat table.
- runMigrations() creates the new table/column, then runs a one-time
  transactional v1->v2 migration keyed on PRAGMA user_version: every legacy
  sub-category becomes a group (its id is preserved), proclamations that
  pointed at a sub-category are remapped to (parent category, new group), and
  the now-stale sub-category rows are deleted. selected flags are never
  touched, so user choices survive. No network is needed, so offline kiosks
  migrate at startup; re-running is a no-op.
- Add replaceGroups() and getAllGroups(); stop reading/writing parent_id.

API sync:
- Add PATH_GROUPS ("/groups") and parseGroupsJson().
- parseProclamationsJson() now also reads groupId.
- syncProclamationsFromApi() fetches /proclamations, /categories and /groups,
  leaves the database untouched if any request or parse fails, then replaces
  categories, replaces groups and upserts proclamations.

Proclamation Options page:
- FlatItem is now Category / Group / Proclamation.
- Each category lists only the groups that have at least one member in that
  category, so impossible pairings (e.g. Physical/Prayers) never render.
- Proclamations with no group, or with a group the portal has removed, sit
  directly under the category. Groups are collapsible and use a distinct color.

Tests:
- test_db: groups round-trip plus a legacy->v2 migration test asserting the
  group is created, proclamations are remapped, selection is preserved and a
  second initialize() is a no-op.
- test_proclamation_sync / test_api_client: parse groupId, parse groups, and
  the live smoke test now also fetches /groups.

Docs: README database table and AGENTS notes updated for groups.
2026-09-10 19:49:47 -05:00

611 lines
18 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
---
## 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.
---
## Rebuild Progress
| Component | Status |
| ----------- | -------- |
| Entry / Event Loop | Done |
| Window + Renderer | Done |
| Page System | Done |
| PageManager (stack) | Done |
| KeybindManager | Done |
| Home Page | Done |
| Settings Page | Done |
| Provisioning FSM | — |
| Pages (11 types) | — |
| OpenGL Context | — |
| Virtual Framebuffer | — |
| Shader Manager | — |
| Font Manager | — |
| Line Patterns | — |
| Color Patterns | — |
| Mixer | — |
| Network / WiFi | — |
| Database (SQLite) | — |
| HTTP Client | — |
| Logging | — |
| Config / App State | — |
---
## 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.