Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3b3d6774a | ||
|
|
9b57e48969 |
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,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";
|
||||||
|
|||||||
@@ -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";
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-5
@@ -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; });
|
||||||
@@ -87,9 +86,28 @@ 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())
|
||||||
|
{
|
||||||
|
// 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>());
|
state_->pages.push(*state_, std::make_unique<ScrollerPage>());
|
||||||
}
|
}
|
||||||
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
state_->pages.push(*state_, std::make_unique<LicenseBlockedPage>());
|
state_->pages.push(*state_, std::make_unique<LicenseBlockedPage>());
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+182
-13
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
void LicenseStatusTracker::run() {
|
bool LicenseStatusTracker::shouldValidateNow(bool forced) {
|
||||||
while (true) {
|
db::DatabaseManager* db = nullptr;
|
||||||
|
bool overdue = false;
|
||||||
{
|
{
|
||||||
std::unique_lock<std::mutex> lock(mutex_);
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
cv_.wait(lock, [this] {
|
db = db_;
|
||||||
return stop_.load() ||
|
overdue = overdueRecheck_;
|
||||||
status_.load() == static_cast<int>(Status::Checking);
|
|
||||||
});
|
|
||||||
if (stop_.load()) return;
|
|
||||||
}
|
}
|
||||||
|
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;
|
db::DatabaseManager* db = nullptr;
|
||||||
{
|
{
|
||||||
std::lock_guard<std::mutex> lock(mutex_);
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
db = db_;
|
db = db_;
|
||||||
|
overdueRecheck_ = false;
|
||||||
|
status_.store(static_cast<int>(Status::Checking));
|
||||||
}
|
}
|
||||||
if (!db) {
|
if (!db) {
|
||||||
status_.store(static_cast<int>(Status::Invalid));
|
status_.store(static_cast<int>(Status::Invalid));
|
||||||
continue;
|
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 Result result = validateStoredLicense(*db);
|
||||||
status_.store(static_cast<int>(statusForResultWithGrace(result, *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() {
|
||||||
|
while (true) {
|
||||||
|
{
|
||||||
|
std::unique_lock<std::mutex> lock(mutex_);
|
||||||
|
if (stop_.load()) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool forced = false;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
|
forced = forced_;
|
||||||
|
forced_ = false;
|
||||||
|
}
|
||||||
|
if (shouldValidateNow(forced)) {
|
||||||
|
performCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
std::unique_lock<std::mutex> lock(mutex_);
|
||||||
|
cv_.wait_for(lock, std::chrono::seconds(kPollSeconds), [this] {
|
||||||
|
return stop_.load() || forced_;
|
||||||
|
});
|
||||||
|
if (stop_.load()) return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ 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_);
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
if (started_) return;
|
if (started_) return;
|
||||||
started_ = true;
|
started_ = true;
|
||||||
@@ -95,8 +96,11 @@ void ScheduleManager::start(db::DatabaseManager& db, const std::string& timezone
|
|||||||
onChanged_ = std::move(onChanged);
|
onChanged_ = std::move(onChanged);
|
||||||
licenseKey_ = db.getKV(db::KV_LICENSE_KEY);
|
licenseKey_ = db.getKV(db::KV_LICENSE_KEY);
|
||||||
enabled_ = true; // config supplies the toggle via setEnabled()
|
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);
|
||||||
|
|||||||
@@ -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},
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ namespace radiantwave::settings {
|
|||||||
enum class SystemSettingId {
|
enum class SystemSettingId {
|
||||||
EmailAddress,
|
EmailAddress,
|
||||||
LicenseKey,
|
LicenseKey,
|
||||||
|
ManualValidateLicense,
|
||||||
SystemName,
|
SystemName,
|
||||||
Schedule,
|
Schedule,
|
||||||
WiFiSetup,
|
WiFiSetup,
|
||||||
|
|||||||
@@ -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 (i == manualIndex)
|
||||||
|
{
|
||||||
|
// White label, then the colored status suffix to its right.
|
||||||
if (item.texture)
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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"})";
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user