From c3b3d6774a1ad685e186d82a65823c65d13b610f Mon Sep 17 00:00:00 2001 From: NeilClack Date: Wed, 14 Oct 2026 10:58:40 -0500 Subject: [PATCH 1/2] 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. --- CMakeLists.txt | 1 + src/audio/audio_downloader.cpp | 59 ++++++++++ src/audio/audio_downloader.h | 45 ++++++++ src/db/db_keys.h | 12 ++ src/email_address_page.cpp | 4 + src/home.cpp | 32 +++++- src/license_key_page.cpp | 28 +++++ src/network/api_client.cpp | 29 ++++- src/network/api_client.h | 8 ++ src/network/license_status.cpp | 197 +++++++++++++++++++++++++++++--- src/network/license_status.h | 67 ++++++++++- src/settings_menu.cpp | 1 + src/settings_menu.h | 1 + src/system_settings_page.cpp | 105 +++++++++++++++-- src/system_settings_page.h | 10 ++ src/warning_page.cpp | 189 ++++++++++++++++++++++++++++++ src/warning_page.h | 54 +++++++++ tests/test_api_client.cpp | 35 ++++++ tests/test_audio_downloader.cpp | 65 +++++++++++ tests/test_license_status.cpp | 84 ++++++++++++++ tests/test_settings_menu.cpp | 25 ++-- 21 files changed, 999 insertions(+), 52 deletions(-) create mode 100644 src/warning_page.cpp create mode 100644 src/warning_page.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 56d0869..bc1386f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -74,6 +74,7 @@ add_executable(radiantwave src/email_address_page.cpp src/license_key_page.cpp src/license_blocked_page.cpp + src/warning_page.cpp src/validation.cpp src/date_format.cpp src/date_format_page.cpp diff --git a/src/audio/audio_downloader.cpp b/src/audio/audio_downloader.cpp index ffe1ad8..09ebbbe 100644 --- a/src/audio/audio_downloader.cpp +++ b/src/audio/audio_downloader.cpp @@ -261,6 +261,35 @@ std::string computeFileMd5(const std::string& path) { 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 ────────────────────────────────────────────────────────── AudioDownloader::~AudioDownloader() { @@ -289,6 +318,30 @@ std::string AudioDownloader::statusString() const { return status_; } +AudioDownloadSnapshot AudioDownloader::snapshot() const { + std::lock_guard lock(mutex_); + return snapshot_; +} + +void AudioDownloader::publishSnapshot( + const std::vector& manifest, + const std::unordered_set& 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 lock(mutex_); + snapshot_ = std::move(snap); +} + void AudioDownloader::setStatus(const std::string& msg) { { std::lock_guard lock(mutex_); @@ -359,6 +412,11 @@ void AudioDownloader::run() { size_t toDownload = queue.size(); 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()) { std::error_code ec; std::filesystem::create_directories(dir, ec); @@ -373,6 +431,7 @@ void AudioDownloader::run() { // Publish immediately so a file that just finished downloading // appears in the list without waiting for the rest. syncNow(); + publishSnapshot(manifest, listed); } } diff --git a/src/audio/audio_downloader.h b/src/audio/audio_downloader.h index f1c5f3e..52efd09 100644 --- a/src/audio/audio_downloader.h +++ b/src/audio/audio_downloader.h @@ -6,6 +6,7 @@ #include #include #include +#include #include namespace radiantwave::db { @@ -33,6 +34,29 @@ struct AudioManifestEntry { 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 files; +}; + // Downloads audio assets from files.radiantwavetech.com in the background // and keeps the database's audio_files table in sync with what is actually // on disk. @@ -66,6 +90,9 @@ public: // Returns the current human-readable status for display (e.g. footer). std::string statusString() const; + // Returns the last published availability snapshot (thread-safe copy). + AudioDownloadSnapshot snapshot() const; + static constexpr const char *AUDIO_BASE_URL = "https://files.radiantwavetech.com/audio/"; static constexpr const char *MANIFEST_FILENAME = "checksums.md5"; @@ -80,11 +107,18 @@ private: void setStatus(const std::string &msg); 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 &manifest, + const std::unordered_set &availableFilenames); + mutable std::mutex mutex_; std::condition_variable cv_; std::thread worker_; std::atomic stop_{false}; std::string status_; + AudioDownloadSnapshot snapshot_; 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. 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 diff --git a/src/db/db_keys.h b/src/db/db_keys.h index 6bcb5f7..f05f452 100644 --- a/src/db/db_keys.h +++ b/src/db/db_keys.h @@ -12,6 +12,18 @@ constexpr const char* KV_LICENSE_KEY = "license_key"; // outages do not revoke a known-good license. 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 // set only here; never a reference or key. constexpr const char* KV_SYSTEM_NAME = "system_name"; diff --git a/src/email_address_page.cpp b/src/email_address_page.cpp index b2fa041..25baa03 100644 --- a/src/email_address_page.cpp +++ b/src/email_address_page.cpp @@ -92,6 +92,8 @@ void EmailAddressPage::submit() storedValue_.clear(); statusMessage_ = "Cleared"; returnTimer_ = 1.0f; + // The credential pair changed: re-validate in the background. + state_->licenseTracker.refresh(); } else { statusMessage_ = "Failed to clear email address"; } @@ -107,6 +109,8 @@ void EmailAddressPage::submit() storedValue_ = input_; statusMessage_ = "Saved"; returnTimer_ = 1.0f; + // The credential pair changed: re-validate in the background. + state_->licenseTracker.refresh(); } else { statusMessage_ = "Failed to save email address"; } diff --git a/src/home.cpp b/src/home.cpp index 9ddfe65..2f66cb4 100644 --- a/src/home.cpp +++ b/src/home.cpp @@ -3,6 +3,7 @@ #include "settings.h" #include "scroller_page.h" #include "license_blocked_page.h" +#include "warning_page.h" #include "proclamation_options_page.h" #include "date_format.h" #include "audio/audio_downloader.h" @@ -45,7 +46,6 @@ void Home::onPush(AppState &state) TTF_Init(); createTextures(state); refreshStatus(); - state.licenseTracker.refresh(); state.keybinds.bind(SDLK_UP, "menu_up", [this]() { state_->currentMenuItem = (state_->currentMenuItem + 1) % 2; }); @@ -59,7 +59,6 @@ void Home::onResume(AppState &state) syncProclamationsIfEmpty(state); createTextures(state); refreshStatus(); - state.licenseTracker.refresh(); state.keybinds.bind(SDLK_UP, "menu_up", [this]() { 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. if (state_->licenseTracker.isValid()) { - state_->pages.push(*state_, std::make_unique()); + // 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( + std::move(warning), [](AppState& state) { + state.pages.replace(state, std::make_unique()); + })); + } + else + { + state_->pages.push(*state_, std::make_unique()); + } } else { @@ -180,9 +198,11 @@ void Home::render(SDL_Renderer *renderer) SDL_RenderFillRect(renderer, &line); } - // Large bold reminder to connect WiFi, drawn below the menu while no - // network is joined (refreshed every 5s in update()). - if (wifiNote_.texture && ssid_.empty()) + // Large bold reminder to connect WiFi, drawn below the menu only when no + // WiFi is joined AND the internet is unreachable. A missing NetworkManager + // 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 noteY = settingsY_ + settings_.height + 40.0f; diff --git a/src/license_key_page.cpp b/src/license_key_page.cpp index c9708f8..423faf6 100644 --- a/src/license_key_page.cpp +++ b/src/license_key_page.cpp @@ -6,6 +6,28 @@ #include "validation.h" #include "logical_size.h" #include +#include +#include +#include + +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) { @@ -94,11 +116,17 @@ void LicenseKeyPage::submit() } 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. // When the email is missing the key is saved but the check is skipped. std::string errorMessage; const radiantwave::license::Result result = radiantwave::license::validateStoredLicense( 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) { case radiantwave::license::Result::Ok: diff --git a/src/network/api_client.cpp b/src/network/api_client.cpp index f624a24..3c31ed3 100644 --- a/src/network/api_client.cpp +++ b/src/network/api_client.cpp @@ -6,6 +6,12 @@ #include #include +// 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 { @@ -258,12 +264,8 @@ LicenseResult validateLicense(const std::string& key, const std::string& email, const std::string& systemName) { LicenseResult result; - std::string jsonBody = "{\"license_key\":\"" + jsonEscape(key) + - "\",\"email_address\":\"" + jsonEscape(email) + "\""; - if (!systemName.empty()) { - jsonBody += ",\"systemName\":\"" + jsonEscape(systemName) + "\""; - } - jsonBody += "}"; + const std::string jsonBody = + buildLicenseRequestJson(key, email, systemName, APP_VERSION); HttpResponse response = httpPost(PATH_LICENSE, jsonBody); result.status = response.status; @@ -281,6 +283,21 @@ LicenseResult validateLicense(const std::string& key, const std::string& email, 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 ────────────────────────────────── bool parseCreateAccountJson(const std::string& body, AccountBody& out) { diff --git a/src/network/api_client.h b/src/network/api_client.h index 26aa8dc..7bbb178 100644 --- a/src/network/api_client.h +++ b/src/network/api_client.h @@ -106,9 +106,17 @@ struct LicenseResult { // 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 // (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, 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 ─────────────────────────────────── // Result of a System Name PUT. `status` is the HTTP status (0 on transport diff --git a/src/network/license_status.cpp b/src/network/license_status.cpp index 172ff2f..c1c72e4 100644 --- a/src/network/license_status.cpp +++ b/src/network/license_status.cpp @@ -1,10 +1,38 @@ #include "network/license_status.h" #include "db/database_manager.h" +#include "db/db_keys.h" -#include +#include +#include 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( + 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) { switch (result) { case Result::Ok: return Status::Valid; @@ -22,6 +50,22 @@ Status statusForResultWithGrace(Result result, const db::DatabaseManager& db) { 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() { shutdown(); } @@ -30,25 +74,78 @@ void LicenseStatusTracker::start(db::DatabaseManager& db) { std::lock_guard lock(mutex_); if (worker_.joinable()) return; db_ = &db; + loadPersistedStateLocked(); 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(initial)); + overdueRecheck_ = overdue; +} + void LicenseStatusTracker::refresh() { { std::lock_guard lock(mutex_); if (!worker_.joinable()) return; - if (status_.load() == static_cast(Status::Checking)) return; + forced_ = true; status_.store(static_cast(Status::Checking)); } 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 checkLock(checkMutex_); + + db::DatabaseManager* db = nullptr; + { + std::lock_guard 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 lock(mutex_); + checkedKey_ = key; + checkedEmail_ = email; + overdueRecheck_ = false; + status_.store(static_cast(s)); + } + persistCheckState(key, email, s); +} + void LicenseStatusTracker::shutdown() { { std::lock_guard lock(mutex_); stop_.store(true); } - cv_.notify_one(); + cv_.notify_all(); if (worker_.joinable()) { worker_.join(); } @@ -62,29 +159,101 @@ bool LicenseStatusTracker::isValid() const { return status() == Status::Valid; } +bool LicenseStatusTracker::shouldValidateNow(bool forced) { + db::DatabaseManager* db = nullptr; + bool overdue = false; + { + std::lock_guard 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 lock(mutex_); + credentialsChanged = (key != checkedKey_ || email != checkedEmail_); + s = static_cast(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 checkLock(checkMutex_); + + db::DatabaseManager* db = nullptr; + { + std::lock_guard lock(mutex_); + db = db_; + overdueRecheck_ = false; + status_.store(static_cast(Status::Checking)); + } + if (!db) { + status_.store(static_cast(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 lock(mutex_); + checkedKey_ = key; + checkedEmail_ = email; + status_.store(static_cast(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 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() { while (true) { { std::unique_lock lock(mutex_); - cv_.wait(lock, [this] { - return stop_.load() || - status_.load() == static_cast(Status::Checking); - }); if (stop_.load()) return; } - db::DatabaseManager* db = nullptr; + bool forced = false; { std::lock_guard lock(mutex_); - db = db_; + forced = forced_; + forced_ = false; } - if (!db) { - status_.store(static_cast(Status::Invalid)); - continue; + if (shouldValidateNow(forced)) { + performCheck(); } - const Result result = validateStoredLicense(*db); - status_.store(static_cast(statusForResultWithGrace(result, *db))); + std::unique_lock lock(mutex_); + cv_.wait_for(lock, std::chrono::seconds(kPollSeconds), [this] { + return stop_.load() || forced_; + }); + if (stop_.load()) return; } } diff --git a/src/network/license_status.h b/src/network/license_status.h index 68b5f1c..53d9d53 100644 --- a/src/network/license_status.h +++ b/src/network/license_status.h @@ -5,6 +5,7 @@ #include #include #include +#include #include namespace radiantwave::db { @@ -22,6 +23,12 @@ enum class Status { 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 // can be unit-tested offline. Network errors are treated as Invalid: a kiosk // that cannot confirm its license must not grant access. @@ -34,10 +41,32 @@ Status statusForResult(Result result); // grace period. 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 -// background. Runs a single worker thread that waits for refresh() requests; -// the result is readable at any time via status()/isValid(). The worker is -// owned by AppState so it outlives individual pages. +// background. Runs a single worker thread that validates on a schedule; the +// result is readable at any time via status()/isValid(). The worker is owned +// 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 { public: LicenseStatusTracker() = default; @@ -46,13 +75,22 @@ public: LicenseStatusTracker(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); - // Triggers a fresh validation. No-op while a check is already running. - // Safe to call from any thread. + // Requests an immediate validation, bypassing the hourly throttle and the + // 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(); + // 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 // database is closed at shutdown. void shutdown(); @@ -62,12 +100,29 @@ public: private: 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_; + // 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::thread worker_; std::atomic stop_{false}; std::atomic status_{static_cast(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; }; diff --git a/src/settings_menu.cpp b/src/settings_menu.cpp index 6274909..0f47f4e 100644 --- a/src/settings_menu.cpp +++ b/src/settings_menu.cpp @@ -6,6 +6,7 @@ std::vector systemSettingEntries() { return { {"Email Address", SystemSettingId::EmailAddress}, {"License Key", SystemSettingId::LicenseKey}, + {"Manually Validate License Key", SystemSettingId::ManualValidateLicense}, {"System Name", SystemSettingId::SystemName}, {"Schedule", SystemSettingId::Schedule}, {"WiFi Setup", SystemSettingId::WiFiSetup}, diff --git a/src/settings_menu.h b/src/settings_menu.h index 5571848..3839e49 100644 --- a/src/settings_menu.h +++ b/src/settings_menu.h @@ -9,6 +9,7 @@ namespace radiantwave::settings { enum class SystemSettingId { EmailAddress, LicenseKey, + ManualValidateLicense, SystemName, Schedule, WiFiSetup, diff --git a/src/system_settings_page.cpp b/src/system_settings_page.cpp index b039af2..0e625c4 100644 --- a/src/system_settings_page.cpp +++ b/src/system_settings_page.cpp @@ -16,6 +16,31 @@ namespace { constexpr const char *TITLE = "System 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 void SystemSettingsPage::onPush(AppState &state) @@ -78,6 +103,11 @@ void SystemSettingsPage::activateSelected(AppState &state) case radiantwave::settings::SystemSettingId::LicenseKey: state.pages.push(state, std::make_unique()); 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: state.pages.push(state, std::make_unique()); break; @@ -156,16 +186,52 @@ void SystemSettingsPage::render(SDL_Renderer *renderer) } 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(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 float sharedX = (fw - maxWidth) / 2.0f; // Start menu list exactly 24 pixels below title text float currentY = titleY + titleHeight_ + 24.0f; - for (auto &item : items_) + for (int i = 0; i < static_cast(items_.size()); ++i) { + auto &item = items_[i]; 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_RenderTexture(renderer, item.texture, nullptr, &dst); @@ -231,18 +297,21 @@ void SystemSettingsPage::createTextures(AppState &state) TTF_CloseFont(fontLarge); } - // Item Font - TTF_Font *fontItem = TTF_OpenFont("assets/fonts/Roboto-Regular.ttf", 36); - if (!fontItem) + // Item Font (kept open for the manual-validate row's live status suffix). + fontItem_ = TTF_OpenFont("assets/fonts/Roboto-Regular.ttf", 36); + if (!fontItem_) return; + manualBaseWidth_ = 0.0f; + const int manualIndex = manualValidateIndex(); + items_.reserve(labels.size()); - for (const auto &label : labels) + for (int i = 0; i < static_cast(labels.size()); ++i) { 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) { item.texture = SDL_CreateTextureFromSurface(state.renderer, surface); @@ -250,10 +319,20 @@ void SystemSettingsPage::createTextures(AppState &state) item.height = static_cast(surface->h); SDL_DestroySurface(surface); } + if (i == manualIndex) + manualBaseWidth_ = item.width; 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(i); + } + return -1; } void SystemSettingsPage::destroyTextures() @@ -277,4 +356,12 @@ void SystemSettingsPage::destroyTextures() } } items_.clear(); + + manualStatusText_.destroy(); + manualBaseWidth_ = 0.0f; + if (fontItem_) + { + TTF_CloseFont(fontItem_); + fontItem_ = nullptr; + } } diff --git a/src/system_settings_page.h b/src/system_settings_page.h index d4c3d00..c46176e 100644 --- a/src/system_settings_page.h +++ b/src/system_settings_page.h @@ -2,6 +2,7 @@ #include "page.h" #include "settings_menu.h" +#include "text_cache.h" #include #include #include @@ -33,6 +34,9 @@ private: void activateSelected(AppState& state); void createTextures(AppState& state); 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; int selectedItem_ = 0; @@ -40,6 +44,12 @@ private: // Setting rows followed by a trailing "Back to Settings" row. std::vector 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; float bgWidth_ = 0; float bgHeight_ = 0; diff --git a/src/warning_page.cpp b/src/warning_page.cpp new file mode 100644 index 0000000..e0064d7 --- /dev/null +++ b/src/warning_page.cpp @@ -0,0 +1,189 @@ +#include "warning_page.h" +#include "app_state.h" +#include "logical_size.h" +#include +#include + +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(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(winW); + float fh = static_cast(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); + } +} diff --git a/src/warning_page.h b/src/warning_page.h new file mode 100644 index 0000000..ff61fec --- /dev/null +++ b/src/warning_page.h @@ -0,0 +1,54 @@ +#pragma once + +#include "page.h" +#include "text_cache.h" +#include +#include +#include +#include + +// 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; + + 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 messageLines_; + CachedText promptText_; + + std::vector 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; +}; diff --git a/tests/test_api_client.cpp b/tests/test_api_client.cpp index 8644e55..63b6005 100644 --- a/tests/test_api_client.cpp +++ b/tests/test_api_client.cpp @@ -96,6 +96,41 @@ TEST(ApiClientTest, ParseLicenseJsonRejectsMalformed) { 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) { const std::string body = R"({"success":true,"message":"Account and license key created","user_id":"abc-123"})"; diff --git a/tests/test_audio_downloader.cpp b/tests/test_audio_downloader.cpp index 259555d..eb54e6a 100644 --- a/tests/test_audio_downloader.cpp +++ b/tests/test_audio_downloader.cpp @@ -150,5 +150,70 @@ TEST(AudioDownloaderMd5, MissingFileReturnsEmpty) { 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 radiantwave::audio diff --git a/tests/test_license_status.cpp b/tests/test_license_status.cpp index c2b42df..9095edb 100644 --- a/tests/test_license_status.cpp +++ b/tests/test_license_status.cpp @@ -97,6 +97,46 @@ TEST(LicenseStatusTest, StatusForResultWithGraceExpired) { 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) ────────────────────────────────────── TEST(LicenseStatusTest, UnknownBeforeStart) { @@ -130,6 +170,50 @@ TEST(LicenseStatusTest, InvalidWhenDatabaseClosed) { 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) ─────────── TEST(LicenseStatusLiveTest, ValidWhenLicensePairAccepted) { diff --git a/tests/test_settings_menu.cpp b/tests/test_settings_menu.cpp index 55fadb8..a92486d 100644 --- a/tests/test_settings_menu.cpp +++ b/tests/test_settings_menu.cpp @@ -7,21 +7,23 @@ namespace settings = radiantwave::settings; TEST(SystemSettingsMenuTest, ListsTheSystemSettingsInOrder) { 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].id, settings::SystemSettingId::EmailAddress); EXPECT_EQ(entries[1].label, "License Key"); EXPECT_EQ(entries[1].id, settings::SystemSettingId::LicenseKey); - EXPECT_EQ(entries[2].label, "System Name"); - EXPECT_EQ(entries[2].id, settings::SystemSettingId::SystemName); - EXPECT_EQ(entries[3].label, "Schedule"); - EXPECT_EQ(entries[3].id, settings::SystemSettingId::Schedule); - EXPECT_EQ(entries[4].label, "WiFi Setup"); - EXPECT_EQ(entries[4].id, settings::SystemSettingId::WiFiSetup); - EXPECT_EQ(entries[5].label, "Time Zone Setup"); - EXPECT_EQ(entries[5].id, settings::SystemSettingId::Timezone); - EXPECT_EQ(entries[6].label, "Date Format"); - EXPECT_EQ(entries[6].id, settings::SystemSettingId::DateFormat); + EXPECT_EQ(entries[2].label, "Manually Validate License Key"); + EXPECT_EQ(entries[2].id, settings::SystemSettingId::ManualValidateLicense); + EXPECT_EQ(entries[3].label, "System Name"); + EXPECT_EQ(entries[3].id, settings::SystemSettingId::SystemName); + EXPECT_EQ(entries[4].label, "Schedule"); + EXPECT_EQ(entries[4].id, settings::SystemSettingId::Schedule); + EXPECT_EQ(entries[5].label, "WiFi Setup"); + EXPECT_EQ(entries[5].id, settings::SystemSettingId::WiFiSetup); + EXPECT_EQ(entries[6].label, "Time Zone Setup"); + 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) { @@ -42,6 +44,7 @@ TEST(SystemSettingsMenuTest, EveryEntryMapsToAKnownPage) { switch (entry.id) { case settings::SystemSettingId::EmailAddress: case settings::SystemSettingId::LicenseKey: + case settings::SystemSettingId::ManualValidateLicense: case settings::SystemSettingId::SystemName: case settings::SystemSettingId::Schedule: case settings::SystemSettingId::WiFiSetup: From 0124e1c67b7208eea576e4f283a48af844a6cd57 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Wed, 14 Oct 2026 15:03:26 -0500 Subject: [PATCH 2/2] Update README --- README.md | 171 +++++++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 143 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 5beb1c9..3231431 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,149 @@ and manages WiFi/provisioning on a headless Linux kiosk. **Audio:** SDL3_mixer **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_` 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/ +# ... commit your work ... +git push -u origin feature/ +``` + +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@ '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..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 " 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 @@ -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 - Do not commit audio assets (`.wav` files) -- downloaded at runtime.