14 Commits
Author SHA1 Message Date
NeilClack 15583edef8 Re-order settings menu 2026-08-25 06:57:43 -05:00
NeilClack 63265cc22f Dynamically size the fullscreen window; present design space via LETTERBOX
build-dev / nix-build (push) Successful in 42s
SDL_CreateWindow hardcoded a 1920x1080 backbuffer while SDL_WINDOW_FULLSCREEN
let compositors run a different output mode; the mismatched buffer reached
the panel unscaled — quarter-size in the top-left on 4K sets, cropped ("4x
too large") on smaller non-1080p panels under cage/Wayland. Logical
presentation never got to map the fixed design space because its target
(the backbuffer) was pinned to stale dimensions.

Size the fullscreen window from SDL_GetCurrentDisplayMode so the first
commit already matches scanout 1:1, switch logical presentation to LETTERBOX
(aspect-safe bars on non-16:9 panels), and route both through new
kDesignWidth/kDesignHeight constants. Harden getLayoutSize() to always write
its out params and null-check the renderer/render-window before use.

Log requested/window/pixel-buffer/render-output sizes at startup and on
every resize event so field units self-diagnose this bug class into
~/.local/radiantwave/log.txt. SDL_WINDOW_HIGH_PIXEL_DENSITY stays off on
purpose: kiosk panels are not HiDPI, and keeping the pixel buffer equal to
the display mode keeps compositor scanout unscaled.
2026-08-23 17:06:05 -05:00
NeilClack 9e73c82641 Fix attic push
build-dev / nix-build (push) Successful in 43s
2026-08-23 15:12:35 -05:00
NeilClack f2a40cd019 Remove attic-client install step
build-dev / nix-build (push) Successful in 39s
2026-08-23 12:46:08 -05:00
NeilClack 5caf1d6ef8 Move to checksums.md5
build-dev / nix-build (push) Failing after 13s
Server is scheduled to generate this file nightly
2026-08-23 12:39:05 -05:00
NeilClack e17e881c00 Split builds 2026-08-23 12:38:44 -05:00
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
NeilClack 359ae905d7 Update runner name 2026-08-17 12:00:28 -05:00
NeilClack 2b60002df3 Update runner name 2026-08-17 11:57:55 -05:00
34 changed files with 605 additions and 530 deletions
+68
View File
@@ -0,0 +1,68 @@
name: build-dev
on:
# Every push to dev: build, run the offline test suite, publish to the
# dev Attic cache.
push:
branches: [dev]
jobs:
nix-build:
runs-on: build-server
steps:
# actions/checkout@v4 is unreliable on host-mode runners (its files are
# copied into the workspace instead of the repo), so clone explicitly.
- name: Checkout
run: |
rm -rf ./* ./.[!.]* 2>/dev/null || true
git config --global http.extraheader "Authorization: token ${{ github.token }}"
git clone --branch "${{ gitea.ref_name }}" \
"${{ gitea.server_url }}/${{ gitea.repository }}.git" .
test -f flake.nix
git log --oneline -1
- name: Inspect workspace
run: |
pwd
ls -la
git rev-parse --show-toplevel 2>&1 || true
git remote -v 2>&1 || true
git describe --tags --always 2>&1 || true
test -f flake.nix && echo "flake.nix present" || echo "NO flake.nix"
# Dev builds fingerprint as the short commit hash.
# Persisted via GITHUB_ENV so the impure nix eval below reads it via
# builtins.getEnv.
- name: Compute version
run: |
echo "APP_VERSION=$(git rev-parse --short HEAD)" >> "$GITHUB_ENV"
# 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 via builtins.getEnv
# (pure evals always get the package defaults).
- name: Build package
run: |
nix build -L ".#packages.$(nix eval --impure --raw --expr 'builtins.currentSystem').default" \
--impure --out-link result-pkg
# The offline test suite runs in the check derivation's checkPhase.
# This is a separate build/output from the package itself.
- name: Build + offline test suite
run: |
nix build -L ".#checks.$(nix eval --impure --raw --expr 'builtins.currentSystem').default" \
--impure --out-link result-checks
- name: Live portal tests
env:
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 }}
run: |
nix develop --command bash -c '
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug
cmake --build build --target test_api_client test_license_service
./build/test_api_client
./build/test_license_service
'
# Dev closure (the actual package, not the check output) lands in the
# dev cache.
- name: Push to Attic dev cache
env:
ATTIC_TOKEN: ${{ secrets.ATTIC_TOKEN_DEV }}
run: |
attic login radiantwave https://attic.radiantwavetech.com "$ATTIC_TOKEN"
attic push radiantwave:radiantwave-dev ./result-pkg
@@ -1,10 +1,8 @@
name: build name: build-main
on: on:
# Manual only for now. Select the branch to build when dispatching: # Manual only. Dispatch against main: build, run the offline test suite,
# main -> APP_VERSION is the current git tag # publish to the release Attic cache.
# dev -> APP_VERSION is the current commit hash
# Both branches bake the portal API key from secrets.
workflow_dispatch: workflow_dispatch:
jobs: jobs:
@@ -13,8 +11,8 @@ jobs:
steps: steps:
# actions/checkout@v4 is unreliable on host-mode runners (its files are # actions/checkout@v4 is unreliable on host-mode runners (its files are
# copied into the workspace instead of the repo), so clone explicitly. # copied into the workspace instead of the repo), so clone explicitly.
# A full clone (no --depth) is required so `git describe --tags` on main # A full clone (no --depth) is required so `git describe --tags` resolves
# resolves the real nearest tag instead of falling back to a hash. # the real nearest tag instead of falling back to a hash.
- name: Checkout - name: Checkout
run: | run: |
rm -rf ./* ./.[!.]* 2>/dev/null || true rm -rf ./* ./.[!.]* 2>/dev/null || true
@@ -33,39 +31,23 @@ jobs:
git describe --tags --always 2>&1 || true git describe --tags --always 2>&1 || true
test -f flake.nix && echo "flake.nix present" || echo "NO flake.nix" test -f flake.nix && echo "flake.nix present" || echo "NO flake.nix"
# Compute the version string baked into the binary's Home footer: # Release builds report the current git tag.
# main reports the git tag, dev reports the commit hash fingerprint. # Persisted via GITHUB_ENV so the impure nix eval below reads it via
# The key stays in the build step's environment (never echoed). # builtins.getEnv.
- name: Compute version - name: Compute version
id: version
run: | run: |
if [ "${{ gitea.ref_name }}" = "main" ]; then echo "APP_VERSION=$(git describe --tags --always)" >> "$GITHUB_ENV"
APP_VERSION="$(git describe --tags --always)"
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 # checks is keyed per-system (checks.<system>.default); nix build's
# #attr shorthand can't resolve it, so resolve the current system inline. # #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 # (pure evals always get the package defaults). The offline test suite
# runs in the package's checkPhase. # runs in the package's checkPhase.
- name: Build + offline test suite - 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 run: nix build -L ".#checks.$(nix eval --impure --raw --expr 'builtins.currentSystem').default" --impure
- name: Live portal tests - name: Live portal tests
if: ${{ secrets.RWT_LIVE_API_KEY != '' }}
env: env:
RWT_LIVE_API_KEY: ${{ secrets.RWT_LIVE_API_KEY }}
RWT_LIVE_ACCOUNT_EMAIL: ${{ secrets.RWT_LIVE_ACCOUNT_EMAIL }} RWT_LIVE_ACCOUNT_EMAIL: ${{ secrets.RWT_LIVE_ACCOUNT_EMAIL }}
RWT_LIVE_LICENSE_EMAIL: ${{ secrets.RWT_LIVE_LICENSE_EMAIL }} RWT_LIVE_LICENSE_EMAIL: ${{ secrets.RWT_LIVE_LICENSE_EMAIL }}
RWT_LIVE_LICENSE_KEY: ${{ secrets.RWT_LIVE_LICENSE_KEY }} RWT_LIVE_LICENSE_KEY: ${{ secrets.RWT_LIVE_LICENSE_KEY }}
@@ -76,3 +58,11 @@ jobs:
./build/test_api_client ./build/test_api_client
./build/test_license_service ./build/test_license_service
' '
# Release closure lands in the release cache.
- name: Push to Attic release cache
env:
ATTIC_TOKEN: ${{ secrets.ATTIC_TOKEN_PROD }}
run: |
attic login radiantwave https://attic.radiantwavetech.com "$ATTIC_TOKEN"
attic push radiantwave:radiantwave ./result
+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` 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 defaults to `0.1.0` in `nix/package.nix`; the flake overrides it from the
`APP_VERSION` env var on impure evals). `nix develop` `APP_VERSION` env var on impure evals). `nix develop`
sets `APP_VERSION=dev` and exports `API_KEY` from the ambient environment sets `APP_VERSION=dev`. CMake seeds a *fresh* configure from the
(falling back to `RWT_LIVE_API_KEY`). CMake seeds a *fresh* configure from the `APP_VERSION` env var; `-D` wins on reconfigure.
`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"
```
- The app resolves `assets/...` relative to its working directory — launch - The app resolves `assets/...` relative to its working directory — launch
`./build/radiantwave` from the repo root. (The Nix wrapper chdirs to `./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 `checks` is keyed per-system, so the workflow resolves the current system
via `nix eval --impure --expr 'builtins.currentSystem'`). The build bakes via `nix eval --impure --expr 'builtins.currentSystem'`). The build bakes
in a per-branch `APP_VERSION` — the current git tag on `main`, the current 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` commit hash on `dev`. The flake reads `APP_VERSION` from the environment via
secret. The flake reads `APP_VERSION`/`API_KEY` from the environment via
`builtins.getEnv`, so the build must pass `--impure` (pure evals get the `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 package defaults). The live portal tests run from `nix develop` when the
`test_api_client` / `test_license_service` against the portal via `RWT_LIVE_*` secrets are set and need `RWT_LIVE_ACCOUNT_EMAIL`,
`nix develop` (outside the sandbox, like a dev machine). Those live tests `RWT_LIVE_LICENSE_EMAIL`, and `RWT_LIVE_LICENSE_KEY`.
also 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 - `test_assets` must pass before committing — it catches missing fonts/images
that break the app at runtime. that break the app at runtime.
- `cmake --build build` builds everything; pass `--target <name>` to build one - `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)") "Version string shown in the Home footer (override at configure time)")
endif() 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 REQUIRED)
find_package(SDL3_ttf REQUIRED) find_package(SDL3_ttf REQUIRED)
find_package(SDL3_image REQUIRED) find_package(SDL3_image REQUIRED)
@@ -90,7 +76,6 @@ add_executable(radiantwave
target_compile_definitions(radiantwave PRIVATE target_compile_definitions(radiantwave PRIVATE
APP_VERSION="${APP_VERSION}" APP_VERSION="${APP_VERSION}"
API_KEY="${API_KEY}"
) )
target_include_directories(radiantwave PRIVATE src) 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 from `CMakeLists.txt` (`project(... VERSION ...)`); a build that defines no
version at all shows `unknown`. The Nix build sets `APP_VERSION` automatically 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. 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` With `--impure` the flake instead reads the `APP_VERSION`
environment variables — this is how CI injects the version and portal key environment variable — this is how CI injects the version
(see the CI note under *Common Pitfalls*). Pure evals always get the package (see the CI note under *Common Pitfalls*). Pure evals always get the package
defaults, because `builtins.getEnv` cannot see the environment in pure defaults, because `builtins.getEnv` cannot see the environment in pure
evaluation mode. evaluation mode.
#### Portal API key #### Version in one configure
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.
### Building with Nix ### 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 nix build .#packages.aarch64-linux.default
``` ```
To bake in a custom version and/or portal key, build impurely — the flake To bake in a custom version, build impurely — the flake
reads `APP_VERSION` and `API_KEY` from the environment on impure evals: reads `APP_VERSION` from the environment on impure evals:
```sh ```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 even if those variables are set in your shell, because pure evaluation cannot
see the environment. CI uses `--impure` to inject them. 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 is `.gitea/workflows/build.yml`. It is **manual-only**
(`workflow_dispatch`) — pick the branch when dispatching. `main` builds bake (`workflow_dispatch`) — pick the branch when dispatching. `main` builds bake
the current git tag as `APP_VERSION`; `dev` builds bake the current commit 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 hash. Both build with `nix build --impure` (which lets the flake's env
`nix build --impure` (which lets the flake's env injection work). The injection work). The offline test suite runs in the package's `checkPhase`;
offline test suite runs in the package's `checkPhase`; the live portal tests the live portal tests run from `nix develop` when the `RWT_LIVE_*` secrets
run from `nix develop` when the `RWT_LIVE_*` secrets are set. are set.
- Asset tests (`test_assets`) must pass before committing -- they catch missing - Asset tests (`test_assets`) must pass before committing -- they catch missing
fonts and images that would break the app at runtime. fonts and images that would break the app at runtime.
+13 -20
View File
@@ -34,18 +34,16 @@
}; };
in in
{ {
# Version and API key are injected from the ambient environment on # Version is injected from the ambient environment on
# impure evals (CI sets APP_VERSION/API_KEY from the git ref and a # impure evals (CI sets APP_VERSION from the git ref). Pure evals
# secret). Pure evals (`nix flake check`, local `nix build`) can't # (`nix flake check`, local `nix build`) can't see the
# see the environment, so builtins.getEnv returns "" and the # environment, so builtins.getEnv returns "" and the package
# package falls back to its 0.1.0 default / no auth header. # falls back to its 0.1.0 default.
packages.default = packages.default =
let let
envVersion = builtins.getEnv "APP_VERSION"; envVersion = builtins.getEnv "APP_VERSION";
envApiKey = builtins.getEnv "API_KEY";
packageArgs = packageArgs =
{ apiKey = envApiKey; } nixpkgs.lib.optionalAttrs (envVersion != "") { version = envVersion; };
// (nixpkgs.lib.optionalAttrs (envVersion != "") { version = envVersion; });
in in
pkgs.callPackage ./nix/package.nix packageArgs; pkgs.callPackage ./nix/package.nix packageArgs;
@@ -53,22 +51,17 @@
inputsFrom = [ self.packages.${system}.default ]; inputsFrom = [ self.packages.${system}.default ];
packages = [ ]; packages = [ ];
env = { env = {
# APP_VERSION and API_KEY are CMake cache variables with no # APP_VERSION is a CMake cache variable with no default; on a
# default; on a fresh configure cmake seeds their cache from # fresh configure cmake seeds its cache from the environment
# environment variables of the same name, so a plain # variable of the same name, so a plain `cmake -S . -B build`
# `cmake -S . -B build` bakes them into the binary. # bakes it into the binary.
APP_VERSION = "dev"; APP_VERSION = "dev";
}; };
shellHook = '' shellHook = ''
# Never commit the real key. Export API_KEY (or the CI name echo "RadiantWave dev shell (APP_VERSION=dev)"
# 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 " Configure: cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug" 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 " Bake version: cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug -DAPP_VERSION=\"1.2.3\""
echo " Nix build (env version+key, impure): APP_VERSION=... API_KEY=... nix build --impure .#default" echo " Nix build (env version, impure): APP_VERSION=... nix build --impure .#default"
echo " Build: cmake --build build" echo " Build: cmake --build build"
echo " Run: ./build/radiantwave" echo " Run: ./build/radiantwave"
echo " Tests: ./build/test_assets && ./build/test_<name>" echo " Tests: ./build/test_assets && ./build/test_<name>"
+1 -7
View File
@@ -15,9 +15,6 @@
curl, curl,
openssl, openssl,
systemdLibs, 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 # 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 # per branch (git tag on main, commit hash on dev); local/pure builds keep
# the 0.1.0 default. # 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 # 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). # 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}" ];
cmakeFlags =
[ "-DAPP_VERSION=${finalAttrs.version}" ]
++ lib.optionals (apiKey != "") [ "-DAPI_KEY=${apiKey}" ];
# gtest is a buildInput, not native: nixpkgs builds it shared, so the # gtest is a buildInput, not native: nixpkgs builds it shared, so the
# check-phase test binaries need its lib dir baked into their RPATH. # check-phase test binaries need its lib dir baked into their RPATH.
+35 -31
View File
@@ -27,10 +27,10 @@ namespace radiantwave::audio {
// honored, anything else falls back to the Hz parsed from the filename, so // honored, anything else falls back to the Hz parsed from the filename, so
// the file's own name is always the safety net. // the file's own name is always the safety net.
struct AudioManifestEntry { struct AudioManifestEntry {
std::string filename; std::string filename;
std::string display_name; std::string display_name;
int solfeggio_frequency_hz = 0; int solfeggio_frequency_hz = 0;
std::string md5; std::string md5;
}; };
// Downloads audio assets from files.radiantwavetech.com in the background // Downloads audio assets from files.radiantwavetech.com in the background
@@ -50,45 +50,49 @@ struct AudioManifestEntry {
// checksums are picked up while running. // checksums are picked up while running.
class AudioDownloader { class AudioDownloader {
public: public:
AudioDownloader() = default; AudioDownloader() = default;
~AudioDownloader(); ~AudioDownloader();
AudioDownloader(const AudioDownloader&) = delete; AudioDownloader(const AudioDownloader &) = delete;
AudioDownloader& operator=(const AudioDownloader&) = delete; AudioDownloader &operator=(const AudioDownloader &) = delete;
// Spawns the background thread. Safe to call repeatedly; only one worker // Spawns the background thread. Safe to call repeatedly; only one worker
// runs at a time. The manifest is reconciled against `db` on every pass. // 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. // Signals the worker to stop and joins the thread.
void shutdown(); void shutdown();
// Returns the current human-readable status for display (e.g. footer). // Returns the current human-readable status for display (e.g. footer).
std::string statusString() const; std::string statusString() const;
static constexpr const char* AUDIO_BASE_URL = "https://files.radiantwavetech.com/audio/"; static constexpr const char *AUDIO_BASE_URL =
static constexpr const char* MANIFEST_FILENAME = "checksums.md5"; "https://files.radiantwavetech.com/audio/";
static constexpr const char *MANIFEST_FILENAME = "checksums.md5";
private: private:
void run(); void run();
bool fetchManifest(std::vector<AudioManifestEntry>& out); bool fetchManifest(std::vector<AudioManifestEntry> &out);
bool downloadAndVerify(const std::string& filename, const std::string& expectedMd5, const std::string& displayName); 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); void wait(std::chrono::seconds seconds);
mutable std::mutex mutex_; mutable std::mutex mutex_;
std::condition_variable cv_; std::condition_variable cv_;
std::thread worker_; std::thread worker_;
std::atomic<bool> stop_{false}; std::atomic<bool> stop_{false};
std::string status_; std::string status_;
db::DatabaseManager* db_ = nullptr; db::DatabaseManager *db_ = nullptr;
}; };
// Parses checksums.md5 manifest text into entries. Blank lines and lines // Parses checksums.md5 manifest text into entries. Blank lines and lines
// starting with '#' are ignored. Lines that don't match a known format are // starting with '#' are ignored. Lines that don't match a known format are
// skipped. Returns false if no valid entries were found. // 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 // 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, // 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" // "396Hz.wav" -> 396. Requires the digits to be followed by "Hz"
// (case-insensitive); returns 0 when the name has no frequency (e.g. // (case-insensitive); returns 0 when the name has no frequency (e.g.
// "AlphaWaves.wav", "OriginalAudio.wav"). // "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. // 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 } // namespace radiantwave::audio
+2 -1
View File
@@ -1,5 +1,6 @@
#include "audio_settings_page.h" #include "audio_settings_page.h"
#include "app_state.h" #include "app_state.h"
#include "logical_size.h"
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
#include <algorithm> #include <algorithm>
#include <cctype> #include <cctype>
@@ -477,7 +478,7 @@ void AudioSettingsPage::renderModal(SDL_Renderer* renderer, float fw, float fh,
void AudioSettingsPage::render(SDL_Renderer* renderer) { void AudioSettingsPage::render(SDL_Renderer* renderer) {
int winW, winH; int winW, winH;
SDL_GetWindowSize(SDL_GetRenderWindow(renderer), &winW, &winH); radiantwave::getLayoutSize(renderer, &winW, &winH);
float fw = static_cast<float>(winW); float fw = static_cast<float>(winW);
float fh = static_cast<float>(winH); float fh = static_cast<float>(winH);
+2 -1
View File
@@ -1,6 +1,7 @@
#include "date_format_page.h" #include "date_format_page.h"
#include "app_state.h" #include "app_state.h"
#include "date_format.h" #include "date_format.h"
#include "logical_size.h"
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
#include <algorithm> #include <algorithm>
#include <ctime> #include <ctime>
@@ -131,7 +132,7 @@ void DateFormatPage::update(float dt)
void DateFormatPage::render(SDL_Renderer* renderer) void DateFormatPage::render(SDL_Renderer* renderer)
{ {
int winW, winH; int winW, winH;
SDL_GetWindowSize(SDL_GetRenderWindow(renderer), &winW, &winH); radiantwave::getLayoutSize(renderer, &winW, &winH);
float fw = static_cast<float>(winW); float fw = static_cast<float>(winW);
float fh = static_cast<float>(winH); float fh = static_cast<float>(winH);
+2 -1
View File
@@ -3,6 +3,7 @@
#include "db/db_keys.h" #include "db/db_keys.h"
#include "provisioning.h" #include "provisioning.h"
#include "validation.h" #include "validation.h"
#include "logical_size.h"
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
namespace { namespace {
@@ -164,7 +165,7 @@ void EmailAddressPage::exitPage(AppState& state)
void EmailAddressPage::render(SDL_Renderer* renderer) void EmailAddressPage::render(SDL_Renderer* renderer)
{ {
int winW, winH; int winW, winH;
SDL_GetWindowSize(SDL_GetRenderWindow(renderer), &winW, &winH); radiantwave::getLayoutSize(renderer, &winW, &winH);
float fw = static_cast<float>(winW); float fw = static_cast<float>(winW);
float fh = static_cast<float>(winH); float fh = static_cast<float>(winH);
+2 -1
View File
@@ -8,6 +8,7 @@
#include "audio/audio_downloader.h" #include "audio/audio_downloader.h"
#include "db/db_keys.h" #include "db/db_keys.h"
#include "network/proclamation_sync.h" #include "network/proclamation_sync.h"
#include "logical_size.h"
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
#include <SDL3_image/SDL_image.h> #include <SDL3_image/SDL_image.h>
#include <ctime> #include <ctime>
@@ -114,7 +115,7 @@ void Home::update(float dt)
void Home::render(SDL_Renderer *renderer) void Home::render(SDL_Renderer *renderer)
{ {
int winW, winH; int winW, winH;
SDL_GetWindowSize(SDL_GetRenderWindow(renderer), &winW, &winH); radiantwave::getLayoutSize(renderer, &winW, &winH);
float fw = static_cast<float>(winW); float fw = static_cast<float>(winW);
float fh = static_cast<float>(winH); float fh = static_cast<float>(winH);
+2 -1
View File
@@ -1,6 +1,7 @@
#include "license_blocked_page.h" #include "license_blocked_page.h"
#include "app_state.h" #include "app_state.h"
#include "home.h" #include "home.h"
#include "logical_size.h"
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
void LicenseBlockedPage::onPush(AppState& state) void LicenseBlockedPage::onPush(AppState& state)
@@ -71,7 +72,7 @@ void LicenseBlockedPage::update(float dt)
void LicenseBlockedPage::render(SDL_Renderer* renderer) void LicenseBlockedPage::render(SDL_Renderer* renderer)
{ {
int winW, winH; int winW, winH;
SDL_GetWindowSize(SDL_GetRenderWindow(renderer), &winW, &winH); radiantwave::getLayoutSize(renderer, &winW, &winH);
float fw = static_cast<float>(winW); float fw = static_cast<float>(winW);
float fh = static_cast<float>(winH); float fh = static_cast<float>(winH);
+3 -2
View File
@@ -4,6 +4,7 @@
#include "network/license_service.h" #include "network/license_service.h"
#include "provisioning.h" #include "provisioning.h"
#include "validation.h" #include "validation.h"
#include "logical_size.h"
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
void LicenseKeyPage::onPush(AppState& state) 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. // When the email is missing the key is saved but the check is skipped.
std::string errorMessage; std::string errorMessage;
const radiantwave::license::Result result = radiantwave::license::validateStoredLicense( const radiantwave::license::Result result = radiantwave::license::validateStoredLicense(
state_->db, "", &errorMessage); state_->db, &errorMessage);
switch (result) { switch (result) {
case radiantwave::license::Result::Ok: case radiantwave::license::Result::Ok:
@@ -172,7 +173,7 @@ void LicenseKeyPage::exitPage(AppState& state)
void LicenseKeyPage::render(SDL_Renderer* renderer) void LicenseKeyPage::render(SDL_Renderer* renderer)
{ {
int winW, winH; int winW, winH;
SDL_GetWindowSize(SDL_GetRenderWindow(renderer), &winW, &winH); radiantwave::getLayoutSize(renderer, &winW, &winH);
float fw = static_cast<float>(winW); float fw = static_cast<float>(winW);
float fh = static_cast<float>(winH); float fh = static_cast<float>(winH);
+2 -1
View File
@@ -1,6 +1,7 @@
#include "log_viewer_page.h" #include "log_viewer_page.h"
#include "app_state.h" #include "app_state.h"
#include "timezone_helper.h" #include "timezone_helper.h"
#include "logical_size.h"
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
#include <algorithm> #include <algorithm>
#include <sstream> #include <sstream>
@@ -162,7 +163,7 @@ void LogViewerPage::update(float dt) {
void LogViewerPage::render(SDL_Renderer* renderer) { void LogViewerPage::render(SDL_Renderer* renderer) {
int winW, winH; int winW, winH;
SDL_GetWindowSize(SDL_GetRenderWindow(renderer), &winW, &winH); radiantwave::getLayoutSize(renderer, &winW, &winH);
float fw = static_cast<float>(winW); float fw = static_cast<float>(winW);
float fh = static_cast<float>(winH); float fh = static_cast<float>(winH);
+51
View File
@@ -0,0 +1,51 @@
#pragma once
#include <SDL3/SDL.h>
namespace radiantwave {
// Design resolution every page lays out against. The renderer's logical
// presentation (LETTERBOX) scales this fixed space onto whatever
// window/backbuffer the display actually provides.
inline constexpr int kDesignWidth = 1920;
inline constexpr int kDesignHeight = 1080;
// Returns the size pages should lay out against: the renderer's logical size
// when presentation is active, otherwise the real window size. The output
// params are always written — design constants are the last-resort fallback.
inline void getLayoutSize(SDL_Renderer *renderer, int *w, int *h)
{
if (!w || !h)
return;
*w = kDesignWidth;
*h = kDesignHeight;
if (!renderer)
return;
SDL_RendererLogicalPresentation mode = SDL_LOGICAL_PRESENTATION_DISABLED;
int logicalW = 0;
int logicalH = 0;
if (SDL_GetRenderLogicalPresentation(renderer, &logicalW, &logicalH,
&mode) &&
mode != SDL_LOGICAL_PRESENTATION_DISABLED && logicalW > 0 &&
logicalH > 0) {
*w = logicalW;
*h = logicalH;
return;
}
SDL_Window *window = SDL_GetRenderWindow(renderer);
if (!window)
return;
int windowW = 0;
int windowH = 0;
SDL_GetWindowSize(window, &windowW, &windowH);
if (windowW > 0 && windowH > 0) {
*w = windowW;
*h = windowH;
}
}
} // namespace radiantwave
+229 -158
View File
@@ -4,198 +4,269 @@
#include "app_state.h" #include "app_state.h"
#include "home.h" #include "home.h"
#include "provisioning.h" #include "logical_size.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 // Size the fullscreen window from the display's actual mode so the very
// compositor never has to scale the moving scroller (scaling moving text // first buffer commit already matches what the compositor scanouts 1:1.
// causes flicker). // Requesting a hardcoded size left the backbuffer at that size on
state->window = SDL_CreateWindow("RadiantWave", 1920, 1080, SDL_WINDOW_FULLSCREEN); // displays running a different mode, and frames reached the panel
if (!state->window) // unscaled (quarter-size on 4K, cropped on smaller panels).
{ const SDL_DisplayMode *displayMode =
SDL_Log("Failed to create window: %s", SDL_GetError()); SDL_GetCurrentDisplayMode(SDL_GetPrimaryDisplay());
delete state; int windowWidth =
return SDL_APP_FAILURE; (displayMode && displayMode->w > 0) ? displayMode->w
} : radiantwave::kDesignWidth;
int windowHeight =
(displayMode && displayMode->h > 0) ? displayMode->h
: radiantwave::kDesignHeight;
state->renderer = SDL_CreateRenderer(state->window, nullptr); // Borderless desktop fullscreen: the compositor never has to scale the
if (!state->renderer) // moving scroller (scaling moving text causes flicker). Deliberately no
{ // SDL_WINDOW_HIGH_PIXEL_DENSITY — kiosk panels are not HiDPI, so keeping
SDL_Log("Failed to create renderer: %s", SDL_GetError()); // the pixel buffer equal to the display mode keeps scanout 1:1.
delete state; state->window = SDL_CreateWindow("RadiantWave", windowWidth, windowHeight,
return SDL_APP_FAILURE; SDL_WINDOW_FULLSCREEN);
} if (!state->window) {
SDL_Log("Failed to create window: %s", SDL_GetError());
delete state;
return SDL_APP_FAILURE;
}
// Present on the display's refresh. Without VSync the kiosk panel tears state->renderer = SDL_CreateRenderer(state->window, nullptr);
// fast-scrolling text into horizontal bands that flicker as the text moves. if (!state->renderer) {
if (!SDL_SetRenderVSync(state->renderer, 1)) { SDL_Log("Failed to create renderer: %s", SDL_GetError());
radiantwave::logger::Logger::instance().warning("Graphics", delete state;
"VSync could not be enabled; scrolling text may tear (VSync error: " + std::string(SDL_GetError()) + ")"); return SDL_APP_FAILURE;
} }
SDL_HideCursor(); // 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()) + ")");
}
// Initialize configuration, database, and network manager // All page layout targets the fixed design resolution; LETTERBOX maps it
if (!state->config.initialize()) { // onto the real window with aspect preserved (bars on non-16:9 panels).
radiantwave::logger::Logger::instance().warning("Config", if (!SDL_SetRenderLogicalPresentation(state->renderer,
"Settings file could not be loaded. Default settings will be used (Config initialization failed at ~/.local/radiantwave/config.yaml)"); radiantwave::kDesignWidth,
} else { radiantwave::kDesignHeight,
radiantwave::logger::Logger::instance().info("Config", "Configuration initialized successfully"); SDL_LOGICAL_PRESENTATION_LETTERBOX)) {
} radiantwave::logger::Logger::instance().warning(
"Graphics", "Could not set logical render size; layout may be "
"wrong on HiDPI displays");
}
if (!state->db.initialize()) { {
radiantwave::logger::Logger::instance().error("Database", // Record how the requested mode, window, pixel buffer, and render
"Database storage unavailable. App features may be limited (Database initialization failed at ~/.local/radiantwave/radiantwave.db)"); // output actually compare — this is the evidence trail for
} else { // display-scaling problems on field units.
radiantwave::logger::Logger::instance().info("Database", "Database initialized and schema migrated"); int windowW = 0, windowH = 0;
} SDL_GetWindowSize(state->window, &windowW, &windowH);
int pixelW = 0, pixelH = 0;
SDL_GetWindowSizeInPixels(state->window, &pixelW, &pixelH);
int outputW = 0, outputH = 0;
SDL_GetRenderOutputSize(state->renderer, &outputW, &outputH);
radiantwave::logger::Logger::instance().info(
"Graphics",
"Requested " + std::to_string(windowWidth) + "x" +
std::to_string(windowHeight) + ", window " +
std::to_string(windowW) + "x" + std::to_string(windowH) +
", pixels " + std::to_string(pixelW) + "x" +
std::to_string(pixelH) + ", render output " +
std::to_string(outputW) + "x" + std::to_string(outputH) +
", presentation LETTERBOX");
}
if (!state->network.initialize()) { SDL_HideCursor();
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");
}
if (!state->audio.initialize(state->config)) { // Initialize configuration, database, and network manager
radiantwave::logger::Logger::instance().warning("Audio", if (!state->config.initialize()) {
"Audio playback is unavailable. Check sound hardware connections (SDL3_mixer initialization failed)"); radiantwave::logger::Logger::instance().warning(
} else { "Config", "Settings file could not be loaded. Default settings "
radiantwave::logger::Logger::instance().info("Audio", "Audio subsystem initialized"); "will be used (Config initialization failed at "
} "~/.local/radiantwave/config.yaml)");
// Let playback logs report user-facing display names from the database. } else {
state->audio.attachDatabase(state->db); radiantwave::logger::Logger::instance().info(
"Config", "Configuration initialized successfully");
}
// Background download of audio assets. The worker keeps retrying until if (!state->db.initialize()) {
// internet is available, so downloads start automatically on connect. radiantwave::logger::Logger::instance().error(
// The downloaded files are reconciled into the database's audio_files table. "Database", "Database storage unavailable. App features may be "
state->audioDownloader.start(state->db); "limited (Database initialization failed at "
"~/.local/radiantwave/radiantwave.db)");
} else {
radiantwave::logger::Logger::instance().info(
"Database", "Database initialized and schema migrated");
}
// Background license validation. Home triggers a refresh whenever it is if (!state->network.initialize()) {
// displayed; the result gates access to Play and the Proclamation options. radiantwave::logger::Logger::instance().warning(
state->licenseTracker.start(state->db); "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");
}
state->config.onChanged([state](const radiantwave::config::AppConfig& new_config) { if (!state->audio.initialize(state->config)) {
SDL_Log("Live config update received: volume=%d, dynamic_audio=%d", radiantwave::logger::Logger::instance().warning(
new_config.audio.volume, new_config.audio.dynamic_audio ? 1 : 0); "Audio", "Audio playback is unavailable. Check sound hardware "
state->db.logEvent("INFO", "Config", "Live configuration hot-reloaded"); "connections (SDL3_mixer initialization failed)");
}); } else {
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);
state->keybinds.bind(SDLK_UP, "menu_up", [state]() { // Background download of audio assets. The worker keeps retrying until
state->currentMenuItem = (state->currentMenuItem + 1) % 2; // internet is available, so downloads start automatically on connect.
}); // The downloaded files are reconciled into the database's audio_files
state->keybinds.bind(SDLK_DOWN, "menu_down", [state]() { // table.
state->currentMenuItem = (state->currentMenuItem + 1) % 2; state->audioDownloader.start(state->db);
});
// Drive the First-Run walkthrough: shows the WiFi page when the internet // Background license validation. Home triggers a refresh whenever it is
// is unreachable, then the email and license pages when those are missing, // displayed; the result gates access to Play and the Proclamation options.
// finishing on Home. The internet check is a real connectivity probe state->licenseTracker.start(state->db);
// (Ethernet, WiFi, or macOS all work), so it never traps a connected
// device on the WiFi page.
radiantwave::provisioning::begin(*state);
*appstate = state; state->config.onChanged(
return SDL_APP_CONTINUE; [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");
});
state->keybinds.bind(SDLK_UP, "menu_up", [state]() {
state->currentMenuItem = (state->currentMenuItem + 1) % 2;
});
state->keybinds.bind(SDLK_DOWN, "menu_down", [state]() {
state->currentMenuItem = (state->currentMenuItem + 1) % 2;
});
// Drive the First-Run walkthrough: shows the WiFi page when the internet
// 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);
*appstate = state;
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 (event->type == SDL_EVENT_WINDOW_RESIZED) {
state.pages.handleEvent(*event); radiantwave::logger::Logger::instance().info(
} "Graphics", "Window resized to " +
std::to_string(event->window.data1) + "x" +
std::to_string(event->window.data2));
// Logical presentation persists across resizes; re-apply defensively
// in case backend state was reset underneath us.
SDL_SetRenderLogicalPresentation(state.renderer,
radiantwave::kDesignWidth,
radiantwave::kDesignHeight,
SDL_LOGICAL_PRESENTATION_LETTERBOX);
}
if (state.quitRequested && state.quitFadeRemaining <= 0.0f) if (!state.keybinds.handleEvent(*event)) {
{ state.pages.handleEvent(*event);
// 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))
{
state.quitFadeRemaining = kQuitFadeSeconds;
return SDL_APP_CONTINUE;
}
return SDL_APP_SUCCESS;
}
return SDL_APP_CONTINUE; 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)) {
state.quitFadeRemaining = kQuitFadeSeconds;
return SDL_APP_CONTINUE;
}
return SDL_APP_SUCCESS;
}
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();
} }
+13 -26
View File
@@ -4,10 +4,6 @@
#include <curl/curl.h> #include <curl/curl.h>
#include <string> #include <string>
#ifndef API_KEY
#define API_KEY ""
#endif
namespace radiantwave::api { namespace radiantwave::api {
namespace { 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. // 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) { bool post, const std::string& jsonBody) {
HttpResponse response; HttpResponse response;
@@ -47,12 +43,6 @@ HttpResponse performRequest(const std::string& url, const std::string& apiKey,
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response.body); curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response.body);
struct curl_slist* headers = nullptr; 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) { if (post) {
headers = curl_slist_append(headers, "Content-Type: application/json"); headers = curl_slist_append(headers, "Content-Type: application/json");
curl_easy_setopt(curl, CURLOPT_POST, 1L); curl_easy_setopt(curl, CURLOPT_POST, 1L);
@@ -79,13 +69,12 @@ HttpResponse performRequest(const std::string& url, const std::string& apiKey,
// ─── HTTP helpers ────────────────────────────────────────────────────── // ─── HTTP helpers ──────────────────────────────────────────────────────
HttpResponse httpGet(const std::string& path, const std::string& apiKey) { HttpResponse httpGet(const std::string& path) {
return performRequest(urlFor(path), apiKey, false, ""); return performRequest(urlFor(path), false, "");
} }
HttpResponse httpPost(const std::string& path, const std::string& jsonBody, HttpResponse httpPost(const std::string& path, const std::string& jsonBody) {
const std::string& apiKey) { return performRequest(urlFor(path), true, jsonBody);
return performRequest(urlFor(path), apiKey, true, jsonBody);
} }
// ─── GET /frequencies ────────────────────────────────────────────────── // ─── GET /frequencies ──────────────────────────────────────────────────
@@ -112,8 +101,8 @@ bool parseFrequenciesJson(const std::string& body, std::vector<Frequency>& out)
return true; return true;
} }
bool fetchFrequencies(std::vector<Frequency>& out, const std::string& apiKey) { bool fetchFrequencies(std::vector<Frequency>& out) {
HttpResponse response = httpGet(PATH_FREQUENCIES, apiKey); HttpResponse response = httpGet(PATH_FREQUENCIES);
if (!response.transportOk || response.status < 200 || response.status >= 300) { if (!response.transportOk || response.status < 200 || response.status >= 300) {
return false; return false;
} }
@@ -169,8 +158,8 @@ bool parseColorPalettesJson(const std::string& body, std::vector<ColorPalette>&
return true; return true;
} }
bool fetchColorPalettes(std::vector<ColorPalette>& out, const std::string& apiKey) { bool fetchColorPalettes(std::vector<ColorPalette>& out) {
HttpResponse response = httpGet(PATH_COLOR_PALETTES, apiKey); HttpResponse response = httpGet(PATH_COLOR_PALETTES);
if (!response.transportOk || response.status < 200 || response.status >= 300) { if (!response.transportOk || response.status < 200 || response.status >= 300) {
return false; return false;
} }
@@ -196,13 +185,12 @@ bool parseLicenseJson(const std::string& body, LicenseInfo& out) {
return !out.key.empty(); return !out.key.empty();
} }
LicenseResult validateLicense(const std::string& key, const std::string& email, LicenseResult validateLicense(const std::string& key, const std::string& email) {
const std::string& apiKey) {
LicenseResult result; LicenseResult result;
const std::string jsonBody = const std::string jsonBody =
"{\"license_key\":\"" + key + "\",\"email_address\":\"" + email + "\"}"; "{\"license_key\":\"" + key + "\",\"email_address\":\"" + email + "\"}";
HttpResponse response = httpPost(PATH_LICENSE, jsonBody, apiKey); HttpResponse response = httpPost(PATH_LICENSE, jsonBody);
result.status = response.status; result.status = response.status;
if (!response.transportOk) { if (!response.transportOk) {
@@ -233,8 +221,7 @@ bool parseCreateAccountJson(const std::string& body, AccountBody& out) {
return true; return true;
} }
AccountResult createAccount(const std::string& email, const std::string& name, AccountResult createAccount(const std::string& email, const std::string& name) {
const std::string& apiKey) {
AccountResult result; AccountResult result;
std::string jsonBody = std::string jsonBody =
@@ -244,7 +231,7 @@ AccountResult createAccount(const std::string& email, const std::string& name,
} }
jsonBody += "}"; jsonBody += "}";
HttpResponse response = httpPost(PATH_CREATE_ACCOUNT, jsonBody, apiKey); HttpResponse response = httpPost(PATH_CREATE_ACCOUNT, jsonBody);
result.status = response.status; result.status = response.status;
if (!response.transportOk) { 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 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 // Performs a GET/POST against `path` (relative to API_BASE_URL).
// non-empty it is sent as `Authorization: Bearer <apiKey>`. When it is empty (or HttpResponse httpGet(const std::string& path);
// omitted) the request falls back to the key baked in at build time via the HttpResponse httpPost(const std::string& path, const std::string& jsonBody);
// `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 = "");
// ─── GET /frequencies ────────────────────────────────────────────────── // ─── GET /frequencies ──────────────────────────────────────────────────
@@ -45,7 +40,7 @@ struct Frequency {
bool parseFrequenciesJson(const std::string& body, std::vector<Frequency>& out); bool parseFrequenciesJson(const std::string& body, std::vector<Frequency>& out);
// Fetch the solfeggio frequencies. Returns true only on HTTP 2xx + successful parse. // 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 ─────────────────────────────────────────────── // ─── 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 // 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 // is deliberately not called anywhere in the app until the route ships, so
// palettes can be updated from the portal rather than code releases. // 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 ───────────────────────────────────────────────────── // ─── POST /license ─────────────────────────────────────────────────────
@@ -98,8 +93,7 @@ struct LicenseResult {
// Validate a license key / email pair against the portal. Never throws; failures // Validate a license key / email pair against the portal. Never throws; failures
// (network, HTTP error status) are reported through the returned LicenseResult. // (network, HTTP error status) are reported through the returned LicenseResult.
// A 2xx response means the license is valid and the subscription is active. // A 2xx response means the license is valid and the subscription is active.
LicenseResult validateLicense(const std::string& key, const std::string& email, LicenseResult validateLicense(const std::string& key, const std::string& email);
const std::string& apiKey = "");
// ─── POST /automations/create/account ────────────────────────────────── // ─── 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 // 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. // to the user and is NOT part of the response. `name` may be empty.
AccountResult createAccount(const std::string& email, const std::string& name = "", AccountResult createAccount(const std::string& email, const std::string& name = "");
const std::string& apiKey = "");
} // namespace radiantwave::api } // namespace radiantwave::api
+1 -2
View File
@@ -17,7 +17,6 @@ long long nowEpochSeconds() {
} // namespace } // namespace
Result validateStoredLicense(radiantwave::db::DatabaseManager& db, Result validateStoredLicense(radiantwave::db::DatabaseManager& db,
const std::string& apiKey,
std::string* outErrorMessage) { std::string* outErrorMessage) {
if (outErrorMessage) outErrorMessage->clear(); if (outErrorMessage) outErrorMessage->clear();
@@ -28,7 +27,7 @@ Result validateStoredLicense(radiantwave::db::DatabaseManager& db,
} }
const radiantwave::api::LicenseResult result = const radiantwave::api::LicenseResult result =
radiantwave::api::validateLicense(key, email, apiKey); radiantwave::api::validateLicense(key, email);
if (result.status == 0) { if (result.status == 0) {
if (outErrorMessage) *outErrorMessage = "network error"; 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. // `outErrorMessage` is an optional out-param.
Result validateStoredLicense(radiantwave::db::DatabaseManager& db, Result validateStoredLicense(radiantwave::db::DatabaseManager& db,
const std::string& apiKey = "",
std::string* outErrorMessage = nullptr); std::string* outErrorMessage = nullptr);
// Records the current time as the "last verified" stamp of the license. Called // 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; return true;
} }
bool syncColorPalettesFromApi(radiantwave::db::DatabaseManager& db, bool syncColorPalettesFromApi(radiantwave::db::DatabaseManager& db) {
const std::string& apiKey) {
std::vector<api::ColorPalette> palettes; std::vector<api::ColorPalette> palettes;
if (!api::fetchColorPalettes(palettes, apiKey)) { if (!api::fetchColorPalettes(palettes)) {
return false; return false;
} }
@@ -211,9 +210,8 @@ bool syncColorPalettesFromApi(radiantwave::db::DatabaseManager& db,
return db.replaceColorPalettes(records); return db.replaceColorPalettes(records);
} }
bool syncProclamationPalettesFromApi(radiantwave::db::DatabaseManager& db, bool syncProclamationPalettesFromApi(radiantwave::db::DatabaseManager& db) {
const std::string& apiKey) { api::HttpResponse response = api::httpGet(api::PATH_PROCLAMATIONS);
api::HttpResponse response = api::httpGet(api::PATH_PROCLAMATIONS, apiKey);
if (!response.transportOk || response.status < 200 || response.status >= 300) { if (!response.transportOk || response.status < 200 || response.status >= 300) {
return false; 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 // 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 // yet. Readiness code: palettes should be updated from the portal, not code
// releases, so callers should treat this as a future sync step. // releases, so callers should treat this as a future sync step.
bool syncColorPalettesFromApi(radiantwave::db::DatabaseManager& db, bool syncColorPalettesFromApi(radiantwave::db::DatabaseManager& db);
const std::string& apiKey = "");
// Alternative to syncColorPalettesFromApi: fetch proclamations and replace the // Alternative to syncColorPalettesFromApi: fetch proclamations and replace the
// local palette table using the palettes embedded in each proclamation. // local palette table using the palettes embedded in each proclamation.
// NOTE: not yet wired into the app flow; see syncColorPalettesFromApi. // NOTE: not yet wired into the app flow; see syncColorPalettesFromApi.
bool syncProclamationPalettesFromApi(radiantwave::db::DatabaseManager& db, bool syncProclamationPalettesFromApi(radiantwave::db::DatabaseManager& db);
const std::string& apiKey = "");
} // namespace radiantwave::sync } // namespace radiantwave::sync
+2 -1
View File
@@ -1,6 +1,7 @@
#include "proclamation_options_page.h" #include "proclamation_options_page.h"
#include "app_state.h" #include "app_state.h"
#include "network/proclamation_sync.h" #include "network/proclamation_sync.h"
#include "logical_size.h"
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
#include <algorithm> #include <algorithm>
#include <cctype> #include <cctype>
@@ -552,7 +553,7 @@ void ProclamationOptionsPage::update(float dt)
void ProclamationOptionsPage::render(SDL_Renderer* renderer) void ProclamationOptionsPage::render(SDL_Renderer* renderer)
{ {
int winW, winH; int winW, winH;
SDL_GetWindowSize(SDL_GetRenderWindow(renderer), &winW, &winH); radiantwave::getLayoutSize(renderer, &winW, &winH);
float fw = static_cast<float>(winW); float fw = static_cast<float>(winW);
float fh = static_cast<float>(winH); float fh = static_cast<float>(winH);
+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
+2 -1
View File
@@ -5,6 +5,7 @@
#include "proclamation_options_page.h" #include "proclamation_options_page.h"
#include "scramble/fibonacci_scrambler.h" #include "scramble/fibonacci_scrambler.h"
#include "utf8_utils.h" #include "utf8_utils.h"
#include "logical_size.h"
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
#include <SDL3_ttf/SDL_ttf.h> #include <SDL3_ttf/SDL_ttf.h>
@@ -35,7 +36,7 @@ void ScrollerPage::onPush(AppState &state)
int winW = 0; int winW = 0;
int winH = 0; int winH = 0;
SDL_GetWindowSize(state.window, &winW, &winH); radiantwave::getLayoutSize(state.renderer, &winW, &winH);
screenW_ = static_cast<float>(winW); screenW_ = static_cast<float>(winW);
screenH_ = static_cast<float>(winH); screenH_ = static_cast<float>(winH);
// Lines are capped at the full screen width; the widest Fibonacci lines are // Lines are capped at the full screen width; the widest Fibonacci lines are
+20 -19
View File
@@ -10,6 +10,7 @@
#include "email_address_page.h" #include "email_address_page.h"
#include "license_key_page.h" #include "license_key_page.h"
#include "date_format_page.h" #include "date_format_page.h"
#include "logical_size.h"
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
#include <SDL3_image/SDL_image.h> #include <SDL3_image/SDL_image.h>
#include <algorithm> #include <algorithm>
@@ -18,18 +19,18 @@ namespace
{ {
constexpr int MENU_ITEM_COUNT = 9; constexpr int MENU_ITEM_COUNT = 9;
// 0 = WiFi Setup, 1 = Audio Options, 2 = Time Zone Setup, 3 = Proclamations, // 0 = Proclamations, 1 = Audio Options, 2 = Email Address, 3 = License Key,
// 4 = Email Address, 5 = License Key, 6 = Date Format, // 4 = WiFi Setup, 5 = Time Zone Setup, 6 = Date Format,
// 7 = View System Logs, 8 = Back // 7 = System Logs, 8 = Back
constexpr const char *MENU_LABELS[MENU_ITEM_COUNT] = { constexpr const char *MENU_LABELS[MENU_ITEM_COUNT] = {
"WiFi Setup",
"Audio Options",
"Time Zone Setup",
"Proclamations", "Proclamations",
"Audio Options",
"Email Address", "Email Address",
"License Key", "License Key",
"WiFi Setup",
"Time Zone Setup",
"Date Format", "Date Format",
"View System Logs", "System Logs",
"Back to Home", "Back to Home",
}; };
@@ -77,15 +78,6 @@ void Settings::handleEvent(const SDL_Event &event)
switch (selectedItem_) switch (selectedItem_)
{ {
case 0: case 0:
state_->pages.push(*state_, std::make_unique<WiFiSetupPage>());
break;
case 1:
state_->pages.push(*state_, std::make_unique<AudioSettingsPage>());
break;
case 2:
state_->pages.push(*state_, std::make_unique<TimeZonePage>());
break;
case 3:
// The Proclamation list is license-gated like Play. // The Proclamation list is license-gated like Play.
if (state_->licenseTracker.isValid()) if (state_->licenseTracker.isValid())
{ {
@@ -96,12 +88,21 @@ void Settings::handleEvent(const SDL_Event &event)
state_->pages.push(*state_, std::make_unique<LicenseBlockedPage>()); state_->pages.push(*state_, std::make_unique<LicenseBlockedPage>());
} }
break; break;
case 4: case 1:
state_->pages.push(*state_, std::make_unique<AudioSettingsPage>());
break;
case 2:
state_->pages.push(*state_, std::make_unique<EmailAddressPage>()); state_->pages.push(*state_, std::make_unique<EmailAddressPage>());
break; break;
case 5: case 3:
state_->pages.push(*state_, std::make_unique<LicenseKeyPage>()); state_->pages.push(*state_, std::make_unique<LicenseKeyPage>());
break; break;
case 4:
state_->pages.push(*state_, std::make_unique<WiFiSetupPage>());
break;
case 5:
state_->pages.push(*state_, std::make_unique<TimeZonePage>());
break;
case 6: case 6:
state_->pages.push(*state_, std::make_unique<DateFormatPage>()); state_->pages.push(*state_, std::make_unique<DateFormatPage>());
break; break;
@@ -128,7 +129,7 @@ void Settings::update(float dt)
void Settings::render(SDL_Renderer *renderer) void Settings::render(SDL_Renderer *renderer)
{ {
int winW, winH; int winW, winH;
SDL_GetWindowSize(SDL_GetRenderWindow(renderer), &winW, &winH); radiantwave::getLayoutSize(renderer, &winW, &winH);
float fw = static_cast<float>(winW); float fw = static_cast<float>(winW);
float fh = static_cast<float>(winH); float fh = static_cast<float>(winH);
+2 -1
View File
@@ -1,5 +1,6 @@
#include "timezone_page.h" #include "timezone_page.h"
#include "app_state.h" #include "app_state.h"
#include "logical_size.h"
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
#include <algorithm> #include <algorithm>
#include <ctime> #include <ctime>
@@ -199,7 +200,7 @@ void TimeZonePage::update(float dt) {
void TimeZonePage::render(SDL_Renderer* renderer) { void TimeZonePage::render(SDL_Renderer* renderer) {
int winW, winH; int winW, winH;
SDL_GetWindowSize(SDL_GetRenderWindow(renderer), &winW, &winH); radiantwave::getLayoutSize(renderer, &winW, &winH);
float fw = static_cast<float>(winW); float fw = static_cast<float>(winW);
float fh = static_cast<float>(winH); float fh = static_cast<float>(winH);
+2 -1
View File
@@ -1,6 +1,7 @@
#include "wifi_setup_page.h" #include "wifi_setup_page.h"
#include "app_state.h" #include "app_state.h"
#include "provisioning.h" #include "provisioning.h"
#include "logical_size.h"
#include <SDL3/SDL.h> #include <SDL3/SDL.h>
#include <algorithm> #include <algorithm>
#include <exception> #include <exception>
@@ -301,7 +302,7 @@ void WiFiSetupPage::update(float dt) {
void WiFiSetupPage::render(SDL_Renderer* renderer) { void WiFiSetupPage::render(SDL_Renderer* renderer) {
int winW, winH; int winW, winH;
SDL_GetWindowSize(SDL_GetRenderWindow(renderer), &winW, &winH); radiantwave::getLayoutSize(renderer, &winW, &winH);
float fw = static_cast<float>(winW); float fw = static_cast<float>(winW);
float fh = static_cast<float>(winH); 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(); 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 } // namespace
// ─── Offline: parse functions ───────────────────────────────────────── // ─── Offline: parse functions ─────────────────────────────────────────
@@ -163,7 +158,7 @@ TEST(ApiClientTest, ParseCategoriesHandlesNullParentId) {
TEST(ApiClientLiveTest, FetchFrequenciesLive) { TEST(ApiClientLiveTest, FetchFrequenciesLive) {
std::vector<Frequency> out; std::vector<Frequency> out;
EXPECT_TRUE(fetchFrequencies(out, liveApiKey())); EXPECT_TRUE(fetchFrequencies(out));
ASSERT_GE(out.size(), 7u); ASSERT_GE(out.size(), 7u);
bool foundSolarPlexus = false; bool foundSolarPlexus = false;
@@ -180,7 +175,7 @@ TEST(ApiClientLiveTest, FetchFrequenciesLive) {
// ─── Live: proclamation + category parsers against the real API ──────── // ─── Live: proclamation + category parsers against the real API ────────
TEST(ApiClientLiveTest, ProclamationsAndCategoriesLiveSmoke) { TEST(ApiClientLiveTest, ProclamationsAndCategoriesLiveSmoke) {
HttpResponse procsResponse = httpGet(PATH_PROCLAMATIONS, liveApiKey()); HttpResponse procsResponse = httpGet(PATH_PROCLAMATIONS);
ASSERT_TRUE(procsResponse.transportOk); ASSERT_TRUE(procsResponse.transportOk);
ASSERT_EQ(procsResponse.status, 200); ASSERT_EQ(procsResponse.status, 200);
@@ -193,7 +188,7 @@ TEST(ApiClientLiveTest, ProclamationsAndCategoriesLiveSmoke) {
EXPECT_FALSE(proclamations[0].id.empty()); EXPECT_FALSE(proclamations[0].id.empty());
} }
HttpResponse catsResponse = httpGet(PATH_CATEGORIES, liveApiKey()); HttpResponse catsResponse = httpGet(PATH_CATEGORIES);
ASSERT_TRUE(catsResponse.transportOk); ASSERT_TRUE(catsResponse.transportOk);
ASSERT_EQ(catsResponse.status, 200); ASSERT_EQ(catsResponse.status, 200);
@@ -205,14 +200,14 @@ TEST(ApiClientLiveTest, ProclamationsAndCategoriesLiveSmoke) {
// ─── Live: POST /license error paths (no credentials required) ───────── // ─── Live: POST /license error paths (no credentials required) ─────────
TEST(ApiClientLiveTest, ValidateLicenseMissingKeyLive) { TEST(ApiClientLiveTest, ValidateLicenseMissingKeyLive) {
LicenseResult result = validateLicense("", "test@example.com", liveApiKey()); LicenseResult result = validateLicense("", "test@example.com");
ASSERT_FALSE(result.ok()); ASSERT_FALSE(result.ok());
EXPECT_EQ(result.status, 400); EXPECT_EQ(result.status, 400);
EXPECT_EQ(result.error_message, "license_key is required"); EXPECT_EQ(result.error_message, "license_key is required");
} }
TEST(ApiClientLiveTest, ValidateLicenseMissingEmailLive) { TEST(ApiClientLiveTest, ValidateLicenseMissingEmailLive) {
LicenseResult result = validateLicense("AAAA-AAAA-AAAA-AAAA", "", liveApiKey()); LicenseResult result = validateLicense("AAAA-AAAA-AAAA-AAAA", "");
ASSERT_FALSE(result.ok()); ASSERT_FALSE(result.ok());
EXPECT_EQ(result.status, 400); EXPECT_EQ(result.status, 400);
EXPECT_EQ(result.error_message, "email_address is required"); EXPECT_EQ(result.error_message, "email_address is required");
@@ -220,7 +215,7 @@ TEST(ApiClientLiveTest, ValidateLicenseMissingEmailLive) {
TEST(ApiClientLiveTest, ValidateLicenseUnknownKeyLive) { TEST(ApiClientLiveTest, ValidateLicenseUnknownKeyLive) {
LicenseResult result = LicenseResult result =
validateLicense("AAAA-AAAA-AAAA-AAAA", "nobody@example.com", liveApiKey()); validateLicense("AAAA-AAAA-AAAA-AAAA", "nobody@example.com");
ASSERT_FALSE(result.ok()); ASSERT_FALSE(result.ok());
EXPECT_EQ(result.status, 400); EXPECT_EQ(result.status, 400);
EXPECT_EQ(result.error_message, "license key not found"); 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"; 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 ASSERT_TRUE(result.ok()) << "status=" << result.status
<< " error=" << result.error_message; << " error=" << result.error_message;
EXPECT_EQ(result.info.key, key); EXPECT_EQ(result.info.key, key);
@@ -245,7 +240,7 @@ TEST(ApiClientLiveTest, ValidateLicenseSuccessLive) {
// ─── Live: POST /automations/create/account error paths (harmless) ───── // ─── Live: POST /automations/create/account error paths (harmless) ─────
TEST(ApiClientLiveTest, CreateAccountMissingEmailLive) { TEST(ApiClientLiveTest, CreateAccountMissingEmailLive) {
AccountResult result = createAccount("", "", liveApiKey()); AccountResult result = createAccount("", "");
ASSERT_FALSE(result.ok()); ASSERT_FALSE(result.ok());
EXPECT_EQ(result.status, 400); EXPECT_EQ(result.status, 400);
EXPECT_EQ(result.error_message, "email is required"); EXPECT_EQ(result.error_message, "email is required");
@@ -260,7 +255,7 @@ TEST(ApiClientLiveTest, CreateAccountSuccessLive) {
GTEST_SKIP() << "RW_LIVE_ACCOUNT_EMAIL not set"; 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 ASSERT_TRUE(result.ok()) << "status=" << result.status
<< " error=" << result.error_message; << " error=" << result.error_message;
EXPECT_TRUE(result.body.success); EXPECT_TRUE(result.body.success);
+1 -1
View File
@@ -175,7 +175,7 @@ TEST(ColorPalettesLiveTest, FetchColorPalettesLive) {
} }
std::vector<ColorPalette> palettes; std::vector<ColorPalette> palettes;
ASSERT_TRUE(fetchColorPalettes(palettes, env("RW_LIVE_API_KEY"))); ASSERT_TRUE(fetchColorPalettes(palettes));
EXPECT_FALSE(palettes.empty()); EXPECT_FALSE(palettes.empty());
for (const auto& p : palettes) { for (const auto& p : palettes) {
EXPECT_GE(p.colors.size(), 1u); 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"); tmp.db.setKV(KV_LICENSE_KEY, "ABCD-EFGH-IJKL-MNOP");
std::string errorMessage = "stale"; std::string errorMessage = "stale";
EXPECT_EQ(validateStoredLicense(tmp.db, "", &errorMessage), EXPECT_EQ(validateStoredLicense(tmp.db, &errorMessage),
Result::NotConfigured); Result::NotConfigured);
EXPECT_EQ(errorMessage, ""); EXPECT_EQ(errorMessage, "");
} }
@@ -57,7 +57,7 @@ TEST(LicenseServiceTest, NotConfiguredWhenNoLicense) {
TempDb tmp; TempDb tmp;
tmp.db.setKV(KV_EMAIL_ADDRESS, "user@example.com"); tmp.db.setKV(KV_EMAIL_ADDRESS, "user@example.com");
EXPECT_EQ(validateStoredLicense(tmp.db, "", nullptr), EXPECT_EQ(validateStoredLicense(tmp.db, nullptr),
Result::NotConfigured); Result::NotConfigured);
} }
@@ -117,7 +117,7 @@ TEST(LicenseServiceLiveTest, ValidatesAndGrantsSubscribedState) {
tmp.db.setKV(KV_EMAIL_ADDRESS, email); tmp.db.setKV(KV_EMAIL_ADDRESS, email);
std::string errorMessage; std::string errorMessage;
const Result result = validateStoredLicense(tmp.db, "", &errorMessage); const Result result = validateStoredLicense(tmp.db, &errorMessage);
EXPECT_EQ(result, Result::Ok) << "error=" << errorMessage; EXPECT_EQ(result, Result::Ok) << "error=" << errorMessage;
// A successful validation is the "subscribed" flag; no subscription-level // 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"); tmp.db.setKV(KV_EMAIL_ADDRESS, "nobody@example.com");
std::string errorMessage; std::string errorMessage;
EXPECT_EQ(validateStoredLicense(tmp.db, "", &errorMessage), EXPECT_EQ(validateStoredLicense(tmp.db, &errorMessage),
Result::Invalid); Result::Invalid);
EXPECT_FALSE(errorMessage.empty()); EXPECT_FALSE(errorMessage.empty());
} }
+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()