Files
radiantwave/AGENTS.md
T
2026-08-17 20:36:59 -05:00

235 lines
11 KiB
Markdown

# RadiantWave — Agent Instructions
## What This Is
A kiosk app for RadiantWave wellness technology. Displays scrolling proclamation
text (Fibonacci line-length pattern, per-frequency color palettes), plays
solfeggio-frequency audio, and runs a first-run provisioning walkthrough on a
headless Linux kiosk. Talks to `https://portal.radiantwavetech.com/api/v2`.
**Language:** C++17 (no extensions). **Build:** CMake + Ninja.
**Graphics:** SDL3 + SDL3_ttf/SDL3_image. **Audio:** SDL3_mixer.
**Also links:** SDL3_net, SQLite3, libcurl, OpenSSL, GTest.
**Linux-only:** libsystemd (sd-bus) via pkg-config, compiled only when
`UNIX AND NOT APPLE` — guarded by `#ifdef __linux__` in the network manager.
---
## Build & Run
```sh
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug
cmake --build build # builds the app AND every test binary
./build/radiantwave
```
The Home footer's top "Version" row is baked in at build time. Default it to
the project version, or pass your own at configure time:
```sh
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug -DAPP_VERSION="1.2.3"
```
The Nix package bakes `-DAPP_VERSION=<pkg version>` via `cmakeFlags` (`version`
defaults to `0.1.0` in `nix/package.nix`; the flake overrides it from the
`APP_VERSION` env var on impure evals). `nix develop`
sets `APP_VERSION=dev`. CMake seeds a *fresh* configure from the
`APP_VERSION` env var; `-D` wins on reconfigure.
- The app resolves `assets/...` relative to its working directory — launch
`./build/radiantwave` from the repo root. (The Nix wrapper chdirs to
`$out/share/radiantwave` before exec, which is why the installed app works.)
- Development happens on macOS; WiFi/D-Bus code is Linux-only, but
`NetworkManager::hasInternet()` (a raw TCP probe) works everywhere, so the
provisioning walkthrough does not trap a Mac on the WiFi page.
- The app creates a **1920x1080 fullscreen** window with VSync and a hidden
cursor. `~/.local/radiantwave/config.yaml` (YAML) and
`~/.local/radiantwave/radiantwave.db` (SQLite, WAL) are created at first run.
- The root `flake.nix` is canonical: `packages.default`, `devShells.default`,
`checks.<system>.default` (the package; its `checkPhase` runs the offline
test suite), a `nixosModules.default`, and `nix build
.#packages.x86_64-linux.test-vm`. `archive/` holds the previous Go
implementation — reference only, do not modify.
---
## Tests
There is **no ctest integration** and no single "run all" target. Each test
file is its own `add_executable` in `CMakeLists.txt`; build them all with
`cmake --build build`, then run each `./build/test_*` binary.
Two frameworks coexist:
- `tests`, `test_assets`, `test_provisioning` — the minimal built-in harness
(`tests/test_harness.h`, `TEST(name)` + `RUN_ALL_TESTS()`).
- Everything else — GoogleTest (`GTest::gtest_main`). GTest is a hard
`find_package` requirement; the build fails without it.
Test targets: `tests`, `test_assets`, `test_provisioning`, `test_network_unit`,
`test_nm_device`, `test_config`, `test_db`, `test_proclamation_sync`,
`test_api_client`, `test_license_service`, `test_license_status`,
`test_validation`, `test_date_format`, `test_timezone`, `test_logger`,
`test_audio`, `test_audio_downloader`, `test_fibonacci_pattern`,
`test_scroller_streams`, `test_fibonacci_scrambler`, `test_color_palettes`.
`test_nm_device` covers the pure `parseAccessPoint()` mapping; the sd-bus reads
in `WifiDevice::getAccessPoints()` are Linux-only and verified live. Do not
wrap the per-AP `ay` SSID property read in `sd_bus_message_enter_container()`
before `sd_bus_message_read_array()` — `read_array` enters/exits the array
itself, and the double-enter silently empties the scan list (see
`src/network/nm_device.cpp`).
New test files must be registered as a new `add_executable` target in
`CMakeLists.txt` (there is no glob). `test_assets` must pass before committing —
it checks the font and image files exist.
`test_api_client` and `test_license_service` include "Live" tests that hit the
portal unconditionally — they are excluded from the Nix `checkPhase` offline
suite and need the `RWT_LIVE_*` secrets to pass.
---
## Runtime data & keys
- `AppState` (in `app_state.h`) holds renderer, `PageManager`, `KeybindManager`,
`config`, `db`, `network`, `audio`, `audioDownloader`, `licenseTracker`, and
provisioning flags. No globals — pass `AppState&` around.
- Device identity/state lives in the SQLite `system_kv` table. Canonical keys
are in `src/db/db_keys.h`: `email_address`, `license_key`,
`license_last_verified` (7-day offline grace window for a known-good license).
A successful `validateStoredLicense` is the "subscribed" flag — the license
is either active (valid) or inactive; there are no subscription levels.
- `ConfigManager` hot-reloads `config.yaml` from a watcher thread and fires
`onChanged` callbacks; `AudioManager` volume/device/`dynamic_audio` are read
from it.
---
## First-run provisioning walkthrough
Fixed prerequisite order (`src/provisioning.h`), evaluated on every boot; the
first missing step's page is pushed:
1. Internet reachable? → `WiFiSetupPage`
2. Email address set? → `EmailAddressPage`
3. License key set? → `LicenseKeyPage`
4. Everything satisfied → `Home`
`advance()` pops the current step and re-evaluates; internet is only re-probed
when it was still missing (cached in `state.walkthroughInternetUp`). A
brand-new device (nothing configured) only sets the `firstRunWalkthrough`
placeholder flag — the welcome walkthrough is not implemented yet.
---
## Architecture
### Entry point (`src/main.cpp`)
SDL3 callback API (`SDL_MAIN_USE_CALLBACKS`): `SDL_AppInit` inits subsystems
and calls `provisioning::begin`, `SDL_AppEvent` routes through keybinds then
pages, `SDL_AppIterate` updates/renders, `SDL_AppQuit` tears down.
### Pages & navigation
`Page` lifecycle (`src/page.h`): `onPush`/`onResume` create textures,
`onPause` destroys them, `onPop` final-cleans. `PageManager` is a stack
(`push`/`pop`/`popToRoot`/`replace`). Home and the provisioning steps use
`replace()`-style flows; only settings sub-pages stack deeply. TAB pops the
stack and pushes a fresh Home.
Existing pages: Home, Settings, WiFiSetupPage, EmailAddressPage, LicenseKeyPage,
LicenseBlockedPage, AudioSettingsPage, ProclamationOptionsPage, ScrollerPage,
LogViewerPage, TimezonePage, DateFormatPage.
### Keybinds (`src/keybind_manager.cpp`)
Binds are checked **before** the active page sees events. Left/right modifier
pairs are normalized to combined masks, so a bind's modifiers must be a subset
of the held modifiers (plain binds fire regardless of modifiers).
Global binds in `main.cpp`: **TAB** returns Home, **UP/DOWN** move the Home
menu selection. There is no quit keybind — the app is a kiosk. App quit (SDL
window/system close) fades audio out over ~5s before teardown. Note: Home's
UP/DOWN handlers both increment `currentMenuItem` `(x+1)%2` (see
`src/home.cpp`) — there is no separate "down" path.
### ScrollerPage
Full-screen vertical scroller. Proclamation streams come from the DB
(`scroller_streams.cpp`), colors from per-frequency palettes with a 528 Hz
fallback, lines built from a Fibonacci length pattern
(`fibonacci_line_pattern.cpp`). Audio is crossfaded per stream via
`AudioManager` fade APIs. Word textures are cached and page text rendering is
cached.
---
## Portal API & color palettes
- Request/response changes against the portal are tracked in `ApiUpdates.md`
(sections #4 and #5).
- **Color palettes are meant to come from the portal, not code releases.** The
client-side pieces are implemented but deliberately **not wired into any sync
or startup flow** until the portal routes exist:
- `api::fetchColorPalettes()` / `api::parseColorPalettesJson()` — the
`GET /color-palettes` route.
- `sync::syncColorPalettesFromApi()` / `sync::syncProclamationPalettesFromApi()`
/ `sync::parseProclamationPalettesJson()` — replace the local
`color_palettes` table via `db::replaceColorPalettes()`.
- `db::getPaletteForFrequencyId()` falls back to the 528 Hz palette for
frequency ids with no rows; the startup seed only backfills missing ids, so
portal values win after a sync.
- Do **not** call the above sync functions from the app until the portal routes
ship. Tests live in `tests/test_color_palettes.cpp` (`test_color_palettes`
target).
- **Intended wiring points** (marked with `TODO(portal #4/#5)` comments):
`syncProclamationsFromApi()` in `src/network/proclamation_sync.cpp`, plus the
call sites `Home::syncProclamationsIfEmpty()` (`src/home.cpp`) and
`ProclamationOptionsPage::onPush()` (`src/proclamation_options_page.cpp`).
---
## Style & UI Layout Rules
- `PascalCase` for types, `camelCase` for functions/vars, `snake_case` for
files, `UPPER_SNAKE` for constants/macros.
- Use `std::unique_ptr`; no raw `new`/`delete` outside the SDL callbacks.
- Use `override` on all virtual implementations. `#pragma once` headers.
- One class per header/source pair. SDL3 headers first, then stdlib, then
project headers.
### UI Menu & List Layout Conventions
- **Left Alignment & Centered Bounding Box**: In vertical menus/lists, all text
options are left-aligned with each other (`sharedX`). The bounding box
enclosing all options (width = widest item, height = sum of item heights +
margins) MUST BE centered on the screen.
- **Vertical Spacing**: All margins between consecutive list items are
**12 pixels**.
- **Selection Underline**: The 6-pixel underline occupies half the 12px margin
(2-3px gap below the item text, 3-4px gap above the next item).
---
## Common Pitfalls
- Do not commit audio assets (`.wav` files) — downloaded at runtime.
- Do not modify files in `archive/` — reference only.
- Build needs SDL3 + SDL3_ttf/image/mixer/net + GTest installed; CI uses
Gitea Actions on a self-hosted host runner (`build-server`, no docker).
The workflow lives in `.gitea/workflows/build.yml` and is **manual-only**
(`workflow_dispatch`); pick the branch when dispatching. It runs
`nix build -L .#checks.<system>.default` (build + the offline test suite;
`checks` is keyed per-system, so the workflow resolves the current system
via `nix eval --impure --expr 'builtins.currentSystem'`). The build bakes
in a per-branch `APP_VERSION` — the current git tag on `main`, the current
commit hash on `dev`. The flake reads `APP_VERSION` from the environment via
`builtins.getEnv`, so the build must pass `--impure` (pure evals get the
package defaults). The live portal tests run from `nix develop` when the
`RWT_LIVE_*` secrets are set and need `RWT_LIVE_ACCOUNT_EMAIL`,
`RWT_LIVE_LICENSE_EMAIL`, and `RWT_LIVE_LICENSE_KEY`.
- `test_assets` must pass before committing — it catches missing fonts/images
that break the app at runtime.
- `cmake --build build` builds everything; pass `--target <name>` to build one
test binary.