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.
521 lines
19 KiB
C++
521 lines
19 KiB
C++
#include "audio/audio_downloader.h"
|
|
|
|
#include "db/database_manager.h"
|
|
#include "logger/logger.h"
|
|
|
|
#include <curl/curl.h>
|
|
#include <openssl/evp.h>
|
|
|
|
#include <cctype>
|
|
#include <cstdio>
|
|
#include <cstdlib>
|
|
#include <filesystem>
|
|
#include <sstream>
|
|
#include <unordered_map>
|
|
#include <unordered_set>
|
|
#include <vector>
|
|
|
|
namespace radiantwave::audio {
|
|
|
|
namespace {
|
|
|
|
constexpr const char* AUDIO_DIR_SUFFIX = "/.local/radiantwave/audio/";
|
|
constexpr const char* USER_AGENT = "RadiantWave-Kiosk/0.1";
|
|
|
|
std::string audioDir() {
|
|
const char* home = std::getenv("HOME");
|
|
return std::string(home && *home ? home : "/tmp") + AUDIO_DIR_SUFFIX;
|
|
}
|
|
|
|
// Strips the "*" binary-mode marker and any "./" prefixes md5sum emits.
|
|
std::string stripManifestPrefix(std::string name) {
|
|
if (!name.empty() && name.front() == '*') name.erase(0, 1);
|
|
while (name.compare(0, 2, "./") == 0) name.erase(0, 2);
|
|
return name;
|
|
}
|
|
|
|
// Produces a friendly display name from a bare filename for legacy manifests
|
|
// that carry no display name.
|
|
std::string humanizeFilename(const std::string& filename) {
|
|
std::string name = filename;
|
|
size_t dot = name.rfind('.');
|
|
if (dot != std::string::npos) name.erase(dot);
|
|
for (char& c : name) {
|
|
if (c == '_') c = ' ';
|
|
}
|
|
return name;
|
|
}
|
|
|
|
// Tokenizes a manifest line, honoring a single-quoted display name that may
|
|
// contain spaces.
|
|
std::vector<std::string> tokenizeManifestLine(const std::string& line) {
|
|
std::vector<std::string> tokens;
|
|
size_t i = 0;
|
|
const size_t n = line.size();
|
|
while (i < n) {
|
|
while (i < n && std::isspace(static_cast<unsigned char>(line[i]))) ++i;
|
|
if (i >= n) break;
|
|
if (line[i] == '\'') {
|
|
size_t close = line.find('\'', i + 1);
|
|
if (close == std::string::npos) break;
|
|
tokens.push_back(line.substr(i + 1, close - i - 1));
|
|
i = close + 1;
|
|
} else {
|
|
size_t start = i;
|
|
while (i < n && !std::isspace(static_cast<unsigned char>(line[i]))) ++i;
|
|
tokens.push_back(line.substr(start, i - start));
|
|
}
|
|
}
|
|
return tokens;
|
|
}
|
|
|
|
// ─── HTTP helpers (libcurl) ─────────────────────────────────────────────
|
|
|
|
bool httpGetString(const std::string& url, std::string& body) {
|
|
CURL* curl = curl_easy_init();
|
|
if (!curl) return false;
|
|
|
|
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
|
|
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
|
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 15L);
|
|
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 10L);
|
|
curl_easy_setopt(curl, CURLOPT_USERAGENT, USER_AGENT);
|
|
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, +[](char* ptr, size_t size, size_t nmemb, void* userdata) -> size_t {
|
|
auto* out = static_cast<std::string*>(userdata);
|
|
out->append(ptr, size * nmemb);
|
|
return size * nmemb;
|
|
});
|
|
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &body);
|
|
|
|
CURLcode res = curl_easy_perform(curl);
|
|
long status = 0;
|
|
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status);
|
|
curl_easy_cleanup(curl);
|
|
|
|
return res == CURLE_OK && status >= 200 && status < 300;
|
|
}
|
|
|
|
bool httpGetFile(const std::string& url, const std::string& path) {
|
|
CURL* curl = curl_easy_init();
|
|
if (!curl) return false;
|
|
|
|
std::FILE* f = std::fopen(path.c_str(), "wb");
|
|
if (!f) {
|
|
curl_easy_cleanup(curl);
|
|
return false;
|
|
}
|
|
|
|
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
|
|
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
|
|
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 120L);
|
|
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 15L);
|
|
curl_easy_setopt(curl, CURLOPT_USERAGENT, USER_AGENT);
|
|
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, +[](char* ptr, size_t size, size_t nmemb, void* userdata) -> size_t {
|
|
return std::fwrite(ptr, size, nmemb, static_cast<std::FILE*>(userdata));
|
|
});
|
|
curl_easy_setopt(curl, CURLOPT_WRITEDATA, f);
|
|
|
|
CURLcode res = curl_easy_perform(curl);
|
|
long status = 0;
|
|
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status);
|
|
curl_easy_cleanup(curl);
|
|
|
|
std::fclose(f);
|
|
|
|
return res == CURLE_OK && status >= 200 && status < 300;
|
|
}
|
|
|
|
} // anonymous namespace
|
|
|
|
// ─── Public pure helpers ────────────────────────────────────────────────
|
|
|
|
// Frequency value (Hz) -> internal solfeggio frequency id. Mirrors the id<->Hz
|
|
// relationships documented in DatabaseManager::seedColorPalettes.
|
|
int frequencyIdForHz(int hz) {
|
|
switch (hz) {
|
|
case 174:
|
|
case 285: return 1;
|
|
case 396: return 2;
|
|
case 417: return 3;
|
|
case 432: return 4;
|
|
case 528: return 5;
|
|
case 639: return 6;
|
|
case 741: return 7;
|
|
case 852: return 8;
|
|
case 963: return 9;
|
|
default: return 0;
|
|
}
|
|
}
|
|
|
|
int hzFromFilename(const std::string& filename) {
|
|
size_t i = 0;
|
|
while (i < filename.size() && filename[i] >= '0' && filename[i] <= '9') ++i;
|
|
if (i == 0) return 0;
|
|
// The digits must be a frequency: "396Hz.wav", "14Hz_Binaural.wav".
|
|
if (i + 2 <= filename.size() &&
|
|
(filename[i] == 'H' || filename[i] == 'h') &&
|
|
(filename[i + 1] == 'Z' || filename[i + 1] == 'z')) {
|
|
int hz = 0;
|
|
for (size_t d = 0; d < i; ++d) {
|
|
hz = hz * 10 + (filename[d] - '0');
|
|
}
|
|
return hz;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
bool parseManifest(const std::string& text, std::vector<AudioManifestEntry>& out) {
|
|
std::vector<AudioManifestEntry> result;
|
|
std::istringstream stream(text);
|
|
std::string line;
|
|
while (std::getline(stream, line)) {
|
|
size_t start = line.find_first_not_of(" \t\r\n");
|
|
if (start == std::string::npos) continue;
|
|
size_t end = line.find_last_not_of(" \t\r\n");
|
|
line = line.substr(start, end - start + 1);
|
|
if (line.empty() || line.front() == '#') continue;
|
|
|
|
std::vector<std::string> tokens = tokenizeManifestLine(line);
|
|
if (tokens.empty()) continue;
|
|
|
|
AudioManifestEntry entry;
|
|
if (tokens.size() == 4) {
|
|
// Current format: <filename> '<display name>' <hz> <md5>
|
|
entry.filename = stripManifestPrefix(tokens[0]);
|
|
entry.display_name = tokens[1];
|
|
entry.solfeggio_frequency_hz = std::atoi(tokens[2].c_str());
|
|
entry.md5 = tokens[3];
|
|
if (entry.filename.empty() || entry.md5.size() != 32) continue;
|
|
// Tolerant parse: honor the Hz value only when it maps to a known
|
|
// frequency (a transitional manifest may carry an id, a typo may
|
|
// carry garbage); otherwise fall back to the Hz from the filename.
|
|
if (frequencyIdForHz(entry.solfeggio_frequency_hz) == 0) {
|
|
int fromFilename = hzFromFilename(entry.filename);
|
|
if (fromFilename != 0) {
|
|
entry.solfeggio_frequency_hz = fromFilename;
|
|
}
|
|
}
|
|
result.push_back(std::move(entry));
|
|
} else if (tokens.size() == 2 && tokens[0].size() == 32) {
|
|
// Legacy format: <md5> <filename>
|
|
entry.md5 = tokens[0];
|
|
entry.filename = stripManifestPrefix(tokens[1]);
|
|
entry.display_name = humanizeFilename(entry.filename);
|
|
entry.solfeggio_frequency_hz = hzFromFilename(entry.filename);
|
|
if (entry.filename.empty()) continue;
|
|
result.push_back(std::move(entry));
|
|
}
|
|
// else: malformed line, skip
|
|
}
|
|
if (result.empty()) return false;
|
|
out = std::move(result);
|
|
return true;
|
|
}
|
|
|
|
std::string computeFileMd5(const std::string& path) {
|
|
std::FILE* f = std::fopen(path.c_str(), "rb");
|
|
if (!f) return std::string();
|
|
|
|
EVP_MD_CTX* ctx = EVP_MD_CTX_new();
|
|
if (!ctx) {
|
|
std::fclose(f);
|
|
return std::string();
|
|
}
|
|
if (EVP_DigestInit_ex(ctx, EVP_md5(), nullptr) != 1) {
|
|
EVP_MD_CTX_free(ctx);
|
|
std::fclose(f);
|
|
return std::string();
|
|
}
|
|
|
|
unsigned char buf[8192];
|
|
size_t n;
|
|
while ((n = std::fread(buf, 1, sizeof(buf), f)) > 0) {
|
|
if (EVP_DigestUpdate(ctx, buf, n) != 1) {
|
|
EVP_MD_CTX_free(ctx);
|
|
std::fclose(f);
|
|
return std::string();
|
|
}
|
|
}
|
|
bool readError = std::ferror(f) != 0;
|
|
std::fclose(f);
|
|
if (readError) {
|
|
EVP_MD_CTX_free(ctx);
|
|
return std::string();
|
|
}
|
|
|
|
unsigned char digest[EVP_MAX_MD_SIZE];
|
|
unsigned int digestLen = 0;
|
|
if (EVP_DigestFinal_ex(ctx, digest, &digestLen) != 1) {
|
|
EVP_MD_CTX_free(ctx);
|
|
return std::string();
|
|
}
|
|
EVP_MD_CTX_free(ctx);
|
|
|
|
static const char* hexDigits = "0123456789abcdef";
|
|
std::string hex;
|
|
hex.reserve(digestLen * 2);
|
|
for (unsigned int i = 0; i < digestLen; ++i) {
|
|
hex += hexDigits[digest[i] >> 4];
|
|
hex += hexDigits[digest[i] & 0x0f];
|
|
}
|
|
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() {
|
|
shutdown();
|
|
}
|
|
|
|
void AudioDownloader::start(db::DatabaseManager& db) {
|
|
std::lock_guard<std::mutex> lock(mutex_);
|
|
if (worker_.joinable()) return;
|
|
db_ = &db;
|
|
stop_ = false;
|
|
worker_ = std::thread(&AudioDownloader::run, this);
|
|
}
|
|
|
|
void AudioDownloader::shutdown() {
|
|
{
|
|
std::lock_guard<std::mutex> lock(mutex_);
|
|
stop_ = true;
|
|
}
|
|
cv_.notify_all();
|
|
if (worker_.joinable()) worker_.join();
|
|
}
|
|
|
|
std::string AudioDownloader::statusString() const {
|
|
std::lock_guard<std::mutex> lock(mutex_);
|
|
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_);
|
|
status_ = msg;
|
|
}
|
|
cv_.notify_all();
|
|
}
|
|
|
|
void AudioDownloader::wait(std::chrono::seconds seconds) {
|
|
std::unique_lock<std::mutex> lock(mutex_);
|
|
cv_.wait_for(lock, seconds, [this]() { return stop_.load(); });
|
|
}
|
|
|
|
// ─── Worker loop ────────────────────────────────────────────────────────
|
|
|
|
void AudioDownloader::run() {
|
|
logger::Logger::instance().info("Audio", "Audio downloader started");
|
|
setStatus("Checking for audio updates...");
|
|
|
|
while (!stop_.load()) {
|
|
std::vector<AudioManifestEntry> manifest;
|
|
if (!fetchManifest(manifest)) {
|
|
setStatus("Waiting for internet...");
|
|
wait(std::chrono::seconds(5));
|
|
continue;
|
|
}
|
|
|
|
std::string dir = audioDir();
|
|
std::unordered_map<std::string, int> orderByFilename;
|
|
for (size_t i = 0; i < manifest.size(); ++i) {
|
|
orderByFilename[manifest[i].filename] = static_cast<int>(i + 1);
|
|
}
|
|
|
|
// `available` is the set of files actually present on disk: files
|
|
// whose checksum matches, plus files present but stale (they stay
|
|
// listed while being re-downloaded). Files that don't exist yet are
|
|
// excluded so the settings list reflects live download progress.
|
|
std::vector<AudioManifestEntry> queue;
|
|
std::vector<db::AudioFileRecord> available;
|
|
std::unordered_set<std::string> listed;
|
|
available.reserve(manifest.size());
|
|
|
|
auto addAvailable = [&](const AudioManifestEntry& entry) {
|
|
if (!listed.insert(entry.filename).second) return;
|
|
available.push_back({0, entry.display_name, entry.filename,
|
|
orderByFilename[entry.filename],
|
|
frequencyIdForHz(entry.solfeggio_frequency_hz)});
|
|
};
|
|
// Publishes the current on-disk set to the database so the audio
|
|
// settings list updates while the user is viewing it.
|
|
auto syncNow = [&]() {
|
|
if (db_ && !available.empty()) db_->syncAudioFiles(available);
|
|
};
|
|
|
|
for (const auto& entry : manifest) {
|
|
std::string localMd5 = computeFileMd5(dir + entry.filename);
|
|
if (localMd5 == entry.md5) {
|
|
addAvailable(entry);
|
|
} else if (!localMd5.empty()) {
|
|
logger::Logger::instance().warning(
|
|
"Audio", "Checksum mismatch for " + entry.filename + ", re-downloading");
|
|
addAvailable(entry);
|
|
queue.push_back(entry);
|
|
} else {
|
|
queue.push_back(entry);
|
|
}
|
|
}
|
|
|
|
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);
|
|
|
|
for (const auto& work : queue) {
|
|
if (stop_.load()) break;
|
|
setStatus("Downloading audio files (" + std::to_string(downloaded + 1) +
|
|
" / " + std::to_string(queue.size()) + ")...");
|
|
if (!downloadAndVerify(work.filename, work.md5, work.display_name)) break;
|
|
addAvailable(work);
|
|
++downloaded;
|
|
// Publish immediately so a file that just finished downloading
|
|
// appears in the list without waiting for the rest.
|
|
syncNow();
|
|
publishSnapshot(manifest, listed);
|
|
}
|
|
}
|
|
|
|
// Remove leftover audio files that are no longer in the manifest.
|
|
std::error_code ec;
|
|
if (std::filesystem::exists(dir)) {
|
|
for (auto it = std::filesystem::directory_iterator(dir, ec), end = std::filesystem::directory_iterator();
|
|
!ec && it != end; it.increment(ec)) {
|
|
if (!it->is_regular_file(ec)) continue;
|
|
std::string name = it->path().filename().string();
|
|
if (name.empty() || name.front() == '.' ||
|
|
name.size() >= 5 && name.compare(name.size() - 5, 5, ".part") == 0) {
|
|
continue;
|
|
}
|
|
if (orderByFilename.count(name) == 0) {
|
|
logger::Logger::instance().debug("Audio", "Removing leftover " + name);
|
|
std::filesystem::remove(it->path(), ec);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Final reconcile so rows for files removed from disk are dropped too.
|
|
// Runs even when nothing is available, so an empty on-disk set clears
|
|
// any stale rows.
|
|
if (db_) db_->syncAudioFiles(available);
|
|
|
|
bool upToDate = downloaded == toDownload;
|
|
if (upToDate && !stop_.load()) {
|
|
setStatus("Audio files up to date");
|
|
wait(std::chrono::seconds(60));
|
|
} else {
|
|
wait(std::chrono::seconds(5));
|
|
}
|
|
}
|
|
|
|
logger::Logger::instance().info("Audio", "Audio downloader stopped");
|
|
}
|
|
|
|
bool AudioDownloader::fetchManifest(std::vector<AudioManifestEntry>& out) {
|
|
std::string body;
|
|
std::string url = std::string(AUDIO_BASE_URL) + MANIFEST_FILENAME;
|
|
if (!httpGetString(url, body)) {
|
|
logger::Logger::instance().debug("Audio", "Failed to fetch audio manifest");
|
|
return false;
|
|
}
|
|
if (!parseManifest(body, out)) {
|
|
logger::Logger::instance().warning("Audio", "Audio manifest was unparseable");
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool AudioDownloader::downloadAndVerify(const std::string& filename, const std::string& expectedMd5, const std::string& displayName) {
|
|
std::string dir = audioDir();
|
|
std::string destPath = dir + filename;
|
|
std::string tmpPath = destPath + ".part";
|
|
|
|
std::error_code ec;
|
|
std::filesystem::remove(tmpPath, ec);
|
|
|
|
if (!httpGetFile(std::string(AUDIO_BASE_URL) + filename, tmpPath)) {
|
|
logger::Logger::instance().warning("Audio", "Download failed for " + filename);
|
|
std::filesystem::remove(tmpPath, ec);
|
|
return false;
|
|
}
|
|
|
|
std::string gotMd5 = computeFileMd5(tmpPath);
|
|
if (gotMd5 != expectedMd5) {
|
|
logger::Logger::instance().warning(
|
|
"Audio", "Checksum mismatch after download for " + filename);
|
|
std::filesystem::remove(tmpPath, ec);
|
|
return false;
|
|
}
|
|
|
|
std::filesystem::rename(tmpPath, destPath, ec);
|
|
if (ec) {
|
|
logger::Logger::instance().warning("Audio", "Could not move " + filename + " into place");
|
|
std::filesystem::remove(tmpPath, ec);
|
|
return false;
|
|
}
|
|
|
|
logger::Logger::instance().info("Audio", "Downloaded and verified " + displayName);
|
|
return true;
|
|
}
|
|
|
|
} // namespace radiantwave::audio
|