6 Commits
Author SHA1 Message Date
NeilClack b96631c374 Add first Attic push 2026-08-21 14:52:14 -05:00
NeilClack 39eb6d7bf7 Update to new_checkshums.md5
new_checksums.md5 enables audio file naming and other audio related features.
2026-08-21 10:03:31 -05:00
NeilClack 3c9de0bee2 Fix double-scaling by locking page layout to logical presentation
Pages computed layout from SDL_GetWindowSize() (real window
coordinates) while SDL_SetRenderLogicalPresentation() independently
remapped every draw call into a 1920x1080 space. The two coordinate
systems only agreed when the window happened to be exactly 1920x1080;
at any other size, layout coordinates were transformed a second time,
so content shrank/grew quadratically toward the top-left on resize or
on non-1080p monitors. Commenting the call out masked the bug by
making rendering 1:1 with the window, but then nothing scaled: fonts
and margins stayed fixed-pixel while backgrounds cover-fit, and HiDPI
displays drew at point resolution and relied on blurry compositor
upsampling.

Restore SDL_SetRenderLogicalPresentation(1920x1080, LETTERBOX) as the
single source of scaling, and route all twelve layout sites through a
new radiantwave::getLayoutSize() helper (src/logical_size.h) that
returns the renderer's logical size, so pages always lay out against
the design resolution regardless of window or monitor geometry. Add
SDL_WINDOW_HIGH_PIXEL_DENSITY so HiDPI displays render at native pixel
density instead of being upscaled by the OS. Cached per-page textures
become resolution-independent since layout no longer depends on window
size.
2026-08-21 09:29:23 -05:00
NeilClack 8e930ef8d9 Remove WiFi gate 2026-08-18 23:37:44 -04:00
NeilClack ec6b76618c Lock compositor to 1920x1080 2026-08-17 22:46:44 -05:00
NeilClack 74245ee07d Remove API key requirement 2026-08-17 20:36:59 -05:00
33 changed files with 427 additions and 498 deletions
+9 -12
View File
@@ -4,7 +4,6 @@ on:
# Manual only for now. Select the branch to build when dispatching:
# main -> APP_VERSION is the current git tag
# dev -> APP_VERSION is the current commit hash
# Both branches bake the portal API key from secrets.
workflow_dispatch:
jobs:
@@ -44,28 +43,18 @@ jobs:
else
APP_VERSION="$(git rev-parse --short HEAD)"
fi
echo "APP_VERSION=$APP_VERSION" >> "$GITHUB_ENV"
echo "Branch: ${{ gitea.ref_name }} -> APP_VERSION=$APP_VERSION"
if [ -n "${{ secrets.RWT_API_KEY }}" ]; then
echo "API key: provided via secret RWT_API_KEY"
else
echo "API key: NOT set (build proceeds without an Authorization header)"
fi
# checks is keyed per-system (checks.<system>.default); nix build's
# #attr shorthand can't resolve it, so resolve the current system inline.
# --impure lets the flake read APP_VERSION/API_KEY via builtins.getEnv
# --impure lets the flake read APP_VERSION via builtins.getEnv
# (pure evals always get the package defaults). The offline test suite
# runs in the package's checkPhase.
- name: Build + offline test suite
env:
API_KEY: ${{ secrets.RWT_API_KEY }}
run: nix build -L ".#checks.$(nix eval --impure --raw --expr 'builtins.currentSystem').default" --impure
- name: Live portal tests
if: ${{ secrets.RWT_LIVE_API_KEY != '' }}
env:
RWT_LIVE_API_KEY: ${{ secrets.RWT_LIVE_API_KEY }}
RWT_LIVE_ACCOUNT_EMAIL: ${{ secrets.RWT_LIVE_ACCOUNT_EMAIL }}
RWT_LIVE_LICENSE_EMAIL: ${{ secrets.RWT_LIVE_LICENSE_EMAIL }}
RWT_LIVE_LICENSE_KEY: ${{ secrets.RWT_LIVE_LICENSE_KEY }}
@@ -76,3 +65,11 @@ jobs:
./build/test_api_client
./build/test_license_service
'
- name: Push to Attic cache
run: |
nix profile install github:zhaofengli/attic#attic-client \
--extra-experimental-features "nix-command flakes"
attic login radiantwave https://attic.radiantwavetech.com "${{ secrets.ATTIC_TOKEN }}"
attic push radiantwave ./result-kiosk ./result-android ./result-commercial
+6 -24
View File
@@ -33,23 +33,8 @@ cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug -DAPP_VERSION="1.2.3"
The Nix package bakes `-DAPP_VERSION=<pkg version>` via `cmakeFlags` (`version`
defaults to `0.1.0` in `nix/package.nix`; the flake overrides it from the
`APP_VERSION` env var on impure evals). `nix develop`
sets `APP_VERSION=dev` and exports `API_KEY` from the ambient environment
(falling back to `RWT_LIVE_API_KEY`). CMake seeds a *fresh* configure from the
`APP_VERSION` / `API_KEY` env vars; `-D` wins on reconfigure.
The portal API key is also a build-time argument: `-DAPI_KEY="..."` (CMake) or
`apiKey` on `callPackage` (Nix). It is compiled into the binary as the
`Authorization: Bearer` token and defaults to empty (no auth header). Keep the
real value in CI secrets, never in source — rotate by rebuilding with a new
key.
Version and API key can be baked in together with a single configure:
```sh
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug \
-DAPP_VERSION="1.2.3" \
-DAPI_KEY="$API_KEY"
```
sets `APP_VERSION=dev`. CMake seeds a *fresh* configure from the
`APP_VERSION` env var; `-D` wins on reconfigure.
- The app resolves `assets/...` relative to its working directory — launch
`./build/radiantwave` from the repo root. (The Nix wrapper chdirs to
@@ -238,14 +223,11 @@ cached.
`checks` is keyed per-system, so the workflow resolves the current system
via `nix eval --impure --expr 'builtins.currentSystem'`). The build bakes
in a per-branch `APP_VERSION` — the current git tag on `main`, the current
commit hash on `dev` — plus the portal API key from the `RWT_API_KEY`
secret. The flake reads `APP_VERSION`/`API_KEY` from the environment via
commit hash on `dev`. The flake reads `APP_VERSION` from the environment via
`builtins.getEnv`, so the build must pass `--impure` (pure evals get the
package defaults). When the `RWT_LIVE_API_KEY` secret is set it also runs
`test_api_client` / `test_license_service` against the portal via
`nix develop` (outside the sandbox, like a dev machine). Those live tests
also need `RWT_LIVE_ACCOUNT_EMAIL`, `RWT_LIVE_LICENSE_EMAIL`, and
`RWT_LIVE_LICENSE_KEY`.
package defaults). The live portal tests run from `nix develop` when the
`RWT_LIVE_*` secrets are set and need `RWT_LIVE_ACCOUNT_EMAIL`,
`RWT_LIVE_LICENSE_EMAIL`, and `RWT_LIVE_LICENSE_KEY`.
- `test_assets` must pass before committing — it catches missing fonts/images
that break the app at runtime.
- `cmake --build build` builds everything; pass `--target <name>` to build one
-15
View File
@@ -18,20 +18,6 @@ else()
"Version string shown in the Home footer (override at configure time)")
endif()
# API bearer key baked into the binary and sent as `Authorization: Bearer <key>`
# on every portal request. Leave empty to send no auth header. Never commit the
# real value — inject it at CI build time from a secret, e.g.:
# cmake ... -DAPI_KEY="${{ secrets.PORTAL_API_KEY }}"
# The `nix develop` shell exports API_KEY from the ambient environment, which
# seeds a fresh configure; -D wins on reconfigure.
if(DEFINED ENV{API_KEY})
set(API_KEY "$ENV{API_KEY}" CACHE STRING
"Portal API key baked into the binary (leave empty to skip auth)")
else()
set(API_KEY "" CACHE STRING
"Portal API key baked into the binary (leave empty to skip auth)")
endif()
find_package(SDL3 REQUIRED)
find_package(SDL3_ttf REQUIRED)
find_package(SDL3_image REQUIRED)
@@ -90,7 +76,6 @@ add_executable(radiantwave
target_compile_definitions(radiantwave PRIVATE
APP_VERSION="${APP_VERSION}"
API_KEY="${API_KEY}"
)
target_include_directories(radiantwave PRIVATE src)
+11 -43
View File
@@ -136,45 +136,13 @@ If `APP_VERSION` is left unset the footer falls back to the project version
from `CMakeLists.txt` (`project(... VERSION ...)`); a build that defines no
version at all shows `unknown`. The Nix build sets `APP_VERSION` automatically
to the flake package version, so a plain `nix build` needs no extra flags.
With `--impure` the flake instead reads the `APP_VERSION` / `API_KEY`
environment variables — this is how CI injects the version and portal key
With `--impure` the flake instead reads the `APP_VERSION`
environment variable — this is how CI injects the version
(see the CI note under *Common Pitfalls*). Pure evals always get the package
defaults, because `builtins.getEnv` cannot see the environment in pure
evaluation mode.
#### Portal API key
Every portal request sends `Authorization: Bearer <key>`. The key is baked
into the binary at build time via the `API_KEY` CMake cache variable. It is
**optional** — when unset, requests go out without an auth header. Keep the
real value out of the source tree and inject it at build time from your CI
secret store so it never lands in `git`:
```sh
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Release \
-DAPI_KEY="${{ secrets.PORTAL_API_KEY }}"
cmake --build build
```
Rotate the key by building with a new value and shipping the new binary. The
Nix package accepts the same value via `callPackage ./nix/package.nix {
apiKey = "…"; }`, or via the `API_KEY` environment variable with
`nix build --impure`; the flake itself never embeds one.
#### Version + API key in one configure
`APP_VERSION` and `API_KEY` are independent CMake cache variables, so pass
both — plus the build type — in a single configure:
```sh
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug \
-DAPP_VERSION="1.2.3" \
-DAPI_KEY="${{ secrets.PORTAL_API_KEY }}"
cmake --build build
```
`-D` values always win over the `APP_VERSION` / `API_KEY` environment
variables, which `nix develop` seeds from the ambient environment.
#### Version in one configure
### Building with Nix
@@ -187,14 +155,14 @@ nix build .#packages.x86_64-linux.default # needs a Linux machine/builders
nix build .#packages.aarch64-linux.default
```
To bake in a custom version and/or portal key, build impurely — the flake
reads `APP_VERSION` and `API_KEY` from the environment on impure evals:
To bake in a custom version, build impurely — the flake
reads `APP_VERSION` from the environment on impure evals:
```sh
APP_VERSION="1.2.3" API_KEY="$PORTAL_API_KEY" nix build --impure .#default
APP_VERSION="1.2.3" nix build --impure .#default
```
A pure `nix build` always uses the package defaults (`0.1.0`, no auth header)
A pure `nix build` always uses the package defaults (`0.1.0`)
even if those variables are set in your shell, because pure evaluation cannot
see the environment. CI uses `--impure` to inject them.
@@ -633,9 +601,9 @@ status to decide which page to show.
workflow is `.gitea/workflows/build.yml`. It is **manual-only**
(`workflow_dispatch`) — pick the branch when dispatching. `main` builds bake
the current git tag as `APP_VERSION`; `dev` builds bake the current commit
hash. Both pass the portal key from the `RWT_API_KEY` secret and build with
`nix build --impure` (which lets the flake's env injection work). The
offline test suite runs in the package's `checkPhase`; the live portal tests
run from `nix develop` when the `RWT_LIVE_*` secrets are set.
hash. Both build with `nix build --impure` (which lets the flake's env
injection work). The offline test suite runs in the package's `checkPhase`;
the live portal tests run from `nix develop` when the `RWT_LIVE_*` secrets
are set.
- Asset tests (`test_assets`) must pass before committing -- they catch missing
fonts and images that would break the app at runtime.
+13 -20
View File
@@ -34,18 +34,16 @@
};
in
{
# Version and API key are injected from the ambient environment on
# impure evals (CI sets APP_VERSION/API_KEY from the git ref and a
# secret). Pure evals (`nix flake check`, local `nix build`) can't
# see the environment, so builtins.getEnv returns "" and the
# package falls back to its 0.1.0 default / no auth header.
# Version is injected from the ambient environment on
# impure evals (CI sets APP_VERSION from the git ref). Pure evals
# (`nix flake check`, local `nix build`) can't see the
# environment, so builtins.getEnv returns "" and the package
# falls back to its 0.1.0 default.
packages.default =
let
envVersion = builtins.getEnv "APP_VERSION";
envApiKey = builtins.getEnv "API_KEY";
packageArgs =
{ apiKey = envApiKey; }
// (nixpkgs.lib.optionalAttrs (envVersion != "") { version = envVersion; });
nixpkgs.lib.optionalAttrs (envVersion != "") { version = envVersion; };
in
pkgs.callPackage ./nix/package.nix packageArgs;
@@ -53,22 +51,17 @@
inputsFrom = [ self.packages.${system}.default ];
packages = [ ];
env = {
# APP_VERSION and API_KEY are CMake cache variables with no
# default; on a fresh configure cmake seeds their cache from
# environment variables of the same name, so a plain
# `cmake -S . -B build` bakes them into the binary.
# APP_VERSION is a CMake cache variable with no default; on a
# fresh configure cmake seeds its cache from the environment
# variable of the same name, so a plain `cmake -S . -B build`
# bakes it into the binary.
APP_VERSION = "dev";
};
shellHook = ''
# Never commit the real key. Export API_KEY (or the CI name
# RWT_LIVE_API_KEY) in your ambient environment and this shell
# picks it up; empty by default (no auth header).
export API_KEY="''${API_KEY:-''${RWT_LIVE_API_KEY:-}}"
echo "RadiantWave dev shell (APP_VERSION=dev, API_KEY=$([[ -n "$API_KEY" ]] && echo set || echo unset))"
echo "RadiantWave dev shell (APP_VERSION=dev)"
echo " Configure: cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug"
echo " Bake version+key: cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug -DAPP_VERSION=\"1.2.3\" -DAPI_KEY=\"<key>\""
echo " Nix build (env version+key, impure): APP_VERSION=... API_KEY=... nix build --impure .#default"
echo " Bake version: cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug -DAPP_VERSION=\"1.2.3\""
echo " Nix build (env version, impure): APP_VERSION=... nix build --impure .#default"
echo " Build: cmake --build build"
echo " Run: ./build/radiantwave"
echo " Tests: ./build/test_assets && ./build/test_<name>"
+1 -7
View File
@@ -15,9 +15,6 @@
curl,
openssl,
systemdLibs,
# Portal API key baked into the binary. Keep it out of the flake; pass it
# when calling callPackage from CI/your own config (see README "App version").
apiKey ? "",
# App version baked into the Home footer's Version row. CI overrides this
# per branch (git tag on main, commit hash on dev); local/pure builds keep
# the 0.1.0 default.
@@ -54,10 +51,7 @@ stdenv.mkDerivation (finalAttrs: {
# Bake the package version into the binary so the Home footer's Version row
# shows it (CMake passes APP_VERSION through as a compile definition).
# API_KEY is only added when a non-empty key is supplied to callPackage.
cmakeFlags =
[ "-DAPP_VERSION=${finalAttrs.version}" ]
++ lib.optionals (apiKey != "") [ "-DAPI_KEY=${apiKey}" ];
cmakeFlags = [ "-DAPP_VERSION=${finalAttrs.version}" ];
# gtest is a buildInput, not native: nixpkgs builds it shared, so the
# check-phase test binaries need its lib dir baked into their RPATH.
+18 -14
View File
@@ -37,7 +37,7 @@ struct AudioManifestEntry {
// and keeps the database's audio_files table in sync with what is actually
// on disk.
//
// Fetches checksums.md5, compares each entry against the local file's MD5,
// Fetches new_checksums.md5, compares each entry against the local file's MD5,
// and re-downloads anything that is missing or whose checksum differs.
// Files present on disk but absent from the manifest are deleted, and the
// audio_files table is reconciled on every pass (and after each individual
@@ -53,12 +53,12 @@ public:
AudioDownloader() = default;
~AudioDownloader();
AudioDownloader(const AudioDownloader&) = delete;
AudioDownloader& operator=(const AudioDownloader&) = delete;
AudioDownloader(const AudioDownloader &) = delete;
AudioDownloader &operator=(const AudioDownloader &) = delete;
// Spawns the background thread. Safe to call repeatedly; only one worker
// runs at a time. The manifest is reconciled against `db` on every pass.
void start(db::DatabaseManager& db);
void start(db::DatabaseManager &db);
// Signals the worker to stop and joins the thread.
void shutdown();
@@ -66,15 +66,18 @@ public:
// Returns the current human-readable status for display (e.g. footer).
std::string statusString() const;
static constexpr const char* AUDIO_BASE_URL = "https://files.radiantwavetech.com/audio/";
static constexpr const char* MANIFEST_FILENAME = "checksums.md5";
static constexpr const char *AUDIO_BASE_URL =
"https://files.radiantwavetech.com/audio/";
static constexpr const char *MANIFEST_FILENAME = "new_checksums.md5";
private:
void run();
bool fetchManifest(std::vector<AudioManifestEntry>& out);
bool downloadAndVerify(const std::string& filename, const std::string& expectedMd5, const std::string& displayName);
bool fetchManifest(std::vector<AudioManifestEntry> &out);
bool downloadAndVerify(const std::string &filename,
const std::string &expectedMd5,
const std::string &displayName);
void setStatus(const std::string& msg);
void setStatus(const std::string &msg);
void wait(std::chrono::seconds seconds);
mutable std::mutex mutex_;
@@ -82,13 +85,14 @@ private:
std::thread worker_;
std::atomic<bool> stop_{false};
std::string status_;
db::DatabaseManager* db_ = nullptr;
db::DatabaseManager *db_ = nullptr;
};
// Parses checksums.md5 manifest text into entries. Blank lines and lines
// Parses new_checksums.md5 manifest text into entries. Blank lines and lines
// starting with '#' are ignored. Lines that don't match a known format are
// skipped. Returns false if no valid entries were found.
bool parseManifest(const std::string& text, std::vector<AudioManifestEntry>& out);
bool parseManifest(const std::string &text,
std::vector<AudioManifestEntry> &out);
// Maps a frequency value in Hz to its internal solfeggio frequency id using
// the same table the color palettes are seeded from (174/285->1, 396->2,
@@ -100,9 +104,9 @@ int frequencyIdForHz(int hz);
// "396Hz.wav" -> 396. Requires the digits to be followed by "Hz"
// (case-insensitive); returns 0 when the name has no frequency (e.g.
// "AlphaWaves.wav", "OriginalAudio.wav").
int hzFromFilename(const std::string& filename);
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);
std::string computeFileMd5(const std::string &path);
} // namespace radiantwave::audio
+2 -1
View File
@@ -1,5 +1,6 @@
#include "audio_settings_page.h"
#include "app_state.h"
#include "logical_size.h"
#include <SDL3/SDL.h>
#include <algorithm>
#include <cctype>
@@ -477,7 +478,7 @@ void AudioSettingsPage::renderModal(SDL_Renderer* renderer, float fw, float fh,
void AudioSettingsPage::render(SDL_Renderer* renderer) {
int winW, winH;
SDL_GetWindowSize(SDL_GetRenderWindow(renderer), &winW, &winH);
radiantwave::getLayoutSize(renderer, &winW, &winH);
float fw = static_cast<float>(winW);
float fh = static_cast<float>(winH);
+2 -1
View File
@@ -1,6 +1,7 @@
#include "date_format_page.h"
#include "app_state.h"
#include "date_format.h"
#include "logical_size.h"
#include <SDL3/SDL.h>
#include <algorithm>
#include <ctime>
@@ -131,7 +132,7 @@ void DateFormatPage::update(float dt)
void DateFormatPage::render(SDL_Renderer* renderer)
{
int winW, winH;
SDL_GetWindowSize(SDL_GetRenderWindow(renderer), &winW, &winH);
radiantwave::getLayoutSize(renderer, &winW, &winH);
float fw = static_cast<float>(winW);
float fh = static_cast<float>(winH);
+2 -1
View File
@@ -3,6 +3,7 @@
#include "db/db_keys.h"
#include "provisioning.h"
#include "validation.h"
#include "logical_size.h"
#include <SDL3/SDL.h>
namespace {
@@ -164,7 +165,7 @@ void EmailAddressPage::exitPage(AppState& state)
void EmailAddressPage::render(SDL_Renderer* renderer)
{
int winW, winH;
SDL_GetWindowSize(SDL_GetRenderWindow(renderer), &winW, &winH);
radiantwave::getLayoutSize(renderer, &winW, &winH);
float fw = static_cast<float>(winW);
float fh = static_cast<float>(winH);
+2 -1
View File
@@ -8,6 +8,7 @@
#include "audio/audio_downloader.h"
#include "db/db_keys.h"
#include "network/proclamation_sync.h"
#include "logical_size.h"
#include <SDL3/SDL.h>
#include <SDL3_image/SDL_image.h>
#include <ctime>
@@ -114,7 +115,7 @@ void Home::update(float dt)
void Home::render(SDL_Renderer *renderer)
{
int winW, winH;
SDL_GetWindowSize(SDL_GetRenderWindow(renderer), &winW, &winH);
radiantwave::getLayoutSize(renderer, &winW, &winH);
float fw = static_cast<float>(winW);
float fh = static_cast<float>(winH);
+2 -1
View File
@@ -1,6 +1,7 @@
#include "license_blocked_page.h"
#include "app_state.h"
#include "home.h"
#include "logical_size.h"
#include <SDL3/SDL.h>
void LicenseBlockedPage::onPush(AppState& state)
@@ -71,7 +72,7 @@ void LicenseBlockedPage::update(float dt)
void LicenseBlockedPage::render(SDL_Renderer* renderer)
{
int winW, winH;
SDL_GetWindowSize(SDL_GetRenderWindow(renderer), &winW, &winH);
radiantwave::getLayoutSize(renderer, &winW, &winH);
float fw = static_cast<float>(winW);
float fh = static_cast<float>(winH);
+3 -2
View File
@@ -4,6 +4,7 @@
#include "network/license_service.h"
#include "provisioning.h"
#include "validation.h"
#include "logical_size.h"
#include <SDL3/SDL.h>
void LicenseKeyPage::onPush(AppState& state)
@@ -97,7 +98,7 @@ void LicenseKeyPage::submit()
// 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);
state_->db, &errorMessage);
switch (result) {
case radiantwave::license::Result::Ok:
@@ -172,7 +173,7 @@ void LicenseKeyPage::exitPage(AppState& state)
void LicenseKeyPage::render(SDL_Renderer* renderer)
{
int winW, winH;
SDL_GetWindowSize(SDL_GetRenderWindow(renderer), &winW, &winH);
radiantwave::getLayoutSize(renderer, &winW, &winH);
float fw = static_cast<float>(winW);
float fh = static_cast<float>(winH);
+2 -1
View File
@@ -1,6 +1,7 @@
#include "log_viewer_page.h"
#include "app_state.h"
#include "timezone_helper.h"
#include "logical_size.h"
#include <SDL3/SDL.h>
#include <algorithm>
#include <sstream>
@@ -162,7 +163,7 @@ void LogViewerPage::update(float dt) {
void LogViewerPage::render(SDL_Renderer* renderer) {
int winW, winH;
SDL_GetWindowSize(SDL_GetRenderWindow(renderer), &winW, &winH);
radiantwave::getLayoutSize(renderer, &winW, &winH);
float fw = static_cast<float>(winW);
float fh = static_cast<float>(winH);
+17
View File
@@ -0,0 +1,17 @@
#pragma once
#include <SDL3/SDL.h>
namespace radiantwave {
inline void getLayoutSize(SDL_Renderer* renderer, int* w, int* h)
{
SDL_RendererLogicalPresentation mode = SDL_LOGICAL_PRESENTATION_DISABLED;
if (!SDL_GetRenderLogicalPresentation(renderer, w, h, &mode) ||
mode == SDL_LOGICAL_PRESENTATION_DISABLED || *w <= 0 || *h <= 0)
{
SDL_GetWindowSize(SDL_GetRenderWindow(renderer), w, h);
}
}
} // namespace radiantwave
+76 -56
View File
@@ -4,27 +4,28 @@
#include "app_state.h"
#include "home.h"
#include "provisioning.h"
#include "logger/logger.h"
#include "provisioning.h"
namespace
{
// How long audio fades out before the app tears down on quit. Only applied
// when audio is actually playing; otherwise quitting is immediate.
constexpr float kQuitFadeSeconds = 5.0f;
namespace {
// How long audio fades out before the app tears down on quit. Only applied
// when audio is actually playing; otherwise quitting is immediate.
constexpr float kQuitFadeSeconds = 5.0f;
} // 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
radiantwave::logger::Logger::instance().initialize();
radiantwave::logger::Logger::instance().info("System", "RadiantWave Kiosk starting up");
radiantwave::logger::Logger::instance().info(
"System", "RadiantWave Kiosk starting up");
if (!SDL_Init(SDL_INIT_VIDEO))
{
if (!SDL_Init(SDL_INIT_VIDEO)) {
std::string err = SDL_GetError();
radiantwave::logger::Logger::instance().fatal("Graphics",
"Screen display initialization failed. Please verify graphics hardware and display connections (SDL_Init error: " + err + ")");
radiantwave::logger::Logger::instance().fatal(
"Graphics",
"Screen display initialization failed. Please verify graphics "
"hardware and display connections (SDL_Init error: " +
err + ")");
SDL_Log("Failed to initialize SDL: %s", err.c_str());
return SDL_APP_FAILURE;
}
@@ -34,17 +35,16 @@ SDL_AppResult SDL_AppInit(void **appstate, int argc, char *argv[])
// SDL_WINDOW_FULLSCREEN fills the desktop's native resolution, so the
// compositor never has to scale the moving scroller (scaling moving text
// causes flicker).
state->window = SDL_CreateWindow("RadiantWave", 1920, 1080, SDL_WINDOW_FULLSCREEN);
if (!state->window)
{
state->window =
SDL_CreateWindow("RadiantWave", 1920, 1080, SDL_WINDOW_FULLSCREEN);
if (!state->window) {
SDL_Log("Failed to create window: %s", SDL_GetError());
delete state;
return SDL_APP_FAILURE;
}
state->renderer = SDL_CreateRenderer(state->window, nullptr);
if (!state->renderer)
{
if (!state->renderer) {
SDL_Log("Failed to create renderer: %s", SDL_GetError());
delete state;
return SDL_APP_FAILURE;
@@ -53,56 +53,83 @@ SDL_AppResult SDL_AppInit(void **appstate, int argc, char *argv[])
// Present on the display's refresh. Without VSync the kiosk panel tears
// fast-scrolling text into horizontal bands that flicker as the text moves.
if (!SDL_SetRenderVSync(state->renderer, 1)) {
radiantwave::logger::Logger::instance().warning("Graphics",
"VSync could not be enabled; scrolling text may tear (VSync error: " + std::string(SDL_GetError()) + ")");
radiantwave::logger::Logger::instance().warning(
"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
// layout code already targets this resolution, so everything renders at
// the correct size on any display — the GPU scales to native pixels.
if (!SDL_SetRenderLogicalPresentation(state->renderer, 1920, 1080,
SDL_LOGICAL_PRESENTATION_STRETCH)) {
radiantwave::logger::Logger::instance().warning(
"Graphics", "Could not set logical render size; layout may be "
"wrong on HiDPI displays");
}
SDL_HideCursor();
// Initialize configuration, database, and network manager
if (!state->config.initialize()) {
radiantwave::logger::Logger::instance().warning("Config",
"Settings file could not be loaded. Default settings will be used (Config initialization failed at ~/.local/radiantwave/config.yaml)");
radiantwave::logger::Logger::instance().warning(
"Config", "Settings file could not be loaded. Default settings "
"will be used (Config initialization failed at "
"~/.local/radiantwave/config.yaml)");
} else {
radiantwave::logger::Logger::instance().info("Config", "Configuration initialized successfully");
radiantwave::logger::Logger::instance().info(
"Config", "Configuration initialized successfully");
}
if (!state->db.initialize()) {
radiantwave::logger::Logger::instance().error("Database",
"Database storage unavailable. App features may be limited (Database initialization failed at ~/.local/radiantwave/radiantwave.db)");
radiantwave::logger::Logger::instance().error(
"Database", "Database storage unavailable. App features may be "
"limited (Database initialization failed at "
"~/.local/radiantwave/radiantwave.db)");
} else {
radiantwave::logger::Logger::instance().info("Database", "Database initialized and schema migrated");
radiantwave::logger::Logger::instance().info(
"Database", "Database initialized and schema migrated");
}
if (!state->network.initialize()) {
radiantwave::logger::Logger::instance().warning("Network",
"WiFi network management is unavailable. Check if NetworkManager is running (D-Bus connection to org.freedesktop.NetworkManager failed)");
radiantwave::logger::Logger::instance().warning(
"Network", "WiFi network management is unavailable. Check if "
"NetworkManager is running (D-Bus connection to "
"org.freedesktop.NetworkManager failed)");
} else {
radiantwave::logger::Logger::instance().info("Network", "NetworkManager service connected");
radiantwave::logger::Logger::instance().info(
"Network", "NetworkManager service connected");
}
if (!state->audio.initialize(state->config)) {
radiantwave::logger::Logger::instance().warning("Audio",
"Audio playback is unavailable. Check sound hardware connections (SDL3_mixer initialization failed)");
radiantwave::logger::Logger::instance().warning(
"Audio", "Audio playback is unavailable. Check sound hardware "
"connections (SDL3_mixer initialization failed)");
} else {
radiantwave::logger::Logger::instance().info("Audio", "Audio subsystem initialized");
radiantwave::logger::Logger::instance().info(
"Audio", "Audio subsystem initialized");
}
// 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
// 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
// table.
state->audioDownloader.start(state->db);
// Background license validation. Home triggers a refresh whenever it is
// displayed; the result gates access to Play and the Proclamation options.
state->licenseTracker.start(state->db);
state->config.onChanged([state](const radiantwave::config::AppConfig& new_config) {
state->config.onChanged(
[state](const radiantwave::config::AppConfig &new_config) {
SDL_Log("Live config update received: volume=%d, dynamic_audio=%d",
new_config.audio.volume, new_config.audio.dynamic_audio ? 1 : 0);
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]() {
@@ -113,9 +140,9 @@ SDL_AppResult SDL_AppInit(void **appstate, int argc, char *argv[])
});
// Drive the First-Run walkthrough: shows the WiFi page when the internet
// is unreachable, then the email and license pages when those are missing,
// finishing on Home. The internet check is a real connectivity probe
// (Ethernet, WiFi, or macOS all work), so it never traps a connected
// is unreachable <DEPRECATED>, then the email and license pages when those
// are missing, finishing on Home. The internet check is a real connectivity
// probe (Ethernet, WiFi, or macOS all work), so it never traps a connected
// device on the WiFi page.
radiantwave::provisioning::begin(*state);
@@ -123,12 +150,10 @@ SDL_AppResult SDL_AppInit(void **appstate, int argc, char *argv[])
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);
if (event->type == SDL_EVENT_QUIT)
{
if (event->type == SDL_EVENT_QUIT) {
state.quitRequested = true;
}
@@ -136,14 +161,12 @@ SDL_AppResult SDL_AppEvent(void *appstate, SDL_Event *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
// running until it completes, then let SDL_AppQuit tear everything
// down. When nothing is playing there is nothing to fade and quitting
// is immediate.
if (state.audio.stopTrackFadeOut(kQuitFadeSeconds))
{
if (state.audio.stopTrackFadeOut(kQuitFadeSeconds)) {
state.quitFadeRemaining = kQuitFadeSeconds;
return SDL_APP_CONTINUE;
}
@@ -153,23 +176,21 @@ SDL_AppResult SDL_AppEvent(void *appstate, SDL_Event *event)
return SDL_APP_CONTINUE;
}
SDL_AppResult SDL_AppIterate(void *appstate)
{
SDL_AppResult SDL_AppIterate(void *appstate) {
auto &state = *static_cast<AppState *>(appstate);
Uint64 nowTicks = SDL_GetTicks();
static Uint64 lastTicks = nowTicks;
float dt = static_cast<float>(nowTicks - lastTicks) / 1000.0f;
if (dt > 0.1f) dt = 0.1f;
if (dt > 0.1f)
dt = 0.1f;
lastTicks = nowTicks;
state.pages.update(dt);
if (state.quitFadeRemaining > 0.0f)
{
if (state.quitFadeRemaining > 0.0f) {
state.quitFadeRemaining -= dt;
if (state.quitFadeRemaining <= 0.0f)
{
if (state.quitFadeRemaining <= 0.0f) {
return SDL_APP_SUCCESS;
}
}
@@ -183,8 +204,7 @@ SDL_AppResult SDL_AppIterate(void *appstate)
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);
if (state) {
state->audioDownloader.shutdown();
+13 -26
View File
@@ -4,10 +4,6 @@
#include <curl/curl.h>
#include <string>
#ifndef API_KEY
#define API_KEY ""
#endif
namespace radiantwave::api {
namespace {
@@ -31,7 +27,7 @@ size_t writeCallback(char* ptr, size_t size, size_t nmemb, void* userdata) {
}
// Shared curl setup + perform. Returns a populated HttpResponse.
HttpResponse performRequest(const std::string& url, const std::string& apiKey,
HttpResponse performRequest(const std::string& url,
bool post, const std::string& jsonBody) {
HttpResponse response;
@@ -47,12 +43,6 @@ HttpResponse performRequest(const std::string& url, const std::string& apiKey,
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response.body);
struct curl_slist* headers = nullptr;
// An explicit apiKey wins; otherwise fall back to the key baked in at
// build time via -DAPI_KEY (empty string when unset).
const std::string effectiveKey = apiKey.empty() ? API_KEY : apiKey;
if (!effectiveKey.empty()) {
headers = curl_slist_append(headers, ("Authorization: Bearer " + effectiveKey).c_str());
}
if (post) {
headers = curl_slist_append(headers, "Content-Type: application/json");
curl_easy_setopt(curl, CURLOPT_POST, 1L);
@@ -79,13 +69,12 @@ HttpResponse performRequest(const std::string& url, const std::string& apiKey,
// ─── HTTP helpers ──────────────────────────────────────────────────────
HttpResponse httpGet(const std::string& path, const std::string& apiKey) {
return performRequest(urlFor(path), apiKey, false, "");
HttpResponse httpGet(const std::string& path) {
return performRequest(urlFor(path), false, "");
}
HttpResponse httpPost(const std::string& path, const std::string& jsonBody,
const std::string& apiKey) {
return performRequest(urlFor(path), apiKey, true, jsonBody);
HttpResponse httpPost(const std::string& path, const std::string& jsonBody) {
return performRequest(urlFor(path), true, jsonBody);
}
// ─── GET /frequencies ──────────────────────────────────────────────────
@@ -112,8 +101,8 @@ bool parseFrequenciesJson(const std::string& body, std::vector<Frequency>& out)
return true;
}
bool fetchFrequencies(std::vector<Frequency>& out, const std::string& apiKey) {
HttpResponse response = httpGet(PATH_FREQUENCIES, apiKey);
bool fetchFrequencies(std::vector<Frequency>& out) {
HttpResponse response = httpGet(PATH_FREQUENCIES);
if (!response.transportOk || response.status < 200 || response.status >= 300) {
return false;
}
@@ -169,8 +158,8 @@ bool parseColorPalettesJson(const std::string& body, std::vector<ColorPalette>&
return true;
}
bool fetchColorPalettes(std::vector<ColorPalette>& out, const std::string& apiKey) {
HttpResponse response = httpGet(PATH_COLOR_PALETTES, apiKey);
bool fetchColorPalettes(std::vector<ColorPalette>& out) {
HttpResponse response = httpGet(PATH_COLOR_PALETTES);
if (!response.transportOk || response.status < 200 || response.status >= 300) {
return false;
}
@@ -196,13 +185,12 @@ bool parseLicenseJson(const std::string& body, LicenseInfo& out) {
return !out.key.empty();
}
LicenseResult validateLicense(const std::string& key, const std::string& email,
const std::string& apiKey) {
LicenseResult validateLicense(const std::string& key, const std::string& email) {
LicenseResult result;
const std::string jsonBody =
"{\"license_key\":\"" + key + "\",\"email_address\":\"" + email + "\"}";
HttpResponse response = httpPost(PATH_LICENSE, jsonBody, apiKey);
HttpResponse response = httpPost(PATH_LICENSE, jsonBody);
result.status = response.status;
if (!response.transportOk) {
@@ -233,8 +221,7 @@ bool parseCreateAccountJson(const std::string& body, AccountBody& out) {
return true;
}
AccountResult createAccount(const std::string& email, const std::string& name,
const std::string& apiKey) {
AccountResult createAccount(const std::string& email, const std::string& name) {
AccountResult result;
std::string jsonBody =
@@ -244,7 +231,7 @@ AccountResult createAccount(const std::string& email, const std::string& name,
}
jsonBody += "}";
HttpResponse response = httpPost(PATH_CREATE_ACCOUNT, jsonBody, apiKey);
HttpResponse response = httpPost(PATH_CREATE_ACCOUNT, jsonBody);
result.status = response.status;
if (!response.transportOk) {
+7 -14
View File
@@ -24,14 +24,9 @@ struct HttpResponse {
bool transportOk = false; // true when the request completed without a transport error
};
// Performs a GET/POST against `path` (relative to API_BASE_URL). When `apiKey` is
// non-empty it is sent as `Authorization: Bearer <apiKey>`. When it is empty (or
// omitted) the request falls back to the key baked in at build time via the
// `API_KEY` compile definition (see CMakeLists.txt); with neither set, no auth
// header is sent.
HttpResponse httpGet(const std::string& path, const std::string& apiKey = "");
HttpResponse httpPost(const std::string& path, const std::string& jsonBody,
const std::string& apiKey = "");
// Performs a GET/POST against `path` (relative to API_BASE_URL).
HttpResponse httpGet(const std::string& path);
HttpResponse httpPost(const std::string& path, const std::string& jsonBody);
// ─── GET /frequencies ──────────────────────────────────────────────────
@@ -45,7 +40,7 @@ struct Frequency {
bool parseFrequenciesJson(const std::string& body, std::vector<Frequency>& out);
// Fetch the solfeggio frequencies. Returns true only on HTTP 2xx + successful parse.
bool fetchFrequencies(std::vector<Frequency>& out, const std::string& apiKey = "");
bool fetchFrequencies(std::vector<Frequency>& out);
// ─── GET /color-palettes ───────────────────────────────────────────────
@@ -75,7 +70,7 @@ bool parseColorPalettesJson(const std::string& body, std::vector<ColorPalette>&
// NOTE: the portal does not expose this route yet. This is readiness code — it
// is deliberately not called anywhere in the app until the route ships, so
// palettes can be updated from the portal rather than code releases.
bool fetchColorPalettes(std::vector<ColorPalette>& out, const std::string& apiKey = "");
bool fetchColorPalettes(std::vector<ColorPalette>& out);
// ─── POST /license ─────────────────────────────────────────────────────
@@ -98,8 +93,7 @@ struct LicenseResult {
// Validate a license key / email pair against the portal. Never throws; failures
// (network, HTTP error status) are reported through the returned LicenseResult.
// A 2xx response means the license is valid and the subscription is active.
LicenseResult validateLicense(const std::string& key, const std::string& email,
const std::string& apiKey = "");
LicenseResult validateLicense(const std::string& key, const std::string& email);
// ─── POST /automations/create/account ──────────────────────────────────
@@ -122,7 +116,6 @@ struct AccountResult {
// Creates (or reuses) a user account and issues a license key. The key is emailed
// to the user and is NOT part of the response. `name` may be empty.
AccountResult createAccount(const std::string& email, const std::string& name = "",
const std::string& apiKey = "");
AccountResult createAccount(const std::string& email, const std::string& name = "");
} // namespace radiantwave::api
+1 -2
View File
@@ -17,7 +17,6 @@ long long nowEpochSeconds() {
} // namespace
Result validateStoredLicense(radiantwave::db::DatabaseManager& db,
const std::string& apiKey,
std::string* outErrorMessage) {
if (outErrorMessage) outErrorMessage->clear();
@@ -28,7 +27,7 @@ Result validateStoredLicense(radiantwave::db::DatabaseManager& db,
}
const radiantwave::api::LicenseResult result =
radiantwave::api::validateLicense(key, email, apiKey);
radiantwave::api::validateLicense(key, email);
if (result.status == 0) {
if (outErrorMessage) *outErrorMessage = "network error";
-1
View File
@@ -26,7 +26,6 @@ constexpr long long GRACE_PERIOD_SECONDS = 7LL * 24 * 60 * 60;
//
// `outErrorMessage` is an optional out-param.
Result validateStoredLicense(radiantwave::db::DatabaseManager& db,
const std::string& apiKey = "",
std::string* outErrorMessage = nullptr);
// Records the current time as the "last verified" stamp of the license. Called
+4 -6
View File
@@ -190,10 +190,9 @@ bool parseProclamationPalettesJson(const std::string& body,
return true;
}
bool syncColorPalettesFromApi(radiantwave::db::DatabaseManager& db,
const std::string& apiKey) {
bool syncColorPalettesFromApi(radiantwave::db::DatabaseManager& db) {
std::vector<api::ColorPalette> palettes;
if (!api::fetchColorPalettes(palettes, apiKey)) {
if (!api::fetchColorPalettes(palettes)) {
return false;
}
@@ -211,9 +210,8 @@ bool syncColorPalettesFromApi(radiantwave::db::DatabaseManager& db,
return db.replaceColorPalettes(records);
}
bool syncProclamationPalettesFromApi(radiantwave::db::DatabaseManager& db,
const std::string& apiKey) {
api::HttpResponse response = api::httpGet(api::PATH_PROCLAMATIONS, apiKey);
bool syncProclamationPalettesFromApi(radiantwave::db::DatabaseManager& db) {
api::HttpResponse response = api::httpGet(api::PATH_PROCLAMATIONS);
if (!response.transportOk || response.status < 200 || response.status >= 300) {
return false;
}
+2 -4
View File
@@ -37,13 +37,11 @@ bool parseProclamationPalettesJson(const std::string& body,
// NOTE: not yet wired into the app flow — the portal does not expose the route
// yet. Readiness code: palettes should be updated from the portal, not code
// releases, so callers should treat this as a future sync step.
bool syncColorPalettesFromApi(radiantwave::db::DatabaseManager& db,
const std::string& apiKey = "");
bool syncColorPalettesFromApi(radiantwave::db::DatabaseManager& db);
// Alternative to syncColorPalettesFromApi: fetch proclamations and replace the
// local palette table using the palettes embedded in each proclamation.
// NOTE: not yet wired into the app flow; see syncColorPalettesFromApi.
bool syncProclamationPalettesFromApi(radiantwave::db::DatabaseManager& db,
const std::string& apiKey = "");
bool syncProclamationPalettesFromApi(radiantwave::db::DatabaseManager& db);
} // namespace radiantwave::sync
+2 -1
View File
@@ -1,6 +1,7 @@
#include "proclamation_options_page.h"
#include "app_state.h"
#include "network/proclamation_sync.h"
#include "logical_size.h"
#include <SDL3/SDL.h>
#include <algorithm>
#include <cctype>
@@ -552,7 +553,7 @@ void ProclamationOptionsPage::update(float dt)
void ProclamationOptionsPage::render(SDL_Renderer* renderer)
{
int winW, winH;
SDL_GetWindowSize(SDL_GetRenderWindow(renderer), &winW, &winH);
radiantwave::getLayoutSize(renderer, &winW, &winH);
float fw = static_cast<float>(winW);
float fh = static_cast<float>(winH);
+18 -16
View File
@@ -1,19 +1,18 @@
#include "provisioning.h"
#include "wifi_setup_page.h"
#include "email_address_page.h"
#include "license_key_page.h"
#include "home.h"
#include "license_key_page.h"
#include "logger/logger.h"
#include "wifi_setup_page.h"
namespace radiantwave::provisioning {
// 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) {
case Step::Internet:
state.pages.push(state, std::make_unique<WiFiSetupPage>());
break;
// case Step::Internet:
// state.pages.push(state, std::make_unique<WiFiSetupPage>());
// break;
case Step::Email:
state.pages.push(state, std::make_unique<EmailAddressPage>());
break;
@@ -27,8 +26,7 @@ static void pushStep(AppState& state, Step step)
}
}
void begin(AppState& state)
{
void begin(AppState &state) {
// Without the database there is nowhere to persist email/license, so the
// walkthrough would trap the user on pages that can never be completed.
if (!state.db.isOpen()) {
@@ -38,14 +36,17 @@ void begin(AppState& state)
}
const bool internetUp = state.network.hasInternet();
const bool hasEmail = !state.db.getKV(radiantwave::db::KV_EMAIL_ADDRESS).empty();
const bool hasLicense = !state.db.getKV(radiantwave::db::KV_LICENSE_KEY).empty();
const bool hasEmail =
!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)) {
// Placeholder: a brand-new device. Later this flag will launch the
// first-run welcome walkthrough (TOS, quick user guide, ...).
state.firstRunWalkthrough = true;
radiantwave::logger::Logger::instance().info("Provisioning",
radiantwave::logger::Logger::instance().info(
"Provisioning",
"First-Run detected (no internet, no email, no license); "
"first-run welcome walkthrough is not yet implemented");
}
@@ -55,8 +56,7 @@ void begin(AppState& state)
pushStep(state, firstMissingStep(internetUp, hasEmail, hasLicense));
}
void advance(AppState& state)
{
void advance(AppState &state) {
if (state.pages.depth() > 0) {
state.pages.pop(state);
}
@@ -70,8 +70,10 @@ void advance(AppState& state)
state.walkthroughInternetUp = internetUp;
}
const bool hasEmail = !state.db.getKV(radiantwave::db::KV_EMAIL_ADDRESS).empty();
const bool hasLicense = !state.db.getKV(radiantwave::db::KV_LICENSE_KEY).empty();
const bool hasEmail =
!state.db.getKV(radiantwave::db::KV_EMAIL_ADDRESS).empty();
const bool hasLicense =
!state.db.getKV(radiantwave::db::KV_LICENSE_KEY).empty();
state.provisioningMode = true;
pushStep(state, firstMissingStep(internetUp, hasEmail, hasLicense));
+10 -10
View File
@@ -24,33 +24,33 @@
namespace radiantwave::provisioning {
// 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.
inline Step firstMissingStep(bool internetUp, bool hasEmail, bool hasLicense)
{
if (!internetUp) return Step::Internet;
if (!hasEmail) return Step::Email;
if (!hasLicense) return Step::License;
inline Step firstMissingStep(bool internetUp, bool hasEmail, bool hasLicense) {
// if (!internetUp) return Step::Internet;
if (!hasEmail)
return Step::Email;
if (!hasLicense)
return Step::License;
return Step::Done;
}
// True when nothing is configured (no internet, no email, no license).
// 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;
}
// Startup walkthrough. Evaluates the prerequisites and pushes the first
// missing step's page (with provisioningMode set), or Home when everything
// 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
// 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
// missing, so completing a step never blocks on a redundant check.
void advance(AppState& state);
void advance(AppState &state);
} // namespace radiantwave::provisioning
+2 -1
View File
@@ -5,6 +5,7 @@
#include "proclamation_options_page.h"
#include "scramble/fibonacci_scrambler.h"
#include "utf8_utils.h"
#include "logical_size.h"
#include <SDL3/SDL.h>
#include <SDL3_ttf/SDL_ttf.h>
@@ -35,7 +36,7 @@ void ScrollerPage::onPush(AppState &state)
int winW = 0;
int winH = 0;
SDL_GetWindowSize(state.window, &winW, &winH);
radiantwave::getLayoutSize(state.renderer, &winW, &winH);
screenW_ = static_cast<float>(winW);
screenH_ = static_cast<float>(winH);
// Lines are capped at the full screen width; the widest Fibonacci lines are
+2 -1
View File
@@ -10,6 +10,7 @@
#include "email_address_page.h"
#include "license_key_page.h"
#include "date_format_page.h"
#include "logical_size.h"
#include <SDL3/SDL.h>
#include <SDL3_image/SDL_image.h>
#include <algorithm>
@@ -128,7 +129,7 @@ void Settings::update(float dt)
void Settings::render(SDL_Renderer *renderer)
{
int winW, winH;
SDL_GetWindowSize(SDL_GetRenderWindow(renderer), &winW, &winH);
radiantwave::getLayoutSize(renderer, &winW, &winH);
float fw = static_cast<float>(winW);
float fh = static_cast<float>(winH);
+2 -1
View File
@@ -1,5 +1,6 @@
#include "timezone_page.h"
#include "app_state.h"
#include "logical_size.h"
#include <SDL3/SDL.h>
#include <algorithm>
#include <ctime>
@@ -199,7 +200,7 @@ void TimeZonePage::update(float dt) {
void TimeZonePage::render(SDL_Renderer* renderer) {
int winW, winH;
SDL_GetWindowSize(SDL_GetRenderWindow(renderer), &winW, &winH);
radiantwave::getLayoutSize(renderer, &winW, &winH);
float fw = static_cast<float>(winW);
float fh = static_cast<float>(winH);
+2 -1
View File
@@ -1,6 +1,7 @@
#include "wifi_setup_page.h"
#include "app_state.h"
#include "provisioning.h"
#include "logical_size.h"
#include <SDL3/SDL.h>
#include <algorithm>
#include <exception>
@@ -301,7 +302,7 @@ void WiFiSetupPage::update(float dt) {
void WiFiSetupPage::render(SDL_Renderer* renderer) {
int winW, winH;
SDL_GetWindowSize(SDL_GetRenderWindow(renderer), &winW, &winH);
radiantwave::getLayoutSize(renderer, &winW, &winH);
float fw = static_cast<float>(winW);
float fh = static_cast<float>(winH);
+9 -14
View File
@@ -19,11 +19,6 @@ std::string env(const char* name) {
return value ? std::string(value) : std::string();
}
// Returns the optional live API key, or "" when none was provided.
std::string liveApiKey() {
return env("RW_LIVE_API_KEY");
}
} // namespace
// ─── Offline: parse functions ─────────────────────────────────────────
@@ -163,7 +158,7 @@ TEST(ApiClientTest, ParseCategoriesHandlesNullParentId) {
TEST(ApiClientLiveTest, FetchFrequenciesLive) {
std::vector<Frequency> out;
EXPECT_TRUE(fetchFrequencies(out, liveApiKey()));
EXPECT_TRUE(fetchFrequencies(out));
ASSERT_GE(out.size(), 7u);
bool foundSolarPlexus = false;
@@ -180,7 +175,7 @@ TEST(ApiClientLiveTest, FetchFrequenciesLive) {
// ─── Live: proclamation + category parsers against the real API ────────
TEST(ApiClientLiveTest, ProclamationsAndCategoriesLiveSmoke) {
HttpResponse procsResponse = httpGet(PATH_PROCLAMATIONS, liveApiKey());
HttpResponse procsResponse = httpGet(PATH_PROCLAMATIONS);
ASSERT_TRUE(procsResponse.transportOk);
ASSERT_EQ(procsResponse.status, 200);
@@ -193,7 +188,7 @@ TEST(ApiClientLiveTest, ProclamationsAndCategoriesLiveSmoke) {
EXPECT_FALSE(proclamations[0].id.empty());
}
HttpResponse catsResponse = httpGet(PATH_CATEGORIES, liveApiKey());
HttpResponse catsResponse = httpGet(PATH_CATEGORIES);
ASSERT_TRUE(catsResponse.transportOk);
ASSERT_EQ(catsResponse.status, 200);
@@ -205,14 +200,14 @@ TEST(ApiClientLiveTest, ProclamationsAndCategoriesLiveSmoke) {
// ─── Live: POST /license error paths (no credentials required) ─────────
TEST(ApiClientLiveTest, ValidateLicenseMissingKeyLive) {
LicenseResult result = validateLicense("", "test@example.com", liveApiKey());
LicenseResult result = validateLicense("", "test@example.com");
ASSERT_FALSE(result.ok());
EXPECT_EQ(result.status, 400);
EXPECT_EQ(result.error_message, "license_key is required");
}
TEST(ApiClientLiveTest, ValidateLicenseMissingEmailLive) {
LicenseResult result = validateLicense("AAAA-AAAA-AAAA-AAAA", "", liveApiKey());
LicenseResult result = validateLicense("AAAA-AAAA-AAAA-AAAA", "");
ASSERT_FALSE(result.ok());
EXPECT_EQ(result.status, 400);
EXPECT_EQ(result.error_message, "email_address is required");
@@ -220,7 +215,7 @@ TEST(ApiClientLiveTest, ValidateLicenseMissingEmailLive) {
TEST(ApiClientLiveTest, ValidateLicenseUnknownKeyLive) {
LicenseResult result =
validateLicense("AAAA-AAAA-AAAA-AAAA", "nobody@example.com", liveApiKey());
validateLicense("AAAA-AAAA-AAAA-AAAA", "nobody@example.com");
ASSERT_FALSE(result.ok());
EXPECT_EQ(result.status, 400);
EXPECT_EQ(result.error_message, "license key not found");
@@ -235,7 +230,7 @@ TEST(ApiClientLiveTest, ValidateLicenseSuccessLive) {
GTEST_SKIP() << "RW_LIVE_LICENSE_KEY / RW_LIVE_LICENSE_EMAIL not set";
}
LicenseResult result = validateLicense(key, email, liveApiKey());
LicenseResult result = validateLicense(key, email);
ASSERT_TRUE(result.ok()) << "status=" << result.status
<< " error=" << result.error_message;
EXPECT_EQ(result.info.key, key);
@@ -245,7 +240,7 @@ TEST(ApiClientLiveTest, ValidateLicenseSuccessLive) {
// ─── Live: POST /automations/create/account error paths (harmless) ─────
TEST(ApiClientLiveTest, CreateAccountMissingEmailLive) {
AccountResult result = createAccount("", "", liveApiKey());
AccountResult result = createAccount("", "");
ASSERT_FALSE(result.ok());
EXPECT_EQ(result.status, 400);
EXPECT_EQ(result.error_message, "email is required");
@@ -260,7 +255,7 @@ TEST(ApiClientLiveTest, CreateAccountSuccessLive) {
GTEST_SKIP() << "RW_LIVE_ACCOUNT_EMAIL not set";
}
AccountResult result = createAccount(email, "RadiantWave Test", liveApiKey());
AccountResult result = createAccount(email, "RadiantWave Test");
ASSERT_TRUE(result.ok()) << "status=" << result.status
<< " error=" << result.error_message;
EXPECT_TRUE(result.body.success);
+1 -1
View File
@@ -175,7 +175,7 @@ TEST(ColorPalettesLiveTest, FetchColorPalettesLive) {
}
std::vector<ColorPalette> palettes;
ASSERT_TRUE(fetchColorPalettes(palettes, env("RW_LIVE_API_KEY")));
ASSERT_TRUE(fetchColorPalettes(palettes));
EXPECT_FALSE(palettes.empty());
for (const auto& p : palettes) {
EXPECT_GE(p.colors.size(), 1u);
+4 -4
View File
@@ -48,7 +48,7 @@ TEST(LicenseServiceTest, NotConfiguredWhenNoEmail) {
tmp.db.setKV(KV_LICENSE_KEY, "ABCD-EFGH-IJKL-MNOP");
std::string errorMessage = "stale";
EXPECT_EQ(validateStoredLicense(tmp.db, "", &errorMessage),
EXPECT_EQ(validateStoredLicense(tmp.db, &errorMessage),
Result::NotConfigured);
EXPECT_EQ(errorMessage, "");
}
@@ -57,7 +57,7 @@ TEST(LicenseServiceTest, NotConfiguredWhenNoLicense) {
TempDb tmp;
tmp.db.setKV(KV_EMAIL_ADDRESS, "user@example.com");
EXPECT_EQ(validateStoredLicense(tmp.db, "", nullptr),
EXPECT_EQ(validateStoredLicense(tmp.db, nullptr),
Result::NotConfigured);
}
@@ -117,7 +117,7 @@ TEST(LicenseServiceLiveTest, ValidatesAndGrantsSubscribedState) {
tmp.db.setKV(KV_EMAIL_ADDRESS, email);
std::string errorMessage;
const Result result = validateStoredLicense(tmp.db, "", &errorMessage);
const Result result = validateStoredLicense(tmp.db, &errorMessage);
EXPECT_EQ(result, Result::Ok) << "error=" << errorMessage;
// A successful validation is the "subscribed" flag; no subscription-level
@@ -134,7 +134,7 @@ TEST(LicenseServiceLiveTest, InvalidPairReportsInvalid) {
tmp.db.setKV(KV_EMAIL_ADDRESS, "nobody@example.com");
std::string errorMessage;
EXPECT_EQ(validateStoredLicense(tmp.db, "", &errorMessage),
EXPECT_EQ(validateStoredLicense(tmp.db, &errorMessage),
Result::Invalid);
EXPECT_FALSE(errorMessage.empty());
}
+8 -22
View File
@@ -1,50 +1,36 @@
#include "test_harness.h"
#include "../src/provisioning.h"
#include "test_harness.h"
using radiantwave::provisioning::firstMissingStep;
using radiantwave::provisioning::indicatesFirstRun;
using radiantwave::provisioning::Step;
TEST(first_missing_step_all_satisfied)
{
TEST(first_missing_step_all_satisfied) {
ASSERT_EQ(firstMissingStep(true, true, true), Step::Done);
}
TEST(first_missing_step_no_internet)
{
// Internet is checked before email/license regardless of the others.
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_email) {
ASSERT_EQ(firstMissingStep(true, false, false), Step::Email);
ASSERT_EQ(firstMissingStep(true, false, true), Step::Email);
}
TEST(first_missing_step_no_license)
{
TEST(first_missing_step_no_license) {
ASSERT_EQ(firstMissingStep(true, true, false), Step::License);
}
TEST(first_run_all_missing)
{
TEST(first_run_all_missing) {
ASSERT_TRUE(indicatesFirstRun(false, false, false));
}
TEST(first_run_has_internet)
{
TEST(first_run_has_internet) {
ASSERT_FALSE(indicatesFirstRun(true, false, false));
}
TEST(first_run_has_email)
{
TEST(first_run_has_email) {
ASSERT_FALSE(indicatesFirstRun(false, true, false));
}
TEST(first_run_has_license)
{
TEST(first_run_has_license) {
ASSERT_FALSE(indicatesFirstRun(false, false, true));
}