Compare commits
27
Commits
359ae905d7
...
1.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fee31c87b8 | ||
|
|
5369532ab4 | ||
|
|
42b915bd25 | ||
|
|
d60a8bd7fb | ||
|
|
ca7b8b7e6f | ||
|
|
bf2d4d1f89 | ||
|
|
2917d33866 | ||
|
|
3f6201182e | ||
|
|
d84307bd0e | ||
|
|
43cd93bbd7 | ||
|
|
92ba9f39fb | ||
|
|
400c6a782b | ||
|
|
9b0bd517c6 | ||
|
|
babb74c7ad | ||
|
|
47785cd4b9 | ||
|
|
15583edef8 | ||
|
|
63265cc22f | ||
|
|
9e73c82641 | ||
|
|
f2a40cd019 | ||
|
|
5caf1d6ef8 | ||
|
|
e17e881c00 | ||
|
|
b96631c374 | ||
|
|
39eb6d7bf7 | ||
|
|
3c9de0bee2 | ||
|
|
8e930ef8d9 | ||
|
|
ec6b76618c | ||
|
|
74245ee07d |
@@ -1,78 +0,0 @@
|
||||
name: build
|
||||
|
||||
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:
|
||||
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.
|
||||
# A full clone (no --depth) is required so `git describe --tags` on main
|
||||
# resolves the real nearest tag instead of falling back to a hash.
|
||||
- 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"
|
||||
|
||||
# Compute the version string baked into the binary's Home footer:
|
||||
# main reports the git tag, dev reports the commit hash fingerprint.
|
||||
# The key stays in the build step's environment (never echoed).
|
||||
- name: Compute version
|
||||
id: version
|
||||
run: |
|
||||
if [ "${{ gitea.ref_name }}" = "main" ]; then
|
||||
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
|
||||
# #attr shorthand can't resolve it, so resolve the current system inline.
|
||||
# --impure lets the flake read APP_VERSION/API_KEY 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 }}
|
||||
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
|
||||
'
|
||||
@@ -0,0 +1,82 @@
|
||||
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
|
||||
|
||||
# Publish the manifest kiosks poll for live app updates. `storePath`
|
||||
# must match exactly the closure pushed to the dev Attic cache above
|
||||
# (readlink resolves the result-pkg out-link symlink to its real
|
||||
# /nix/store path), so a kiosk can `nix copy --from` it without ever
|
||||
# building from source. Upload mirrors the ISO scp pattern to the
|
||||
# fileserver via the build-server's `radiantwave-infra` SSH alias.
|
||||
- name: Publish manifest
|
||||
run: |
|
||||
STOREPATH=$(readlink -f ./result-pkg)
|
||||
printf '{"version":"%s","storePath":"%s"}\n' "${{ env.APP_VERSION }}" "$STOREPATH" > manifest.json
|
||||
cat manifest.json
|
||||
scp manifest.json \
|
||||
radiantwave-infra:/mnt/infrastructure_data/files/radiantwave-dev/manifest.json
|
||||
@@ -0,0 +1,90 @@
|
||||
name: build-main
|
||||
|
||||
on:
|
||||
# Manual only. Dispatch against main: build, run the offline test suite,
|
||||
# publish to the release Attic cache and live-update manifest.
|
||||
workflow_dispatch:
|
||||
|
||||
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.
|
||||
# A full clone (no --depth) is required so `git describe --tags` resolves
|
||||
# the real nearest tag instead of falling back to a hash.
|
||||
- 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"
|
||||
|
||||
# Release builds report the current git tag.
|
||||
# Persisted via GITHUB_ENV so the impure nix eval below reads it via
|
||||
# builtins.getEnv.
|
||||
- name: Compute version
|
||||
run: |
|
||||
echo "APP_VERSION=$(git describe --tags --always)" >> "$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
|
||||
'
|
||||
|
||||
# Release closure (the actual package, not the check output) 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-pkg
|
||||
|
||||
# Publish the manifest production kiosks poll for live app updates.
|
||||
# `storePath` must match exactly the closure pushed to the release Attic
|
||||
# cache above (readlink resolves the result-pkg out-link symlink to its
|
||||
# real /nix/store path), so a kiosk can `nix copy --from` it without ever
|
||||
# building from source. Upload mirrors the ISO scp pattern to the
|
||||
# fileserver via the build-server's `radiantwave-infra` SSH alias.
|
||||
- name: Publish manifest
|
||||
run: |
|
||||
STOREPATH=$(readlink -f ./result-pkg)
|
||||
printf '{"version":"%s","storePath":"%s"}\n' "${{ env.APP_VERSION }}" "$STOREPATH" > manifest.json
|
||||
cat manifest.json
|
||||
scp manifest.json \
|
||||
radiantwave-infra:/mnt/infrastructure_data/files/radiantwave/manifest.json
|
||||
@@ -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
|
||||
|
||||
+8
-16
@@ -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)
|
||||
@@ -127,7 +112,14 @@ add_executable(test_provisioning
|
||||
tests/test_provisioning.cpp
|
||||
)
|
||||
target_include_directories(test_provisioning PRIVATE src)
|
||||
target_link_libraries(test_provisioning PRIVATE SDL3::SDL3)
|
||||
# provisioning.h pulls in app_state.h, so the test compiles against the SDL3,
|
||||
# SDL3_mixer, and SQLite headers transitively (app_state.h -> audio_manager.h ->
|
||||
# SDL3_mixer; app_state.h -> database_manager.h -> sqlite3.h).
|
||||
target_link_libraries(test_provisioning PRIVATE
|
||||
SDL3::SDL3
|
||||
SDL3_mixer::SDL3_mixer
|
||||
SQLite3::SQLite3
|
||||
)
|
||||
|
||||
add_executable(test_assets
|
||||
tests/test_assets.cpp
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -112,6 +112,18 @@ in
|
||||
export WLR_RENDERER=pixman
|
||||
export WLR_NO_HARDWARE_CURSORS=1
|
||||
|
||||
# Prefer the PipeWire audio backend so SDL enumerates every sink
|
||||
# (built-in analog, HDMI, and the loopback virtual speaker) instead of
|
||||
# silently falling back to an ALSA-only device view that hides the
|
||||
# PipeWire sinks and makes the device selector show a single entry.
|
||||
export SDL_AUDIODRIVER=pipewire
|
||||
if [ -n "$${XDG_RUNTIME_DIR:-}" ]; then
|
||||
for i in {1..20}; do
|
||||
[ -S "$XDG_RUNTIME_DIR/pipewire-0" ] && break
|
||||
sleep 0.5
|
||||
done
|
||||
fi
|
||||
|
||||
cage -- ${radiantWaveApp}/bin/radiantwave > /tmp/kiosk.log 2>&1
|
||||
fi
|
||||
'';
|
||||
|
||||
@@ -27,10 +27,10 @@ namespace radiantwave::audio {
|
||||
// honored, anything else falls back to the Hz parsed from the filename, so
|
||||
// the file's own name is always the safety net.
|
||||
struct AudioManifestEntry {
|
||||
std::string filename;
|
||||
std::string display_name;
|
||||
int solfeggio_frequency_hz = 0;
|
||||
std::string md5;
|
||||
std::string filename;
|
||||
std::string display_name;
|
||||
int solfeggio_frequency_hz = 0;
|
||||
std::string md5;
|
||||
};
|
||||
|
||||
// Downloads audio assets from files.radiantwavetech.com in the background
|
||||
@@ -50,45 +50,49 @@ struct AudioManifestEntry {
|
||||
// checksums are picked up while running.
|
||||
class AudioDownloader {
|
||||
public:
|
||||
AudioDownloader() = default;
|
||||
~AudioDownloader();
|
||||
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);
|
||||
// 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);
|
||||
|
||||
// Signals the worker to stop and joins the thread.
|
||||
void shutdown();
|
||||
// Signals the worker to stop and joins the thread.
|
||||
void shutdown();
|
||||
|
||||
// Returns the current human-readable status for display (e.g. footer).
|
||||
std::string statusString() const;
|
||||
// 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 = "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);
|
||||
void run();
|
||||
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 wait(std::chrono::seconds seconds);
|
||||
void setStatus(const std::string &msg);
|
||||
void wait(std::chrono::seconds seconds);
|
||||
|
||||
mutable std::mutex mutex_;
|
||||
std::condition_variable cv_;
|
||||
std::thread worker_;
|
||||
std::atomic<bool> stop_{false};
|
||||
std::string status_;
|
||||
db::DatabaseManager* db_ = nullptr;
|
||||
mutable std::mutex mutex_;
|
||||
std::condition_variable cv_;
|
||||
std::thread worker_;
|
||||
std::atomic<bool> stop_{false};
|
||||
std::string status_;
|
||||
db::DatabaseManager *db_ = nullptr;
|
||||
};
|
||||
|
||||
// Parses 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
|
||||
|
||||
+388
-31
@@ -32,6 +32,7 @@ namespace radiantwave::audio
|
||||
if (lower.find("analog") != std::string::npos ||
|
||||
lower.find("built-in") != std::string::npos ||
|
||||
lower.find("builtin") != std::string::npos ||
|
||||
lower.find("virtual") != std::string::npos ||
|
||||
lower.find("realtek") != std::string::npos ||
|
||||
lower.find("hda") != std::string::npos ||
|
||||
lower.find("speaker") != std::string::npos ||
|
||||
@@ -76,7 +77,6 @@ namespace radiantwave::audio
|
||||
enabled_ = cfg.audio.enabled;
|
||||
currentDevice_ = cfg.audio.output_device;
|
||||
currentTrack_ = cfg.audio.current_track;
|
||||
|
||||
if (!MIX_Init())
|
||||
{
|
||||
std::string err = SDL_GetError();
|
||||
@@ -86,17 +86,68 @@ namespace radiantwave::audio
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create mixer device with default audio device
|
||||
mixer_ = MIX_CreateMixerDevice(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK, nullptr);
|
||||
// Startup diagnostics: which audio backend SDL picked (PipeWire, ALSA,
|
||||
// Pulse, dummy, ...) and what playback devices it actually sees. This is
|
||||
// the first place a "only the built-in speakers show up" report points;
|
||||
// a single device here means SDL itself never enumerated the HDMI sink.
|
||||
const char *driver = SDL_GetCurrentAudioDriver();
|
||||
std::string driverName = driver ? driver : "(none)";
|
||||
int deviceCount = 0;
|
||||
SDL_AudioDeviceID *devices = SDL_GetAudioPlaybackDevices(&deviceCount);
|
||||
std::string deviceNames;
|
||||
if (devices)
|
||||
{
|
||||
for (int i = 0; i < deviceCount; ++i)
|
||||
{
|
||||
if (i)
|
||||
{
|
||||
deviceNames += ", ";
|
||||
}
|
||||
const char *n = SDL_GetAudioDeviceName(devices[i]);
|
||||
deviceNames += n ? n : "(unnamed)";
|
||||
}
|
||||
SDL_free(devices);
|
||||
}
|
||||
logger::Logger::instance().info(
|
||||
"Audio",
|
||||
"SDL audio driver: " + driverName + "; " +
|
||||
std::to_string(deviceCount) + " playback device(s) [" +
|
||||
(deviceNames.empty() ? "(none)" : deviceNames) + "]");
|
||||
|
||||
// Try to open the mixer on the device previously persisted in config;
|
||||
// fall back to the system default when the configured device is gone.
|
||||
SDL_AudioDeviceID targetId = SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK;
|
||||
if (!currentDevice_.empty())
|
||||
{
|
||||
int count = 0;
|
||||
SDL_AudioDeviceID *devices = SDL_GetAudioPlaybackDevices(&count);
|
||||
if (devices)
|
||||
{
|
||||
for (int i = 0; i < count; ++i)
|
||||
{
|
||||
const char *name = SDL_GetAudioDeviceName(devices[i]);
|
||||
if (name && currentDevice_ == name)
|
||||
{
|
||||
targetId = devices[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
SDL_free(devices);
|
||||
}
|
||||
}
|
||||
|
||||
mixer_ = MIX_CreateMixerDevice(targetId, nullptr);
|
||||
if (!mixer_)
|
||||
{
|
||||
std::string err = SDL_GetError();
|
||||
logger::Logger::instance().warning("Audio",
|
||||
"Could not open default audio playback device. Check speaker connections (MIX_CreateMixerDevice error: " + err + ")");
|
||||
"Could not open audio playback device. Check speaker connections (MIX_CreateMixerDevice error: " + err + ")");
|
||||
initialized_ = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
currentDeviceId_ = targetId;
|
||||
|
||||
slots_[0].track = MIX_CreateTrack(mixer_);
|
||||
slots_[1].track = MIX_CreateTrack(mixer_);
|
||||
initialized_ = true;
|
||||
@@ -135,6 +186,40 @@ namespace radiantwave::audio
|
||||
return enabled_ ? (volume_ / 100.0f) : 0.0f;
|
||||
}
|
||||
|
||||
SDL_PropertiesID AudioManager::createInfiniteLoopOptions()
|
||||
{
|
||||
// MIX_PlayTrack()'s options default to a single pass; requesting
|
||||
// infinite loops is what keeps a track going until it is explicitly
|
||||
// faded out or the app shuts down.
|
||||
SDL_PropertiesID props = SDL_CreateProperties();
|
||||
if (props)
|
||||
{
|
||||
SDL_SetNumberProperty(props, MIX_PROP_PLAY_LOOPS_NUMBER, -1);
|
||||
}
|
||||
return props;
|
||||
}
|
||||
|
||||
bool AudioManager::playTrackLooping(int slotIndex)
|
||||
{
|
||||
if (!initialized_ || !mixer_ || slotIndex < 0 || slotIndex >= 2 ||
|
||||
!slots_[slotIndex].track)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
SDL_PropertiesID props = createInfiniteLoopOptions();
|
||||
if (!props)
|
||||
{
|
||||
logger::Logger::instance().error(
|
||||
"Audio", "Failed to create playback options for " +
|
||||
displayNameFor(currentTrack_));
|
||||
return false;
|
||||
}
|
||||
bool ok = MIX_PlayTrack(slots_[slotIndex].track, props);
|
||||
SDL_DestroyProperties(props);
|
||||
return ok;
|
||||
}
|
||||
|
||||
void AudioManager::shutdown()
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
@@ -181,18 +266,62 @@ namespace radiantwave::audio
|
||||
{
|
||||
const char *name = SDL_GetAudioDeviceName(devices[i]);
|
||||
std::string raw = name ? name : "";
|
||||
list.push_back({raw, mapToFriendlyDeviceName(raw)});
|
||||
AudioDeviceItem item;
|
||||
item.raw_name = raw;
|
||||
item.friendly_name = mapToFriendlyDeviceName(raw);
|
||||
item.id = devices[i];
|
||||
list.push_back(item);
|
||||
}
|
||||
SDL_free(devices);
|
||||
}
|
||||
else
|
||||
{
|
||||
list.push_back({"", "Built-In Speakers"});
|
||||
if (devices)
|
||||
{
|
||||
SDL_free(devices);
|
||||
}
|
||||
const char *driver = SDL_GetCurrentAudioDriver();
|
||||
// Zero enumerated devices is a symptom, not a real output. Surface
|
||||
// the truth (a single "System Default" entry that still routes to
|
||||
// whatever the OS default is) instead of pretending a fabricated
|
||||
// device, and say which backend was active so kiosk logs show why.
|
||||
logger::Logger::instance().debug(
|
||||
"Audio",
|
||||
"No playback devices enumerated (audio driver: " +
|
||||
std::string(driver ? driver : "none") +
|
||||
"); falling back to the system default");
|
||||
list.push_back({"", "System Default", 0});
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
std::string AudioManager::resolvePlaybackDeviceName(SDL_AudioDeviceID id)
|
||||
{
|
||||
if (id == 0 || id == SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
|
||||
int count = 0;
|
||||
SDL_AudioDeviceID *devices = SDL_GetAudioPlaybackDevices(&count);
|
||||
std::string found;
|
||||
if (devices)
|
||||
{
|
||||
for (int i = 0; i < count; ++i)
|
||||
{
|
||||
if (devices[i] == id)
|
||||
{
|
||||
const char *name = SDL_GetAudioDeviceName(devices[i]);
|
||||
found = name ? name : "";
|
||||
break;
|
||||
}
|
||||
}
|
||||
SDL_free(devices);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
void AudioManager::setVolume(int volume_0_to_100)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
@@ -253,14 +382,76 @@ namespace radiantwave::audio
|
||||
bool AudioManager::switchDevice(const std::string &raw_device_name)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
currentDevice_ = raw_device_name;
|
||||
|
||||
logger::Logger::instance().debug("Audio", "Switched audio output device to: " + mapToFriendlyDeviceName(raw_device_name));
|
||||
// Resolve the config's raw name to an SDL device id so the mixer is
|
||||
// actually reopened on the requested device (""/unresolvable = default).
|
||||
SDL_AudioDeviceID targetId = SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK;
|
||||
if (!raw_device_name.empty())
|
||||
{
|
||||
int count = 0;
|
||||
SDL_AudioDeviceID *devices = SDL_GetAudioPlaybackDevices(&count);
|
||||
if (devices)
|
||||
{
|
||||
for (int i = 0; i < count; ++i)
|
||||
{
|
||||
const char *name = SDL_GetAudioDeviceName(devices[i]);
|
||||
if (name && raw_device_name == name)
|
||||
{
|
||||
targetId = devices[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
SDL_free(devices);
|
||||
}
|
||||
}
|
||||
|
||||
return switchDeviceById(targetId);
|
||||
}
|
||||
|
||||
bool AudioManager::switchDeviceById(SDL_AudioDeviceID id)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
|
||||
// 0 is the synthetic "default / built-in" entry from getPlaybackDevices().
|
||||
if (id == 0)
|
||||
{
|
||||
id = SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK;
|
||||
}
|
||||
|
||||
// No mixer yet: just record the choice for initialize().
|
||||
if (!mixer_ || !initialized_)
|
||||
{
|
||||
currentDeviceId_ = id;
|
||||
currentDevice_ = resolvePlaybackDeviceName(id);
|
||||
if (configManager_)
|
||||
{
|
||||
configManager_->update([name = currentDevice_](config::AppConfig &cfg)
|
||||
{ cfg.audio.output_device = name; });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!recreateMixerOnDevice(id))
|
||||
{
|
||||
logger::Logger::instance().error("Audio",
|
||||
"Failed to switch audio output device to: " + mapToFriendlyDeviceName(resolvePlaybackDeviceName(id)));
|
||||
return false;
|
||||
}
|
||||
|
||||
currentDeviceId_ = id;
|
||||
// After recreation SDL may have normalized the id (e.g. default).
|
||||
if (SDL_GetAudioDeviceName(id) == nullptr && id != SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK)
|
||||
{
|
||||
currentDeviceId_ = SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK;
|
||||
}
|
||||
currentDevice_ = resolvePlaybackDeviceName(currentDeviceId_);
|
||||
|
||||
logger::Logger::instance().info("Audio", "Switched audio output device to: " + mapToFriendlyDeviceName(currentDevice_));
|
||||
|
||||
if (configManager_)
|
||||
{
|
||||
configManager_->update([raw_device_name](config::AppConfig &cfg)
|
||||
{ cfg.audio.output_device = raw_device_name; });
|
||||
configManager_->update([name = currentDevice_](config::AppConfig &cfg)
|
||||
{ cfg.audio.output_device = name; });
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -272,6 +463,150 @@ namespace radiantwave::audio
|
||||
return currentDevice_;
|
||||
}
|
||||
|
||||
SDL_AudioDeviceID AudioManager::getCurrentDeviceId() const
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
return currentDeviceId_;
|
||||
}
|
||||
|
||||
bool AudioManager::handleDeviceEvent(const SDL_Event &event)
|
||||
{
|
||||
if (event.type != SDL_EVENT_AUDIO_DEVICE_ADDED &&
|
||||
event.type != SDL_EVENT_AUDIO_DEVICE_REMOVED)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (!mixer_ || !initialized_)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Only playback device events are relevant to the active output.
|
||||
if (event.adevice.recording)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const SDL_AudioDeviceID changedId = event.adevice.which;
|
||||
|
||||
// If a specific device (not the default) was removed, fail over to the
|
||||
// system default so playback keeps going on whatever remains.
|
||||
if (event.type == SDL_EVENT_AUDIO_DEVICE_REMOVED &&
|
||||
currentDeviceId_ != SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK &&
|
||||
changedId == currentDeviceId_)
|
||||
{
|
||||
logger::Logger::instance().warning("Audio",
|
||||
"Active audio output device removed; failing over to the system default");
|
||||
if (recreateMixerOnDevice(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK))
|
||||
{
|
||||
currentDeviceId_ = SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK;
|
||||
currentDevice_ = "";
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// A device arrived or a non-active device left: nothing to remap, but
|
||||
// the audio settings page refreshes its list on its own timer.
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must be called with mutex_ held. Creates a fresh mixer on the given
|
||||
// device, recreating the two track slots and resuming the active track if
|
||||
// one was playing.
|
||||
//
|
||||
// The new mixer is created BEFORE the current one is destroyed. Reversing
|
||||
// the order (destroy-then-reopen) quits the whole SDL audio subsystem the
|
||||
// moment the last device closes: MIX_DestroyMixer() follows up with
|
||||
// SDL_QuitSubSystem(SDL_INIT_AUDIO), which tears down SDL's device registry
|
||||
// and renumbers every enumerated device id on the way back up. The id this
|
||||
// switch was asked to open is then stale and the reopen fails with
|
||||
// "Invalid audio device instance ID". Keeping the current device open while
|
||||
// the replacement opens also means a failed switch leaves the current
|
||||
// output playing instead of going permanently mute.
|
||||
bool AudioManager::recreateMixerOnDevice(SDL_AudioDeviceID targetId)
|
||||
{
|
||||
// Capture the playback state we want to preserve across the switch.
|
||||
std::string resumeTrack = currentTrack_;
|
||||
float fadeSeconds = slots_[activeSlot_].fadeInDuration > 0.0f
|
||||
? slots_[activeSlot_].fadeInDuration
|
||||
: 0.0f;
|
||||
|
||||
// Open the replacement mixer on the target device first, while the
|
||||
// current device (and SDL's device registry) is still alive.
|
||||
MIX_Mixer *newMixer = MIX_CreateMixerDevice(targetId, nullptr);
|
||||
if (!newMixer)
|
||||
{
|
||||
std::string err = SDL_GetError();
|
||||
logger::Logger::instance().error(
|
||||
"Audio",
|
||||
"Could not open audio output device "
|
||||
+ mapToFriendlyDeviceName(resolvePlaybackDeviceName(targetId))
|
||||
+ "; keeping the current device (MIX_CreateMixerDevice error: "
|
||||
+ err + ")");
|
||||
return false;
|
||||
}
|
||||
|
||||
MIX_Track *newTracks[2];
|
||||
for (int i = 0; i < 2; ++i)
|
||||
{
|
||||
newTracks[i] = MIX_CreateTrack(newMixer);
|
||||
}
|
||||
if (!newTracks[0] || !newTracks[1])
|
||||
{
|
||||
MIX_DestroyMixer(newMixer);
|
||||
logger::Logger::instance().error(
|
||||
"Audio", "Could not create mixer tracks; keeping the current device (SDL error: "
|
||||
+ std::string(SDL_GetError()) + ")");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Tear down the existing mixer (its tracks die with it) and adopt the
|
||||
// new one.
|
||||
for (int i = 0; i < 2; ++i)
|
||||
{
|
||||
slots_[i].track = nullptr;
|
||||
if (slots_[i].audio)
|
||||
{
|
||||
MIX_DestroyAudio(slots_[i].audio);
|
||||
slots_[i].audio = nullptr;
|
||||
}
|
||||
}
|
||||
if (mixer_)
|
||||
{
|
||||
MIX_DestroyMixer(mixer_);
|
||||
mixer_ = nullptr;
|
||||
}
|
||||
|
||||
mixer_ = newMixer;
|
||||
slots_[0].track = newTracks[0];
|
||||
slots_[1].track = newTracks[1];
|
||||
|
||||
float gain = enabled_ ? (volume_ / 100.0f) : 0.0f;
|
||||
MIX_SetMixerGain(mixer_, gain);
|
||||
|
||||
// Resume the previously active track at full gain (or restart its
|
||||
// fade-in if it was still ramping).
|
||||
if (!resumeTrack.empty())
|
||||
{
|
||||
if (loadTrackInto(activeSlot_, resumeTrack))
|
||||
{
|
||||
if (fadeSeconds > 0.0f)
|
||||
{
|
||||
startFadeIn(activeSlot_, fadeSeconds);
|
||||
}
|
||||
else
|
||||
{
|
||||
MIX_SetTrackGain(slots_[activeSlot_].track, 1.0f);
|
||||
playTrackLooping(activeSlot_); // Loop playback
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool AudioManager::loadTrackInto(int slotIndex, const std::string &wav_filename)
|
||||
{
|
||||
if (!initialized_ || !mixer_ || !slots_[slotIndex].track)
|
||||
@@ -322,7 +657,7 @@ namespace radiantwave::audio
|
||||
slot.fadeInDuration = seconds;
|
||||
slot.fadeInElapsed = 0.0f;
|
||||
MIX_SetTrackGain(slot.track, 0.0f);
|
||||
MIX_PlayTrack(slot.track, 0); // Loop playback
|
||||
playTrackLooping(slotIndex); // Loop playback
|
||||
logger::Logger::instance().info("Audio", "Started playback of " + displayNameFor(currentTrack_) + " (fade-in)");
|
||||
}
|
||||
|
||||
@@ -336,7 +671,7 @@ namespace radiantwave::audio
|
||||
return false;
|
||||
|
||||
MIX_SetTrackGain(slots_[activeSlot_].track, 1.0f);
|
||||
MIX_PlayTrack(slots_[activeSlot_].track, 0); // Loop playback
|
||||
playTrackLooping(activeSlot_); // Loop playback
|
||||
|
||||
// Explicit user selection: persist the manual track so it survives a
|
||||
// restart and is used whenever dynamic audio is turned off. Dynamic
|
||||
@@ -373,10 +708,11 @@ namespace radiantwave::audio
|
||||
slot.fadeInElapsed = 0.0f;
|
||||
slot.fadeOutRemaining = 0.0f;
|
||||
MIX_SetTrackGain(slot.track, 1.0f);
|
||||
if (!MIX_TrackPlaying(slot.track))
|
||||
{
|
||||
MIX_PlayTrack(slot.track, 0); // Loop playback
|
||||
}
|
||||
// Always restart: MIX_PlayTrack() re-starts a playing track too, so
|
||||
// this cancels any in-flight stop-fade inherited from a quick
|
||||
// pop->push within the fade window. Gating on MIX_TrackPlaying()
|
||||
// left a fading track to finish its fade and go silently stopped.
|
||||
playTrackLooping(activeSlot_); // Loop playback
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -411,10 +747,14 @@ namespace radiantwave::audio
|
||||
if (!loadTrackInto(otherSlot, wav_filename))
|
||||
return false;
|
||||
|
||||
// Fade out whatever is currently playing on the active slot.
|
||||
// Fade out whatever is currently playing on the active slot. Clear any
|
||||
// in-flight fade-in bookkeeping first so update() stops ramping this
|
||||
// slot's gain up while the mixer fades it down.
|
||||
TrackSlot &active = slots_[activeSlot_];
|
||||
if (active.audio)
|
||||
{
|
||||
active.fadeInDuration = 0.0f;
|
||||
active.fadeInElapsed = 0.0f;
|
||||
MIX_StopTrack(active.track, MIX_TrackMSToFrames(active.track, static_cast<Sint64>(fade * 1000.0)));
|
||||
active.fadeOutRemaining = fade;
|
||||
}
|
||||
@@ -442,6 +782,10 @@ namespace radiantwave::audio
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// The mixer owns the ramp now; drop any pending fade-in so the
|
||||
// per-frame update() cannot fight the fade-out.
|
||||
slot.fadeInDuration = 0.0f;
|
||||
slot.fadeInElapsed = 0.0f;
|
||||
MIX_StopTrack(slot.track, MIX_TrackMSToFrames(slot.track, static_cast<Sint64>(fade * 1000.0)));
|
||||
slot.fadeOutRemaining = fade;
|
||||
faded = true;
|
||||
@@ -476,7 +820,29 @@ namespace radiantwave::audio
|
||||
{
|
||||
TrackSlot &slot = slots_[i];
|
||||
if (!slot.track)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// A slot mid stop-fade belongs to the mixer: skip both the fade-in
|
||||
// ramp and the active-gain pin so nothing drives its gain up while
|
||||
// the mixer ramps it down (this also keeps a quit/pop fade clean).
|
||||
if (slot.fadeOutRemaining > 0.0f)
|
||||
{
|
||||
slot.fadeOutRemaining -= dt;
|
||||
if (slot.fadeOutRemaining <= 0.0f)
|
||||
{
|
||||
slot.fadeOutRemaining = 0.0f;
|
||||
// Free the audio once the fade-out has finished; the track
|
||||
// object itself is reused.
|
||||
if (slot.audio)
|
||||
{
|
||||
MIX_DestroyAudio(slot.audio);
|
||||
slot.audio = nullptr;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (slot.fadeInDuration > 0.0f)
|
||||
{
|
||||
@@ -493,21 +859,12 @@ namespace radiantwave::audio
|
||||
// Keep the active track at full per-track gain; the mixer gain
|
||||
// carries the user's volume / mute state.
|
||||
MIX_SetTrackGain(slot.track, 1.0f);
|
||||
}
|
||||
|
||||
if (slot.fadeOutRemaining > 0.0f)
|
||||
{
|
||||
slot.fadeOutRemaining -= dt;
|
||||
if (slot.fadeOutRemaining <= 0.0f)
|
||||
// Self-heal: playback must continue indefinitely, so restart a
|
||||
// track that stopped unexpectedly (there is no pause state and
|
||||
// nothing else should legitimately stop the active track).
|
||||
if (slot.audio && enabled_ && !MIX_TrackPlaying(slot.track))
|
||||
{
|
||||
slot.fadeOutRemaining = 0.0f;
|
||||
// Free the audio once the fade-out has finished; the track
|
||||
// object itself is reused.
|
||||
if (slot.audio)
|
||||
{
|
||||
MIX_DestroyAudio(slot.audio);
|
||||
slot.audio = nullptr;
|
||||
}
|
||||
playTrackLooping(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include "config/config_manager.h"
|
||||
#include <SDL3/SDL.h>
|
||||
#include <SDL3_mixer/SDL_mixer.h>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
@@ -15,6 +16,7 @@ namespace radiantwave::audio {
|
||||
struct AudioDeviceItem {
|
||||
std::string raw_name; // e.g. alsa_output.pci-0000_00_1f.3.analog-stereo
|
||||
std::string friendly_name; // e.g. "Built-In Speakers"
|
||||
SDL_AudioDeviceID id = 0; // SDL playback device id (0 = synthetic fallback)
|
||||
};
|
||||
|
||||
// Maps raw technical audio device strings to user-friendly labels
|
||||
@@ -51,7 +53,24 @@ public:
|
||||
|
||||
// Audio device selection
|
||||
bool switchDevice(const std::string& raw_device_name);
|
||||
bool switchDeviceById(SDL_AudioDeviceID id);
|
||||
std::string getCurrentDevice() const;
|
||||
SDL_AudioDeviceID getCurrentDeviceId() const;
|
||||
|
||||
// Handle an SDL audio device event (SDL_EVENT_AUDIO_DEVICE_ADDED / REMOVED).
|
||||
// Returns true if the active device was affected and the mixer was recreated.
|
||||
bool handleDeviceEvent(const SDL_Event& event);
|
||||
|
||||
// Pure helper (no SDL audio open): find the playback device with the given
|
||||
// SDL id, returning its raw name ("" when not found / id invalid). Exposed
|
||||
// for tests; the real lookup is through getPlaybackDevices().
|
||||
static std::string resolvePlaybackDeviceName(SDL_AudioDeviceID id);
|
||||
|
||||
// Builds the SDL_PropertiesID passed to MIX_PlayTrack() for tracks that
|
||||
// loop forever (MIX_PROP_PLAY_LOOPS_NUMBER = -1). Exposed for tests; no
|
||||
// mixer, track, or audio device is required. The caller owns the property
|
||||
// set and must release it with SDL_DestroyProperties().
|
||||
static SDL_PropertiesID createInfiniteLoopOptions();
|
||||
|
||||
// Playback control
|
||||
bool playTrack(const std::string& wav_filename);
|
||||
@@ -84,6 +103,21 @@ private:
|
||||
void startFadeIn(int slotIndex, float seconds);
|
||||
std::string displayNameFor(const std::string& wav_filename) const;
|
||||
|
||||
// Starts the track on the given slot with infinite looping. The track must
|
||||
// already be loaded on the slot. Calling this on a playing/stopped/fading
|
||||
// track restarts it (cancelling any in-flight stop-fade).
|
||||
bool playTrackLooping(int slotIndex);
|
||||
|
||||
// Recreates the mixer on the given playback device id (0 = system default).
|
||||
// Called with mutex_ held. Preserves playback state: whichever track was
|
||||
// playing on the active slot is reloaded and resumed at full gain, an
|
||||
// in-progress fade is restarted. The replacement mixer is opened before the
|
||||
// current one is destroyed so SDL's device ids stay valid (destroy-first
|
||||
// quits the whole audio subsystem and orphans the id being opened).
|
||||
// Returns false on failure without touching the current mixer, so the
|
||||
// existing output keeps playing.
|
||||
bool recreateMixerOnDevice(SDL_AudioDeviceID targetId);
|
||||
|
||||
config::ConfigManager* configManager_ = nullptr;
|
||||
db::DatabaseManager* db_ = nullptr;
|
||||
|
||||
@@ -91,6 +125,7 @@ private:
|
||||
bool enabled_ = true;
|
||||
int volume_ = 80; // 0 - 100
|
||||
std::string currentDevice_ = "";
|
||||
SDL_AudioDeviceID currentDeviceId_ = SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK;
|
||||
std::string currentTrack_ = "";
|
||||
|
||||
MIX_Mixer* mixer_ = nullptr;
|
||||
|
||||
@@ -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>
|
||||
@@ -76,7 +77,7 @@ void AudioSettingsPage::onPush(AppState& state) {
|
||||
} else if (modalState_ == ModalState::DeviceSelector) {
|
||||
if (!devices_.empty()) {
|
||||
currentDeviceIndex_ = modalSelectedIndex_;
|
||||
state_->audio.switchDevice(devices_[currentDeviceIndex_].raw_name);
|
||||
applyDeviceSelection();
|
||||
}
|
||||
modalState_ = ModalState::None;
|
||||
} else if (modalState_ == ModalState::TrackSelector) {
|
||||
@@ -155,7 +156,7 @@ void AudioSettingsPage::onResume(AppState& state) {
|
||||
} else if (modalState_ == ModalState::DeviceSelector) {
|
||||
if (!devices_.empty()) {
|
||||
currentDeviceIndex_ = modalSelectedIndex_;
|
||||
state_->audio.switchDevice(devices_[currentDeviceIndex_].raw_name);
|
||||
applyDeviceSelection();
|
||||
}
|
||||
modalState_ = ModalState::None;
|
||||
} else if (modalState_ == ModalState::TrackSelector) {
|
||||
@@ -279,6 +280,19 @@ void AudioSettingsPage::refreshDeviceAndTrackLists() {
|
||||
}
|
||||
}
|
||||
|
||||
void AudioSettingsPage::applyDeviceSelection() {
|
||||
if (!state_) return;
|
||||
if (currentDeviceIndex_ < 0 || currentDeviceIndex_ >= static_cast<int>(devices_.size())) return;
|
||||
|
||||
const bool ok = state_->audio.switchDeviceById(devices_[currentDeviceIndex_].id);
|
||||
if (ok) {
|
||||
statusMessage_ = "Switched to " + devices_[currentDeviceIndex_].friendly_name;
|
||||
} else {
|
||||
statusMessage_ = "Device unavailable - kept the current output";
|
||||
}
|
||||
refreshDeviceAndTrackLists();
|
||||
}
|
||||
|
||||
void AudioSettingsPage::enableDynamicAudio() {
|
||||
if (!state_) return;
|
||||
state_->config.update([](radiantwave::config::AppConfig& cfg) {
|
||||
@@ -323,7 +337,7 @@ void AudioSettingsPage::handleEvent(const SDL_Event& event) {
|
||||
if (modalState_ == ModalState::DeviceSelector) {
|
||||
if (!devices_.empty()) {
|
||||
currentDeviceIndex_ = modalSelectedIndex_;
|
||||
state_->audio.switchDevice(devices_[currentDeviceIndex_].raw_name);
|
||||
applyDeviceSelection();
|
||||
}
|
||||
} else if (modalState_ == ModalState::TrackSelector) {
|
||||
if (modalSelectedIndex_ == 0) {
|
||||
@@ -477,7 +491,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);
|
||||
|
||||
@@ -517,7 +531,7 @@ void AudioSettingsPage::render(SDL_Renderer* renderer) {
|
||||
}
|
||||
|
||||
std::string devName = (currentDeviceIndex_ >= 0 && currentDeviceIndex_ < static_cast<int>(devices_.size()))
|
||||
? devices_[currentDeviceIndex_].friendly_name : "Built-In Speakers";
|
||||
? devices_[currentDeviceIndex_].friendly_name : "System Default";
|
||||
deviceText_.ensure(renderer, fontHeader_, "Device: " + devName, white);
|
||||
if (deviceText_.texture) {
|
||||
SDL_FRect dst{500.0f, currentY, deviceText_.width, deviceText_.height};
|
||||
|
||||
@@ -36,6 +36,10 @@ private:
|
||||
void enableDynamicAudio();
|
||||
void selectTrack(int trackIdx);
|
||||
|
||||
// Applies the currently selected device (devices_[currentDeviceIndex_]) via
|
||||
// the audio manager and records user-facing success/failure feedback.
|
||||
void applyDeviceSelection();
|
||||
|
||||
AppState* state_ = nullptr;
|
||||
int selectedItem_ = 0; // 0 = Volume, 1 = Device, 2 = Track, 3 = Status, 4 = Back
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
+28
-3
@@ -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);
|
||||
|
||||
@@ -179,6 +180,16 @@ void Home::render(SDL_Renderer *renderer)
|
||||
SDL_RenderFillRect(renderer, &line);
|
||||
}
|
||||
|
||||
// Large bold reminder to connect WiFi, drawn below the menu while no
|
||||
// network is joined (refreshed every 5s in update()).
|
||||
if (wifiNote_.texture && ssid_.empty())
|
||||
{
|
||||
float noteX = (fw - wifiNote_.width) / 2.0f;
|
||||
float noteY = settingsY_ + settings_.height + 40.0f;
|
||||
SDL_FRect noteDst{noteX, noteY, wifiNote_.width, wifiNote_.height};
|
||||
SDL_RenderTexture(renderer, wifiNote_.texture, nullptr, ¬eDst);
|
||||
}
|
||||
|
||||
// Bottom-right status footer (20pt, aligned to a virtual rectangle:
|
||||
// names left-aligned at the rect's left edge, values right-aligned at
|
||||
// the rect's right edge; the longest name's colon sits 4 spaces from its
|
||||
@@ -280,10 +291,10 @@ void Home::render(SDL_Renderer *renderer)
|
||||
fourSpaces.destroy();
|
||||
|
||||
const float margin = 20.0f;
|
||||
float rectRight = fw - margin;
|
||||
float rectRight = fw - margin - 50.0f;
|
||||
float rectLeft = rectRight - (maxNameW + spaceW4 + maxValueW);
|
||||
|
||||
float lineY = fh - margin;
|
||||
float lineY = fh - margin - 50.0f;
|
||||
const float lineGap = 6.0f;
|
||||
for (size_t i = footerNames_.size(); i-- > 0;)
|
||||
{
|
||||
@@ -351,6 +362,15 @@ void Home::createTextures(AppState &state)
|
||||
|
||||
TTF_CloseFont(font);
|
||||
|
||||
font = TTF_OpenFont("assets/fonts/Roboto-Regular.ttf", 48);
|
||||
if (font)
|
||||
{
|
||||
TTF_SetFontStyle(font, TTF_STYLE_BOLD);
|
||||
wifiNote_ = renderText(state.renderer, font, "Please connect to WiFi", white);
|
||||
TTF_SetFontStyle(font, TTF_STYLE_NORMAL);
|
||||
TTF_CloseFont(font);
|
||||
}
|
||||
|
||||
SDL_Surface *bgSurface = IMG_Load("assets/images/RWT-Circular-Color-ForOnDark-NoText.png");
|
||||
if (bgSurface)
|
||||
{
|
||||
@@ -409,6 +429,11 @@ void Home::destroyTextures()
|
||||
SDL_DestroyTexture(settings_.texture);
|
||||
settings_.texture = nullptr;
|
||||
}
|
||||
if (wifiNote_.texture)
|
||||
{
|
||||
SDL_DestroyTexture(wifiNote_.texture);
|
||||
wifiNote_.texture = nullptr;
|
||||
}
|
||||
for (auto &t : footerNames_)
|
||||
t.destroy();
|
||||
for (auto &t : footerValues_)
|
||||
|
||||
@@ -34,6 +34,7 @@ private:
|
||||
TextLine play_;
|
||||
TextLine settings_;
|
||||
TextLine bg_;
|
||||
TextLine wifiNote_;
|
||||
|
||||
AppState* state_ = nullptr;
|
||||
float playY_ = 0;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
+241
-158
@@ -4,198 +4,281 @@
|
||||
|
||||
#include "app_state.h"
|
||||
#include "home.h"
|
||||
#include "provisioning.h"
|
||||
#include "logical_size.h"
|
||||
#include "logger/logger.h"
|
||||
#include "provisioning.h"
|
||||
#include "scroller_page.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[])
|
||||
{
|
||||
// Initialize text logger
|
||||
radiantwave::logger::Logger::instance().initialize();
|
||||
radiantwave::logger::Logger::instance().info("System", "RadiantWave Kiosk starting up");
|
||||
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");
|
||||
|
||||
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 + ")");
|
||||
SDL_Log("Failed to initialize SDL: %s", err.c_str());
|
||||
return SDL_APP_FAILURE;
|
||||
}
|
||||
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 + ")");
|
||||
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
|
||||
// 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)
|
||||
{
|
||||
SDL_Log("Failed to create window: %s", SDL_GetError());
|
||||
delete state;
|
||||
return SDL_APP_FAILURE;
|
||||
}
|
||||
// Size the fullscreen window from the display's actual mode so the very
|
||||
// first buffer commit already matches what the compositor scanouts 1:1.
|
||||
// Requesting a hardcoded size left the backbuffer at that size on
|
||||
// displays running a different mode, and frames reached the panel
|
||||
// unscaled (quarter-size on 4K, cropped on smaller panels).
|
||||
const SDL_DisplayMode *displayMode =
|
||||
SDL_GetCurrentDisplayMode(SDL_GetPrimaryDisplay());
|
||||
int windowWidth =
|
||||
(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);
|
||||
if (!state->renderer)
|
||||
{
|
||||
SDL_Log("Failed to create renderer: %s", SDL_GetError());
|
||||
delete state;
|
||||
return SDL_APP_FAILURE;
|
||||
}
|
||||
// Borderless desktop fullscreen: the compositor never has to scale the
|
||||
// moving scroller (scaling moving text causes flicker). Deliberately no
|
||||
// SDL_WINDOW_HIGH_PIXEL_DENSITY — kiosk panels are not HiDPI, so keeping
|
||||
// the pixel buffer equal to the display mode keeps scanout 1:1.
|
||||
state->window = SDL_CreateWindow("RadiantWave", windowWidth, windowHeight,
|
||||
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
|
||||
// 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()) + ")");
|
||||
}
|
||||
state->renderer = SDL_CreateRenderer(state->window, nullptr);
|
||||
if (!state->renderer) {
|
||||
SDL_Log("Failed to create renderer: %s", SDL_GetError());
|
||||
delete state;
|
||||
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
|
||||
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)");
|
||||
} else {
|
||||
radiantwave::logger::Logger::instance().info("Config", "Configuration initialized successfully");
|
||||
}
|
||||
// All page layout targets the fixed design resolution; LETTERBOX maps it
|
||||
// onto the real window with aspect preserved (bars on non-16:9 panels).
|
||||
if (!SDL_SetRenderLogicalPresentation(state->renderer,
|
||||
radiantwave::kDesignWidth,
|
||||
radiantwave::kDesignHeight,
|
||||
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",
|
||||
"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");
|
||||
}
|
||||
{
|
||||
// Record how the requested mode, window, pixel buffer, and render
|
||||
// output actually compare — this is the evidence trail for
|
||||
// display-scaling problems on field units.
|
||||
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()) {
|
||||
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");
|
||||
}
|
||||
SDL_HideCursor();
|
||||
|
||||
if (!state->audio.initialize(state->config)) {
|
||||
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");
|
||||
}
|
||||
// Let playback logs report user-facing display names from the database.
|
||||
state->audio.attachDatabase(state->db);
|
||||
// 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)");
|
||||
} else {
|
||||
radiantwave::logger::Logger::instance().info(
|
||||
"Config", "Configuration initialized successfully");
|
||||
}
|
||||
|
||||
// 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.
|
||||
state->audioDownloader.start(state->db);
|
||||
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)");
|
||||
} else {
|
||||
radiantwave::logger::Logger::instance().info(
|
||||
"Database", "Database initialized and schema migrated");
|
||||
}
|
||||
|
||||
// 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);
|
||||
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)");
|
||||
} else {
|
||||
radiantwave::logger::Logger::instance().info(
|
||||
"Network", "NetworkManager service connected");
|
||||
}
|
||||
|
||||
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");
|
||||
});
|
||||
if (!state->audio.initialize(state->config)) {
|
||||
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");
|
||||
}
|
||||
// Let playback logs report user-facing display names from the database.
|
||||
state->audio.attachDatabase(state->db);
|
||||
|
||||
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;
|
||||
});
|
||||
// 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.
|
||||
state->audioDownloader.start(state->db);
|
||||
|
||||
// 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
|
||||
// device on the WiFi page.
|
||||
radiantwave::provisioning::begin(*state);
|
||||
// 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);
|
||||
|
||||
*appstate = state;
|
||||
return SDL_APP_CONTINUE;
|
||||
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");
|
||||
});
|
||||
|
||||
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)
|
||||
{
|
||||
auto &state = *static_cast<AppState *>(appstate);
|
||||
SDL_AppResult SDL_AppEvent(void *appstate, SDL_Event *event) {
|
||||
auto &state = *static_cast<AppState *>(appstate);
|
||||
|
||||
if (event->type == SDL_EVENT_QUIT)
|
||||
{
|
||||
state.quitRequested = true;
|
||||
}
|
||||
if (event->type == SDL_EVENT_QUIT) {
|
||||
state.quitRequested = true;
|
||||
}
|
||||
|
||||
if (!state.keybinds.handleEvent(*event)) {
|
||||
state.pages.handleEvent(*event);
|
||||
}
|
||||
if (event->type == SDL_EVENT_AUDIO_DEVICE_ADDED ||
|
||||
event->type == SDL_EVENT_AUDIO_DEVICE_REMOVED) {
|
||||
// Route device hotplug to the audio manager so an active device that
|
||||
// disappears is failed over to the system default automatically.
|
||||
state.audio.handleDeviceEvent(*event);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
if (event->type == SDL_EVENT_WINDOW_RESIZED) {
|
||||
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. Skip while the
|
||||
// scroller is active — it owns its own presentation (NONE, full
|
||||
// window) and restores LETTERBOX in onPop.
|
||||
if (!dynamic_cast<ScrollerPage *>(state.pages.current())) {
|
||||
SDL_SetRenderLogicalPresentation(state.renderer,
|
||||
radiantwave::kDesignWidth,
|
||||
radiantwave::kDesignHeight,
|
||||
SDL_LOGICAL_PRESENTATION_LETTERBOX);
|
||||
}
|
||||
}
|
||||
|
||||
return SDL_APP_CONTINUE;
|
||||
if (!state.keybinds.handleEvent(*event)) {
|
||||
state.pages.handleEvent(*event);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
auto &state = *static_cast<AppState *>(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;
|
||||
lastTicks = nowTicks;
|
||||
Uint64 nowTicks = SDL_GetTicks();
|
||||
static Uint64 lastTicks = nowTicks;
|
||||
float dt = static_cast<float>(nowTicks - lastTicks) / 1000.0f;
|
||||
if (dt > 0.1f)
|
||||
dt = 0.1f;
|
||||
lastTicks = nowTicks;
|
||||
|
||||
state.pages.update(dt);
|
||||
state.pages.update(dt);
|
||||
|
||||
if (state.quitFadeRemaining > 0.0f)
|
||||
{
|
||||
state.quitFadeRemaining -= dt;
|
||||
if (state.quitFadeRemaining <= 0.0f)
|
||||
{
|
||||
return SDL_APP_SUCCESS;
|
||||
}
|
||||
}
|
||||
if (state.quitFadeRemaining > 0.0f) {
|
||||
state.quitFadeRemaining -= dt;
|
||||
if (state.quitFadeRemaining <= 0.0f) {
|
||||
return SDL_APP_SUCCESS;
|
||||
}
|
||||
}
|
||||
|
||||
SDL_SetRenderDrawColor(state.renderer, 30, 30, 46, 255);
|
||||
SDL_RenderClear(state.renderer);
|
||||
SDL_SetRenderDrawColor(state.renderer, 30, 30, 46, 255);
|
||||
SDL_RenderClear(state.renderer);
|
||||
|
||||
state.pages.render(state.renderer);
|
||||
state.pages.render(state.renderer);
|
||||
|
||||
SDL_RenderPresent(state.renderer);
|
||||
return SDL_APP_CONTINUE;
|
||||
SDL_RenderPresent(state.renderer);
|
||||
return SDL_APP_CONTINUE;
|
||||
}
|
||||
|
||||
void SDL_AppQuit(void *appstate, SDL_AppResult result)
|
||||
{
|
||||
auto *state = static_cast<AppState *>(appstate);
|
||||
if (state) {
|
||||
state->audioDownloader.shutdown();
|
||||
state->licenseTracker.shutdown();
|
||||
state->audio.shutdown();
|
||||
state->network.shutdown();
|
||||
state->config.shutdown();
|
||||
state->db.close();
|
||||
SDL_DestroyRenderer(state->renderer);
|
||||
SDL_DestroyWindow(state->window);
|
||||
delete state;
|
||||
}
|
||||
SDL_Quit();
|
||||
void SDL_AppQuit(void *appstate, SDL_AppResult result) {
|
||||
auto *state = static_cast<AppState *>(appstate);
|
||||
if (state) {
|
||||
state->audioDownloader.shutdown();
|
||||
state->licenseTracker.shutdown();
|
||||
state->audio.shutdown();
|
||||
state->network.shutdown();
|
||||
state->config.shutdown();
|
||||
state->db.close();
|
||||
SDL_DestroyRenderer(state->renderer);
|
||||
SDL_DestroyWindow(state->window);
|
||||
delete state;
|
||||
}
|
||||
SDL_Quit();
|
||||
}
|
||||
|
||||
+13
-26
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -197,7 +198,7 @@ void ProclamationOptionsPage::rebuildItems()
|
||||
FlatItem::Type::SubCategory,
|
||||
std::to_string(sub.id),
|
||||
sub.name,
|
||||
1, false, false, false,
|
||||
1, true, false, false,
|
||||
});
|
||||
|
||||
for (const auto& p : proclamations_) {
|
||||
@@ -267,7 +268,7 @@ void ProclamationOptionsPage::rebuildVisible()
|
||||
parentVisible = true; // the always-on top-level proclamation
|
||||
} else if (currentRoot >= 0 && items_[currentRoot].expanded) {
|
||||
parentVisible = (item.depth == 1) ||
|
||||
(item.depth == 2 && currentSub >= 0 && items_[currentSub].expanded);
|
||||
(item.depth == 2 && currentSub >= 0);
|
||||
}
|
||||
if (parentVisible) visible_.push_back(i);
|
||||
}
|
||||
@@ -282,7 +283,7 @@ void ProclamationOptionsPage::toggleItem()
|
||||
if (visible_.empty()) return;
|
||||
FlatItem& item = items_[visible_[highlightIndex_]];
|
||||
|
||||
if (item.type == FlatItem::Type::RootCategory || item.type == FlatItem::Type::SubCategory) {
|
||||
if (item.type == FlatItem::Type::RootCategory) {
|
||||
item.expanded = !item.expanded;
|
||||
rebuildVisible();
|
||||
return;
|
||||
@@ -387,7 +388,7 @@ void ProclamationOptionsPage::collapseCurrent()
|
||||
{
|
||||
if (visible_.empty()) return;
|
||||
FlatItem& item = items_[visible_[highlightIndex_]];
|
||||
if (item.type == FlatItem::Type::RootCategory || item.type == FlatItem::Type::SubCategory) {
|
||||
if (item.type == FlatItem::Type::RootCategory) {
|
||||
if (item.expanded) {
|
||||
item.expanded = false;
|
||||
rebuildVisible();
|
||||
@@ -399,7 +400,7 @@ void ProclamationOptionsPage::expandCurrent()
|
||||
{
|
||||
if (visible_.empty()) return;
|
||||
FlatItem& item = items_[visible_[highlightIndex_]];
|
||||
if (item.type == FlatItem::Type::RootCategory || item.type == FlatItem::Type::SubCategory) {
|
||||
if (item.type == FlatItem::Type::RootCategory) {
|
||||
if (!item.expanded) {
|
||||
item.expanded = true;
|
||||
rebuildVisible();
|
||||
@@ -525,8 +526,7 @@ void ProclamationOptionsPage::handleEvent(const SDL_Event& event)
|
||||
if (searchActive_) return;
|
||||
if (!visible_.empty()) {
|
||||
const FlatItem& item = items_[visible_[highlightIndex_]];
|
||||
if ((item.type == FlatItem::Type::RootCategory ||
|
||||
item.type == FlatItem::Type::SubCategory) && item.expanded) {
|
||||
if (item.type == FlatItem::Type::RootCategory && item.expanded) {
|
||||
collapseCurrent();
|
||||
} else {
|
||||
moveToParent();
|
||||
@@ -552,7 +552,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);
|
||||
|
||||
@@ -654,14 +654,18 @@ void ProclamationOptionsPage::render(SDL_Renderer* renderer)
|
||||
SDL_RenderTexture(renderer, emptyHintText_.texture, nullptr, &dst);
|
||||
}
|
||||
} else {
|
||||
// Compute the widest row so the block can be centered.
|
||||
// Stable width: compute from every result so the list doesn't shift.
|
||||
float maxTotalWidth = 0.0f;
|
||||
for (const auto& r : searchResults_) {
|
||||
int w = 0, h = 0;
|
||||
TTF_GetStringSize(fontItem_, proclamations_[r].title.c_str(), 0, &w, &h);
|
||||
maxTotalWidth = std::max(maxTotalWidth, static_cast<float>(w));
|
||||
}
|
||||
|
||||
std::vector<float> textWidths(static_cast<size_t>(listSize));
|
||||
for (int i = scrollOffset_; i < scrollOffset_ + visibleCount_ && i < listSize; ++i) {
|
||||
const std::string& label = proclamations_[searchResults_[i]].title;
|
||||
int w = 0, h = 0;
|
||||
TTF_GetStringSize(fontItem_, label.c_str(), 0, &w, &h);
|
||||
maxTotalWidth = std::max(maxTotalWidth, static_cast<float>(w));
|
||||
TTF_GetStringSize(fontItem_, proclamations_[searchResults_[i]].title.c_str(), 0, &w, &h);
|
||||
textWidths[i] = static_cast<float>(w);
|
||||
}
|
||||
float sharedX = (fw - maxTotalWidth) / 2.0f;
|
||||
@@ -694,19 +698,29 @@ void ProclamationOptionsPage::render(SDL_Renderer* renderer)
|
||||
}
|
||||
} else {
|
||||
// --- Browse tree ---
|
||||
// Compute the widest row so the block can be centered.
|
||||
// Stable width: compute from every item so the list doesn't
|
||||
// shift left/right when groups expand or collapse.
|
||||
float maxTotalWidth = 0.0f;
|
||||
std::vector<float> textWidths(visible_.size());
|
||||
for (int i = scrollOffset_; i < scrollOffset_ + visibleCount_ && i < static_cast<int>(visible_.size()); ++i) {
|
||||
const FlatItem& item = items_[visible_[i]];
|
||||
for (const auto& item : items_) {
|
||||
std::string label = item.label;
|
||||
if (item.type == FlatItem::Type::RootCategory || item.type == FlatItem::Type::SubCategory) {
|
||||
if (item.type == FlatItem::Type::RootCategory) {
|
||||
label = (item.expanded ? "[-] " : "[+] ") + label;
|
||||
}
|
||||
int w = 0, h = 0;
|
||||
TTF_GetStringSize(fontItem_, label.c_str(), 0, &w, &h);
|
||||
float total = item.depth * indent + static_cast<float>(w);
|
||||
maxTotalWidth = std::max(maxTotalWidth, total);
|
||||
}
|
||||
|
||||
std::vector<float> textWidths(visible_.size());
|
||||
for (int i = scrollOffset_; i < scrollOffset_ + visibleCount_ && i < static_cast<int>(visible_.size()); ++i) {
|
||||
const FlatItem& item = items_[visible_[i]];
|
||||
std::string label = item.label;
|
||||
if (item.type == FlatItem::Type::RootCategory) {
|
||||
label = (item.expanded ? "[-] " : "[+] ") + label;
|
||||
}
|
||||
int w = 0, h = 0;
|
||||
TTF_GetStringSize(fontItem_, label.c_str(), 0, &w, &h);
|
||||
textWidths[i] = static_cast<float>(w);
|
||||
}
|
||||
|
||||
@@ -720,7 +734,7 @@ void ProclamationOptionsPage::render(SDL_Renderer* renderer)
|
||||
float y = listTop + rowIndex * rowPitch;
|
||||
|
||||
std::string label = item.label;
|
||||
if (item.type == FlatItem::Type::RootCategory || item.type == FlatItem::Type::SubCategory) {
|
||||
if (item.type == FlatItem::Type::RootCategory) {
|
||||
label = (item.expanded ? "[-] " : "[+] ") + label;
|
||||
}
|
||||
|
||||
|
||||
+61
-59
@@ -1,80 +1,82 @@
|
||||
#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)
|
||||
{
|
||||
switch (step) {
|
||||
case Step::Internet:
|
||||
state.pages.push(state, std::make_unique<WiFiSetupPage>());
|
||||
break;
|
||||
case Step::Email:
|
||||
state.pages.push(state, std::make_unique<EmailAddressPage>());
|
||||
break;
|
||||
case Step::License:
|
||||
state.pages.push(state, std::make_unique<LicenseKeyPage>());
|
||||
break;
|
||||
case Step::Done:
|
||||
state.provisioningMode = false;
|
||||
state.pages.push(state, std::make_unique<Home>());
|
||||
break;
|
||||
}
|
||||
static void pushStep(AppState &state, Step step) {
|
||||
switch (step) {
|
||||
// case Step::Internet:
|
||||
// state.pages.push(state, std::make_unique<WiFiSetupPage>());
|
||||
// break;
|
||||
case Step::Email:
|
||||
state.pages.push(state, std::make_unique<EmailAddressPage>());
|
||||
break;
|
||||
case Step::License:
|
||||
state.pages.push(state, std::make_unique<LicenseKeyPage>());
|
||||
break;
|
||||
case Step::Done:
|
||||
state.provisioningMode = false;
|
||||
state.pages.push(state, std::make_unique<Home>());
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
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()) {
|
||||
state.provisioningMode = false;
|
||||
state.pages.push(state, std::make_unique<Home>());
|
||||
return;
|
||||
}
|
||||
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()) {
|
||||
state.provisioningMode = false;
|
||||
state.pages.push(state, std::make_unique<Home>());
|
||||
return;
|
||||
}
|
||||
|
||||
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 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();
|
||||
|
||||
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",
|
||||
"First-Run detected (no internet, no email, no license); "
|
||||
"first-run welcome walkthrough is not yet implemented");
|
||||
}
|
||||
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",
|
||||
"First-Run detected (no internet, no email, no license); "
|
||||
"first-run welcome walkthrough is not yet implemented");
|
||||
}
|
||||
|
||||
state.walkthroughInternetUp = internetUp;
|
||||
state.provisioningMode = true;
|
||||
pushStep(state, firstMissingStep(internetUp, hasEmail, hasLicense));
|
||||
state.walkthroughInternetUp = internetUp;
|
||||
state.provisioningMode = true;
|
||||
pushStep(state, firstMissingStep(internetUp, hasEmail, hasLicense));
|
||||
}
|
||||
|
||||
void advance(AppState& state)
|
||||
{
|
||||
if (state.pages.depth() > 0) {
|
||||
state.pages.pop(state);
|
||||
}
|
||||
void advance(AppState &state) {
|
||||
if (state.pages.depth() > 0) {
|
||||
state.pages.pop(state);
|
||||
}
|
||||
|
||||
// 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
|
||||
// walkthrough is coming from the WiFi page).
|
||||
bool internetUp = state.walkthroughInternetUp;
|
||||
if (!internetUp) {
|
||||
internetUp = state.network.hasInternet();
|
||||
state.walkthroughInternetUp = internetUp;
|
||||
}
|
||||
// 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
|
||||
// walkthrough is coming from the WiFi page).
|
||||
bool internetUp = state.walkthroughInternetUp;
|
||||
if (!internetUp) {
|
||||
internetUp = state.network.hasInternet();
|
||||
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));
|
||||
state.provisioningMode = true;
|
||||
pushStep(state, firstMissingStep(internetUp, hasEmail, hasLicense));
|
||||
}
|
||||
|
||||
} // namespace radiantwave::provisioning
|
||||
|
||||
+12
-12
@@ -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;
|
||||
return Step::Done;
|
||||
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)
|
||||
{
|
||||
return !internetUp && !hasEmail && !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
|
||||
|
||||
+624
-690
File diff suppressed because it is too large
Load Diff
@@ -115,4 +115,8 @@ private:
|
||||
float velocityDelay_ = 2.5f;
|
||||
int velocityState_ = 0;
|
||||
float velocityTimer_ = 0.0f;
|
||||
|
||||
int savedLogicalW_ = 0;
|
||||
int savedLogicalH_ = 0;
|
||||
SDL_RendererLogicalPresentation savedMode_ = SDL_LOGICAL_PRESENTATION_DISABLED;
|
||||
};
|
||||
|
||||
+20
-19
@@ -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>
|
||||
@@ -18,18 +19,18 @@ namespace
|
||||
{
|
||||
|
||||
constexpr int MENU_ITEM_COUNT = 9;
|
||||
// 0 = WiFi Setup, 1 = Audio Options, 2 = Time Zone Setup, 3 = Proclamations,
|
||||
// 4 = Email Address, 5 = License Key, 6 = Date Format,
|
||||
// 7 = View System Logs, 8 = Back
|
||||
// 0 = Proclamations, 1 = Audio Options, 2 = Email Address, 3 = License Key,
|
||||
// 4 = WiFi Setup, 5 = Time Zone Setup, 6 = Date Format,
|
||||
// 7 = System Logs, 8 = Back
|
||||
constexpr const char *MENU_LABELS[MENU_ITEM_COUNT] = {
|
||||
"WiFi Setup",
|
||||
"Audio Options",
|
||||
"Time Zone Setup",
|
||||
"Proclamations",
|
||||
"Audio Options",
|
||||
"Email Address",
|
||||
"License Key",
|
||||
"WiFi Setup",
|
||||
"Time Zone Setup",
|
||||
"Date Format",
|
||||
"View System Logs",
|
||||
"System Logs",
|
||||
"Back to Home",
|
||||
};
|
||||
|
||||
@@ -77,15 +78,6 @@ void Settings::handleEvent(const SDL_Event &event)
|
||||
switch (selectedItem_)
|
||||
{
|
||||
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.
|
||||
if (state_->licenseTracker.isValid())
|
||||
{
|
||||
@@ -96,12 +88,21 @@ void Settings::handleEvent(const SDL_Event &event)
|
||||
state_->pages.push(*state_, std::make_unique<LicenseBlockedPage>());
|
||||
}
|
||||
break;
|
||||
case 4:
|
||||
case 1:
|
||||
state_->pages.push(*state_, std::make_unique<AudioSettingsPage>());
|
||||
break;
|
||||
case 2:
|
||||
state_->pages.push(*state_, std::make_unique<EmailAddressPage>());
|
||||
break;
|
||||
case 5:
|
||||
case 3:
|
||||
state_->pages.push(*state_, std::make_unique<LicenseKeyPage>());
|
||||
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:
|
||||
state_->pages.push(*state_, std::make_unique<DateFormatPage>());
|
||||
break;
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -8,9 +8,34 @@ TEST(AudioTest, FriendlyDeviceNameMapping) {
|
||||
EXPECT_EQ(mapToFriendlyDeviceName("AMD Radeon HD Audio Controller HDMI"), "TV / HDMI Monitor");
|
||||
EXPECT_EQ(mapToFriendlyDeviceName("USB Audio Headset Dongle"), "USB Audio Device");
|
||||
EXPECT_EQ(mapToFriendlyDeviceName("bluez_sink.00_11_22_33_44_55"), "Bluetooth Audio");
|
||||
EXPECT_EQ(mapToFriendlyDeviceName("Virtual Speaker"), "Built-In Speakers");
|
||||
EXPECT_EQ(mapToFriendlyDeviceName(""), "Default Audio Output");
|
||||
}
|
||||
|
||||
TEST(AudioTest, ResolvePlaybackDeviceNameHandlesSentinels) {
|
||||
// The sentinel and 0 mean "system default" and never correspond to a real
|
||||
// enumerated playback device, so they resolve to the empty (default) name.
|
||||
EXPECT_EQ(AudioManager::resolvePlaybackDeviceName(0), "");
|
||||
EXPECT_EQ(AudioManager::resolvePlaybackDeviceName(SDL_AUDIO_DEVICE_DEFAULT_PLAYBACK), "");
|
||||
}
|
||||
|
||||
TEST(AudioTest, GetPlaybackDevicesFallsBackWithIdZeroWhenNone) {
|
||||
AudioManager am;
|
||||
auto devices = am.getPlaybackDevices();
|
||||
// Headless hosts (CI) expose no playback devices, so expect the synthetic
|
||||
// fallback entry that carries id 0 (the "system default" marker).
|
||||
if (devices.empty()) {
|
||||
// No devices and no fallback would be a contract violation.
|
||||
FAIL() << "getPlaybackDevices() returned an empty list without fallback";
|
||||
}
|
||||
for (const auto& d : devices) {
|
||||
if (d.raw_name.empty()) {
|
||||
EXPECT_EQ(d.id, 0u);
|
||||
EXPECT_EQ(d.friendly_name, "System Default");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TEST(AudioTest, AudioManagerVolumeClamping) {
|
||||
radiantwave::config::ConfigManager config;
|
||||
std::string temp_path = (std::filesystem::temp_directory_path() / "test_rw_audio_cfg.yaml").string();
|
||||
@@ -36,3 +61,14 @@ TEST(AudioTest, AudioManagerVolumeClamping) {
|
||||
am.shutdown();
|
||||
std::filesystem::remove(temp_path);
|
||||
}
|
||||
|
||||
TEST(AudioTest, PlayOptionsLoopInfinite) {
|
||||
// Regression: MIX_PlayTrack() must not default to a single pass. Tracks
|
||||
// have to loop forever so playback never runs out mid-scroll; a track that
|
||||
// stops (rather than attenuates) also can't be revived by raising app
|
||||
// volume. Assert the options request infinite looping (-1).
|
||||
SDL_PropertiesID props = AudioManager::createInfiniteLoopOptions();
|
||||
ASSERT_NE(props, 0u);
|
||||
EXPECT_EQ(SDL_GetNumberProperty(props, MIX_PROP_PLAY_LOOPS_NUMBER, 0), -1);
|
||||
SDL_DestroyProperties(props);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
+16
-30
@@ -1,51 +1,37 @@
|
||||
#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)
|
||||
{
|
||||
ASSERT_EQ(firstMissingStep(true, true, true), Step::Done);
|
||||
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) {
|
||||
ASSERT_EQ(firstMissingStep(true, false, false), Step::Email);
|
||||
ASSERT_EQ(firstMissingStep(true, false, true), Step::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) {
|
||||
ASSERT_EQ(firstMissingStep(true, true, false), Step::License);
|
||||
}
|
||||
|
||||
TEST(first_missing_step_no_license)
|
||||
{
|
||||
ASSERT_EQ(firstMissingStep(true, true, false), Step::License);
|
||||
TEST(first_run_all_missing) {
|
||||
ASSERT_TRUE(indicatesFirstRun(false, false, false));
|
||||
}
|
||||
|
||||
TEST(first_run_all_missing)
|
||||
{
|
||||
ASSERT_TRUE(indicatesFirstRun(false, false, false));
|
||||
TEST(first_run_has_internet) {
|
||||
ASSERT_FALSE(indicatesFirstRun(true, false, false));
|
||||
}
|
||||
|
||||
TEST(first_run_has_internet)
|
||||
{
|
||||
ASSERT_FALSE(indicatesFirstRun(true, false, false));
|
||||
TEST(first_run_has_email) {
|
||||
ASSERT_FALSE(indicatesFirstRun(false, true, false));
|
||||
}
|
||||
|
||||
TEST(first_run_has_email)
|
||||
{
|
||||
ASSERT_FALSE(indicatesFirstRun(false, true, false));
|
||||
}
|
||||
|
||||
TEST(first_run_has_license)
|
||||
{
|
||||
ASSERT_FALSE(indicatesFirstRun(false, false, true));
|
||||
TEST(first_run_has_license) {
|
||||
ASSERT_FALSE(indicatesFirstRun(false, false, true));
|
||||
}
|
||||
|
||||
RUN_ALL_TESTS()
|
||||
|
||||
@@ -94,6 +94,62 @@ TEST(ScrollerStreamsTest, InterleavesSelectedWithDefault)
|
||||
EXPECT_EQ(streams[3].audioFilename, "OriginalAudio.wav");
|
||||
}
|
||||
|
||||
TEST(ScrollerStreamsTest, InterleavedListCyclesBackToFirstSelected)
|
||||
{
|
||||
// Three selected proclamations: the built list must be
|
||||
// [S1, Default, S2, Default, S3, Default]. ScrollerPage advances through
|
||||
// this list with a modulo, so once the trailing default finishes the cycle
|
||||
// restarts at the FIRST selected proclamation — the "always on" default
|
||||
// must never accumulate or take over the queue.
|
||||
ProclamationRecord b;
|
||||
b.id = "B";
|
||||
b.title = "B";
|
||||
b.content = "bbbb";
|
||||
b.selected = true;
|
||||
b.solfeggio_frequency_id = 2;
|
||||
|
||||
ProclamationRecord c;
|
||||
c.id = "C";
|
||||
c.title = "C";
|
||||
c.content = "cccc";
|
||||
c.selected = true;
|
||||
c.solfeggio_frequency_id = 3;
|
||||
|
||||
ProclamationRecord d;
|
||||
d.id = "D";
|
||||
d.title = "D";
|
||||
d.content = "dddd";
|
||||
d.selected = true;
|
||||
d.solfeggio_frequency_id = 4;
|
||||
|
||||
ProclamationRecord def;
|
||||
def.id = kDefaultId;
|
||||
def.title = "Default";
|
||||
def.content = "dddddddddd";
|
||||
def.selected = true;
|
||||
def.solfeggio_frequency_id = 5;
|
||||
|
||||
auto streams = buildStreams({b, def, c, d}, {}, palette(), {}, kDefaultId);
|
||||
|
||||
ASSERT_EQ(streams.size(), 6u);
|
||||
EXPECT_EQ(streams[0].name, "B");
|
||||
EXPECT_EQ(streams[1].name, "Default");
|
||||
EXPECT_EQ(streams[2].name, "C");
|
||||
EXPECT_EQ(streams[3].name, "Default");
|
||||
EXPECT_EQ(streams[4].name, "D");
|
||||
EXPECT_EQ(streams[5].name, "Default");
|
||||
|
||||
// Every default copy is independent playback state (fresh counters), so a
|
||||
// copy finishing its passes advances to the next stream rather than
|
||||
// restarting "the default" forever.
|
||||
for (size_t i = 1; i < streams.size(); i += 2) {
|
||||
EXPECT_EQ(streams[i].name, "Default");
|
||||
EXPECT_EQ(streams[i].completed, 0) << "default copy " << i;
|
||||
EXPECT_EQ(streams[i].wordsPlaced, 0) << "default copy " << i;
|
||||
EXPECT_EQ(streams[i].repeat, kDefaultRepeatCount) << "default copy " << i;
|
||||
}
|
||||
}
|
||||
|
||||
TEST(ScrollerStreamsTest, DefaultOnlyWhenNothingSelected)
|
||||
{
|
||||
ProclamationRecord p1;
|
||||
|
||||
Reference in New Issue
Block a user