5 Commits
Author SHA1 Message Date
NeilClack 809882b333 Merge branch 'dev'
build / nix-build (push) Successful in 10m59s
2026-10-14 15:03:33 -05:00
NeilClack 0124e1c67b Update README 2026-10-14 15:03:26 -05:00
NeilClack c3b3d6774a Warning screen, smarter license re-validation, version reporting
Warning screen
- New reusable WarningPage (word-wrapped message, ENTER acknowledges and
  continues to the screen you were about to enter).
- First use: pressing Play while the selected audio is still downloading.
  Dynamic Audio requires every file; a fixed track requires its own file.

License re-validation
- No longer re-verifies on every Home render. Checks now run only when the
  email/license key is entered or changed, on the manual option, at startup
  when already due, and hourly while valid.
- Invalid + unchanged credentials are never re-checked (persisted pair).
- Valid re-checks at most once per hour, anchored on a DB timestamp so an
  overdue check runs immediately after a power cycle.
- New system_kv keys track the checked pair, outcome, and last-check time.
- Added a System Settings row "Manually Validate License Key OK|Invalid"
  (OK green, Invalid red, label white) that runs a check immediately.

Portal / local integration
- License validation request now sends the app version as `appVersion`.
- The entered license key is mirrored to ~/.local/radiantwave/license.txt.

Misc
- "Please connect to WiFi" only shows when no WiFi is joined AND the internet
  is unreachable (fixes the NetworkManager-missing and Ethernet cases).
- Added unit tests for the audio-download warning, license scheduling policy,
  license request shape, and the new settings-menu entry.
2026-10-14 10:58:40 -05:00
NeilClack 476202f8e2 Merge dev into main: Bug Fix - ScheduleManager startup self-deadlock
build / nix-build (push) Successful in 9m50s
Bug Fix: ScheduleManager::start() held mutex_ and then called loadCache(), which re-locks the same non-recursive mutex. Whenever a schedule cache file was present (the normal state for a provisioned kiosk), this self-deadlocked and blocked SDL_AppInit before the first frame was presented, so the kiosk booted to a black screen showing only the compositor cursor.

Narrow the lock scope so loadCache() acquires mutex_ itself, and add a regression test that starts with a cached payload present.
2026-10-07 08:58:46 -05:00
NeilClack 9b57e48969 Fix ScheduleManager start() self-deadlock on cached schedule
start() held mutex_ and then called loadCache(), which re-locks the same
non-recursive mutex. When a schedule cache file exists and parses (the
normal case for a provisioned kiosk), this self-deadlocks and blocks
SDL_AppInit before the first frame, leaving only a black screen and the
compositor cursor.

