235 lines
11 KiB
Markdown
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.
|