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