Warning screen, smarter license re-validation, version reporting

Warning screen
- New reusable WarningPage (word-wrapped message, ENTER acknowledges and
  continues to the screen you were about to enter).
- First use: pressing Play while the selected audio is still downloading.
  Dynamic Audio requires every file; a fixed track requires its own file.

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

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

Misc
- "Please connect to WiFi" only shows when no WiFi is joined AND the internet
  is unreachable (fixes the NetworkManager-missing and Ethernet cases).
- Added unit tests for the audio-download warning, license scheduling policy,
  license request shape, and the new settings-menu entry.
This commit is contained in:
NeilClack
2026-10-14 10:58:40 -05:00
parent 9b57e48969
commit c3b3d6774a
21 changed files with 999 additions and 52 deletions
+59
View File
@@ -261,6 +261,35 @@ std::string computeFileMd5(const std::string& path) {
return hex;
}
bool buildAudioDownloadWarning(const AudioDownloadSnapshot& snapshot,
bool dynamicAudio,
const std::string& selectedFilename,
std::string& message) {
if (dynamicAudio) {
if (snapshot.allAvailable) return false;
message =
"You currently have 'Dynamic Audio' selected, but some audio "
"files are still being downloaded. Some audio will not play until "
"these files have finished downloading.";
return true;
}
for (const auto& file : snapshot.files) {
if (file.filename != selectedFilename) continue;
if (file.available) return false;
const std::string name =
file.display_name.empty() ? file.filename : file.display_name;
message = "You currently have '" + name +
"' selected, but it is still being downloaded. No audio "
"will play until this file has finished downloading.";
return true;
}
// The selection is not part of the manifest (the manifest may not be known
// yet), so the downloader cannot confirm whether it is still downloading.
return false;
}
// ─── Lifecycle ──────────────────────────────────────────────────────────
AudioDownloader::~AudioDownloader() {
@@ -289,6 +318,30 @@ std::string AudioDownloader::statusString() const {
return status_;
}
AudioDownloadSnapshot AudioDownloader::snapshot() const {
std::lock_guard<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_);
@@ -359,6 +412,11 @@ void AudioDownloader::run() {
size_t toDownload = queue.size();
size_t downloaded = 0;
// Publish what is (and is not) on disk before downloads begin so the
// UI can warn immediately, then again as each file lands.
publishSnapshot(manifest, listed);
if (!queue.empty()) {
std::error_code ec;
std::filesystem::create_directories(dir, ec);
@@ -373,6 +431,7 @@ void AudioDownloader::run() {
// Publish immediately so a file that just finished downloading
// appears in the list without waiting for the rest.
syncNow();
publishSnapshot(manifest, listed);
}
}
+45
View File
@@ -6,6 +6,7 @@
#include <mutex>
#include <string>
#include <thread>
#include <unordered_set>
#include <vector>
namespace radiantwave::db {
@@ -33,6 +34,29 @@ struct AudioManifestEntry {
std::string md5;
};
// Availability of one manifest entry, as published by the worker.
// `available` means the file is present on disk (and therefore playable),
// regardless of whether its checksum still needs refreshing.
struct AudioFileStatus {
std::string filename;
std::string display_name;
bool available = false;
};
// A snapshot of the worker's most recent manifest reconciliation. Used by the
// UI to warn the user when they start playback before every required audio
// file has finished downloading.
struct AudioDownloadSnapshot {
// A manifest has been fetched at least once. Before this, the set of
// expected files is unknown.
bool manifestKnown = false;
// Every manifest file is present on disk. False until the first manifest
// is known.
bool allAvailable = false;
// One entry per manifest file, in manifest order.
std::vector<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.
@@ -66,6 +90,9 @@ public:
// Returns the current human-readable status for display (e.g. footer).
std::string statusString() const;
// Returns the last published availability snapshot (thread-safe copy).
AudioDownloadSnapshot snapshot() const;
static constexpr const char *AUDIO_BASE_URL =
"https://files.radiantwavetech.com/audio/";
static constexpr const char *MANIFEST_FILENAME = "checksums.md5";
@@ -80,11 +107,18 @@ private:
void setStatus(const std::string &msg);
void wait(std::chrono::seconds seconds);
// Rebuilds `snapshot_` from the manifest and the set of filenames present
// on disk. Called by the worker after its initial scan and after each
// completed download so the UI sees progress.
void publishSnapshot(const std::vector<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;
};
@@ -109,4 +143,15 @@ int hzFromFilename(const std::string &filename);
// Computes the lowercase MD5 hex of a file, or an empty string on failure.
std::string computeFileMd5(const std::string &path);
// Fills `message` and returns true when starting playback should first show a
// warning because required audio has not finished downloading:
// * dynamic audio needs the whole manifest (the message names "ALL");
// * a fixed track needs just its own file (the message names it).
// Returns false when nothing required is missing, or when the selection is not
// part of the manifest (so the downloader cannot confirm it either way).
bool buildAudioDownloadWarning(const AudioDownloadSnapshot &snapshot,
bool dynamicAudio,
const std::string &selectedFilename,
std::string &message);
} // namespace radiantwave::audio