# 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`). ### 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` / `API_KEY` environment variables — this is how CI injects the version and portal key (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. #### Portal API key Every portal request sends `Authorization: Bearer `. The key is baked into the binary at build time via the `API_KEY` CMake cache variable. It is **optional** — when unset, requests go out without an auth header. Keep the real value out of the source tree and inject it at build time from your CI secret store so it never lands in `git`: ```sh cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release \ -DAPI_KEY="${{ secrets.PORTAL_API_KEY }}" cmake --build build ``` Rotate the key by building with a new value and shipping the new binary. The Nix package accepts the same value via `callPackage ./nix/package.nix { apiKey = "…"; }`, or via the `API_KEY` environment variable with `nix build --impure`; the flake itself never embeds one. #### Version + API key in one configure `APP_VERSION` and `API_KEY` are independent CMake cache variables, so pass both — plus the build type — in a single configure: ```sh cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug \ -DAPP_VERSION="1.2.3" \ -DAPI_KEY="${{ secrets.PORTAL_API_KEY }}" cmake --build build ``` `-D` values always win over the `APP_VERSION` / `API_KEY` environment variables, which `nix develop` seeds from the ambient environment. ### 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 and/or portal key, build impurely — the flake reads `APP_VERSION` and `API_KEY` from the environment on impure evals: ```sh APP_VERSION="1.2.3" API_KEY="$PORTAL_API_KEY" nix build --impure .#default ``` A pure `nix build` always uses the package defaults (`0.1.0`, no auth header) 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_ 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()); pages.pop(state); pages.popToRoot(state); // pop all pages, resume root pages.replace(state, std::make_unique()); // 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 | | proclamation_categories | Category metadata | | 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 pass the portal key from the `RWT_API_KEY` secret and 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.