NeilClack babb74c7ad
build-dev / nix-build (push) Successful in 40s
Fix attic upload
2026-08-25 07:55:32 -05:00
2026-08-25 07:55:32 -05:00
2026-08-07 14:51:41 -05:00
2026-08-17 20:36:59 -05:00
2026-08-07 14:51:41 -05:00
2026-08-18 23:37:44 -04:00
2026-08-15 15:43:49 -05:00
2026-08-15 15:43:49 -05:00
2026-08-17 20:36:59 -05:00
2026-08-07 14:51:41 -05:00
2026-08-17 20:36:59 -05:00
2026-08-07 14:51:41 -05:00
2026-08-17 20:36:59 -05:00
2026-08-17 20:36:59 -05:00

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)

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:

  1. Create tests/test_yourthing.cpp
  2. 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()
  1. Add the file to the tests target in CMakeLists.txt:
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:
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

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

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
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.
S
Description
No description provided
Readme
6.1 MiB
Languages
C++ 96.7%
Nix 1.7%
CMake 1.4%
C 0.2%