Narrow the lock to the member setup so loadCache() can take the lock
itself. Add a regression test that starts with a cached payload present.
2026-10-07 08:43:13 -05:00
24 changed files with 1183 additions and 89 deletions
+1
View File
@@ -74,6 +74,7 @@ add_executable(radiantwave
src/email_address_page.cpp src/email_address_page.cpp
src/license_key_page.cpp src/license_key_page.cpp
src/license_blocked_page.cpp src/license_blocked_page.cpp
src/warning_page.cpp
src/validation.cpp src/validation.cpp
src/date_format.cpp src/date_format.cpp
src/date_format_page.cpp src/date_format_page.cpp
+143 -28
View File
@@ -10,6 +10,149 @@ and manages WiFi/provisioning on a headless Linux kiosk.
**Audio:** SDL3_mixer **Audio:** SDL3_mixer
**Target:** Linux kiosk (Wayland); developed on macOS **Target:** Linux kiosk (Wayland); developed on macOS
---
## Version Deployment
> How to release a new version of RadiantWave, and what happens on the kiosks when you do.
### Two channels, two update paths
| Channel | App input | Attic cache | Manifest |
|---|---|---|---|
| `dev` | `radiantwave-dev` (`?ref=dev`) | `attic.../radiantwave-dev` | `files.../radiantwave-dev/manifest.json` |
| `main` | `radiantwave` (`?ref=refs/tags/1.8`) | `attic.../radiantwave` | `files.../radiantwave/manifest.json` |
A kiosk gets RadiantWave by two independent paths:
- **Live app update** -- CI publishes a manifest; the kiosk's
`radiantwave-updater.timer` fetches the closure from Attic and swaps
`/var/lib/radiantwave/current`. Takes effect in ~2 min, no rebuild.
- **NixOS rebuild** -- `system.autoUpgrade` runs every 10 min against the
`KioskConfig` repo with `--refresh` and rebuilds the whole system closure.
`KioskConfig` enables `services.radiantwaveUpdater` on all four configs on
**both** branches, so the live path is always available.
**An app-only feature only needs the live app update path. You do not commit,
push, or rebuild `../KioskConfig`.** Its `flake.lock` pin of `radiantwave-dev`
only determines the closure baked into the ISO; the updater overwrites that
pointer as soon as it sees a newer manifest.
### Test a feature on the dev kiosk -- manual steps
**1. Build and test locally.**
```sh
nix develop --command bash -c '
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug
cmake --build build
./build/test_assets
'
```
Run each `./build/test_<name>` binary you touched -- there is no ctest
integration and no aggregate target. If you added a test file, register it as
a new `add_executable` in `CMakeLists.txt` first.
**2. Land it on `dev`.**
```sh
git switch dev && git pull
git switch -c feature/<name>
# ... commit your work ...
git push -u origin feature/<name>
```
Open a PR against `dev` and merge. The kiosk only ever builds `dev`.
**3. Dispatch the build workflow** on the RadiantWave repo in Gitea Actions.
**4. Verify.**
The Home footer's Version row shows the new short commit hash, and:
```sh
ssh kiosk@<dev-kiosk> 'readlink /var/lib/radiantwave/current'
curl -fsSL https://files.radiantwavetech.com/radiantwave-dev/manifest.json | jq .storePath
```
Both must match.
### Test a feature on the dev kiosk -- automated by CI
> Everything between dispatching `build.yml` and the kiosk running the new
> binary. No human step is required for any of it.
>
> **Trigger.** `workflow_dispatch` force-clones the `dev` branch
> (`REF=dev`, hardcoded -- you cannot pick a branch). A tag push clones the tag.
> Either way `APP_VERSION` becomes the short commit hash on dispatch, or
> `git describe --tags --always` on a tag.
>
> **Build.** Native `x86_64-linux` package, then the offline suite via
> `checks.<system>.default`, then the live portal tests from `nix develop`
> (`RWT_LIVE_*` secrets), then the `aarch64-linux` package and checks. The
> aarch64 build is emulated via binfmt/qemu-user and is the long pole.
>
> **Publish.** `attic push radiantwave:radiantwave-dev` with both
> `result-pkg` and `result-pkg-aarch64`, using `ATTIC_TOKEN_DEV`. Then it
> writes `{"version","storePath"}` from `readlink -f` of each result into
> `manifest.json` (x86) and `aarch64-manifest.json` (arm), and `scp`s both to
> `157.245.124.103:/mnt/infrastructure_data/files/radiantwave-dev/`, which is
> served as `https://files.radiantwavetech.com/radiantwave-dev/`.
>
> **Kiosk side.** `radiantwave-updater.timer` (`OnBootSec=2min`,
> `OnUnitActiveSec=2min`, `Persistent`) runs the oneshot
> `radiantwave-updater.service`, which curls the manifest, `jq`s `.storePath`,
> and compares it against `readlink /var/lib/radiantwave/current`. On a new
> path it fetches the closure, never building:
>
> ```sh
> nix-store -r "$NEW_PATH" \
> --add-root /var/lib/radiantwave/.current-root \
> --option extra-substituters "<atticUrl> https://cache.nixos.org"
> ```
>
> Then it swaps atomically with `ln -sfn` plus `mv -T`. Both Attic caches and
> their public keys are already trusted in `configuration.nix`, so the kiosk
> needs no credentials.
>
> **Failure mode.** The Attic push and manifest publish are ordinary steps, not
> `always()`, so **any failing test blocks publication** and the kiosk keeps
> running the previous build. Check the run's log before assuming a deploy
> landed.
### Gotchas
- **Dispatch hardcodes `dev`.** `build.yml` sets `REF=dev` on
`workflow_dispatch`; you cannot select a branch.
- **Push before you dispatch.** CI clones `dev` at dispatch time, so dispatching
first publishes the previous commit and the feature appears not to deploy.
- **Reboot briefly reverts the app.** `radiantwave-updater.nix` seeds the
pointer with tmpfiles type `L`, which re-points `current` at the ISO's baked
baseline on every boot. The 2-minute timer restores the live build shortly
after, so the window is brief but real.
- **A reboot does not lose kiosk data.** `radiantwave-backup.service` rsyncs
`~/.local/radiantwave` (minus `audio/`) before every upgrade attempt.
- **Don't bump `channel.appVersion` for an app-only deploy.** It only labels
the baked ISO baseline. Bump it when rebuilding a dev ISO so it advertises
the new fingerprint.
- **No local Linux builder on macOS.** `extra-platforms = x86_64-darwin` only,
so `nix build .#packages.x86_64-linux.default` and
`nixos-rebuild switch --target-host` are not available from a Mac. The
`orion-lxc` runner is the only `x86_64-linux` builder.
### Releasing to production
1. Merge `dev` -> `main` in `radiantwave`.
2. Tag and push: `git tag 1.9 && git push origin 1.9`. The tag push triggers
`build.yml`, which publishes to the `radiantwave` Attic cache and
`radiantwave/manifest.json` using `ATTIC_TOKEN_PROD`.
3. In `KioskConfig`, on `main`: set `radiantwave.url` to
`?ref=refs/tags/1.9`, set `channel.appVersion = "1.9"`, and run
`nix flake update radiantwave`. Commit both -- the lock and the channel
block must agree.
4. Its CI fails fast unless the baked baseline exactly matches the published
manifest, so this step cannot drift silently.
--- ---
## Project Layout ## Project Layout
@@ -564,34 +707,6 @@ 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 ## Common Pitfalls
- Do not commit audio assets (`.wav` files) -- downloaded at runtime. - Do not commit audio assets (`.wav` files) -- downloaded at runtime.
+59
View File
@@ -261,6 +261,35 @@ std::string computeFileMd5(const std::string& path) {
return hex; return hex;
} }
bool buildAudioDownloadWarning(const AudioDownloadSnapshot& snapshot,
bool dynamicAudio,
const std::string& selectedFilename,
std::string& message) {
if (dynamicAudio) {
if (snapshot.allAvailable) return false;
message =
"You currently have 'Dynamic Audio' selected, but some audio "
"files are still being downloaded. Some audio will not play until "
"these files have finished downloading.";
return true;
}
for (const auto& file : snapshot.files) {
if (file.filename != selectedFilename) continue;
if (file.available) return false;
const std::string name =
file.display_name.empty() ? file.filename : file.display_name;
message = "You currently have '" + name +
"' selected, but it is still being downloaded. No audio "
"will play until this file has finished downloading.";
return true;
}
// The selection is not part of the manifest (the manifest may not be known
// yet), so the downloader cannot confirm whether it is still downloading.
return false;
}
// ─── Lifecycle ────────────────────────────────────────────────────────── // ─── Lifecycle ──────────────────────────────────────────────────────────
AudioDownloader::~AudioDownloader() { AudioDownloader::~AudioDownloader() {
@@ -289,6 +318,30 @@ std::string AudioDownloader::statusString() const {
return status_; return status_;
} }
AudioDownloadSnapshot AudioDownloader::snapshot() const {
std::lock_guard<std::mutex> lock(mutex_);
return snapshot_;
}
void AudioDownloader::publishSnapshot(
const std::vector<AudioManifestEntry>& manifest,
const std::unordered_set<std::string>& availableFilenames) {
AudioDownloadSnapshot snap;
snap.manifestKnown = true;
snap.allAvailable = !manifest.empty();
snap.files.reserve(manifest.size());
for (const auto& entry : manifest) {
const bool available = availableFilenames.count(entry.filename) != 0;
const std::string displayName =
entry.display_name.empty() ? humanizeFilename(entry.filename)
: entry.display_name;
snap.files.push_back({entry.filename, displayName, available});
if (!available) snap.allAvailable = false;
}
std::lock_guard<std::mutex> lock(mutex_);
snapshot_ = std::move(snap);
}
void AudioDownloader::setStatus(const std::string& msg) { void AudioDownloader::setStatus(const std::string& msg) {
{ {
std::lock_guard<std::mutex> lock(mutex_); std::lock_guard<std::mutex> lock(mutex_);
@@ -359,6 +412,11 @@ void AudioDownloader::run() {
size_t toDownload = queue.size(); size_t toDownload = queue.size();
size_t downloaded = 0; size_t downloaded = 0;
// Publish what is (and is not) on disk before downloads begin so the
// UI can warn immediately, then again as each file lands.
publishSnapshot(manifest, listed);
if (!queue.empty()) { if (!queue.empty()) {
std::error_code ec; std::error_code ec;
std::filesystem::create_directories(dir, ec); std::filesystem::create_directories(dir, ec);
@@ -373,6 +431,7 @@ void AudioDownloader::run() {
// Publish immediately so a file that just finished downloading // Publish immediately so a file that just finished downloading
// appears in the list without waiting for the rest. // appears in the list without waiting for the rest.
syncNow(); syncNow();
publishSnapshot(manifest, listed);
} }
} }
+45
View File
@@ -6,6 +6,7 @@
#include <mutex> #include <mutex>
#include <string> #include <string>
#include <thread> #include <thread>
#include <unordered_set>
#include <vector> #include <vector>
namespace radiantwave::db { namespace radiantwave::db {
@@ -33,6 +34,29 @@ struct AudioManifestEntry {
std::string md5; std::string md5;
}; };
// Availability of one manifest entry, as published by the worker.
// `available` means the file is present on disk (and therefore playable),
// regardless of whether its checksum still needs refreshing.
struct AudioFileStatus {
std::string filename;
std::string display_name;
bool available = false;
};
// A snapshot of the worker's most recent manifest reconciliation. Used by the
// UI to warn the user when they start playback before every required audio
// file has finished downloading.
struct AudioDownloadSnapshot {
// A manifest has been fetched at least once. Before this, the set of
// expected files is unknown.
bool manifestKnown = false;
// Every manifest file is present on disk. False until the first manifest
// is known.
bool allAvailable = false;
// One entry per manifest file, in manifest order.
std::vector<AudioFileStatus> files;
};
// Downloads audio assets from files.radiantwavetech.com in the background // Downloads audio assets from files.radiantwavetech.com in the background
// and keeps the database's audio_files table in sync with what is actually // and keeps the database's audio_files table in sync with what is actually
// on disk. // on disk.
@@ -66,6 +90,9 @@ public:
// Returns the current human-readable status for display (e.g. footer). // Returns the current human-readable status for display (e.g. footer).
std::string statusString() const; std::string statusString() const;
// Returns the last published availability snapshot (thread-safe copy).
AudioDownloadSnapshot snapshot() const;
static constexpr const char *AUDIO_BASE_URL = static constexpr const char *AUDIO_BASE_URL =
"https://files.radiantwavetech.com/audio/"; "https://files.radiantwavetech.com/audio/";
static constexpr const char *MANIFEST_FILENAME = "checksums.md5"; static constexpr const char *MANIFEST_FILENAME = "checksums.md5";
@@ -80,11 +107,18 @@ private:
void setStatus(const std::string &msg); void setStatus(const std::string &msg);
void wait(std::chrono::seconds seconds); void wait(std::chrono::seconds seconds);
// Rebuilds `snapshot_` from the manifest and the set of filenames present
// on disk. Called by the worker after its initial scan and after each
// completed download so the UI sees progress.
void publishSnapshot(const std::vector<AudioManifestEntry> &manifest,
const std::unordered_set<std::string> &availableFilenames);
mutable std::mutex mutex_; mutable std::mutex mutex_;
std::condition_variable cv_; std::condition_variable cv_;
std::thread worker_; std::thread worker_;
std::atomic<bool> stop_{false}; std::atomic<bool> stop_{false};
std::string status_; std::string status_;
AudioDownloadSnapshot snapshot_;
db::DatabaseManager *db_ = nullptr; db::DatabaseManager *db_ = nullptr;
}; };
@@ -109,4 +143,15 @@ int hzFromFilename(const std::string &filename);
// Computes the lowercase MD5 hex of a file, or an empty string on failure. // Computes the lowercase MD5 hex of a file, or an empty string on failure.
std::string computeFileMd5(const std::string &path); std::string computeFileMd5(const std::string &path);
// Fills `message` and returns true when starting playback should first show a
// warning because required audio has not finished downloading:
// * dynamic audio needs the whole manifest (the message names "ALL");
// * a fixed track needs just its own file (the message names it).
// Returns false when nothing required is missing, or when the selection is not
// part of the manifest (so the downloader cannot confirm it either way).
bool buildAudioDownloadWarning(const AudioDownloadSnapshot &snapshot,
bool dynamicAudio,
const std::string &selectedFilename,
std::string &message);
} // namespace radiantwave::audio } // namespace radiantwave::audio
+12
View File
@@ -12,6 +12,18 @@ constexpr const char* KV_LICENSE_KEY = "license_key";
// outages do not revoke a known-good license. // outages do not revoke a known-good license.
constexpr const char* KV_LICENSE_LAST_VERIFIED = "license_last_verified"; constexpr const char* KV_LICENSE_LAST_VERIFIED = "license_last_verified";
// The license key/email pair the most recent completed check used, and its
// outcome ("valid"/"invalid"). An invalid pair is not re-checked until one of
// the two stored values changes, so a slow device does not hammer the portal.
constexpr const char* KV_LICENSE_CHECKED_KEY = "license_checked_key";
constexpr const char* KV_LICENSE_CHECKED_EMAIL = "license_checked_email";
constexpr const char* KV_LICENSE_CHECK_STATUS = "license_check_status";
// Unix epoch seconds of the last license check attempt (success or failure).
// Anchors the hourly re-check of a valid license; an overdue check runs
// immediately on the next boot instead of waiting a fresh hour.
constexpr const char* KV_LICENSE_LAST_CHECK = "license_last_check";
// The System Name the user typed on the Kiosk (trimmed). A human-readable label // The System Name the user typed on the Kiosk (trimmed). A human-readable label
// set only here; never a reference or key. // set only here; never a reference or key.
constexpr const char* KV_SYSTEM_NAME = "system_name"; constexpr const char* KV_SYSTEM_NAME = "system_name";
+4
View File
@@ -92,6 +92,8 @@ void EmailAddressPage::submit()
storedValue_.clear(); storedValue_.clear();
statusMessage_ = "Cleared"; statusMessage_ = "Cleared";
returnTimer_ = 1.0f; returnTimer_ = 1.0f;
// The credential pair changed: re-validate in the background.
state_->licenseTracker.refresh();
} else { } else {
statusMessage_ = "Failed to clear email address"; statusMessage_ = "Failed to clear email address";
} }
@@ -107,6 +109,8 @@ void EmailAddressPage::submit()
storedValue_ = input_; storedValue_ = input_;
statusMessage_ = "Saved"; statusMessage_ = "Saved";
returnTimer_ = 1.0f; returnTimer_ = 1.0f;
// The credential pair changed: re-validate in the background.
state_->licenseTracker.refresh();
} else { } else {
statusMessage_ = "Failed to save email address"; statusMessage_ = "Failed to save email address";
} }
+26 -6
View File
@@ -3,6 +3,7 @@
#include "settings.h" #include "settings.h"
#include "scroller_page.h" #include "scroller_page.h"
#include "license_blocked_page.h" #include "license_blocked_page.h"
#include "warning_page.h"
#include "proclamation_options_page.h" #include "proclamation_options_page.h"
#include "date_format.h" #include "date_format.h"
#include "audio/audio_downloader.h" #include "audio/audio_downloader.h"
@@ -45,7 +46,6 @@ void Home::onPush(AppState &state)
TTF_Init(); TTF_Init();
createTextures(state); createTextures(state);
refreshStatus(); refreshStatus();
state.licenseTracker.refresh();
state.keybinds.bind(SDLK_UP, "menu_up", [this]() state.keybinds.bind(SDLK_UP, "menu_up", [this]()
{ state_->currentMenuItem = (state_->currentMenuItem + 1) % 2; }); { state_->currentMenuItem = (state_->currentMenuItem + 1) % 2; });
@@ -59,7 +59,6 @@ void Home::onResume(AppState &state)
syncProclamationsIfEmpty(state); syncProclamationsIfEmpty(state);
createTextures(state); createTextures(state);
refreshStatus(); refreshStatus();
state.licenseTracker.refresh();
state.keybinds.bind(SDLK_UP, "menu_up", [this]() state.keybinds.bind(SDLK_UP, "menu_up", [this]()
{ state_->currentMenuItem = (state_->currentMenuItem + 1) % 2; }); { state_->currentMenuItem = (state_->currentMenuItem + 1) % 2; });
@@ -88,7 +87,26 @@ void Home::handleEvent(const SDL_Event &event)
// Only a confirmed-valid license unlocks the scroller. // Only a confirmed-valid license unlocks the scroller.
if (state_->licenseTracker.isValid()) if (state_->licenseTracker.isValid())
{ {
state_->pages.push(*state_, std::make_unique<ScrollerPage>()); // Starting playback while the selected audio is still
// downloading would open a silent scroller: warn first, then
// continue on to the scroller when acknowledged.
std::string warning;
const radiantwave::config::AppConfig cfg = state_->config.get();
if (radiantwave::audio::buildAudioDownloadWarning(
state_->audioDownloader.snapshot(),
cfg.audio.dynamic_audio,
cfg.audio.current_track,
warning))
{
state_->pages.push(*state_, std::make_unique<WarningPage>(
std::move(warning), [](AppState& state) {
state.pages.replace(state, std::make_unique<ScrollerPage>());
}));
}
else
{
state_->pages.push(*state_, std::make_unique<ScrollerPage>());
}
} }
else else
{ {
@@ -180,9 +198,11 @@ void Home::render(SDL_Renderer *renderer)
SDL_RenderFillRect(renderer, &line); SDL_RenderFillRect(renderer, &line);
} }
// Large bold reminder to connect WiFi, drawn below the menu while no // Large bold reminder to connect WiFi, drawn below the menu only when no
// network is joined (refreshed every 5s in update()). // WiFi is joined AND the internet is unreachable. A missing NetworkManager
if (wifiNote_.texture && ssid_.empty()) // on a dev machine (no SSID, but internet via a wired/host link) or a
// customer on Ethernet must not see this (refreshed every 5s in update()).
if (wifiNote_.texture && ssid_.empty() && !internet_)
{ {
float noteX = (fw - wifiNote_.width) / 2.0f; float noteX = (fw - wifiNote_.width) / 2.0f;
float noteY = settingsY_ + settings_.height + 40.0f; float noteY = settingsY_ + settings_.height + 40.0f;
+28
View File
@@ -6,6 +6,28 @@
#include "validation.h" #include "validation.h"
#include "logical_size.h" #include "logical_size.h"
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
#include <cstdlib>
#include <filesystem>
#include <fstream>
namespace {
// Mirrors the active license key to ~/.local/radiantwave/license.txt so local
// system tooling (e.g. naming this device in Tailscale) can read the kiosk's
// identity. The file is overwritten on every key entry.
void writeLicenseTextFile(const std::string& licenseKey) {
const char* home = std::getenv("HOME");
const std::string base = (home && *home) ? home : "/tmp";
const std::string dir = base + "/.local/radiantwave";
std::error_code ec;
std::filesystem::create_directories(dir, ec);
std::ofstream out(dir + "/license.txt", std::ios::trunc);
if (out) {
out << licenseKey << "\n";
}
}
} // anonymous namespace
void LicenseKeyPage::onPush(AppState& state) void LicenseKeyPage::onPush(AppState& state)
{ {
@@ -94,11 +116,17 @@ void LicenseKeyPage::submit()
} }
storedValue_ = formatted; storedValue_ = formatted;
// Keep ~/.local/radiantwave/license.txt in sync with the entered key.
writeLicenseTextFile(formatted);
// Both the email address and the license key are required to validate. // Both the email address and the license key are required to validate.
// When the email is missing the key is saved but the check is skipped. // When the email is missing the key is saved but the check is skipped.
std::string errorMessage; std::string errorMessage;
const radiantwave::license::Result result = radiantwave::license::validateStoredLicense( const radiantwave::license::Result result = radiantwave::license::validateStoredLicense(
state_->db, &errorMessage); state_->db, &errorMessage);
// Hand the outcome to the tracker so it adopts this check (and persists the
// checked pair) instead of scheduling an immediate duplicate.
state_->licenseTracker.recordResult(result);
switch (result) { switch (result) {
case radiantwave::license::Result::Ok: case radiantwave::license::Result::Ok:
+23 -6
View File
@@ -6,6 +6,12 @@
#include <cstdio> #include <cstdio>
#include <string> #include <string>
// The app version is baked in at build time via -DAPP_VERSION; tests and any
// build that omits it fall back to "dev".
#ifndef APP_VERSION
#define APP_VERSION "dev"
#endif
namespace radiantwave::api { namespace radiantwave::api {
namespace { namespace {
@@ -258,12 +264,8 @@ LicenseResult validateLicense(const std::string& key, const std::string& email,
const std::string& systemName) { const std::string& systemName) {
LicenseResult result; LicenseResult result;
std::string jsonBody = "{\"license_key\":\"" + jsonEscape(key) + const std::string jsonBody =
"\",\"email_address\":\"" + jsonEscape(email) + "\""; buildLicenseRequestJson(key, email, systemName, APP_VERSION);
if (!systemName.empty()) {
jsonBody += ",\"systemName\":\"" + jsonEscape(systemName) + "\"";
}
jsonBody += "}";
HttpResponse response = httpPost(PATH_LICENSE, jsonBody); HttpResponse response = httpPost(PATH_LICENSE, jsonBody);
result.status = response.status; result.status = response.status;
@@ -281,6 +283,21 @@ LicenseResult validateLicense(const std::string& key, const std::string& email,
return result; return result;
} }
std::string buildLicenseRequestJson(const std::string& key, const std::string& email,
const std::string& systemName,
const std::string& appVersion) {
std::string jsonBody = "{\"license_key\":\"" + jsonEscape(key) +
"\",\"email_address\":\"" + jsonEscape(email) + "\"";
if (!appVersion.empty()) {
jsonBody += ",\"appVersion\":\"" + jsonEscape(appVersion) + "\"";
}
if (!systemName.empty()) {
jsonBody += ",\"systemName\":\"" + jsonEscape(systemName) + "\"";
}
jsonBody += "}";
return jsonBody;
}
// ─── POST /automations/create/account ────────────────────────────────── // ─── POST /automations/create/account ──────────────────────────────────
bool parseCreateAccountJson(const std::string& body, AccountBody& out) { bool parseCreateAccountJson(const std::string& body, AccountBody& out) {
+8
View File
@@ -106,9 +106,17 @@ struct LicenseResult {
// A 2xx response means the license is valid and the subscription is active. // A 2xx response means the license is valid and the subscription is active.
// When `systemName` is non-empty it is sent as the additive `systemName` field // When `systemName` is non-empty it is sent as the additive `systemName` field
// (the Kiosk is the only writer); the response's `systemNameStatus` is advisory. // (the Kiosk is the only writer); the response's `systemNameStatus` is advisory.
// The request also carries the app's version as `appVersion` so the portal can
// eventually gate accounts on an out-of-date build.
LicenseResult validateLicense(const std::string& key, const std::string& email, LicenseResult validateLicense(const std::string& key, const std::string& email,
const std::string& systemName = ""); const std::string& systemName = "");
// Builds the JSON body for POST /license. `appVersion` is omitted when empty.
// Exposed so the request shape can be unit-tested without the network.
std::string buildLicenseRequestJson(const std::string& key, const std::string& email,
const std::string& systemName,
const std::string& appVersion);
// ─── PUT /licenses/{key}/system-name ─────────────────────────────────── // ─── PUT /licenses/{key}/system-name ───────────────────────────────────
// Result of a System Name PUT. `status` is the HTTP status (0 on transport // Result of a System Name PUT. `status` is the HTTP status (0 on transport
+183 -14
View File
@@ -1,10 +1,38 @@
#include "network/license_status.h" #include "network/license_status.h"
#include "db/database_manager.h" #include "db/database_manager.h"
#include "db/db_keys.h"
#include <utility> #include <chrono>
#include <cstdlib>
namespace radiantwave::license { namespace radiantwave::license {
namespace {
// How often the worker wakes to re-evaluate the schedule while idle. The
// hourly re-check only needs minute granularity, so a 60s poll is plenty;
// forced checks wake the worker immediately instead of waiting for it.
constexpr int kPollSeconds = 60;
long long nowEpochSeconds() {
const auto now = std::chrono::system_clock::now();
return std::chrono::duration_cast<std::chrono::seconds>(
now.time_since_epoch())
.count();
}
long long readEpochSeconds(const db::DatabaseManager& db, const char* key) {
const std::string raw = db.getKV(key);
if (raw.empty()) return 0;
char* end = nullptr;
const long long stamp = std::strtoll(raw.c_str(), &end, 10);
if (!end || *end != '\0' || stamp <= 0) return 0;
return stamp;
}
} // namespace
Status statusForResult(Result result) { Status statusForResult(Result result) {
switch (result) { switch (result) {
case Result::Ok: return Status::Valid; case Result::Ok: return Status::Valid;
@@ -22,6 +50,22 @@ Status statusForResultWithGrace(Result result, const db::DatabaseManager& db) {
return statusForResult(result); return statusForResult(result);
} }
bool shouldValidate(bool forced, bool credentialsChanged, bool overdueRecheck,
Status status, long long nowEpochSeconds,
long long lastCheckEpochSeconds) {
if (forced || credentialsChanged || overdueRecheck) return true;
if (status == Status::Valid) {
// A missing/corrupt stamp counts as overdue so a valid license is
// always re-confirmed at least once per interval.
if (lastCheckEpochSeconds <= 0) return true;
return nowEpochSeconds - lastCheckEpochSeconds >= RECHECK_INTERVAL_SECONDS;
}
// Invalid (or not yet known) with unchanged credentials: do not re-check.
return false;
}
LicenseStatusTracker::~LicenseStatusTracker() { LicenseStatusTracker::~LicenseStatusTracker() {
shutdown(); shutdown();
} }
@@ -30,25 +74,78 @@ void LicenseStatusTracker::start(db::DatabaseManager& db) {
std::lock_guard<std::mutex> lock(mutex_); std::lock_guard<std::mutex> lock(mutex_);
if (worker_.joinable()) return; if (worker_.joinable()) return;
db_ = &db; db_ = &db;
loadPersistedStateLocked();
worker_ = std::thread(&LicenseStatusTracker::run, this); worker_ = std::thread(&LicenseStatusTracker::run, this);
} }
void LicenseStatusTracker::loadPersistedStateLocked() {
if (!db_) return;
checkedKey_ = db_->getKV(db::KV_LICENSE_CHECKED_KEY);
checkedEmail_ = db_->getKV(db::KV_LICENSE_CHECKED_EMAIL);
const std::string stored = db_->getKV(db::KV_LICENSE_CHECK_STATUS);
Status initial = Status::Unknown;
bool overdue = false;
if (stored == "valid") {
if (isLicenseWithinGracePeriod(*db_)) {
initial = Status::Valid;
} else {
// Trusted once, but offline past the grace window: it must be
// re-proven immediately rather than waiting on the hourly timer.
initial = Status::Invalid;
overdue = true;
}
} else if (stored == "invalid") {
initial = Status::Invalid;
}
status_.store(static_cast<int>(initial));
overdueRecheck_ = overdue;
}
void LicenseStatusTracker::refresh() { void LicenseStatusTracker::refresh() {
{ {
std::lock_guard<std::mutex> lock(mutex_); std::lock_guard<std::mutex> lock(mutex_);
if (!worker_.joinable()) return; if (!worker_.joinable()) return;
if (status_.load() == static_cast<int>(Status::Checking)) return; forced_ = true;
status_.store(static_cast<int>(Status::Checking)); status_.store(static_cast<int>(Status::Checking));
} }
cv_.notify_one(); cv_.notify_one();
} }
void LicenseStatusTracker::recordResult(Result result) {
// Serialize with the background worker: a slow in-flight check must not
// land its stale result after this fresher one.
std::lock_guard<std::mutex> checkLock(checkMutex_);
db::DatabaseManager* db = nullptr;
{
std::lock_guard<std::mutex> lock(mutex_);
db = db_;
}
if (!db) return;
const std::string key = db->getKV(db::KV_LICENSE_KEY);
const std::string email = db->getKV(db::KV_EMAIL_ADDRESS);
const Status s = statusForResultWithGrace(result, *db);
{
std::lock_guard<std::mutex> lock(mutex_);
checkedKey_ = key;
checkedEmail_ = email;
overdueRecheck_ = false;
status_.store(static_cast<int>(s));
}
persistCheckState(key, email, s);
}
void LicenseStatusTracker::shutdown() { void LicenseStatusTracker::shutdown() {
{ {
std::lock_guard<std::mutex> lock(mutex_); std::lock_guard<std::mutex> lock(mutex_);
stop_.store(true); stop_.store(true);
} }
cv_.notify_one(); cv_.notify_all();
if (worker_.joinable()) { if (worker_.joinable()) {
worker_.join(); worker_.join();
} }
@@ -62,29 +159,101 @@ bool LicenseStatusTracker::isValid() const {
return status() == Status::Valid; return status() == Status::Valid;
} }
bool LicenseStatusTracker::shouldValidateNow(bool forced) {
db::DatabaseManager* db = nullptr;
bool overdue = false;
{
std::lock_guard<std::mutex> lock(mutex_);
db = db_;
overdue = overdueRecheck_;
}
if (!db) return false;
if (forced) return true;
const std::string key = db->getKV(db::KV_LICENSE_KEY);
const std::string email = db->getKV(db::KV_EMAIL_ADDRESS);
bool credentialsChanged = false;
Status s = Status::Unknown;
{
std::lock_guard<std::mutex> lock(mutex_);
credentialsChanged = (key != checkedKey_ || email != checkedEmail_);
s = static_cast<Status>(status_.load());
}
const long long lastCheck = readEpochSeconds(*db, db::KV_LICENSE_LAST_CHECK);
return shouldValidate(forced, credentialsChanged, overdue, s,
nowEpochSeconds(), lastCheck);
}
void LicenseStatusTracker::performCheck() {
// Serialize with any synchronous check; only one validation in flight.
std::lock_guard<std::mutex> checkLock(checkMutex_);
db::DatabaseManager* db = nullptr;
{
std::lock_guard<std::mutex> lock(mutex_);
db = db_;
overdueRecheck_ = false;
status_.store(static_cast<int>(Status::Checking));
}
if (!db) {
status_.store(static_cast<int>(Status::Invalid));
return;
}
const std::string key = db->getKV(db::KV_LICENSE_KEY);
const std::string email = db->getKV(db::KV_EMAIL_ADDRESS);
const Result result = validateStoredLicense(*db);
const Status s = statusForResultWithGrace(result, *db);
{
std::lock_guard<std::mutex> lock(mutex_);
checkedKey_ = key;
checkedEmail_ = email;
status_.store(static_cast<int>(s));
}
persistCheckState(key, email, s);
}
void LicenseStatusTracker::persistCheckState(const std::string& key,
const std::string& email,
Status status) {
db::DatabaseManager* db = nullptr;
{
std::lock_guard<std::mutex> lock(mutex_);
db = db_;
}
if (!db) return;
db->setKV(db::KV_LICENSE_CHECKED_KEY, key);
db->setKV(db::KV_LICENSE_CHECKED_EMAIL, email);
db->setKV(db::KV_LICENSE_CHECK_STATUS,
status == Status::Valid ? "valid" : "invalid");
db->setKV(db::KV_LICENSE_LAST_CHECK, std::to_string(nowEpochSeconds()));
}
void LicenseStatusTracker::run() { void LicenseStatusTracker::run() {
while (true) { while (true) {
{ {
std::unique_lock<std::mutex> lock(mutex_); std::unique_lock<std::mutex> lock(mutex_);
cv_.wait(lock, [this] {
return stop_.load() ||
status_.load() == static_cast<int>(Status::Checking);
});
if (stop_.load()) return; if (stop_.load()) return;
} }
db::DatabaseManager* db = nullptr; bool forced = false;
{ {
std::lock_guard<std::mutex> lock(mutex_); std::lock_guard<std::mutex> lock(mutex_);
db = db_; forced = forced_;
forced_ = false;
} }
if (!db) { if (shouldValidateNow(forced)) {
status_.store(static_cast<int>(Status::Invalid)); performCheck();
continue;
} }
const Result result = validateStoredLicense(*db); std::unique_lock<std::mutex> lock(mutex_);
status_.store(static_cast<int>(statusForResultWithGrace(result, *db))); cv_.wait_for(lock, std::chrono::seconds(kPollSeconds), [this] {
return stop_.load() || forced_;
});
if (stop_.load()) return;
} }
} }
+61 -6
View File
@@ -5,6 +5,7 @@
#include <atomic> #include <atomic>
#include <condition_variable> #include <condition_variable>
#include <mutex> #include <mutex>
#include <string>
#include <thread> #include <thread>
namespace radiantwave::db { namespace radiantwave::db {
@@ -22,6 +23,12 @@ enum class Status {
Invalid, // missing credentials, portal rejection, or unreachable server Invalid, // missing credentials, portal rejection, or unreachable server
}; };
// How long a valid license is trusted between background re-checks. A valid
// license is re-validated at most once per hour. The anchor is stored in the
// database, so a check that came due while the device was powered off runs
// immediately on the next boot instead of waiting a fresh hour.
constexpr long long RECHECK_INTERVAL_SECONDS = 60LL * 60;
// Maps a validateStoredLicense() result onto the tracker status. Pure, so it // Maps a validateStoredLicense() result onto the tracker status. Pure, so it
// can be unit-tested offline. Network errors are treated as Invalid: a kiosk // can be unit-tested offline. Network errors are treated as Invalid: a kiosk
// that cannot confirm its license must not grant access. // that cannot confirm its license must not grant access.
@@ -34,10 +41,32 @@ Status statusForResult(Result result);
// grace period. // grace period.
Status statusForResultWithGrace(Result result, const db::DatabaseManager& db); Status statusForResultWithGrace(Result result, const db::DatabaseManager& db);
// Pure scheduling policy: decides whether a validation should run now.
// A check is due when any of these hold:
// * `forced` -- credentials were just entered/changed, or the
// user picked the manual validate option;
// * `credentialsChanged`-- the stored key/email differ from the pair the last
// completed check used;
// * `overdueRecheck` -- the license was valid but has since fallen outside
// the offline grace window, so it must be re-proven;
// * the license is Valid and the last check is at least
// RECHECK_INTERVAL_SECONDS old (a missing stamp counts as overdue).
// An invalid license with unchanged credentials is never re-checked.
bool shouldValidate(bool forced, bool credentialsChanged, bool overdueRecheck,
Status status, long long nowEpochSeconds,
long long lastCheckEpochSeconds);
// Validates the stored license key/email pair against the portal in the // Validates the stored license key/email pair against the portal in the
// background. Runs a single worker thread that waits for refresh() requests; // background. Runs a single worker thread that validates on a schedule; the
// the result is readable at any time via status()/isValid(). The worker is // result is readable at any time via status()/isValid(). The worker is owned
// owned by AppState so it outlives individual pages. // by AppState so it outlives individual pages.
//
// Checks are deliberately NOT triggered by rendering a screen. They run:
// * once at startup if a check is already due (overdue hourly re-check, or
// credentials that have never been checked);
// * when the license key or email is entered/changed (refresh());
// * on the manual validate option (refresh());
// * hourly while the license is Valid.
class LicenseStatusTracker { class LicenseStatusTracker {
public: public:
LicenseStatusTracker() = default; LicenseStatusTracker() = default;
@@ -46,13 +75,22 @@ public:
LicenseStatusTracker(const LicenseStatusTracker&) = delete; LicenseStatusTracker(const LicenseStatusTracker&) = delete;
LicenseStatusTracker& operator=(const LicenseStatusTracker&) = delete; LicenseStatusTracker& operator=(const LicenseStatusTracker&) = delete;
// Spawns the worker thread. Safe to call repeatedly; only one worker runs. // Spawns the worker thread and loads the persisted schedule state. Safe to
// call repeatedly; only one worker runs.
void start(db::DatabaseManager& db); void start(db::DatabaseManager& db);
// Triggers a fresh validation. No-op while a check is already running. // Requests an immediate validation, bypassing the hourly throttle and the
// Safe to call from any thread. // unchanged-credentials short-circuit. Call when a license key or email is
// entered/changed, or when the user asks to validate manually. Safe to call
// from any thread; a no-op before start().
void refresh(); void refresh();
// Adopts the outcome of a validation the caller already ran synchronously
// (e.g. the license-key page, which shows the detailed result). Updates the
// status and the persisted last-checked pair so the worker does not
// immediately repeat the same check. Safe to call from any thread.
void recordResult(Result result);
// Stops the worker and joins the thread. Must be called before the // Stops the worker and joins the thread. Must be called before the
// database is closed at shutdown. // database is closed at shutdown.
void shutdown(); void shutdown();
@@ -62,12 +100,29 @@ public:
private: private:
void run(); void run();
// Whether the current state warrants a validation. Reads the database for
// the stored credentials and schedule stamp.
bool shouldValidateNow(bool forced);
// Performs one validation, updates status, and persists the schedule state.
void performCheck();
// Loads the persisted checked pair / status / stamp. Caller holds mutex_.
void loadPersistedStateLocked();
// Writes the checked pair, outcome, and last-check time. Not under mutex_.
void persistCheckState(const std::string& key, const std::string& email,
Status status);
mutable std::mutex mutex_; mutable std::mutex mutex_;
// Serializes validations so the worker and a synchronous recordResult()
// never run at once. Held only while a check is in flight.
std::mutex checkMutex_;
std::condition_variable cv_; std::condition_variable cv_;
std::thread worker_; std::thread worker_;
std::atomic<bool> stop_{false}; std::atomic<bool> stop_{false};
std::atomic<int> status_{static_cast<int>(Status::Unknown)}; std::atomic<int> status_{static_cast<int>(Status::Unknown)};
bool forced_ = false; // guarded by mutex_
bool overdueRecheck_ = false; // guarded by mutex_
std::string checkedKey_; // guarded by mutex_
std::string checkedEmail_; // guarded by mutex_
db::DatabaseManager* db_ = nullptr; db::DatabaseManager* db_ = nullptr;
}; };
+13 -9
View File
@@ -87,16 +87,20 @@ void ScheduleManager::refreshOnce() {
void ScheduleManager::start(db::DatabaseManager& db, const std::string& timezone, void ScheduleManager::start(db::DatabaseManager& db, const std::string& timezone,
std::function<void()> onChanged) { std::function<void()> onChanged) {
std::lock_guard<std::mutex> lock(mutex_); {
if (started_) return; std::lock_guard<std::mutex> lock(mutex_);
started_ = true; if (started_) return;
db_ = &db; started_ = true;
timezone_ = timezone; db_ = &db;
onChanged_ = std::move(onChanged); timezone_ = timezone;
licenseKey_ = db.getKV(db::KV_LICENSE_KEY); onChanged_ = std::move(onChanged);
enabled_ = true; // config supplies the toggle via setEnabled() licenseKey_ = db.getKV(db::KV_LICENSE_KEY);
enabled_ = true; // config supplies the toggle via setEnabled()
}
// Offline first: load any cached payload immediately. // Offline first: load any cached payload immediately. Called outside the
// lock because loadCache() acquires mutex_ itself; holding it here would
// self-deadlock on the non-recursive mutex when a cache file exists.
loadCache(); loadCache();
stop_.store(false); stop_.store(false);
+1
View File
@@ -6,6 +6,7 @@ std::vector<SystemSettingEntry> systemSettingEntries() {
return { return {
{"Email Address", SystemSettingId::EmailAddress}, {"Email Address", SystemSettingId::EmailAddress},
{"License Key", SystemSettingId::LicenseKey}, {"License Key", SystemSettingId::LicenseKey},
{"Manually Validate License Key", SystemSettingId::ManualValidateLicense},
{"System Name", SystemSettingId::SystemName}, {"System Name", SystemSettingId::SystemName},
{"Schedule", SystemSettingId::Schedule}, {"Schedule", SystemSettingId::Schedule},
{"WiFi Setup", SystemSettingId::WiFiSetup}, {"WiFi Setup", SystemSettingId::WiFiSetup},
+1
View File
@@ -9,6 +9,7 @@ namespace radiantwave::settings {
enum class SystemSettingId { enum class SystemSettingId {
EmailAddress, EmailAddress,
LicenseKey, LicenseKey,
ManualValidateLicense,
SystemName, SystemName,
Schedule, Schedule,
WiFiSetup, WiFiSetup,
+96 -9
View File
@@ -16,6 +16,31 @@ namespace
{ {
constexpr const char *TITLE = "System Settings"; constexpr const char *TITLE = "System Settings";
constexpr const char *BACK_LABEL = "Back to Settings"; constexpr const char *BACK_LABEL = "Back to Settings";
constexpr float MANUAL_STATUS_GAP = 12.0f;
// Text + color for the "Manually Validate License Key" row's suffix. Valid
// shows "OK" in green, Invalid shows "Invalid" in red; a check in flight
// shows a neutral "Checking...".
void manualValidateAppearance(radiantwave::license::Status status,
std::string &text, SDL_Color &color)
{
switch (status)
{
case radiantwave::license::Status::Valid:
text = "OK";
color = {110, 220, 130, 255};
break;
case radiantwave::license::Status::Checking:
text = "Checking...";
color = {180, 180, 180, 255};
break;
case radiantwave::license::Status::Invalid:
default:
text = "Invalid";
color = {240, 80, 80, 255};
break;
}
}
} // anonymous namespace } // anonymous namespace
void SystemSettingsPage::onPush(AppState &state) void SystemSettingsPage::onPush(AppState &state)
@@ -78,6 +103,11 @@ void SystemSettingsPage::activateSelected(AppState &state)
case radiantwave::settings::SystemSettingId::LicenseKey: case radiantwave::settings::SystemSettingId::LicenseKey:
state.pages.push(state, std::make_unique<LicenseKeyPage>()); state.pages.push(state, std::make_unique<LicenseKeyPage>());
break; break;
case radiantwave::settings::SystemSettingId::ManualValidateLicense:
// Run the check immediately; the row's OK/Invalid suffix reflects the
// result as soon as it lands.
state.licenseTracker.refresh();
break;
case radiantwave::settings::SystemSettingId::SystemName: case radiantwave::settings::SystemSettingId::SystemName:
state.pages.push(state, std::make_unique<SystemNamePage>()); state.pages.push(state, std::make_unique<SystemNamePage>());
break; break;
@@ -156,16 +186,52 @@ void SystemSettingsPage::render(SDL_Renderer *renderer)
} }
float margin = 12.0f; // 12px vertical margin rule float margin = 12.0f; // 12px vertical margin rule
// The manual-validate row shows a status suffix ("OK" green / "Invalid"
// red) after its white label. Re-rasterize the suffix every frame so the
// wording updates the moment the license status changes.
const int manualIndex = manualValidateIndex();
if (manualIndex >= 0 && manualIndex < static_cast<int>(items_.size()) && fontItem_)
{
std::string suffixText;
SDL_Color suffixColor{255, 255, 255, 255};
manualValidateAppearance(state_ ? state_->licenseTracker.status()
: radiantwave::license::Status::Unknown,
suffixText, suffixColor);
manualStatusText_.ensure(renderer, fontItem_, suffixText, suffixColor);
const float suffixW = manualStatusText_.texture ? manualStatusText_.width : 0.0f;
const float total = manualBaseWidth_ +
(suffixW > 0.0f ? MANUAL_STATUS_GAP + suffixW : 0.0f);
maxWidth = std::max(maxWidth, total);
items_[manualIndex].width = total;
}
// Shared left edge for left-aligned items with centered bounding box // Shared left edge for left-aligned items with centered bounding box
float sharedX = (fw - maxWidth) / 2.0f; float sharedX = (fw - maxWidth) / 2.0f;
// Start menu list exactly 24 pixels below title text // Start menu list exactly 24 pixels below title text
float currentY = titleY + titleHeight_ + 24.0f; float currentY = titleY + titleHeight_ + 24.0f;
for (auto &item : items_) for (int i = 0; i < static_cast<int>(items_.size()); ++i)
{ {
auto &item = items_[i];
item.y = currentY; item.y = currentY;
if (item.texture) if (i == manualIndex)
{
// White label, then the colored status suffix to its right.
if (item.texture)
{
SDL_FRect dst{sharedX, item.y, manualBaseWidth_, item.height};
SDL_RenderTexture(renderer, item.texture, nullptr, &dst);
}
if (manualStatusText_.texture)
{
float sx = sharedX + manualBaseWidth_ + MANUAL_STATUS_GAP;
float sy = item.y + (item.height - manualStatusText_.height) / 2.0f;
SDL_FRect dst{sx, sy, manualStatusText_.width, manualStatusText_.height};
SDL_RenderTexture(renderer, manualStatusText_.texture, nullptr, &dst);
}
}
else if (item.texture)
{ {
SDL_FRect dst{sharedX, item.y, item.width, item.height}; SDL_FRect dst{sharedX, item.y, item.width, item.height};
SDL_RenderTexture(renderer, item.texture, nullptr, &dst); SDL_RenderTexture(renderer, item.texture, nullptr, &dst);
@@ -231,18 +297,21 @@ void SystemSettingsPage::createTextures(AppState &state)
TTF_CloseFont(fontLarge); TTF_CloseFont(fontLarge);
} }
// Item Font // Item Font (kept open for the manual-validate row's live status suffix).
TTF_Font *fontItem = TTF_OpenFont("assets/fonts/Roboto-Regular.ttf", 36); fontItem_ = TTF_OpenFont("assets/fonts/Roboto-Regular.ttf", 36);
if (!fontItem) if (!fontItem_)
return; return;
manualBaseWidth_ = 0.0f;
const int manualIndex = manualValidateIndex();
items_.reserve(labels.size()); items_.reserve(labels.size());
for (const auto &label : labels) for (int i = 0; i < static_cast<int>(labels.size()); ++i)
{ {
MenuItem item; MenuItem item;
item.label = label; item.label = labels[i];
SDL_Surface *surface = TTF_RenderText_Blended(fontItem, label.c_str(), 0, white); SDL_Surface *surface = TTF_RenderText_Blended(fontItem_, labels[i].c_str(), 0, white);
if (surface) if (surface)
{ {
item.texture = SDL_CreateTextureFromSurface(state.renderer, surface); item.texture = SDL_CreateTextureFromSurface(state.renderer, surface);
@@ -250,10 +319,20 @@ void SystemSettingsPage::createTextures(AppState &state)
item.height = static_cast<float>(surface->h); item.height = static_cast<float>(surface->h);
SDL_DestroySurface(surface); SDL_DestroySurface(surface);
} }
if (i == manualIndex)
manualBaseWidth_ = item.width;
items_.push_back(std::move(item)); items_.push_back(std::move(item));
} }
}
TTF_CloseFont(fontItem); int SystemSettingsPage::manualValidateIndex() const
{
for (size_t i = 0; i < entries_.size(); ++i)
{
if (entries_[i].id == radiantwave::settings::SystemSettingId::ManualValidateLicense)
return static_cast<int>(i);
}
return -1;
} }
void SystemSettingsPage::destroyTextures() void SystemSettingsPage::destroyTextures()
@@ -277,4 +356,12 @@ void SystemSettingsPage::destroyTextures()
} }
} }
items_.clear(); items_.clear();
manualStatusText_.destroy();
manualBaseWidth_ = 0.0f;
if (fontItem_)
{
TTF_CloseFont(fontItem_);
fontItem_ = nullptr;
}
} }
+10
View File
@@ -2,6 +2,7 @@
#include "page.h" #include "page.h"
#include "settings_menu.h" #include "settings_menu.h"
#include "text_cache.h"
#include <SDL3_ttf/SDL_ttf.h> #include <SDL3_ttf/SDL_ttf.h>
#include <string> #include <string>
#include <vector> #include <vector>
@@ -33,6 +34,9 @@ private:
void activateSelected(AppState& state); void activateSelected(AppState& state);
void createTextures(AppState& state); void createTextures(AppState& state);
void destroyTextures(); void destroyTextures();
// Index of the "Manually Validate License Key" row in items_, or -1 when
// the entry is absent.
int manualValidateIndex() const;
AppState* state_ = nullptr; AppState* state_ = nullptr;
int selectedItem_ = 0; int selectedItem_ = 0;
@@ -40,6 +44,12 @@ private:
// Setting rows followed by a trailing "Back to Settings" row. // Setting rows followed by a trailing "Back to Settings" row.
std::vector<MenuItem> items_; std::vector<MenuItem> items_;
// Kept open so the manual-validate row's colored OK/Invalid suffix can be
// re-rendered every frame as the license status changes.
TTF_Font* fontItem_ = nullptr;
CachedText manualStatusText_;
float manualBaseWidth_ = 0;
SDL_Texture* bgTexture_ = nullptr; SDL_Texture* bgTexture_ = nullptr;
float bgWidth_ = 0; float bgWidth_ = 0;
float bgHeight_ = 0; float bgHeight_ = 0;
+189
View File
@@ -0,0 +1,189 @@
#include "warning_page.h"
#include "app_state.h"
#include "logical_size.h"
#include <SDL3/SDL.h>
#include <utility>
WarningPage::WarningPage(std::string message, ContinueAction onContinue)
: message_(std::move(message)), onContinue_(std::move(onContinue)) {}
void WarningPage::onPush(AppState& state) {
state_ = &state;
TTF_Init();
createFonts();
}
void WarningPage::onResume(AppState& state) {
state_ = &state;
createFonts();
}
void WarningPage::onPause(AppState& state) {
destroyTextures();
destroyFonts();
}
void WarningPage::onPop(AppState& state) {
destroyTextures();
destroyFonts();
TTF_Quit();
}
void WarningPage::createFonts() {
destroyFonts();
fontTitle_ = TTF_OpenFont("assets/fonts/Roboto-Regular.ttf", 56);
fontMessage_ = TTF_OpenFont("assets/fonts/Roboto-Regular.ttf", 32);
fontPrompt_ = TTF_OpenFont("assets/fonts/Roboto-Regular.ttf", 24);
}
void WarningPage::destroyFonts() {
if (fontTitle_) { TTF_CloseFont(fontTitle_); fontTitle_ = nullptr; }
if (fontMessage_) { TTF_CloseFont(fontMessage_); fontMessage_ = nullptr; }
if (fontPrompt_) { TTF_CloseFont(fontPrompt_); fontPrompt_ = nullptr; }
}
void WarningPage::destroyTextures() {
titleText_.destroy();
for (auto& line : messageLines_) line.destroy();
messageLines_.clear();
promptText_.destroy();
}
void WarningPage::wrapMessage(float maxWidth) {
wrappedLines_.clear();
if (!fontMessage_) return;
// Honor explicit newlines as hard breaks, then greedily word-wrap each
// paragraph to the available width.
std::string paragraph;
auto wrapParagraph = [&](const std::string& text) {
std::string line;
size_t i = 0;
while (i < text.size()) {
size_t space = text.find(' ', i);
std::string word = text.substr(i, space == std::string::npos ? std::string::npos : space - i);
i = (space == std::string::npos) ? text.size() : space + 1;
if (word.empty()) continue;
std::string candidate = line.empty() ? word : line + " " + word;
int w = 0;
TTF_GetStringSize(fontMessage_, candidate.c_str(), 0, &w, nullptr);
if (!line.empty() && static_cast<float>(w) > maxWidth) {
wrappedLines_.push_back(line);
line = std::move(word);
} else {
line = std::move(candidate);
}
}
wrappedLines_.push_back(line);
};
for (char c : message_) {
if (c == '\n') {
wrapParagraph(paragraph);
paragraph.clear();
} else {
paragraph += c;
}
}
wrapParagraph(paragraph);
}
void WarningPage::handleEvent(const SDL_Event& event) {
if (event.type != SDL_EVENT_KEY_DOWN) return;
if (event.key.key != SDLK_RETURN && event.key.key != SDLK_KP_ENTER) return;
AppState* state = state_;
if (!state) return;
// Move the continuation out before invoking it: it may replace this page,
// destroying `this` (and the std::function we would otherwise be running).
ContinueAction action = std::move(onContinue_);
onContinue_ = nullptr;
if (action) {
action(*state);
} else {
state->pages.pop(*state);
}
}
void WarningPage::update(float dt) {
}
void WarningPage::render(SDL_Renderer* renderer) {
int winW, winH;
radiantwave::getLayoutSize(renderer, &winW, &winH);
float fw = static_cast<float>(winW);
float fh = static_cast<float>(winH);
SDL_SetRenderDrawBlendMode(renderer, SDL_BLENDMODE_BLEND);
SDL_SetRenderDrawColor(renderer, 15, 18, 25, 255);
SDL_FRect bgRect{0, 0, fw, fh};
SDL_RenderFillRect(renderer, &bgRect);
if (!fontTitle_ || !fontMessage_ || !fontPrompt_) return;
const float maxTextWidth = fw * 0.8f;
if (wrappedForWidth_ != maxTextWidth) {
// Re-wrapping may change the line count; release any old line
// textures first so a shrink cannot orphan them.
for (auto& line : messageLines_) line.destroy();
messageLines_.clear();
wrapMessage(maxTextWidth);
wrappedForWidth_ = maxTextWidth;
}
SDL_Color white = {255, 255, 255, 255};
SDL_Color amber = {255, 200, 0, 255};
SDL_Color gray = {170, 170, 170, 255};
titleText_.ensure(renderer, fontTitle_, "Warning", amber);
if (messageLines_.size() != wrappedLines_.size()) {
messageLines_.resize(wrappedLines_.size());
}
for (size_t i = 0; i < wrappedLines_.size(); ++i) {
messageLines_[i].ensure(renderer, fontMessage_, wrappedLines_[i], white);
}
promptText_.ensure(renderer, fontPrompt_,
"Press Enter to acknowledge and continue", gray);
constexpr float titleGap = 48.0f;
constexpr float lineGap = 12.0f;
constexpr float promptGap = 48.0f;
float totalH = titleText_.texture ? titleText_.height + titleGap : 0.0f;
for (size_t i = 0; i < messageLines_.size(); ++i) {
if (messageLines_[i].texture) totalH += messageLines_[i].height;
if (i + 1 < messageLines_.size()) totalH += lineGap;
}
if (promptText_.texture) totalH += promptGap + promptText_.height;
float y = (fh - totalH) / 2.0f;
if (titleText_.texture) {
float x = (fw - titleText_.width) / 2.0f;
SDL_FRect dst{x, y, titleText_.width, titleText_.height};
SDL_RenderTexture(renderer, titleText_.texture, nullptr, &dst);
y += titleText_.height + titleGap;
}
for (size_t i = 0; i < messageLines_.size(); ++i) {
const CachedText& line = messageLines_[i];
if (line.texture) {
float x = (fw - line.width) / 2.0f;
SDL_FRect dst{x, y, line.width, line.height};
SDL_RenderTexture(renderer, line.texture, nullptr, &dst);
y += line.height;
}
if (i + 1 < messageLines_.size()) y += lineGap;
}
if (promptText_.texture) {
y += promptGap;
float x = (fw - promptText_.width) / 2.0f;
SDL_FRect dst{x, y, promptText_.width, promptText_.height};
SDL_RenderTexture(renderer, promptText_.texture, nullptr, &dst);
}
}
+54
View File
@@ -0,0 +1,54 @@
#pragma once
#include "page.h"
#include "text_cache.h"
#include <SDL3_ttf/SDL_ttf.h>
#include <functional>
#include <string>
#include <vector>
// Full-screen, dismissible warning. Displays a configurable message (word
// wrapped) and always lets the user acknowledge with ENTER, which runs the
// optional continue action -- typically "go on to the screen you were about to
// enter". With no continue action, ENTER simply pops back to the page below.
//
// The first use is Home's Play: when the selected audio has not finished
// downloading it warns, then continues on to the scroller.
class WarningPage : public Page {
public:
using ContinueAction = std::function<void(AppState&)>;
explicit WarningPage(std::string message, ContinueAction onContinue = {});
void onPush(AppState& state) override;
void onResume(AppState& state) override;
void onPause(AppState& state) override;
void onPop(AppState& state) override;
void handleEvent(const SDL_Event& event) override;
void update(float dt) override;
void render(SDL_Renderer* renderer) override;
private:
void createFonts();
void destroyFonts();
void destroyTextures();
// Wraps message_ into wrappedLines_ so no line exceeds maxWidth pixels.
void wrapMessage(float maxWidth);
AppState* state_ = nullptr;
std::string message_;
ContinueAction onContinue_;
TTF_Font* fontTitle_ = nullptr;
TTF_Font* fontMessage_ = nullptr;
TTF_Font* fontPrompt_ = nullptr;
CachedText titleText_;
std::vector<CachedText> messageLines_;
CachedText promptText_;
std::vector<std::string> wrappedLines_;
// Width wrapper last ran at, so it only re-wraps when the layout width
// changes (e.g. the window resizes).
float wrappedForWidth_ = 0.0f;
};
+35
View File
@@ -96,6 +96,41 @@ TEST(ApiClientTest, ParseLicenseJsonRejectsMalformed) {
EXPECT_FALSE(parseLicenseJson(R"({"key":""})", info)); EXPECT_FALSE(parseLicenseJson(R"({"key":""})", info));
} }
TEST(ApiClientTest, BuildLicenseRequestJsonIncludesVersionAndSystemName) {
const std::string body = buildLicenseRequestJson(
"ABCD-EFGH-IJKL-MNOP", "user@example.com", "Living Room", "1.2.3");
// Round-trips through the parser used for the request body shape.
radiantwave::json::JsonValue root;
ASSERT_TRUE(radiantwave::json::parseDocument(body, root));
EXPECT_EQ(root.stringField("license_key"), "ABCD-EFGH-IJKL-MNOP");
EXPECT_EQ(root.stringField("email_address"), "user@example.com");
EXPECT_EQ(root.stringField("appVersion"), "1.2.3");
EXPECT_EQ(root.stringField("systemName"), "Living Room");
}
TEST(ApiClientTest, BuildLicenseRequestJsonOmitsEmptyOptionalFields) {
const std::string body =
buildLicenseRequestJson("ABCD-EFGH-IJKL-MNOP", "user@example.com", "", "");
radiantwave::json::JsonValue root;
ASSERT_TRUE(radiantwave::json::parseDocument(body, root));
EXPECT_EQ(root.stringField("license_key"), "ABCD-EFGH-IJKL-MNOP");
EXPECT_EQ(root.stringField("appVersion"), "");
EXPECT_EQ(root.stringField("systemName"), "");
EXPECT_EQ(root.find("appVersion"), nullptr);
EXPECT_EQ(root.find("systemName"), nullptr);
}
TEST(ApiClientTest, BuildLicenseRequestJsonEscapesValues) {
const std::string body =
buildLicenseRequestJson("k\"ey", "a@b.com", "N\"ame", "1.0");
radiantwave::json::JsonValue root;
ASSERT_TRUE(radiantwave::json::parseDocument(body, root));
EXPECT_EQ(root.stringField("license_key"), "k\"ey");
EXPECT_EQ(root.stringField("systemName"), "N\"ame");
}
TEST(ApiClientTest, ParseCreateAccountJson) { TEST(ApiClientTest, ParseCreateAccountJson) {
const std::string body = const std::string body =
R"({"success":true,"message":"Account and license key created","user_id":"abc-123"})"; R"({"success":true,"message":"Account and license key created","user_id":"abc-123"})";
+65
View File
@@ -150,5 +150,70 @@ TEST(AudioDownloaderMd5, MissingFileReturnsEmpty) {
EXPECT_EQ(computeFileMd5(tempPath("_missing.bin")), ""); EXPECT_EQ(computeFileMd5(tempPath("_missing.bin")), "");
} }
TEST(AudioDownloadWarning, DynamicAudioWarnsWhenAnyFileMissing) {
AudioDownloadSnapshot snap;
snap.manifestKnown = true;
snap.allAvailable = false;
snap.files = {
{"174Hz.wav", "174 Hz", true},
{"396Hz.wav", "396 Hz", false},
};
std::string message;
ASSERT_TRUE(buildAudioDownloadWarning(snap, true, "396Hz.wav", message));
EXPECT_NE(message.find("'Dynamic Audio'"), std::string::npos);
EXPECT_NE(message.find("these files"), std::string::npos);
}
TEST(AudioDownloadWarning, DynamicAudioSilentWhenAllAvailable) {
AudioDownloadSnapshot snap;
snap.manifestKnown = true;
snap.allAvailable = true;
snap.files = {{"174Hz.wav", "174 Hz", true}};
std::string message;
EXPECT_FALSE(buildAudioDownloadWarning(snap, true, "174Hz.wav", message));
}
TEST(AudioDownloadWarning, FixedTrackWarnsWhenItsFileMissing) {
AudioDownloadSnapshot snap;
snap.manifestKnown = true;
snap.allAvailable = false;
snap.files = {
{"174Hz.wav", "174 Hz", true},
{"OriginalAudio.wav", "Original Audio", false},
};
std::string message;
ASSERT_TRUE(buildAudioDownloadWarning(snap, false, "OriginalAudio.wav", message));
EXPECT_NE(message.find("'Original Audio'"), std::string::npos);
EXPECT_NE(message.find("this file"), std::string::npos);
}
TEST(AudioDownloadWarning, FixedTrackSilentWhenItsFilePresentEvenIfOthersMissing) {
AudioDownloadSnapshot snap;
snap.manifestKnown = true;
snap.allAvailable = false;
snap.files = {
{"174Hz.wav", "174 Hz", true},
{"396Hz.wav", "396 Hz", false},
};
std::string message;
EXPECT_FALSE(buildAudioDownloadWarning(snap, false, "174Hz.wav", message));
}
TEST(AudioDownloadWarning, UnknownSelectionNeverWarns) {
// No manifest fetched yet: the downloader has no evidence either way.
AudioDownloadSnapshot snap;
std::string message;
EXPECT_FALSE(buildAudioDownloadWarning(snap, false, "OriginalAudio.wav", message));
}
TEST(AudioDownloadWarning, FallsBackToFilenameForDisplayName) {
AudioDownloadSnapshot snap;
snap.manifestKnown = true;
snap.files = {{"Mystery.wav", "", false}};
std::string message;
ASSERT_TRUE(buildAudioDownloadWarning(snap, false, "Mystery.wav", message));
EXPECT_NE(message.find("'Mystery.wav'"), std::string::npos);
}
} // namespace } // namespace
} // namespace radiantwave::audio } // namespace radiantwave::audio
+84
View File
@@ -97,6 +97,46 @@ TEST(LicenseStatusTest, StatusForResultWithGraceExpired) {
EXPECT_EQ(statusForResultWithGrace(Result::NetworkError, tmp.db), Status::Invalid); EXPECT_EQ(statusForResultWithGrace(Result::NetworkError, tmp.db), Status::Invalid);
} }
// ─── Scheduling policy (pure) ───────────────────────────────────────────
TEST(LicenseStatusPolicyTest, ForcedAlwaysRuns) {
EXPECT_TRUE(shouldValidate(true, false, false, Status::Invalid, 1000, 999));
EXPECT_TRUE(shouldValidate(true, false, false, Status::Unknown, 1000, 0));
}
TEST(LicenseStatusPolicyTest, CredentialChangeRuns) {
EXPECT_TRUE(shouldValidate(false, true, false, Status::Invalid, 1000, 999));
}
TEST(LicenseStatusPolicyTest, OverdueRecheckRuns) {
EXPECT_TRUE(shouldValidate(false, false, true, Status::Invalid, 1000, 999));
}
TEST(LicenseStatusPolicyTest, InvalidUnchangedDoesNotRun) {
// An invalid license with unchanged credentials is never re-checked, no
// matter how old the last attempt is.
EXPECT_FALSE(shouldValidate(false, false, false, Status::Invalid, 1000, 999));
EXPECT_FALSE(shouldValidate(false, false, false, Status::Invalid,
1000, 1000 - 10 * RECHECK_INTERVAL_SECONDS));
EXPECT_FALSE(shouldValidate(false, false, false, Status::Unknown, 1000, 0));
}
TEST(LicenseStatusPolicyTest, ValidRechecksAfterInterval) {
const long long now = 1000000;
EXPECT_FALSE(shouldValidate(false, false, false, Status::Valid, now, now - 10));
EXPECT_FALSE(shouldValidate(false, false, false, Status::Valid, now,
now - (RECHECK_INTERVAL_SECONDS - 1)));
EXPECT_TRUE(shouldValidate(false, false, false, Status::Valid, now,
now - RECHECK_INTERVAL_SECONDS));
// Off for several hours: still immediately due, not another full hour away.
EXPECT_TRUE(shouldValidate(false, false, false, Status::Valid, now,
now - 5 * RECHECK_INTERVAL_SECONDS));
}
TEST(LicenseStatusPolicyTest, ValidWithMissingStampRechecks) {
EXPECT_TRUE(shouldValidate(false, false, false, Status::Valid, 1000, 0));
}
// ─── Worker lifecycle (no network) ────────────────────────────────────── // ─── Worker lifecycle (no network) ──────────────────────────────────────
TEST(LicenseStatusTest, UnknownBeforeStart) { TEST(LicenseStatusTest, UnknownBeforeStart) {
@@ -130,6 +170,50 @@ TEST(LicenseStatusTest, InvalidWhenDatabaseClosed) {
tracker.shutdown(); tracker.shutdown();
} }
TEST(LicenseStatusTest, LoadsPersistedValidWithinGraceWithoutChecking) {
TempDb tmp;
tmp.db.setKV(KV_LICENSE_KEY, "k");
tmp.db.setKV(KV_EMAIL_ADDRESS, "e");
tmp.db.setKV(KV_LICENSE_CHECKED_KEY, "k");
tmp.db.setKV(KV_LICENSE_CHECKED_EMAIL, "e");
tmp.db.setKV(KV_LICENSE_CHECK_STATUS, "valid");
tmp.db.setKV(KV_LICENSE_LAST_VERIFIED, std::to_string(epochSeconds()));
tmp.db.setKV(KV_LICENSE_LAST_CHECK, std::to_string(epochSeconds()));
LicenseStatusTracker tracker;
tracker.start(tmp.db);
// Seeded valid + recently checked + unchanged credentials: the worker must
// stay idle (no network), and the status is valid immediately.
EXPECT_EQ(tracker.status(), Status::Valid);
tracker.shutdown();
}
TEST(LicenseStatusTest, RecordResultUpdatesStatusAndPersistsCheckedPair) {
TempDb tmp;
const std::string key = "AAAA-BBBB-CCCC-DDDD";
const std::string email = "user@example.com";
tmp.db.setKV(KV_LICENSE_KEY, key);
tmp.db.setKV(KV_EMAIL_ADDRESS, email);
// Seed the checked pair as already-invalid so the worker does not start a
// network check; this exercises recordResult() in isolation.
tmp.db.setKV(KV_LICENSE_CHECKED_KEY, key);
tmp.db.setKV(KV_LICENSE_CHECKED_EMAIL, email);
tmp.db.setKV(KV_LICENSE_CHECK_STATUS, "invalid");
LicenseStatusTracker tracker;
tracker.start(tmp.db);
EXPECT_EQ(tracker.status(), Status::Invalid);
tracker.recordResult(Result::Ok);
EXPECT_EQ(tracker.status(), Status::Valid);
EXPECT_TRUE(tracker.isValid());
EXPECT_EQ(tmp.db.getKV(KV_LICENSE_CHECKED_KEY), key);
EXPECT_EQ(tmp.db.getKV(KV_LICENSE_CHECKED_EMAIL), email);
EXPECT_EQ(tmp.db.getKV(KV_LICENSE_CHECK_STATUS), "valid");
EXPECT_FALSE(tmp.db.getKV(KV_LICENSE_LAST_CHECK).empty());
tracker.shutdown();
}
// ─── Live: confirmed-valid license (requires env credentials) ─────────── // ─── Live: confirmed-valid license (requires env credentials) ───────────
TEST(LicenseStatusLiveTest, ValidWhenLicensePairAccepted) { TEST(LicenseStatusLiveTest, ValidWhenLicensePairAccepted) {
+28
View File
@@ -297,6 +297,34 @@ TEST(ScheduleManagerTest, OfflineFallsBackToCache) {
radiantwave::sync::schedule_cache::clear(); radiantwave::sync::schedule_cache::clear();
} }
// Regression: start() must not deadlock when a cache file is present. It used
// to hold mutex_ and then call loadCache(), which re-locked the same
// non-recursive mutex (self-deadlock), blocking SDL_AppInit before the first
// frame was ever presented — the kiosk sat on a black screen.
TEST(ScheduleManagerTest, StartWithCachedPayloadDoesNotDeadlock) {
const std::string dir = tempCacheDir();
setenv("HOME", dir.c_str(), 1);
SchedulePayload p = dailyPayload();
ASSERT_TRUE(radiantwave::sync::schedule_cache::write(p, "\"cached\""));
const std::string dbPath =
(std::filesystem::temp_directory_path() / "rw_schedule_start_test.db")
.string();
std::filesystem::remove(dbPath);
radiantwave::db::DatabaseManager db;
ASSERT_TRUE(db.initialize(dbPath));
radiantwave::schedule::ScheduleManager manager;
manager.start(db, "UTC");
EXPECT_TRUE(manager.hasSchedule());
manager.shutdown();
db.close();
std::filesystem::remove(dbPath);
radiantwave::sync::schedule_cache::clear();
}
// ─── Phase 5: consume real Portal-generated payloads ─────────────────── // ─── Phase 5: consume real Portal-generated payloads ───────────────────
// //
// These fixtures were captured from a running Portal (GET // These fixtures were captured from a running Portal (GET
+14 -11
View File
@@ -7,21 +7,23 @@ namespace settings = radiantwave::settings;
TEST(SystemSettingsMenuTest, ListsTheSystemSettingsInOrder) { TEST(SystemSettingsMenuTest, ListsTheSystemSettingsInOrder) {
const auto entries = settings::systemSettingEntries(); const auto entries = settings::systemSettingEntries();
ASSERT_EQ(entries.size(), 7u); ASSERT_EQ(entries.size(), 8u);
EXPECT_EQ(entries[0].label, "Email Address"); EXPECT_EQ(entries[0].label, "Email Address");
EXPECT_EQ(entries[0].id, settings::SystemSettingId::EmailAddress); EXPECT_EQ(entries[0].id, settings::SystemSettingId::EmailAddress);
EXPECT_EQ(entries[1].label, "License Key"); EXPECT_EQ(entries[1].label, "License Key");
EXPECT_EQ(entries[1].id, settings::SystemSettingId::LicenseKey); EXPECT_EQ(entries[1].id, settings::SystemSettingId::LicenseKey);
EXPECT_EQ(entries[2].label, "System Name"); EXPECT_EQ(entries[2].label, "Manually Validate License Key");
EXPECT_EQ(entries[2].id, settings::SystemSettingId::SystemName); EXPECT_EQ(entries[2].id, settings::SystemSettingId::ManualValidateLicense);
EXPECT_EQ(entries[3].label, "Schedule"); EXPECT_EQ(entries[3].label, "System Name");
EXPECT_EQ(entries[3].id, settings::SystemSettingId::Schedule); EXPECT_EQ(entries[3].id, settings::SystemSettingId::SystemName);
EXPECT_EQ(entries[4].label, "WiFi Setup"); EXPECT_EQ(entries[4].label, "Schedule");
EXPECT_EQ(entries[4].id, settings::SystemSettingId::WiFiSetup); EXPECT_EQ(entries[4].id, settings::SystemSettingId::Schedule);
EXPECT_EQ(entries[5].label, "Time Zone Setup"); EXPECT_EQ(entries[5].label, "WiFi Setup");
EXPECT_EQ(entries[5].id, settings::SystemSettingId::Timezone); EXPECT_EQ(entries[5].id, settings::SystemSettingId::WiFiSetup);
EXPECT_EQ(entries[6].label, "Date Format"); EXPECT_EQ(entries[6].label, "Time Zone Setup");
EXPECT_EQ(entries[6].id, settings::SystemSettingId::DateFormat); EXPECT_EQ(entries[6].id, settings::SystemSettingId::Timezone);
EXPECT_EQ(entries[7].label, "Date Format");
EXPECT_EQ(entries[7].id, settings::SystemSettingId::DateFormat);
} }
TEST(SystemSettingsMenuTest, LabelsAreNonEmptyAndUnique) { TEST(SystemSettingsMenuTest, LabelsAreNonEmptyAndUnique) {
@@ -42,6 +44,7 @@ TEST(SystemSettingsMenuTest, EveryEntryMapsToAKnownPage) {
switch (entry.id) { switch (entry.id) {
case settings::SystemSettingId::EmailAddress: case settings::SystemSettingId::EmailAddress:
case settings::SystemSettingId::LicenseKey: case settings::SystemSettingId::LicenseKey:
case settings::SystemSettingId::ManualValidateLicense:
case settings::SystemSettingId::SystemName: case settings::SystemSettingId::SystemName:
case settings::SystemSettingId::Schedule: case settings::SystemSettingId::Schedule:
case settings::SystemSettingId::WiFiSetup: case settings::SystemSettingId::WiFiSetup: