Remove WiFi gate

This commit is contained in:
NeilClack
2026-08-18 23:37:44 -04:00
parent ec6b76618c
commit 8e930ef8d9
4 changed files with 266 additions and 266 deletions
+177 -165
View File
@@ -4,206 +4,218 @@
#include "app_state.h" #include "app_state.h"
#include "home.h" #include "home.h"
#include "provisioning.h"
#include "logger/logger.h" #include "logger/logger.h"
#include "provisioning.h"
namespace namespace {
{ // How long audio fades out before the app tears down on quit. Only applied
// How long audio fades out before the app tears down on quit. Only applied // when audio is actually playing; otherwise quitting is immediate.
// when audio is actually playing; otherwise quitting is immediate. constexpr float kQuitFadeSeconds = 5.0f;
constexpr float kQuitFadeSeconds = 5.0f;
} // anonymous namespace } // anonymous namespace
SDL_AppResult SDL_AppInit(void **appstate, int argc, char *argv[]) SDL_AppResult SDL_AppInit(void **appstate, int argc, char *argv[]) {
{ // Initialize text logger
// Initialize text logger radiantwave::logger::Logger::instance().initialize();
radiantwave::logger::Logger::instance().initialize(); radiantwave::logger::Logger::instance().info(
radiantwave::logger::Logger::instance().info("System", "RadiantWave Kiosk starting up"); "System", "RadiantWave Kiosk starting up");
if (!SDL_Init(SDL_INIT_VIDEO)) if (!SDL_Init(SDL_INIT_VIDEO)) {
{ std::string err = SDL_GetError();
std::string err = SDL_GetError(); radiantwave::logger::Logger::instance().fatal(
radiantwave::logger::Logger::instance().fatal("Graphics", "Graphics",
"Screen display initialization failed. Please verify graphics hardware and display connections (SDL_Init error: " + err + ")"); "Screen display initialization failed. Please verify graphics "
SDL_Log("Failed to initialize SDL: %s", err.c_str()); "hardware and display connections (SDL_Init error: " +
return SDL_APP_FAILURE; err + ")");
} SDL_Log("Failed to initialize SDL: %s", err.c_str());
return SDL_APP_FAILURE;
}
auto *state = new AppState{}; auto *state = new AppState{};
// SDL_WINDOW_FULLSCREEN fills the desktop's native resolution, so the // SDL_WINDOW_FULLSCREEN fills the desktop's native resolution, so the
// compositor never has to scale the moving scroller (scaling moving text // compositor never has to scale the moving scroller (scaling moving text
// causes flicker). // causes flicker).
state->window = SDL_CreateWindow("RadiantWave", 1920, 1080, SDL_WINDOW_FULLSCREEN); state->window =
if (!state->window) SDL_CreateWindow("RadiantWave", 1920, 1080, SDL_WINDOW_FULLSCREEN);
{ if (!state->window) {
SDL_Log("Failed to create window: %s", SDL_GetError()); SDL_Log("Failed to create window: %s", SDL_GetError());
delete state; delete state;
return SDL_APP_FAILURE; return SDL_APP_FAILURE;
} }
state->renderer = SDL_CreateRenderer(state->window, nullptr); state->renderer = SDL_CreateRenderer(state->window, nullptr);
if (!state->renderer) if (!state->renderer) {
{ SDL_Log("Failed to create renderer: %s", SDL_GetError());
SDL_Log("Failed to create renderer: %s", SDL_GetError()); delete state;
delete state; return SDL_APP_FAILURE;
return SDL_APP_FAILURE; }
}
// Present on the display's refresh. Without VSync the kiosk panel tears // Present on the display's refresh. Without VSync the kiosk panel tears
// fast-scrolling text into horizontal bands that flicker as the text moves. // fast-scrolling text into horizontal bands that flicker as the text moves.
if (!SDL_SetRenderVSync(state->renderer, 1)) { if (!SDL_SetRenderVSync(state->renderer, 1)) {
radiantwave::logger::Logger::instance().warning("Graphics", radiantwave::logger::Logger::instance().warning(
"VSync could not be enabled; scrolling text may tear (VSync error: " + std::string(SDL_GetError()) + ")"); "Graphics", "VSync could not be enabled; scrolling text may tear "
} "(VSync error: " +
std::string(SDL_GetError()) + ")");
}
// Lock the renderer to a 1920x1080 logical coordinate space. All page // Lock the renderer to a 1920x1080 logical coordinate space. All page
// layout code already targets this resolution, so everything renders at // layout code already targets this resolution, so everything renders at
// the correct size on any display — the GPU scales to native pixels. // the correct size on any display — the GPU scales to native pixels.
if (!SDL_SetRenderLogicalPresentation(state->renderer, 1920, 1080, SDL_LOGICAL_PRESENTATION_STRETCH)) { // if (!SDL_SetRenderLogicalPresentation(state->renderer, 1920, 1080,
radiantwave::logger::Logger::instance().warning("Graphics", // SDL_LOGICAL_PRESENTATION_STRETCH)) {
"Could not set logical render size; layout may be wrong on HiDPI displays"); // radiantwave::logger::Logger::instance().warning("Graphics",
} // "Could not set logical render size; layout may be wrong on HiDPI
// displays");
// }
SDL_HideCursor(); SDL_HideCursor();
// Initialize configuration, database, and network manager // Initialize configuration, database, and network manager
if (!state->config.initialize()) { if (!state->config.initialize()) {
radiantwave::logger::Logger::instance().warning("Config", radiantwave::logger::Logger::instance().warning(
"Settings file could not be loaded. Default settings will be used (Config initialization failed at ~/.local/radiantwave/config.yaml)"); "Config", "Settings file could not be loaded. Default settings "
} else { "will be used (Config initialization failed at "
radiantwave::logger::Logger::instance().info("Config", "Configuration initialized successfully"); "~/.local/radiantwave/config.yaml)");
} } else {
radiantwave::logger::Logger::instance().info(
"Config", "Configuration initialized successfully");
}
if (!state->db.initialize()) { if (!state->db.initialize()) {
radiantwave::logger::Logger::instance().error("Database", radiantwave::logger::Logger::instance().error(
"Database storage unavailable. App features may be limited (Database initialization failed at ~/.local/radiantwave/radiantwave.db)"); "Database", "Database storage unavailable. App features may be "
} else { "limited (Database initialization failed at "
radiantwave::logger::Logger::instance().info("Database", "Database initialized and schema migrated"); "~/.local/radiantwave/radiantwave.db)");
} } else {
radiantwave::logger::Logger::instance().info(
"Database", "Database initialized and schema migrated");
}
if (!state->network.initialize()) { if (!state->network.initialize()) {
radiantwave::logger::Logger::instance().warning("Network", radiantwave::logger::Logger::instance().warning(
"WiFi network management is unavailable. Check if NetworkManager is running (D-Bus connection to org.freedesktop.NetworkManager failed)"); "Network", "WiFi network management is unavailable. Check if "
} else { "NetworkManager is running (D-Bus connection to "
radiantwave::logger::Logger::instance().info("Network", "NetworkManager service connected"); "org.freedesktop.NetworkManager failed)");
} } else {
radiantwave::logger::Logger::instance().info(
"Network", "NetworkManager service connected");
}
if (!state->audio.initialize(state->config)) { if (!state->audio.initialize(state->config)) {
radiantwave::logger::Logger::instance().warning("Audio", radiantwave::logger::Logger::instance().warning(
"Audio playback is unavailable. Check sound hardware connections (SDL3_mixer initialization failed)"); "Audio", "Audio playback is unavailable. Check sound hardware "
} else { "connections (SDL3_mixer initialization failed)");
radiantwave::logger::Logger::instance().info("Audio", "Audio subsystem initialized"); } else {
} radiantwave::logger::Logger::instance().info(
// Let playback logs report user-facing display names from the database. "Audio", "Audio subsystem initialized");
state->audio.attachDatabase(state->db); }
// Let playback logs report user-facing display names from the database.
state->audio.attachDatabase(state->db);
// Background download of audio assets. The worker keeps retrying until // Background download of audio assets. The worker keeps retrying until
// internet is available, so downloads start automatically on connect. // internet is available, so downloads start automatically on connect.
// The downloaded files are reconciled into the database's audio_files table. // The downloaded files are reconciled into the database's audio_files
state->audioDownloader.start(state->db); // table.
state->audioDownloader.start(state->db);
// Background license validation. Home triggers a refresh whenever it is // Background license validation. Home triggers a refresh whenever it is
// displayed; the result gates access to Play and the Proclamation options. // displayed; the result gates access to Play and the Proclamation options.
state->licenseTracker.start(state->db); state->licenseTracker.start(state->db);
state->config.onChanged([state](const radiantwave::config::AppConfig& new_config) { state->config.onChanged(
SDL_Log("Live config update received: volume=%d, dynamic_audio=%d", [state](const radiantwave::config::AppConfig &new_config) {
new_config.audio.volume, new_config.audio.dynamic_audio ? 1 : 0); SDL_Log("Live config update received: volume=%d, dynamic_audio=%d",
state->db.logEvent("INFO", "Config", "Live configuration hot-reloaded"); new_config.audio.volume,
}); new_config.audio.dynamic_audio ? 1 : 0);
state->db.logEvent("INFO", "Config",
"Live configuration hot-reloaded");
});
state->keybinds.bind(SDLK_UP, "menu_up", [state]() { state->keybinds.bind(SDLK_UP, "menu_up", [state]() {
state->currentMenuItem = (state->currentMenuItem + 1) % 2; state->currentMenuItem = (state->currentMenuItem + 1) % 2;
}); });
state->keybinds.bind(SDLK_DOWN, "menu_down", [state]() { state->keybinds.bind(SDLK_DOWN, "menu_down", [state]() {
state->currentMenuItem = (state->currentMenuItem + 1) % 2; state->currentMenuItem = (state->currentMenuItem + 1) % 2;
}); });
// Drive the First-Run walkthrough: shows the WiFi page when the internet // Drive the First-Run walkthrough: shows the WiFi page when the internet
// is unreachable, then the email and license pages when those are missing, // is unreachable <DEPRECATED>, then the email and license pages when those
// finishing on Home. The internet check is a real connectivity probe // are missing, finishing on Home. The internet check is a real connectivity
// (Ethernet, WiFi, or macOS all work), so it never traps a connected // probe (Ethernet, WiFi, or macOS all work), so it never traps a connected
// device on the WiFi page. // device on the WiFi page.
radiantwave::provisioning::begin(*state); radiantwave::provisioning::begin(*state);
*appstate = state; *appstate = state;
return SDL_APP_CONTINUE; return SDL_APP_CONTINUE;
} }
SDL_AppResult SDL_AppEvent(void *appstate, SDL_Event *event) SDL_AppResult SDL_AppEvent(void *appstate, SDL_Event *event) {
{ auto &state = *static_cast<AppState *>(appstate);
auto &state = *static_cast<AppState *>(appstate);
if (event->type == SDL_EVENT_QUIT) if (event->type == SDL_EVENT_QUIT) {
{ state.quitRequested = true;
state.quitRequested = true; }
}
if (!state.keybinds.handleEvent(*event)) { if (!state.keybinds.handleEvent(*event)) {
state.pages.handleEvent(*event); state.pages.handleEvent(*event);
} }
if (state.quitRequested && state.quitFadeRemaining <= 0.0f) if (state.quitRequested && state.quitFadeRemaining <= 0.0f) {
{ // Graceful shutdown: start the audio fade-out and keep the loop
// Graceful shutdown: start the audio fade-out and keep the loop // running until it completes, then let SDL_AppQuit tear everything
// running until it completes, then let SDL_AppQuit tear everything // down. When nothing is playing there is nothing to fade and quitting
// down. When nothing is playing there is nothing to fade and quitting // is immediate.
// is immediate. if (state.audio.stopTrackFadeOut(kQuitFadeSeconds)) {
if (state.audio.stopTrackFadeOut(kQuitFadeSeconds)) state.quitFadeRemaining = kQuitFadeSeconds;
{ return SDL_APP_CONTINUE;
state.quitFadeRemaining = kQuitFadeSeconds; }
return SDL_APP_CONTINUE; return SDL_APP_SUCCESS;
} }
return SDL_APP_SUCCESS;
}
return SDL_APP_CONTINUE; return SDL_APP_CONTINUE;
} }
SDL_AppResult SDL_AppIterate(void *appstate) SDL_AppResult SDL_AppIterate(void *appstate) {
{ auto &state = *static_cast<AppState *>(appstate);
auto &state = *static_cast<AppState *>(appstate);
Uint64 nowTicks = SDL_GetTicks(); Uint64 nowTicks = SDL_GetTicks();
static Uint64 lastTicks = nowTicks; static Uint64 lastTicks = nowTicks;
float dt = static_cast<float>(nowTicks - lastTicks) / 1000.0f; float dt = static_cast<float>(nowTicks - lastTicks) / 1000.0f;
if (dt > 0.1f) dt = 0.1f; if (dt > 0.1f)
lastTicks = nowTicks; dt = 0.1f;
lastTicks = nowTicks;
state.pages.update(dt); state.pages.update(dt);
if (state.quitFadeRemaining > 0.0f) if (state.quitFadeRemaining > 0.0f) {
{ state.quitFadeRemaining -= dt;
state.quitFadeRemaining -= dt; if (state.quitFadeRemaining <= 0.0f) {
if (state.quitFadeRemaining <= 0.0f) return SDL_APP_SUCCESS;
{ }
return SDL_APP_SUCCESS; }
}
}
SDL_SetRenderDrawColor(state.renderer, 30, 30, 46, 255); SDL_SetRenderDrawColor(state.renderer, 30, 30, 46, 255);
SDL_RenderClear(state.renderer); SDL_RenderClear(state.renderer);
state.pages.render(state.renderer); state.pages.render(state.renderer);
SDL_RenderPresent(state.renderer); SDL_RenderPresent(state.renderer);
return SDL_APP_CONTINUE; return SDL_APP_CONTINUE;
} }
void SDL_AppQuit(void *appstate, SDL_AppResult result) void SDL_AppQuit(void *appstate, SDL_AppResult result) {
{ auto *state = static_cast<AppState *>(appstate);
auto *state = static_cast<AppState *>(appstate); if (state) {
if (state) { state->audioDownloader.shutdown();
state->audioDownloader.shutdown(); state->licenseTracker.shutdown();
state->licenseTracker.shutdown(); state->audio.shutdown();
state->audio.shutdown(); state->network.shutdown();
state->network.shutdown(); state->config.shutdown();
state->config.shutdown(); state->db.close();
state->db.close(); SDL_DestroyRenderer(state->renderer);
SDL_DestroyRenderer(state->renderer); SDL_DestroyWindow(state->window);
SDL_DestroyWindow(state->window); delete state;
delete state; }
} SDL_Quit();
SDL_Quit();
} }
+61 -59
View File
@@ -1,80 +1,82 @@
#include "provisioning.h" #include "provisioning.h"
#include "wifi_setup_page.h"
#include "email_address_page.h" #include "email_address_page.h"
#include "license_key_page.h"
#include "home.h" #include "home.h"
#include "license_key_page.h"
#include "logger/logger.h" #include "logger/logger.h"
#include "wifi_setup_page.h"
namespace radiantwave::provisioning { namespace radiantwave::provisioning {
// Push the page for a walkthrough step, or Home when the walkthrough is done. // Push the page for a walkthrough step, or Home when the walkthrough is done.
static void pushStep(AppState& state, Step step) static void pushStep(AppState &state, Step step) {
{ switch (step) {
switch (step) { // case Step::Internet:
case Step::Internet: // state.pages.push(state, std::make_unique<WiFiSetupPage>());
state.pages.push(state, std::make_unique<WiFiSetupPage>()); // break;
break; case Step::Email:
case Step::Email: state.pages.push(state, std::make_unique<EmailAddressPage>());
state.pages.push(state, std::make_unique<EmailAddressPage>()); break;
break; case Step::License:
case Step::License: state.pages.push(state, std::make_unique<LicenseKeyPage>());
state.pages.push(state, std::make_unique<LicenseKeyPage>()); break;
break; case Step::Done:
case Step::Done: state.provisioningMode = false;
state.provisioningMode = false; state.pages.push(state, std::make_unique<Home>());
state.pages.push(state, std::make_unique<Home>()); break;
break; }
}
} }
void begin(AppState& state) void begin(AppState &state) {
{ // Without the database there is nowhere to persist email/license, so the
// Without the database there is nowhere to persist email/license, so the // walkthrough would trap the user on pages that can never be completed.
// walkthrough would trap the user on pages that can never be completed. if (!state.db.isOpen()) {
if (!state.db.isOpen()) { state.provisioningMode = false;
state.provisioningMode = false; state.pages.push(state, std::make_unique<Home>());
state.pages.push(state, std::make_unique<Home>()); return;
return; }
}
const bool internetUp = state.network.hasInternet(); const bool internetUp = state.network.hasInternet();
const bool hasEmail = !state.db.getKV(radiantwave::db::KV_EMAIL_ADDRESS).empty(); const bool hasEmail =
const bool hasLicense = !state.db.getKV(radiantwave::db::KV_LICENSE_KEY).empty(); !state.db.getKV(radiantwave::db::KV_EMAIL_ADDRESS).empty();
const bool hasLicense =
!state.db.getKV(radiantwave::db::KV_LICENSE_KEY).empty();
if (indicatesFirstRun(internetUp, hasEmail, hasLicense)) { if (indicatesFirstRun(internetUp, hasEmail, hasLicense)) {
// Placeholder: a brand-new device. Later this flag will launch the // Placeholder: a brand-new device. Later this flag will launch the
// first-run welcome walkthrough (TOS, quick user guide, ...). // first-run welcome walkthrough (TOS, quick user guide, ...).
state.firstRunWalkthrough = true; state.firstRunWalkthrough = true;
radiantwave::logger::Logger::instance().info("Provisioning", radiantwave::logger::Logger::instance().info(
"First-Run detected (no internet, no email, no license); " "Provisioning",
"first-run welcome walkthrough is not yet implemented"); "First-Run detected (no internet, no email, no license); "
} "first-run welcome walkthrough is not yet implemented");
}
state.walkthroughInternetUp = internetUp; state.walkthroughInternetUp = internetUp;
state.provisioningMode = true; state.provisioningMode = true;
pushStep(state, firstMissingStep(internetUp, hasEmail, hasLicense)); pushStep(state, firstMissingStep(internetUp, hasEmail, hasLicense));
} }
void advance(AppState& state) void advance(AppState &state) {
{ if (state.pages.depth() > 0) {
if (state.pages.depth() > 0) { state.pages.pop(state);
state.pages.pop(state); }
}
// Internet is the first prerequisite and never gets "un-reached" once // Internet is the first prerequisite and never gets "un-reached" once
// satisfied, so it is only re-probed when it was still missing (i.e. the // satisfied, so it is only re-probed when it was still missing (i.e. the
// walkthrough is coming from the WiFi page). // walkthrough is coming from the WiFi page).
bool internetUp = state.walkthroughInternetUp; bool internetUp = state.walkthroughInternetUp;
if (!internetUp) { if (!internetUp) {
internetUp = state.network.hasInternet(); internetUp = state.network.hasInternet();
state.walkthroughInternetUp = internetUp; state.walkthroughInternetUp = internetUp;
} }
const bool hasEmail = !state.db.getKV(radiantwave::db::KV_EMAIL_ADDRESS).empty(); const bool hasEmail =
const bool hasLicense = !state.db.getKV(radiantwave::db::KV_LICENSE_KEY).empty(); !state.db.getKV(radiantwave::db::KV_EMAIL_ADDRESS).empty();
const bool hasLicense =
!state.db.getKV(radiantwave::db::KV_LICENSE_KEY).empty();
state.provisioningMode = true; state.provisioningMode = true;
pushStep(state, firstMissingStep(internetUp, hasEmail, hasLicense)); pushStep(state, firstMissingStep(internetUp, hasEmail, hasLicense));
} }
} // namespace radiantwave::provisioning } // namespace radiantwave::provisioning
+12 -12
View File
@@ -24,33 +24,33 @@
namespace radiantwave::provisioning { namespace radiantwave::provisioning {
// Prerequisite steps in walkthrough priority order. // Prerequisite steps in walkthrough priority order.
enum class Step { Done, Internet, Email, License }; enum class Step { Done, Email, License };
// First unsatisfied step, given the current conditions. // First unsatisfied step, given the current conditions.
inline Step firstMissingStep(bool internetUp, bool hasEmail, bool hasLicense) inline Step firstMissingStep(bool internetUp, bool hasEmail, bool hasLicense) {
{ // if (!internetUp) return Step::Internet;
if (!internetUp) return Step::Internet; if (!hasEmail)
if (!hasEmail) return Step::Email; return Step::Email;
if (!hasLicense) return Step::License; if (!hasLicense)
return Step::Done; return Step::License;
return Step::Done;
} }
// True when nothing is configured (no internet, no email, no license). // True when nothing is configured (no internet, no email, no license).
// Signals a brand-new, never-provisioned device. // Signals a brand-new, never-provisioned device.
inline bool indicatesFirstRun(bool internetUp, bool hasEmail, bool hasLicense) inline bool indicatesFirstRun(bool internetUp, bool hasEmail, bool hasLicense) {
{ return !internetUp && !hasEmail && !hasLicense;
return !internetUp && !hasEmail && !hasLicense;
} }
// Startup walkthrough. Evaluates the prerequisites and pushes the first // Startup walkthrough. Evaluates the prerequisites and pushes the first
// missing step's page (with provisioningMode set), or Home when everything // missing step's page (with provisioningMode set), or Home when everything
// is satisfied. Home is always the end of the walkthrough. // is satisfied. Home is always the end of the walkthrough.
void begin(AppState& state); void begin(AppState &state);
// Called by a walkthrough page when the user finishes or exits it. Pops the // Called by a walkthrough page when the user finishes or exits it. Pops the
// current page, re-evaluates the prerequisites, and pushes the next missing // current page, re-evaluates the prerequisites, and pushes the next missing
// step's page (or Home). Internet is only re-probed when it was still // step's page (or Home). Internet is only re-probed when it was still
// missing, so completing a step never blocks on a redundant check. // missing, so completing a step never blocks on a redundant check.
void advance(AppState& state); void advance(AppState &state);
} // namespace radiantwave::provisioning } // namespace radiantwave::provisioning
+16 -30
View File
@@ -1,51 +1,37 @@
#include "test_harness.h"
#include "../src/provisioning.h" #include "../src/provisioning.h"
#include "test_harness.h"
using radiantwave::provisioning::firstMissingStep; using radiantwave::provisioning::firstMissingStep;
using radiantwave::provisioning::indicatesFirstRun; using radiantwave::provisioning::indicatesFirstRun;
using radiantwave::provisioning::Step; using radiantwave::provisioning::Step;
TEST(first_missing_step_all_satisfied) TEST(first_missing_step_all_satisfied) {
{ ASSERT_EQ(firstMissingStep(true, true, true), Step::Done);
ASSERT_EQ(firstMissingStep(true, true, true), Step::Done);
} }
TEST(first_missing_step_no_internet) TEST(first_missing_step_no_email) {
{ ASSERT_EQ(firstMissingStep(true, false, false), Step::Email);
// Internet is checked before email/license regardless of the others. ASSERT_EQ(firstMissingStep(true, false, true), Step::Email);
ASSERT_EQ(firstMissingStep(false, false, false), Step::Internet);
ASSERT_EQ(firstMissingStep(false, true, true), Step::Internet);
} }
TEST(first_missing_step_no_email) TEST(first_missing_step_no_license) {
{ ASSERT_EQ(firstMissingStep(true, true, false), Step::License);
ASSERT_EQ(firstMissingStep(true, false, false), Step::Email);
ASSERT_EQ(firstMissingStep(true, false, true), Step::Email);
} }
TEST(first_missing_step_no_license) TEST(first_run_all_missing) {
{ ASSERT_TRUE(indicatesFirstRun(false, false, false));
ASSERT_EQ(firstMissingStep(true, true, false), Step::License);
} }
TEST(first_run_all_missing) TEST(first_run_has_internet) {
{ ASSERT_FALSE(indicatesFirstRun(true, false, false));
ASSERT_TRUE(indicatesFirstRun(false, false, false));
} }
TEST(first_run_has_internet) TEST(first_run_has_email) {
{ ASSERT_FALSE(indicatesFirstRun(false, true, false));
ASSERT_FALSE(indicatesFirstRun(true, false, false));
} }
TEST(first_run_has_email) TEST(first_run_has_license) {
{ ASSERT_FALSE(indicatesFirstRun(false, false, true));
ASSERT_FALSE(indicatesFirstRun(false, true, false));
}
TEST(first_run_has_license)
{
ASSERT_FALSE(indicatesFirstRun(false, false, true));
} }
RUN_ALL_TESTS() RUN_ALL_TESTS()