Bug Fix: ScheduleManager::start() held mutex_ and then called loadCache(), which re-locks the same non-recursive mutex. Whenever a schedule cache file was present (the normal state for a provisioned kiosk), this self-deadlocked and blocked SDL_AppInit before the first frame was presented, so the kiosk booted to a black screen showing only the compositor cursor. Narrow the lock scope so loadCache() acquires mutex_ itself, and add a regression test that starts with a cached payload present.
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'sresolv
Install the dependencies for your platform:
macOS (Homebrew)
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)
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:
nix develop
Configure, build, run (CMake + Ninja)
# 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:
cmake -S . -B build-release -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build build-release
./build-release/radiantwave
Clean:
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:
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):
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:
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:
# 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):
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:
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):
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:
- Create
tests/test_yourthing.cpp - Include the harness and the header under test:
#include "test_harness.h"
#include "../src/your_header.h"
TEST(my_new_test)
{
// use ASSERT_TRUE, ASSERT_FALSE, ASSERT_EQ
}
RUN_ALL_TESTS()
- Add the file to the
teststarget inCMakeLists.txt:
add_executable(tests
tests/test_page_manager.cpp
tests/test_yourthing.cpp # <-- add here
src/page_manager.cpp
src/your_source.cpp
)
- Rebuild and run:
cmake --build build --target tests
./build/tests
Test conventions
- One
TEST()per behavior. - Name tests as
what_you_are_testing(snake_case). - Use
TestPagemock fromtest_page.hto verify lifecycle calls. - Call
clear_log()before each test that checks lifecycle events. AppStateis defined minimally in test files — pages must not depend on fields beyond whatAppStateactually 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
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:
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 getsonPush(). - Pop: top page gets
onPop(), previous page getsonResume(). - Replace: top page gets
onPop(), new page getsonPush().
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.
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. .wavfiles downloaded at runtime fromfiles.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:
PascalCasefor types, classes, structs, enums.camelCasefor functions, methods, variables.snake_casefor files (page_manager.cpp).UPPER_SNAKE_CASEfor constants and macros.
- Includes: SDL3 first, then stdlib, then project headers.
- Ownership: Use
std::unique_ptrfor heap allocation. No rawnew/deleteoutside of the SDL callback entry/exit points. - Callbacks: Use
overrideon all virtual method implementations. - Error handling: Return
SDL_APP_FAILUREon unrecoverable errors. Log withSDL_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
TestPagemock to verify lifecycle calls. - Call
clear_log()before tests that inspect lifecycle events. - Prefer
ASSERT_EQoverASSERT_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 (
.wavfiles) -- 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.mainbuilds bake the current git tag asAPP_VERSION;devbuilds bake the current commit hash. Both build withnix build --impure(which lets the flake's env injection work). The offline test suite runs in the package'scheckPhase; the live portal tests run fromnix developwhen theRWT_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.