610 lines
18 KiB
Markdown
610 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 |
|
|
| 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.
|