initial commit

This commit is contained in:
NeilClack
2026-08-07 14:51:41 -05:00
commit 9251b3a196
604 changed files with 464448 additions and 0 deletions
+599
View File
@@ -0,0 +1,599 @@
# 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/X11); 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 `nix build` needs no extra flags.
#### Portal API key
Every portal request sends `Authorization: Bearer <key>`. 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 = "…"; }`; the flake itself never embeds one.
### 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
```
`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()
```
3. 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
)
```
4. 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 self-hosted Forgejo runners.
- Asset tests (`test_assets`) must pass before committing -- they catch missing
fonts and images that would break the app at runtime.