Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
476202f8e2 |
@@ -74,7 +74,6 @@ 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
|
||||
|
||||
@@ -261,35 +261,6 @@ 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() {
|
||||
@@ -318,30 +289,6 @@ std::string AudioDownloader::statusString() const {
|
||||
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) {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
@@ -412,11 +359,6 @@ 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);
|
||||
@@ -431,7 +373,6 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
namespace radiantwave::db {
|
||||
@@ -34,29 +33,6 @@ 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<AudioFileStatus> 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.
|
||||
@@ -90,9 +66,6 @@ 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";
|
||||
@@ -107,18 +80,11 @@ 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<AudioManifestEntry> &manifest,
|
||||
const std::unordered_set<std::string> &availableFilenames);
|
||||
|
||||
mutable std::mutex mutex_;
|
||||
std::condition_variable cv_;
|
||||
std::thread worker_;
|
||||
std::atomic<bool> stop_{false};
|
||||
std::string status_;
|
||||
AudioDownloadSnapshot snapshot_;
|
||||
db::DatabaseManager *db_ = nullptr;
|
||||
};
|
||||
|
||||
@@ -143,15 +109,4 @@ 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
|
||||
|
||||
@@ -12,18 +12,6 @@ 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";
|
||||
|
||||
@@ -92,8 +92,6 @@ 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";
|
||||
}
|
||||
@@ -109,8 +107,6 @@ 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";
|
||||
}
|
||||
|
||||
+6
-26
@@ -3,7 +3,6 @@
|
||||
#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"
|
||||
@@ -46,6 +45,7 @@ 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,6 +59,7 @@ 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; });
|
||||
@@ -87,26 +88,7 @@ void Home::handleEvent(const SDL_Event &event)
|
||||
// Only a confirmed-valid license unlocks the scroller.
|
||||
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
|
||||
{
|
||||
@@ -198,11 +180,9 @@ void Home::render(SDL_Renderer *renderer)
|
||||
SDL_RenderFillRect(renderer, &line);
|
||||
}
|
||||
|
||||
// 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_)
|
||||
// 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())
|
||||
{
|
||||
float noteX = (fw - wifiNote_.width) / 2.0f;
|
||||
float noteY = settingsY_ + settings_.height + 40.0f;
|
||||
|
||||
@@ -6,28 +6,6 @@
|
||||
#include "validation.h"
|
||||
#include "logical_size.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)
|
||||
{
|
||||
@@ -116,17 +94,11 @@ 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:
|
||||
|
||||
@@ -6,12 +6,6 @@
|
||||
#include <cstdio>
|
||||
#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 {
|
||||
@@ -264,8 +258,12 @@ LicenseResult validateLicense(const std::string& key, const std::string& email,
|
||||
const std::string& systemName) {
|
||||
LicenseResult result;
|
||||
|
||||
const std::string jsonBody =
|
||||
buildLicenseRequestJson(key, email, systemName, APP_VERSION);
|
||||
std::string jsonBody = "{\"license_key\":\"" + jsonEscape(key) +
|
||||
"\",\"email_address\":\"" + jsonEscape(email) + "\"";
|
||||
if (!systemName.empty()) {
|
||||
jsonBody += ",\"systemName\":\"" + jsonEscape(systemName) + "\"";
|
||||
}
|
||||
jsonBody += "}";
|
||||
|
||||
HttpResponse response = httpPost(PATH_LICENSE, jsonBody);
|
||||
result.status = response.status;
|
||||
@@ -283,21 +281,6 @@ 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) {
|
||||
|
||||
@@ -106,17 +106,9 @@ 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
|
||||
|
||||
+14
-183
@@ -1,38 +1,10 @@
|
||||
#include "network/license_status.h"
|
||||
#include "db/database_manager.h"
|
||||
#include "db/db_keys.h"
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdlib>
|
||||
#include <utility>
|
||||
|
||||
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) {
|
||||
switch (result) {
|
||||
case Result::Ok: return Status::Valid;
|
||||
@@ -50,22 +22,6 @@ 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();
|
||||
}
|
||||
@@ -74,78 +30,25 @@ void LicenseStatusTracker::start(db::DatabaseManager& db) {
|
||||
std::lock_guard<std::mutex> 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<int>(initial));
|
||||
overdueRecheck_ = overdue;
|
||||
}
|
||||
|
||||
void LicenseStatusTracker::refresh() {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (!worker_.joinable()) return;
|
||||
forced_ = true;
|
||||
if (status_.load() == static_cast<int>(Status::Checking)) return;
|
||||
status_.store(static_cast<int>(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<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() {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
stop_.store(true);
|
||||
}
|
||||
cv_.notify_all();
|
||||
cv_.notify_one();
|
||||
if (worker_.joinable()) {
|
||||
worker_.join();
|
||||
}
|
||||
@@ -159,101 +62,29 @@ bool LicenseStatusTracker::isValid() const {
|
||||
return status() == Status::Valid;
|
||||
}
|
||||
|
||||
bool LicenseStatusTracker::shouldValidateNow(bool forced) {
|
||||
db::DatabaseManager* db = nullptr;
|
||||
bool overdue = false;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
db = db_;
|
||||
overdue = overdueRecheck_;
|
||||
}
|
||||
if (!db) return false;
|
||||
if (forced) return true;
|
||||
|
||||
const std::string key = db->getKV(db::KV_LICENSE_KEY);
|
||||
const std::string email = db->getKV(db::KV_EMAIL_ADDRESS);
|
||||
bool credentialsChanged = false;
|
||||
Status s = Status::Unknown;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
credentialsChanged = (key != checkedKey_ || email != checkedEmail_);
|
||||
s = static_cast<Status>(status_.load());
|
||||
}
|
||||
|
||||
const long long lastCheck = readEpochSeconds(*db, db::KV_LICENSE_LAST_CHECK);
|
||||
return shouldValidate(forced, credentialsChanged, overdue, s,
|
||||
nowEpochSeconds(), lastCheck);
|
||||
}
|
||||
|
||||
void LicenseStatusTracker::performCheck() {
|
||||
// Serialize with any synchronous check; only one validation in flight.
|
||||
std::lock_guard<std::mutex> checkLock(checkMutex_);
|
||||
|
||||
db::DatabaseManager* db = nullptr;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
db = db_;
|
||||
overdueRecheck_ = false;
|
||||
status_.store(static_cast<int>(Status::Checking));
|
||||
}
|
||||
if (!db) {
|
||||
status_.store(static_cast<int>(Status::Invalid));
|
||||
return;
|
||||
}
|
||||
|
||||
const std::string key = db->getKV(db::KV_LICENSE_KEY);
|
||||
const std::string email = db->getKV(db::KV_EMAIL_ADDRESS);
|
||||
const Result result = validateStoredLicense(*db);
|
||||
const Status s = statusForResultWithGrace(result, *db);
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
checkedKey_ = key;
|
||||
checkedEmail_ = email;
|
||||
status_.store(static_cast<int>(s));
|
||||
}
|
||||
persistCheckState(key, email, s);
|
||||
}
|
||||
|
||||
void LicenseStatusTracker::persistCheckState(const std::string& key,
|
||||
const std::string& email,
|
||||
Status status) {
|
||||
db::DatabaseManager* db = nullptr;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
db = db_;
|
||||
}
|
||||
if (!db) return;
|
||||
|
||||
db->setKV(db::KV_LICENSE_CHECKED_KEY, key);
|
||||
db->setKV(db::KV_LICENSE_CHECKED_EMAIL, email);
|
||||
db->setKV(db::KV_LICENSE_CHECK_STATUS,
|
||||
status == Status::Valid ? "valid" : "invalid");
|
||||
db->setKV(db::KV_LICENSE_LAST_CHECK, std::to_string(nowEpochSeconds()));
|
||||
}
|
||||
|
||||
void LicenseStatusTracker::run() {
|
||||
while (true) {
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(mutex_);
|
||||
cv_.wait(lock, [this] {
|
||||
return stop_.load() ||
|
||||
status_.load() == static_cast<int>(Status::Checking);
|
||||
});
|
||||
if (stop_.load()) return;
|
||||
}
|
||||
|
||||
bool forced = false;
|
||||
db::DatabaseManager* db = nullptr;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
forced = forced_;
|
||||
forced_ = false;
|
||||
db = db_;
|
||||
}
|
||||
if (shouldValidateNow(forced)) {
|
||||
performCheck();
|
||||
if (!db) {
|
||||
status_.store(static_cast<int>(Status::Invalid));
|
||||
continue;
|
||||
}
|
||||
|
||||
std::unique_lock<std::mutex> lock(mutex_);
|
||||
cv_.wait_for(lock, std::chrono::seconds(kPollSeconds), [this] {
|
||||
return stop_.load() || forced_;
|
||||
});
|
||||
if (stop_.load()) return;
|
||||
const Result result = validateStoredLicense(*db);
|
||||
status_.store(static_cast<int>(statusForResultWithGrace(result, *db)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
#include <atomic>
|
||||
#include <condition_variable>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
namespace radiantwave::db {
|
||||
@@ -23,12 +22,6 @@ 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.
|
||||
@@ -41,32 +34,10 @@ 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 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.
|
||||
// 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.
|
||||
class LicenseStatusTracker {
|
||||
public:
|
||||
LicenseStatusTracker() = default;
|
||||
@@ -75,22 +46,13 @@ public:
|
||||
LicenseStatusTracker(const LicenseStatusTracker&) = delete;
|
||||
LicenseStatusTracker& operator=(const LicenseStatusTracker&) = delete;
|
||||
|
||||
// Spawns the worker thread and loads the persisted schedule state. Safe to
|
||||
// call repeatedly; only one worker runs.
|
||||
// Spawns the worker thread. Safe to call repeatedly; only one worker runs.
|
||||
void start(db::DatabaseManager& db);
|
||||
|
||||
// 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().
|
||||
// Triggers a fresh validation. No-op while a check is already running.
|
||||
// Safe to call from any thread.
|
||||
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();
|
||||
@@ -100,29 +62,12 @@ 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<bool> stop_{false};
|
||||
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;
|
||||
};
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ std::vector<SystemSettingEntry> 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},
|
||||
|
||||
@@ -9,7 +9,6 @@ namespace radiantwave::settings {
|
||||
enum class SystemSettingId {
|
||||
EmailAddress,
|
||||
LicenseKey,
|
||||
ManualValidateLicense,
|
||||
SystemName,
|
||||
Schedule,
|
||||
WiFiSetup,
|
||||
|
||||
@@ -16,31 +16,6 @@ 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)
|
||||
@@ -103,11 +78,6 @@ void SystemSettingsPage::activateSelected(AppState &state)
|
||||
case radiantwave::settings::SystemSettingId::LicenseKey:
|
||||
state.pages.push(state, std::make_unique<LicenseKeyPage>());
|
||||
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<SystemNamePage>());
|
||||
break;
|
||||
@@ -186,52 +156,16 @@ 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<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
|
||||
float sharedX = (fw - maxWidth) / 2.0f;
|
||||
|
||||
// Start menu list exactly 24 pixels below title text
|
||||
float currentY = titleY + titleHeight_ + 24.0f;
|
||||
|
||||
for (int i = 0; i < static_cast<int>(items_.size()); ++i)
|
||||
for (auto &item : items_)
|
||||
{
|
||||
auto &item = items_[i];
|
||||
item.y = currentY;
|
||||
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)
|
||||
if (item.texture)
|
||||
{
|
||||
SDL_FRect dst{sharedX, item.y, item.width, item.height};
|
||||
SDL_RenderTexture(renderer, item.texture, nullptr, &dst);
|
||||
@@ -297,21 +231,18 @@ void SystemSettingsPage::createTextures(AppState &state)
|
||||
TTF_CloseFont(fontLarge);
|
||||
}
|
||||
|
||||
// Item Font (kept open for the manual-validate row's live status suffix).
|
||||
fontItem_ = TTF_OpenFont("assets/fonts/Roboto-Regular.ttf", 36);
|
||||
if (!fontItem_)
|
||||
// Item Font
|
||||
TTF_Font *fontItem = TTF_OpenFont("assets/fonts/Roboto-Regular.ttf", 36);
|
||||
if (!fontItem)
|
||||
return;
|
||||
|
||||
manualBaseWidth_ = 0.0f;
|
||||
const int manualIndex = manualValidateIndex();
|
||||
|
||||
items_.reserve(labels.size());
|
||||
for (int i = 0; i < static_cast<int>(labels.size()); ++i)
|
||||
for (const auto &label : labels)
|
||||
{
|
||||
MenuItem item;
|
||||
item.label = labels[i];
|
||||
item.label = label;
|
||||
|
||||
SDL_Surface *surface = TTF_RenderText_Blended(fontItem_, labels[i].c_str(), 0, white);
|
||||
SDL_Surface *surface = TTF_RenderText_Blended(fontItem, label.c_str(), 0, white);
|
||||
if (surface)
|
||||
{
|
||||
item.texture = SDL_CreateTextureFromSurface(state.renderer, surface);
|
||||
@@ -319,20 +250,10 @@ void SystemSettingsPage::createTextures(AppState &state)
|
||||
item.height = static_cast<float>(surface->h);
|
||||
SDL_DestroySurface(surface);
|
||||
}
|
||||
if (i == manualIndex)
|
||||
manualBaseWidth_ = item.width;
|
||||
items_.push_back(std::move(item));
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
TTF_CloseFont(fontItem);
|
||||
}
|
||||
|
||||
void SystemSettingsPage::destroyTextures()
|
||||
@@ -356,12 +277,4 @@ void SystemSettingsPage::destroyTextures()
|
||||
}
|
||||
}
|
||||
items_.clear();
|
||||
|
||||
manualStatusText_.destroy();
|
||||
manualBaseWidth_ = 0.0f;
|
||||
if (fontItem_)
|
||||
{
|
||||
TTF_CloseFont(fontItem_);
|
||||
fontItem_ = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
#include "page.h"
|
||||
#include "settings_menu.h"
|
||||
#include "text_cache.h"
|
||||
#include <SDL3_ttf/SDL_ttf.h>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
@@ -34,9 +33,6 @@ 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;
|
||||
@@ -44,12 +40,6 @@ private:
|
||||
// Setting rows followed by a trailing "Back to Settings" row.
|
||||
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;
|
||||
float bgWidth_ = 0;
|
||||
float bgHeight_ = 0;
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
#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);
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
#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,41 +96,6 @@ 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"})";
|
||||
|
||||
@@ -150,70 +150,5 @@ 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
|
||||
|
||||
@@ -97,46 +97,6 @@ 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) {
|
||||
@@ -170,50 +130,6 @@ 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) {
|
||||
|
||||
@@ -7,23 +7,21 @@ namespace settings = radiantwave::settings;
|
||||
|
||||
TEST(SystemSettingsMenuTest, ListsTheSystemSettingsInOrder) {
|
||||
const auto entries = settings::systemSettingEntries();
|
||||
ASSERT_EQ(entries.size(), 8u);
|
||||
ASSERT_EQ(entries.size(), 7u);
|
||||
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, "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);
|
||||
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);
|
||||
}
|
||||
|
||||
TEST(SystemSettingsMenuTest, LabelsAreNonEmptyAndUnique) {
|
||||
@@ -44,7 +42,6 @@ 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:
|
||||
|
||||
Reference in New Issue
Block a user