Files
radiantwave/AGENTS.md
T

11 KiB

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

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:

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 and exports API_KEY from the ambient environment (falling back to RWT_LIVE_API_KEY). CMake seeds a fresh configure from the APP_VERSION / API_KEY env vars; -D wins on reconfigure.

The portal API key is also a build-time argument: -DAPI_KEY="..." (CMake) or apiKey on callPackage (Nix). It is compiled into the binary as the Authorization: Bearer token and defaults to empty (no auth header). Keep the real value in CI secrets, never in source — rotate by rebuilding with a new key.

Version and API key can be baked in together with a single configure:

cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug \
  -DAPP_VERSION="1.2.3" \
  -DAPI_KEY="$API_KEY"
  • 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, subscription_level_id/name/description, license_last_verified (7-day offline grace window for a known-good license).
  • 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 — plus the portal API key from the RWT_API_KEY secret. The flake reads APP_VERSION/API_KEY from the environment via builtins.getEnv, so the build must pass --impure (pure evals get the package defaults). When the RWT_LIVE_API_KEY secret is set it also runs test_api_client / test_license_service against the portal via nix develop (outside the sandbox, like a dev machine). Those live tests also 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.