Compare commits
39
Commits
aa65f75e9f
..
1.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15583edef8 | ||
|
|
63265cc22f | ||
|
|
9e73c82641 | ||
|
|
f2a40cd019 | ||
|
|
5caf1d6ef8 | ||
|
|
e17e881c00 | ||
|
|
b96631c374 | ||
|
|
39eb6d7bf7 | ||
|
|
3c9de0bee2 | ||
|
|
8e930ef8d9 | ||
|
|
ec6b76618c | ||
|
|
74245ee07d | ||
|
|
359ae905d7 | ||
|
|
2b60002df3 | ||
|
|
e401bd4ec5 | ||
|
|
f14bf3e29a | ||
|
|
8ca60de647 | ||
|
|
ac69c7d694 | ||
|
|
7c74198d4f | ||
|
|
c53abede6a | ||
|
|
515056a92c | ||
|
|
1c14c0401e | ||
|
|
2f014aec8a | ||
|
|
e774fbfda9 | ||
|
|
fe2c9ba6a5 | ||
|
|
4abcfa473f | ||
|
|
c35f928c85 | ||
|
|
bea9a86358 | ||
|
|
bc165ef40f | ||
|
|
20af03fe82 | ||
|
|
44927ebb98 | ||
|
|
442565508e | ||
|
|
666a4eca97 | ||
|
|
c5dac10611 | ||
|
|
7d9b3f19a2 | ||
|
|
cad834f232 | ||
|
|
646ad76c00 | ||
|
|
ae12d50116 | ||
|
|
23821e1be6 |
@@ -0,0 +1,9 @@
|
||||
# We'll use defaults from the LLVM style, but with some modifications so that it's close to the CDT K&R style.
|
||||
BasedOnStyle: LLVM
|
||||
UseTab: Always
|
||||
IndentWidth: 4
|
||||
TabWidth: 4
|
||||
PackConstructorInitializers: NextLineOnly
|
||||
BreakConstructorInitializers: AfterColon
|
||||
IndentAccessModifiers: false
|
||||
AccessModifierOffset: -4
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<?fileVersion 4.0.0?><cproject storage_type_id="org.eclipse.cdt.core.XmlProjectDescriptionStorage">
|
||||
<storageModule moduleId="org.eclipse.cdt.core.settings">
|
||||
<cconfiguration id="org.eclipse.cdt.core.default.config.2139874559">
|
||||
<storageModule buildSystemId="org.eclipse.cdt.core.defaultConfigDataProvider" id="org.eclipse.cdt.core.default.config.2139874559" moduleId="org.eclipse.cdt.core.settings" name="Configuration">
|
||||
<externalSettings/>
|
||||
<extensions/>
|
||||
</storageModule>
|
||||
<storageModule moduleId="org.eclipse.cdt.core.externalSettings"/>
|
||||
</cconfiguration>
|
||||
</storageModule>
|
||||
<storageModule moduleId="org.eclipse.cdt.core.pathentry">
|
||||
<pathentry excluding="**/CMakeFiles/**" kind="out" path="build"/>
|
||||
</storageModule>
|
||||
<storageModule moduleId="org.eclipse.cdt.core.LanguageSettingsProviders"/>
|
||||
</cproject>
|
||||
@@ -1,30 +0,0 @@
|
||||
name: build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [dev, main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
nix-build:
|
||||
runs-on: build-server
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Build + offline test suite
|
||||
run: nix build -L .#checks.default
|
||||
|
||||
- name: Live portal tests
|
||||
if: ${{ secrets.RW_LIVE_API_KEY != '' }}
|
||||
env:
|
||||
RW_LIVE_API_KEY: ${{ secrets.RW_LIVE_API_KEY }}
|
||||
RW_LIVE_ACCOUNT_EMAIL: ${{ secrets.RW_LIVE_ACCOUNT_EMAIL }}
|
||||
RW_LIVE_LICENSE_EMAIL: ${{ secrets.RW_LIVE_LICENSE_EMAIL }}
|
||||
RW_LIVE_LICENSE_KEY: ${{ secrets.RW_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,68 @@
|
||||
name: build-dev
|
||||
on:
|
||||
# Every push to dev: build, run the offline test suite, publish to the
|
||||
# dev Attic cache.
|
||||
push:
|
||||
branches: [dev]
|
||||
jobs:
|
||||
nix-build:
|
||||
runs-on: build-server
|
||||
steps:
|
||||
# actions/checkout@v4 is unreliable on host-mode runners (its files are
|
||||
# copied into the workspace instead of the repo), so clone explicitly.
|
||||
- name: Checkout
|
||||
run: |
|
||||
rm -rf ./* ./.[!.]* 2>/dev/null || true
|
||||
git config --global http.extraheader "Authorization: token ${{ github.token }}"
|
||||
git clone --branch "${{ gitea.ref_name }}" \
|
||||
"${{ gitea.server_url }}/${{ gitea.repository }}.git" .
|
||||
test -f flake.nix
|
||||
git log --oneline -1
|
||||
- name: Inspect workspace
|
||||
run: |
|
||||
pwd
|
||||
ls -la
|
||||
git rev-parse --show-toplevel 2>&1 || true
|
||||
git remote -v 2>&1 || true
|
||||
git describe --tags --always 2>&1 || true
|
||||
test -f flake.nix && echo "flake.nix present" || echo "NO flake.nix"
|
||||
# Dev builds fingerprint as the short commit hash.
|
||||
# Persisted via GITHUB_ENV so the impure nix eval below reads it via
|
||||
# builtins.getEnv.
|
||||
- name: Compute version
|
||||
run: |
|
||||
echo "APP_VERSION=$(git rev-parse --short HEAD)" >> "$GITHUB_ENV"
|
||||
# checks is keyed per-system (checks.<system>.default); nix build's
|
||||
# #attr shorthand can't resolve it, so resolve the current system inline.
|
||||
# --impure lets the flake read APP_VERSION via builtins.getEnv
|
||||
# (pure evals always get the package defaults).
|
||||
- name: Build package
|
||||
run: |
|
||||
nix build -L ".#packages.$(nix eval --impure --raw --expr 'builtins.currentSystem').default" \
|
||||
--impure --out-link result-pkg
|
||||
# The offline test suite runs in the check derivation's checkPhase.
|
||||
# This is a separate build/output from the package itself.
|
||||
- name: Build + offline test suite
|
||||
run: |
|
||||
nix build -L ".#checks.$(nix eval --impure --raw --expr 'builtins.currentSystem').default" \
|
||||
--impure --out-link result-checks
|
||||
- name: Live portal tests
|
||||
env:
|
||||
RWT_LIVE_ACCOUNT_EMAIL: ${{ secrets.RWT_LIVE_ACCOUNT_EMAIL }}
|
||||
RWT_LIVE_LICENSE_EMAIL: ${{ secrets.RWT_LIVE_LICENSE_EMAIL }}
|
||||
RWT_LIVE_LICENSE_KEY: ${{ secrets.RWT_LIVE_LICENSE_KEY }}
|
||||
run: |
|
||||
nix develop --command bash -c '
|
||||
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug
|
||||
cmake --build build --target test_api_client test_license_service
|
||||
./build/test_api_client
|
||||
./build/test_license_service
|
||||
'
|
||||
# Dev closure (the actual package, not the check output) lands in the
|
||||
# dev cache.
|
||||
- name: Push to Attic dev cache
|
||||
env:
|
||||
ATTIC_TOKEN: ${{ secrets.ATTIC_TOKEN_DEV }}
|
||||
run: |
|
||||
attic login radiantwave https://attic.radiantwavetech.com "$ATTIC_TOKEN"
|
||||
attic push radiantwave:radiantwave-dev ./result-pkg
|
||||
@@ -0,0 +1,68 @@
|
||||
name: build-main
|
||||
|
||||
on:
|
||||
# Manual only. Dispatch against main: build, run the offline test suite,
|
||||
# publish to the release Attic cache.
|
||||
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). The offline test suite
|
||||
# runs in the package's checkPhase.
|
||||
- name: Build + offline test suite
|
||||
run: nix build -L ".#checks.$(nix eval --impure --raw --expr 'builtins.currentSystem').default" --impure
|
||||
|
||||
- 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 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
|
||||
@@ -2,6 +2,9 @@
|
||||
build/
|
||||
cmake-build-*/
|
||||
|
||||
# Editor caches
|
||||
.cache/
|
||||
|
||||
# Nix
|
||||
.direnv/
|
||||
result
|
||||
@@ -16,6 +19,7 @@ result
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
._*
|
||||
|
||||
# Build artifacts
|
||||
*.o
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<projectDescription>
|
||||
<name>radiantwave</name>
|
||||
<comment></comment>
|
||||
<projects>
|
||||
</projects>
|
||||
<buildSpec>
|
||||
<buildCommand>
|
||||
<name>org.eclipse.cdt.core.cBuilder</name>
|
||||
<triggers>clean,full,incremental,</triggers>
|
||||
<arguments>
|
||||
</arguments>
|
||||
</buildCommand>
|
||||
</buildSpec>
|
||||
<natures>
|
||||
<nature>org.eclipse.cdt.core.cnature</nature>
|
||||
<nature>org.eclipse.cdt.core.ccnature</nature>
|
||||
<nature>org.eclipse.cdt.cmake.core.cmakeNature</nature>
|
||||
</natures>
|
||||
</projectDescription>
|
||||
@@ -30,21 +30,26 @@ the project version, or pass your own at configure time:
|
||||
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug -DAPP_VERSION="1.2.3"
|
||||
```
|
||||
|
||||
The Nix package passes its flake version automatically via `cmakeFlags`.
|
||||
|
||||
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.
|
||||
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`. 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
|
||||
`$out/share/radiantwave` before exec, which is why the installed app works.)
|
||||
- Development happens on macOS; WiFi/D-Bus code is Linux-only, but
|
||||
`NetworkManager::hasInternet()` (a raw TCP probe) works everywhere, so the
|
||||
provisioning walkthrough does not trap a Mac on the WiFi page.
|
||||
- The app creates a **1920x1080 fullscreen** window with VSync and a hidden
|
||||
cursor. `~/.local/radiantwave/config.yaml` (YAML) and
|
||||
`~/.local/radiantwave/radiantwave.db` (SQLite, WAL) are created at first run.
|
||||
- No root `flake.nix` exists; the Nix flake in `archive/` is reference only.
|
||||
- The root `flake.nix` is canonical: `packages.default`, `devShells.default`,
|
||||
`checks.<system>.default` (the package; its `checkPhase` runs the offline
|
||||
test suite), a `nixosModules.default`, and `nix build
|
||||
.#packages.x86_64-linux.test-vm`. `archive/` holds the previous Go
|
||||
implementation — reference only, do not modify.
|
||||
|
||||
---
|
||||
|
||||
@@ -61,16 +66,27 @@ Two frameworks coexist:
|
||||
`find_package` requirement; the build fails without it.
|
||||
|
||||
Test targets: `tests`, `test_assets`, `test_provisioning`, `test_network_unit`,
|
||||
`test_config`, `test_db`, `test_proclamation_sync`, `test_api_client`,
|
||||
`test_license_service`, `test_license_status`, `test_validation`,
|
||||
`test_date_format`, `test_timezone`, `test_logger`, `test_audio`,
|
||||
`test_audio_downloader`, `test_fibonacci_pattern`, `test_scroller_streams`,
|
||||
`test_fibonacci_scrambler`, `test_color_palettes`.
|
||||
`test_nm_device`, `test_config`, `test_db`, `test_proclamation_sync`,
|
||||
`test_api_client`, `test_license_service`, `test_license_status`,
|
||||
`test_validation`, `test_date_format`, `test_timezone`, `test_logger`,
|
||||
`test_audio`, `test_audio_downloader`, `test_fibonacci_pattern`,
|
||||
`test_scroller_streams`, `test_fibonacci_scrambler`, `test_color_palettes`.
|
||||
|
||||
`test_nm_device` covers the pure `parseAccessPoint()` mapping; the sd-bus reads
|
||||
in `WifiDevice::getAccessPoints()` are Linux-only and verified live. Do not
|
||||
wrap the per-AP `ay` SSID property read in `sd_bus_message_enter_container()`
|
||||
before `sd_bus_message_read_array()` — `read_array` enters/exits the array
|
||||
itself, and the double-enter silently empties the scan list (see
|
||||
`src/network/nm_device.cpp`).
|
||||
|
||||
New test files must be registered as a new `add_executable` target in
|
||||
`CMakeLists.txt` (there is no glob). `test_assets` must pass before committing —
|
||||
it checks the font and image files exist.
|
||||
|
||||
`test_api_client` and `test_license_service` include "Live" tests that hit the
|
||||
portal unconditionally — they are excluded from the Nix `checkPhase` offline
|
||||
suite and need the `RWT_LIVE_*` secrets to pass.
|
||||
|
||||
---
|
||||
|
||||
## Runtime data & keys
|
||||
@@ -80,8 +96,9 @@ it checks the font and image files exist.
|
||||
provisioning flags. No globals — pass `AppState&` around.
|
||||
- Device identity/state lives in the SQLite `system_kv` table. Canonical keys
|
||||
are in `src/db/db_keys.h`: `email_address`, `license_key`,
|
||||
`subscription_level_id/name/description`, `license_last_verified` (7-day
|
||||
offline grace window for a known-good license).
|
||||
`license_last_verified` (7-day offline grace window for a known-good license).
|
||||
A successful `validateStoredLicense` is the "subscribed" flag — the license
|
||||
is either active (valid) or inactive; there are no subscription levels.
|
||||
- `ConfigManager` hot-reloads `config.yaml` from a watcher thread and fires
|
||||
`onChanged` callbacks; `AudioManager` volume/device/`dynamic_audio` are read
|
||||
from it.
|
||||
@@ -200,11 +217,17 @@ cached.
|
||||
- Do not modify files in `archive/` — reference only.
|
||||
- Build needs SDL3 + SDL3_ttf/image/mixer/net + GTest installed; CI uses
|
||||
Gitea Actions on a self-hosted host runner (`build-server`, no docker).
|
||||
The workflow lives in `.gitea/workflows/build.yml` and runs on both `dev`
|
||||
and `main`. It runs `nix build -L .#checks.default` (build + the offline
|
||||
test suite); when the `RW_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).
|
||||
The workflow lives in `.gitea/workflows/build.yml` and is **manual-only**
|
||||
(`workflow_dispatch`); pick the branch when dispatching. It runs
|
||||
`nix build -L .#checks.<system>.default` (build + the offline test suite;
|
||||
`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`. The flake reads `APP_VERSION` from the environment via
|
||||
`builtins.getEnv`, so the build must pass `--impure` (pure evals get the
|
||||
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
|
||||
|
||||
+30
-16
@@ -8,15 +8,15 @@ set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
|
||||
# App version string shown at the top of the Home footer. Defaults to the
|
||||
# project version; override at configure time with:
|
||||
# cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug -DAPP_VERSION="1.2.3"
|
||||
set(APP_VERSION "${PROJECT_VERSION}" CACHE STRING
|
||||
"Version string shown in the Home footer (override at configure time)")
|
||||
|
||||
# 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 }}"
|
||||
set(API_KEY "" CACHE STRING
|
||||
"Portal API key baked into the binary (leave empty to skip auth)")
|
||||
# or by exporting APP_VERSION in the environment (the `nix develop` shell does
|
||||
# this; the env var only seeds a fresh configure, -D wins on reconfigure).
|
||||
if(DEFINED ENV{APP_VERSION})
|
||||
set(APP_VERSION "$ENV{APP_VERSION}" CACHE STRING
|
||||
"Version string shown in the Home footer (override at configure time)")
|
||||
else()
|
||||
set(APP_VERSION "${PROJECT_VERSION}" CACHE STRING
|
||||
"Version string shown in the Home footer (override at configure time)")
|
||||
endif()
|
||||
|
||||
find_package(SDL3 REQUIRED)
|
||||
find_package(SDL3_ttf REQUIRED)
|
||||
@@ -31,8 +31,8 @@ include(GoogleTest)
|
||||
|
||||
# Network manager dependencies (Linux only)
|
||||
if(UNIX AND NOT APPLE)
|
||||
find_package(PkgConfig REQUIRED)
|
||||
pkg_check_modules(SDBUS REQUIRED libsystemd)
|
||||
find_package(PkgConfig REQUIRED)
|
||||
pkg_check_modules(SDBUS REQUIRED libsystemd)
|
||||
endif()
|
||||
|
||||
add_executable(radiantwave
|
||||
@@ -76,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)
|
||||
@@ -92,9 +91,9 @@ target_link_libraries(radiantwave PRIVATE
|
||||
)
|
||||
|
||||
if(UNIX AND NOT APPLE)
|
||||
target_link_libraries(radiantwave PRIVATE ${SDBUS_LIBRARIES} resolv)
|
||||
target_include_directories(radiantwave PRIVATE ${SDBUS_INCLUDE_DIRS})
|
||||
target_compile_definitions(radiantwave PRIVATE HAS_DBUS=1)
|
||||
target_link_libraries(radiantwave PRIVATE ${SDBUS_LIBRARIES} resolv)
|
||||
target_include_directories(radiantwave PRIVATE ${SDBUS_INCLUDE_DIRS})
|
||||
target_compile_definitions(radiantwave PRIVATE HAS_DBUS=1)
|
||||
endif()
|
||||
|
||||
install(TARGETS radiantwave RUNTIME DESTINATION bin)
|
||||
@@ -128,6 +127,20 @@ add_executable(test_network_unit
|
||||
target_include_directories(test_network_unit PRIVATE src)
|
||||
target_link_libraries(test_network_unit PRIVATE GTest::gtest_main)
|
||||
|
||||
# Access point parsing unit tests (pure mapping; the sd-bus reads are Linux-only)
|
||||
add_executable(test_nm_device
|
||||
tests/network/test_nm_device.cpp
|
||||
src/network/nm_device.cpp
|
||||
src/network/nm_connection.cpp
|
||||
src/logger/logger.cpp
|
||||
)
|
||||
target_include_directories(test_nm_device PRIVATE src)
|
||||
target_link_libraries(test_nm_device PRIVATE GTest::gtest_main)
|
||||
if(UNIX AND NOT APPLE)
|
||||
target_link_libraries(test_nm_device PRIVATE ${SDBUS_LIBRARIES})
|
||||
target_include_directories(test_nm_device PRIVATE ${SDBUS_INCLUDE_DIRS})
|
||||
endif()
|
||||
|
||||
# Config unit tests
|
||||
add_executable(test_config
|
||||
tests/test_config.cpp
|
||||
@@ -226,12 +239,13 @@ target_link_libraries(test_logger PRIVATE GTest::gtest_main)
|
||||
add_executable(test_audio
|
||||
tests/test_audio.cpp
|
||||
src/audio/audio_manager.cpp
|
||||
src/db/database_manager.cpp
|
||||
src/config/yaml_parser.cpp
|
||||
src/config/config_manager.cpp
|
||||
src/logger/logger.cpp
|
||||
)
|
||||
target_include_directories(test_audio PRIVATE src)
|
||||
target_link_libraries(test_audio PRIVATE GTest::gtest_main SDL3_mixer::SDL3_mixer SDL3::SDL3)
|
||||
target_link_libraries(test_audio PRIVATE GTest::gtest_main SDL3_mixer::SDL3_mixer SDL3::SDL3 SQLite3::SQLite3)
|
||||
|
||||
# Audio downloader unit tests
|
||||
add_executable(test_audio_downloader
|
||||
|
||||
@@ -1,575 +0,0 @@
|
||||
# RadiantWave Kiosk — User Manual (Reference Draft)
|
||||
|
||||
> **Reference edition.** Every keybind, navigation path, screen behavior, and
|
||||
> status message below is derived directly from the application source code.
|
||||
> Use this as the checklist when writing the polished end-user manual.
|
||||
|
||||
**Input device:** physical keyboard only. No mouse or touchscreen. The cursor
|
||||
is hidden. Text input uses the on-screen text-entry model (SDL text input).
|
||||
|
||||
---
|
||||
|
||||
## 0. Screen Map (UX Flow)
|
||||
|
||||
```
|
||||
Power on
|
||||
└─ First-Run Walkthrough (if a prerequisite is missing)
|
||||
1. Internet reachable? no → WiFi Setup
|
||||
2. Email address set? no → Email Address
|
||||
3. License key set? no → License Key
|
||||
4. All satisfied → Home
|
||||
└─ Home
|
||||
├─ [Play] ────────────────────────────→ Scroller ──(ESC)──→ Home
|
||||
└─ [Settings] ────────────────────────→ Settings
|
||||
├─ WiFi Setup (ESC/TAB → Settings)
|
||||
├─ Audio Options (ESC/TAB → Settings)
|
||||
├─ Time Zone Setup (ESC/TAB → Settings)
|
||||
├─ Proclamations (ESC → Settings) [license-gated]
|
||||
├─ Email Address (ESC/TAB → Settings)
|
||||
├─ License Key (ESC/TAB → Settings)
|
||||
├─ Date Format (ESC/TAB → Settings)
|
||||
├─ View System Logs (ESC/TAB → Settings)
|
||||
└─ Back to Home → Home
|
||||
```
|
||||
|
||||
When **Play** or **Proclamations** is selected but the license cannot be
|
||||
verified, a **License Blocked** screen appears instead (ESC → Home).
|
||||
|
||||
The **Home** screen is the root of the whole UI. **TAB** is intended to return
|
||||
to Home from anywhere (see the caveat in §12).
|
||||
|
||||
---
|
||||
|
||||
## 1. Global Controls
|
||||
|
||||
These binds are registered at startup (`main.cpp`) and are the only binds not
|
||||
owned by a specific screen.
|
||||
|
||||
| Key | Action | Where it actually works |
|
||||
| :--- | :--- | :--- |
|
||||
| **`TAB`** | Return to the Home screen (pops every page on the stack and pushes a fresh Home). | Home; most screens also handle TAB locally (see per-screen tables) |
|
||||
| **`UP` / `DOWN`** | Move the Home menu selection. **Both keys increment the same counter** — there is no separate "down" behavior; either key cycles Play ↔ Settings. | Home |
|
||||
|
||||
> **No key quits the application.** There is intentionally no quit keybind;
|
||||
> the app is meant to run as a kiosk. (The underlying SDL window/system close
|
||||
> path still exists, but no keyboard key triggers it.)
|
||||
|
||||
**Per-screen binds are checked before the screen sees key events.** A keybind
|
||||
registered on the current screen is consumed by the keybind system; keys not
|
||||
bound there fall through to the screen's own event handler.
|
||||
|
||||
---
|
||||
|
||||
## 2. Boot & First-Run Walkthrough
|
||||
|
||||
On every boot the app checks three prerequisites in a fixed order and shows the
|
||||
**first** missing one (`provisioning.h`):
|
||||
|
||||
| # | Prerequisite | Screen shown if missing |
|
||||
| :--- | :--- | :--- |
|
||||
| 1 | Internet reachable (real connectivity probe — Ethernet, WiFi, or any OS link) | WiFi Setup |
|
||||
| 2 | Email address saved | Email Address |
|
||||
| 3 | License key saved | License Key |
|
||||
| 4 | — | Home |
|
||||
|
||||
Walkthrough screens call "advance" when they finish or are backed out of, which
|
||||
pops the current step and re-evaluates. Completing the WiFi page does **not**
|
||||
re-run the internet probe (cached); the other steps only read the DB.
|
||||
|
||||
- A brand-new device (no internet, no email, no license) also raises an
|
||||
internal `firstRunWalkthrough` flag (a future welcome walkthrough; today it
|
||||
is only set and logged).
|
||||
- If the database failed to open, the walkthrough is skipped entirely and the
|
||||
app goes straight to Home.
|
||||
- On the WiFi page during the walkthrough, once the internet becomes reachable
|
||||
the page waits ~1.5 s ("Internet connected. Continuing setup...") and then
|
||||
advances automatically.
|
||||
- On the Email and License pages, after a successful save the page waits ~1 s
|
||||
and then advances automatically.
|
||||
|
||||
---
|
||||
|
||||
## 3. Home
|
||||
|
||||
**Reached from:** app start, TAB, ESC on License Blocked, "Back to Home" in
|
||||
Settings, ESC on the Scroller.
|
||||
|
||||
**Screen contents:**
|
||||
- Centered "Radiant Wave" logo title.
|
||||
- Two menu items: **Play** and **Settings** (white underline marks the
|
||||
selection).
|
||||
- Bottom-right status footer (see §11).
|
||||
|
||||
**Keybinds:**
|
||||
|
||||
| Key | Action |
|
||||
| :--- | :--- |
|
||||
| **`UP`** | Cycle menu selection (Play → Settings → Play). |
|
||||
| **`DOWN`** | Same as UP — cycles the selection the same way. |
|
||||
| **`RETURN`** / **`ENTER`** | Activate the highlighted item. |
|
||||
| **`TAB`** | Re-push Home (no visible change) (until a page is pushed on top of Home). |
|
||||
|
||||
> The **UP** and **DOWN** keys are wired identically (both increment the
|
||||
> selection), so either arrow moves the highlight the same way; there is no
|
||||
> dedicated "down" behavior. There is **no quit key** on Home.
|
||||
|
||||
**Behavior:**
|
||||
- **Play** (item 0) with a confirmed-valid license → **Scroller**.
|
||||
- **Play** with an invalid/unverifiable license → **License Blocked**.
|
||||
- **Settings** (item 1) → replaces Home with **Settings**.
|
||||
- The internet status refreshes automatically every 5 seconds while Home is
|
||||
shown. Play only unlocks when the license is valid (validated in the
|
||||
background; result shown in the footer).
|
||||
|
||||
---
|
||||
|
||||
## 4. Scroller (Play)
|
||||
|
||||
**Reached from:** Home → Play (license-gated).
|
||||
|
||||
**Screen contents:** full-screen, vertically scrolling proclamation text.
|
||||
Proclamation text is never rendered as readable type — each glyph is permuted
|
||||
into a scrambled tile by the Fibonacci affine map, so letterforms are
|
||||
destroyed on screen. Lines follow the Fibonacci line-length pattern; colors
|
||||
come from per-frequency palettes; audio crossfades between streams.
|
||||
|
||||
**Keybinds:**
|
||||
|
||||
| Key | Action |
|
||||
| :--- | :--- |
|
||||
| **`ESC`** | Exit back to Home. |
|
||||
|
||||
No other keys do anything on this screen. Scrolling speed ramps automatically
|
||||
(up and down) with pauses at each extreme; there is no user speed control.
|
||||
|
||||
**Behavior:**
|
||||
- Plays audio with a 5 s fade-in; on exit fades out over ~5 s.
|
||||
- Dynamic Audio mode: the current stream's solfeggio-frequency track is
|
||||
crossfaded as proclamations change. Fixed-track mode: the configured track
|
||||
plays and never switches.
|
||||
- If a stream's audio file is not on disk yet, playback falls back to the
|
||||
manual track rather than silence.
|
||||
|
||||
---
|
||||
|
||||
## 5. Settings
|
||||
|
||||
**Reached from:** Home → Settings. (Home is replaced, not stacked.)
|
||||
|
||||
**Screen contents:** title "Settings" and a 9-item menu:
|
||||
|
||||
| # | Item | Opens | License-gated? |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| 0 | WiFi Setup | WiFi Setup screen | no |
|
||||
| 1 | Audio Options | Audio Options screen | no |
|
||||
| 2 | Time Zone Setup | Time Zone Setup screen | no |
|
||||
| 3 | Proclamations | Proclamation Options | **yes** (else License Blocked) |
|
||||
| 4 | Email Address | Email Address screen | no |
|
||||
| 5 | License Key | License Key screen | no |
|
||||
| 6 | Date Format | Date Format screen | no |
|
||||
| 7 | View System Logs | System Log Viewer | no |
|
||||
| 8 | Back to Home | Home | — |
|
||||
|
||||
**Keybinds:**
|
||||
|
||||
| Key | Action |
|
||||
| :--- | :--- |
|
||||
| **`UP`** | Move selection up (wraps around). |
|
||||
| **`DOWN`** | Move selection down (wraps around). |
|
||||
| **`RETURN`** / **`ENTER`** | Open the highlighted item. |
|
||||
| **`ESC`** | Return to Home (replaces Settings). |
|
||||
| **`TAB`** | Return to Home. |
|
||||
|
||||
---
|
||||
|
||||
## 6. WiFi Setup
|
||||
|
||||
**Reached from:** Settings → WiFi Setup, or the First-Run walkthrough.
|
||||
|
||||
**Screen contents:**
|
||||
- Title "WiFi Configuration".
|
||||
- Status row: **WiFi: Connected/Disconnected** (green/red), **Network:
|
||||
\<SSID\>/None**, **Internet: Reachable/Unreachable** (green/red).
|
||||
- Status message banner (blue-bordered): "Scanning for available
|
||||
networks...", "Connecting to \<SSID\>...", "Connected successfully!",
|
||||
"Connection failed: \<reason\>", "Password entry cancelled", etc.
|
||||
- Network list (50 px rows). Color coding:
|
||||
- 🟢 **GREEN** — currently connected network.
|
||||
- 🟡 **YELLOW** — previously saved/remembered network.
|
||||
- ⚪ **WHITE** — new/unknown network.
|
||||
- Each row also shows a security badge `[Open]/[WPA/...]` and a signal
|
||||
strength percentage.
|
||||
- Empty state: "No WiFi networks found. Press F5 to scan."
|
||||
- Footer: `[UP/DOWN] Select [ENTER] Connect [F5] Rescan Networks [ESC/TAB] Back`.
|
||||
|
||||
**Keybinds (normal mode):**
|
||||
|
||||
| Key | Action |
|
||||
| :--- | :--- |
|
||||
| **`UP`** | Select previous network. |
|
||||
| **`DOWN`** | Select next network. |
|
||||
| **`RETURN`** / **`ENTER`** | Connect. Open network → connects immediately. Secured network → opens the password box. |
|
||||
| **`F5`** | Re-scan for networks. |
|
||||
| **`ESC`** | Back (Settings, or walkthrough advance). |
|
||||
| **`TAB`** | Back (same as ESC). |
|
||||
|
||||
**Password box (modal):**
|
||||
|
||||
| Key | Action |
|
||||
| :--- | :--- |
|
||||
| **type any keys** | Type the password (shown in plain text, unmasked, with a cursor). |
|
||||
| **`BACKSPACE`** | Delete the last character. |
|
||||
| **`RETURN`** / **`ENTER`** | Submit password and connect. |
|
||||
| **`ESC`** | Cancel password entry (returns to the network list). |
|
||||
|
||||
Password modal footer: `[ENTER] Submit Password [ESC] Cancel`.
|
||||
|
||||
**Behavior:**
|
||||
- Connect is asynchronous. On completion: "Connected successfully!" or
|
||||
"Connection failed: \<error\>" (falls back to "Authentication or signal
|
||||
error").
|
||||
- While a connection is in progress the list is locked.
|
||||
- **Walkthrough only:** once internet is reachable, auto-advances after ~1.5 s.
|
||||
|
||||
---
|
||||
|
||||
## 7. Audio Options
|
||||
|
||||
**Reached from:** Settings → Audio Options.
|
||||
|
||||
**Screen contents:** title "Audio Configuration"; status row **Audio:
|
||||
Enabled/Disabled** (green/red) and **Device: \<name\>**; status banner; 5-item
|
||||
menu:
|
||||
|
||||
| # | Item | Interaction |
|
||||
| :--- | :--- | :--- |
|
||||
| 0 | Volume: `[▓▓▓▓▓▓▓▓░░] 80%` | LEFT/RIGHT step −/+ 5% (0–100, clamps). |
|
||||
| 1 | Output Device: `[\<name\>]` | ENTER opens the Device selector modal. |
|
||||
| 2 | Audio Track: `[\<name\>]` | ENTER opens the Track selector modal. |
|
||||
| 3 | Audio Status: `[ON]`/`[OFF]` | ENTER toggles audio system-wide on/off. |
|
||||
| 4 | Back to Settings | ENTER pops back to Settings. |
|
||||
|
||||
Footer: `[UP/DOWN] Select [LEFT/RIGHT] Volume [ENTER] Confirm [ESC/TAB] Back`.
|
||||
|
||||
**Keybinds (main menu):**
|
||||
|
||||
| Key | Action |
|
||||
| :--- | :--- |
|
||||
| **`UP`** | Select previous item. |
|
||||
| **`DOWN`** | Select next item. |
|
||||
| **`LEFT`** | Volume −5% (only when Volume is highlighted). |
|
||||
| **`RIGHT`** | Volume +5% (only when Volume is highlighted). |
|
||||
| **`RETURN`** / **`ENTER`** | Open device/track modal, toggle audio, or Back. |
|
||||
| **`ESC`** | Back to Settings (closes a modal first if one is open). |
|
||||
| **`TAB`** | Back to Settings (only when no modal is open). |
|
||||
|
||||
**Device selector modal** — "Select Output Device":
|
||||
|
||||
| Key | Action |
|
||||
| :--- | :--- |
|
||||
| **`UP`** / **`DOWN`** | Move selection. |
|
||||
| **`PAGE UP`** / **`PAGE DOWN`** | Jump selection by 8. |
|
||||
| **`RETURN`** / **`ENTER`** | Switch to the highlighted device. |
|
||||
| **`ESC`** | Cancel (no change). |
|
||||
|
||||
**Track selector modal** — "Select Audio Track":
|
||||
|
||||
| Key | Action |
|
||||
| :--- | :--- |
|
||||
| **`UP`** / **`DOWN`** | Move selection. |
|
||||
| **`PAGE UP`** / **`PAGE DOWN`** | Jump selection by 8. |
|
||||
| **`RETURN`** / **`ENTER`** | Select the highlighted option. |
|
||||
| **`ESC`** | Cancel (no change). |
|
||||
|
||||
Modal footer: `[UP/DOWN] Select [ENTER] Confirm [ESC] Cancel`.
|
||||
|
||||
**Behavior:**
|
||||
- The track list always leads with **Dynamic Audio** (cyan; "audio follows the
|
||||
displayed proclamation"), then **Original Audio** pinned first, then every
|
||||
other track sorted alphabetically. Up to 8 rows visible; the list scrolls
|
||||
and stays centered on the selection.
|
||||
- Choosing a fixed track turns Dynamic Audio **off**. Choosing Dynamic Audio
|
||||
turns it on (status: "Dynamic Audio mode enabled - audio follows the
|
||||
displayed proclamation"). Choosing a track shows "Fixed track selected".
|
||||
Playback itself starts when the Scroller plays, not here.
|
||||
- Device and track lists refresh automatically every 1 second.
|
||||
|
||||
---
|
||||
|
||||
## 8. Time Zone Setup
|
||||
|
||||
**Reached from:** Settings → Time Zone Setup.
|
||||
|
||||
**Screen contents:** title "Time Zone Configuration"; **Configured Timezone:
|
||||
\<code\>** (yellow) and a live **Local Time:** sample (green); status banner;
|
||||
then either a **country list** or a **timezone list** for the chosen country.
|
||||
Green rows mark the currently configured country/zone; the white underline
|
||||
marks the selection.
|
||||
|
||||
**Country step:**
|
||||
|
||||
| Key | Action |
|
||||
| :--- | :--- |
|
||||
| **`UP`** / **`DOWN`** | Select country/region. |
|
||||
| **`RETURN`** / **`ENTER`** | Show that country's timezone list ("Select timezone for \<Country\>"). |
|
||||
| **`ESC`** / **`TAB`** / **`BACKSPACE`** | Back to Settings. |
|
||||
|
||||
**Timezone step:**
|
||||
|
||||
| Key | Action |
|
||||
| :--- | :--- |
|
||||
| **`UP`** / **`DOWN`** | Select a timezone. |
|
||||
| **`RETURN`** / **`ENTER`** | Save the timezone (status "Timezone set to \<code\>") and return to the country list. |
|
||||
| **`ESC`** / **`BACKSPACE`** / **`TAB`** | Back to the country list. |
|
||||
|
||||
Footers:
|
||||
- Country: `[UP/DOWN] Select Country [ENTER] Choose [ESC/TAB] Back to Settings`
|
||||
- Timezone: `[UP/DOWN] Select Timezone [ENTER] Apply Timezone [ESC/BACKSPACE] Back to Countries`
|
||||
|
||||
**Behavior:** the saved timezone is written to the live config immediately;
|
||||
logs and the Home clock use it right away.
|
||||
|
||||
---
|
||||
|
||||
## 9. Proclamations
|
||||
|
||||
**Reached from:** Settings → Proclamations (license-gated; else License
|
||||
Blocked).
|
||||
|
||||
**Screen contents:** title "Proclamation Options"; status line (sync result +
|
||||
"Selected: N"); an optional **search bar** while searching; a collapsible
|
||||
category tree (or a flat search-result list); footer hints.
|
||||
|
||||
**Colors:** categories blue; selected proclamations orange; the always-on
|
||||
top-level proclamation orange; locked (above your subscription) rows flash
|
||||
**red**; search text yellow.
|
||||
|
||||
**Keybinds (browse mode):**
|
||||
|
||||
| Key | Action |
|
||||
| :--- | :--- |
|
||||
| **`UP`** / **`DOWN`** | Move highlight. |
|
||||
| **`RETURN`** / **`ENTER`** | Toggle the highlighted row: expand/collapse a category, or select/deselect a proclamation. |
|
||||
| **`LEFT`** | Collapse the highlighted category; if the row is a sub-item, jump to its parent. |
|
||||
| **`RIGHT`** | Expand the highlighted category. |
|
||||
| **`PAGE UP`** / **`PAGE DOWN`** | Scroll by a page. |
|
||||
| **`CTRL` + `D`** | Clear all selections (the top-level proclamation stays on). |
|
||||
| **type any printable character** | Enter search mode. |
|
||||
| **`ESC`** | Back to Settings. |
|
||||
|
||||
**Search mode:**
|
||||
|
||||
| Key | Action |
|
||||
| :--- | :--- |
|
||||
| **type any printable character** | Add to the search query (word-prefix match on title + content). |
|
||||
| **`BACKSPACE`** | Delete the last query character; when the query empties, search mode closes. |
|
||||
| **`UP`** / **`DOWN`** | Move highlight. |
|
||||
| **`RETURN`** / **`ENTER`** | Toggle the highlighted result. |
|
||||
| **`ESC`** | Cancel search and return to the browse tree. |
|
||||
|
||||
Footers:
|
||||
- Browse: `[UP/DOWN] Move [ENTER] Toggle [Type to Search] [LEFT/RIGHT] Expand/Collapse [CTRL+D] Clear All [ESC] Back`
|
||||
- Search: `[UP/DOWN] Move [ENTER] Select [BACKSPACE] Edit [ESC] Cancel Search`
|
||||
|
||||
**Behavior:**
|
||||
- On open the app syncs the list with the server: "Synced with server" or
|
||||
"Offline - showing saved proclamations". The "Selected: N" count on the
|
||||
status line excludes the always-on top-level proclamation.
|
||||
- The top-level proclamation ("Synchronize, Harmonize & Energize") is **always
|
||||
on** and cannot be deselected ("The top-level proclamation is always on").
|
||||
- Selecting a proclamation whose required subscription level is above the one
|
||||
attached to your license key is rejected: the row flashes red and a status
|
||||
line explains it's "available on \<Level\> systems".
|
||||
- A bare **SPACE** does not open search and never selects anything; in search
|
||||
mode SPACE types a space into the query.
|
||||
- **TAB is NOT handled on this screen** — use ESC to go back.
|
||||
|
||||
---
|
||||
|
||||
## 10. Email Address, License Key, Date Format
|
||||
|
||||
### 10.1 Email Address
|
||||
|
||||
**Reached from:** Settings → Email Address, or the walkthrough.
|
||||
|
||||
**Screen contents:** title "Email Address"; "Saved: \<address\>" (gray = not
|
||||
set, yellow = set); input box with cursor; status line; footer.
|
||||
|
||||
**Keybinds:**
|
||||
|
||||
| Key | Action |
|
||||
| :--- | :--- |
|
||||
| **type any keys** | Type/edit the email (up to 254 chars). |
|
||||
| **`BACKSPACE`** | Delete the last character. |
|
||||
| **`RETURN`** / **`ENTER`** | Save. Empty input clears the saved address ("Cleared"). Invalid address shows "Invalid email address". Valid save shows "Saved". |
|
||||
| **`ESC`** | Back without saving (Settings, or walkthrough advance). |
|
||||
| **`TAB`** | Back (same as ESC). |
|
||||
|
||||
- After a successful save/clear, the page auto-exits after ~1 s.
|
||||
- Footer: `[Type] Enter email [ENTER] Save [BACKSPACE] Delete [ESC/TAB] Back`.
|
||||
|
||||
### 10.2 License Key
|
||||
|
||||
**Reached from:** Settings → License Key, or the walkthrough.
|
||||
|
||||
**Screen contents:** title "License Key"; "Saved: \<key\>" (gray/yellow); input
|
||||
box showing the key formatted `XXXX-XXXX-XXXX-XXXX`; status line; footer.
|
||||
|
||||
**Keybinds:**
|
||||
|
||||
| Key | Action |
|
||||
| :--- | :--- |
|
||||
| **type any keys** | Type the key. Only alphanumerics are accepted (uppercased); max 16 characters. Dashes are added automatically for display. |
|
||||
| **`BACKSPACE`** | Delete the last character. |
|
||||
| **`RETURN`** / **`ENTER`** | Save and validate. |
|
||||
| **`ESC`** | Back without saving (Settings, or walkthrough advance). |
|
||||
| **`TAB`** | Back (same as ESC). |
|
||||
|
||||
**Validation statuses on ENTER:**
|
||||
- Fewer than 16 characters → "License key must be 16 characters
|
||||
(XXXX-XXXX-XXXX-XXXX)".
|
||||
- Save failed → "Failed to save license key".
|
||||
- Server validated → "License validated (\<level\>)".
|
||||
- Missing email → "Saved - add your email address to validate the license".
|
||||
- Invalid → "License check failed: \<reason\>".
|
||||
- No network → "License saved, but the server could not be reached to
|
||||
validate".
|
||||
|
||||
- Auto-exits after ~1 s once saved.
|
||||
- Footer: `[Type] Enter key (dashes added automatically) [ENTER] Save [BACKSPACE] Delete [ESC/TAB] Back`.
|
||||
|
||||
### 10.3 Date Format
|
||||
|
||||
**Reached from:** Settings → Date Format.
|
||||
|
||||
**Screen contents:** title "Date Format"; "Today looks like: \<example\>"
|
||||
(yellow); 7 format options each with a live sample date; status line; footer.
|
||||
|
||||
**Format options:** Month/Day/Year, Day/Month/Year, Year-Month-Day,
|
||||
Month.Day.Year, Day.Month.Year, "Month Day, Year", "Day Month Year".
|
||||
|
||||
> The option currently in use is shown in yellow, the others in white; every
|
||||
> option's live sample date is gray.
|
||||
|
||||
**Keybinds:**
|
||||
|
||||
| Key | Action |
|
||||
| :--- | :--- |
|
||||
| **`UP`** / **`DOWN`** | Choose a format. |
|
||||
| **`RETURN`** / **`ENTER`** | Apply the format and return to Settings ("Date format saved"). |
|
||||
| **`ESC`** | Back without saving. |
|
||||
| **`TAB`** | Back (same as ESC). |
|
||||
|
||||
- Footer: `[UP/DOWN] Choose [ENTER] Apply & Back [ESC/TAB] Back`.
|
||||
|
||||
---
|
||||
|
||||
## 11. System Log Viewer
|
||||
|
||||
**Reached from:** Settings → View System Logs.
|
||||
|
||||
**Screen contents:** title "System Event Log Viewer"; header bar showing
|
||||
**Filter: \<level\> | Sort: Newest/Oldest First | Entries: N | TZ: \<code\>**;
|
||||
word-wrapped log lines (color-coded by severity) with local-time timestamps;
|
||||
footer.
|
||||
|
||||
**Log line colors:** DEBUG gray, INFO white, WARNING yellow, ERROR red,
|
||||
FATAL magenta/pink.
|
||||
|
||||
**Keybinds:**
|
||||
|
||||
| Key | Action |
|
||||
| :--- | :--- |
|
||||
| **`UP`** / **`DOWN`** | Scroll one line. |
|
||||
| **`PAGE UP`** / **`PAGE DOWN`** | Scroll 10 lines. |
|
||||
| **`L`** or **`F2`** or **`RIGHT`** | Cycle the filter level forward: ALL → DEBUG → INFO → WARNING → ERROR → FATAL. |
|
||||
| **`LEFT`** | Cycle the filter level backward. |
|
||||
| **`S`** or **`F3`** | Toggle sort order: Newest First ↔ Oldest First. |
|
||||
| **`C`** | Clear all log history. |
|
||||
| **`ESC`** | Back to Settings. |
|
||||
| **`TAB`** | Back (same as ESC). |
|
||||
|
||||
- Footer: `[UP/DOWN] Scroll [L/F2] Filter Level [S/F3] Sort Order [C] Clear Logs [ESC/TAB] Back`.
|
||||
- The log list auto-refreshes every 0.25 s while the screen is open.
|
||||
|
||||
---
|
||||
|
||||
## 12. License Blocked Screen
|
||||
|
||||
**Reached from:** Play or Settings → Proclamations when the license cannot be
|
||||
verified.
|
||||
|
||||
**Screen contents:** centered message "License could not be verified",
|
||||
instruction text ("Please check that you are connected to the internet, and
|
||||
double check your license key and email address."), and "Press ESC to return."
|
||||
|
||||
**Keybinds:**
|
||||
|
||||
| Key | Action |
|
||||
| :--- | :--- |
|
||||
| **`ESC`** | Return to Home (pops every page and pushes a fresh Home). |
|
||||
|
||||
---
|
||||
|
||||
## 13. Home Status Footer (legend)
|
||||
|
||||
The bottom-right corner of Home shows a two-column readout:
|
||||
|
||||
| Field | Meaning / values |
|
||||
| :--- | :--- |
|
||||
| **License:** | `OK` (green) · `Invalid` (red, bold) · `Checking...` · `Unknown` |
|
||||
| **Key:** | saved license key, or `Not set` |
|
||||
| **Email:** | saved email, or `Not set` |
|
||||
| **Subscription:** | subscription level name from the license, or `Not set` |
|
||||
| **WiFi:** | current SSID, or `Not connected` |
|
||||
| **Internet:** | `Connected` / `Offline` |
|
||||
| **Audio:** | audio-downloader status string, or `Idle` |
|
||||
| **Date:** | formatted per the selected date format |
|
||||
| **Time:** | `HH:MM:SS` (24-hour) |
|
||||
|
||||
Internet status refreshes every 5 s while Home is shown; the license check
|
||||
re-runs in the background each time Home appears.
|
||||
|
||||
---
|
||||
|
||||
## 14. Data & Configuration (background)
|
||||
|
||||
- **`~/.local/radiantwave/config.yaml`** — settings (audio volume, output
|
||||
device, current track, dynamic audio, timezone, proclamation font size).
|
||||
Saved automatically. **Hot-reloaded live**: editing it while the app runs
|
||||
applies immediately without a restart.
|
||||
- **`~/.local/radiantwave/radiantwave.db`** (SQLite, WAL) — email, license key,
|
||||
subscription level, license last-verified timestamp, proclamation/category
|
||||
data, audio file records, system log history.
|
||||
- **Audio assets** (`.wav` files) are downloaded in the background on first
|
||||
connect and stored under `~/.local/radiantwave/audio/`; they are not part of
|
||||
the app install.
|
||||
- License checks run online; a known-good license stays valid for a **7-day
|
||||
offline grace window**.
|
||||
|
||||
---
|
||||
|
||||
## 15. Implementation Notes / Caveats (for the manual author)
|
||||
|
||||
These are behaviors visible in the source that differ from the "intended"
|
||||
controls, so you can decide how to document or fix them:
|
||||
|
||||
1. **TAB (Home) is registered once at startup and is removed whenever any page
|
||||
pauses** (every page calls `keybinds.unbindAll()` in `onPause`, and Home
|
||||
only re-binds UP/DOWN in `onResume`). In practice **TAB returns Home
|
||||
reliably only on the Home screen, and only until you first push a page on
|
||||
top of it — popping back to Home does not restore it**. Screens that handle
|
||||
TAB locally (Settings, WiFi, Audio, Email, License, Date Format, Log
|
||||
Viewer) keep working; the Scroller, Proclamations, and License Blocked do
|
||||
not respond to TAB.
|
||||
2. **Home's UP and DOWN both move the selection the same direction** — both
|
||||
call `(x+1)%2`. There is no down-only arrow behavior.
|
||||
3. **Proclamation page and Scroller have no TAB handler** — only ESC returns
|
||||
from the Proclamation page / Scroller.
|
||||
4. **TAB on the Time Zone page behaves differently per step**: in the
|
||||
country list it exits to Settings; in the timezone list it returns to the
|
||||
country list.
|
||||
5. The walkthrough's WiFi page auto-advances as soon as internet is reachable
|
||||
(≈1.5 s), and the Email/License pages auto-advance ≈1 s after a successful
|
||||
save — the walkthrough can never be completed by pressing Enter alone on a
|
||||
connected device.
|
||||
@@ -8,7 +8,7 @@ and manages WiFi/provisioning on a headless Linux kiosk.
|
||||
**Build:** CMake + Ninja (local) or Nix (flake)
|
||||
**Graphics:** SDL3
|
||||
**Audio:** SDL3_mixer
|
||||
**Target:** Linux kiosk (Wayland/X11); developed on macOS
|
||||
**Target:** Linux kiosk (Wayland); developed on macOS
|
||||
|
||||
---
|
||||
|
||||
@@ -48,6 +48,8 @@ radiantwave/
|
||||
Two supported ways to build: **CMake + Ninja** (any machine with the
|
||||
dependencies installed, or inside `nix develop`) and **Nix** (the flake builds
|
||||
the package and runs the offline test suite in its `checkPhase`).
|
||||
The Nix builds are really only important for testing builds compile, and
|
||||
building the actual Nix deployment binary
|
||||
|
||||
### Prerequisites (CMake + Ninja)
|
||||
|
||||
@@ -133,25 +135,14 @@ cmake --build build
|
||||
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 `nix build` needs no extra flags.
|
||||
to the flake package version, so a plain `nix build` needs no extra flags.
|
||||
With `--impure` the flake instead reads the `APP_VERSION`
|
||||
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 = "…"; }`; the flake itself never embeds one.
|
||||
#### Version in one configure
|
||||
|
||||
### Building with Nix
|
||||
|
||||
@@ -164,6 +155,17 @@ nix build .#packages.x86_64-linux.default # needs a Linux machine/builders
|
||||
nix build .#packages.aarch64-linux.default
|
||||
```
|
||||
|
||||
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" nix build --impure .#default
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
`nix build` runs the full offline test suite in its `checkPhase`; the
|
||||
live-portal test binaries (`test_api_client`, `test_license_service`) are
|
||||
excluded. `nix flake check` builds the package and therefore also runs the
|
||||
@@ -225,7 +227,7 @@ code --install-extension vadimcn.vscode-lldb
|
||||
Tasks are preconfigured in `.vscode/tasks.json`:
|
||||
|
||||
| Task | Shortcut | What it does |
|
||||
|------|----------|-------------|
|
||||
| ------ | ---------- | ------------- |
|
||||
| `build-debug` | `Cmd+Shift+B` | Configure + build debug |
|
||||
| `build-release` | — | Configure + build release |
|
||||
| `build-tests` | — | Build test target only |
|
||||
@@ -295,7 +297,7 @@ TEST(my_new_test)
|
||||
RUN_ALL_TESTS()
|
||||
```
|
||||
|
||||
3. Add the file to the `tests` target in `CMakeLists.txt`:
|
||||
1. Add the file to the `tests` target in `CMakeLists.txt`:
|
||||
|
||||
```cmake
|
||||
add_executable(tests
|
||||
@@ -306,7 +308,7 @@ add_executable(tests
|
||||
)
|
||||
```
|
||||
|
||||
4. Rebuild and run:
|
||||
1. Rebuild and run:
|
||||
|
||||
```sh
|
||||
cmake --build build --target tests
|
||||
@@ -332,7 +334,7 @@ The app uses SDL3's callback system (`SDL_MAIN_USE_CALLBACKS`) instead of a
|
||||
manual event loop. SDL calls these functions for you:
|
||||
|
||||
| Callback | Runs | Purpose |
|
||||
|----------|------|---------|
|
||||
| ---------- | ------ | --------- |
|
||||
| `SDL_AppInit` | Once at startup | Init SDL, create window + renderer, push first page |
|
||||
| `SDL_AppEvent` | Per event | Forward events to the active page |
|
||||
| `SDL_AppIterate` | Per frame | Update + render the active page |
|
||||
@@ -416,7 +418,7 @@ settings sub-pages (e.g., Settings → AudioOptions → DevicePicker).
|
||||
## Pages
|
||||
|
||||
| Page | Purpose | Status |
|
||||
|------|---------|--------|
|
||||
| ------ | --------- | -------- |
|
||||
| Home | Logo, menu selection (Play, Settings) | Done |
|
||||
| Settings | Title-only settings stub | Done |
|
||||
| ScrollerPage | Main screen: scrolling text with shaders + audio | — |
|
||||
@@ -436,7 +438,7 @@ settings sub-pages (e.g., Settings → AudioOptions → DevicePicker).
|
||||
Global keybinds, active from any screen:
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| ----- | -------- |
|
||||
| TAB | Return to Home screen |
|
||||
| UP Arrow | Move selection up (wraps) |
|
||||
| DOWN Arrow | Move selection down (wraps) |
|
||||
@@ -458,7 +460,7 @@ Decouples layout from display size.
|
||||
### Shaders
|
||||
|
||||
| Shader | Purpose |
|
||||
|--------|---------|
|
||||
| -------- | --------- |
|
||||
| scroller.vert/frag | Text with font texture; uniform `u_textColor` |
|
||||
| text.vert/frag | Non-scrolling text elements |
|
||||
| solid_color.vert/frag | Flat color (backgrounds, UI) |
|
||||
@@ -479,7 +481,7 @@ Decouples layout from display size.
|
||||
SQLite at `~/.local/share/radiantwave/database.sqlite` (WAL mode).
|
||||
|
||||
| Table | Purpose |
|
||||
|-------|---------|
|
||||
| ------- | --------- |
|
||||
| app_config | Key-value store: license, email, audio device, volume |
|
||||
| logs | Application logs |
|
||||
| proclamations | Synced proclamation text |
|
||||
@@ -492,6 +494,7 @@ SQLite at `~/.local/share/radiantwave/database.sqlite` (WAL mode).
|
||||
## Network
|
||||
|
||||
D-Bus integration via sdbus-c++:
|
||||
|
||||
- Async WiFi scan
|
||||
- WPA/WPA2 connection
|
||||
- Captive portal detection
|
||||
@@ -499,7 +502,7 @@ D-Bus integration via sdbus-c++:
|
||||
### Remote APIs
|
||||
|
||||
| Endpoint | Purpose |
|
||||
|----------|---------|
|
||||
| ---------- | --------- |
|
||||
| `portal.radiantwavetech.com/api/v2/proclamations` | Sync proclamations |
|
||||
| `files.radiantwavetech.com/files/` | Audio file downloads |
|
||||
| HTTP API | License validation |
|
||||
@@ -563,7 +566,7 @@ status to decide which page to show.
|
||||
## Rebuild Progress
|
||||
|
||||
| Component | Status |
|
||||
|-----------|--------|
|
||||
| ----------- | -------- |
|
||||
| Entry / Event Loop | Done |
|
||||
| Window + Renderer | Done |
|
||||
| Page System | Done |
|
||||
@@ -595,6 +598,12 @@ status to decide which page to show.
|
||||
- `build/` is gitignored. Do not commit build artifacts.
|
||||
- Use the Nix dev shell or ensure SDL3 is installed for building.
|
||||
- CI uses Gitea Actions on a self-hosted host runner (`build-server`); the
|
||||
workflow is `.gitea/workflows/build.yml`.
|
||||
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 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.
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
use flake
|
||||
@@ -1,66 +0,0 @@
|
||||
# This action builds the `dev` branch any time a Pull Request is submitted to dev.
|
||||
# The purpose of this is two fold:
|
||||
# - Prevent breaking changes from being merged to dev
|
||||
# - Automates the building of RadiantWave-Dev which can then be uploaded
|
||||
# to the Attic server, and the development machines can pull prebuilt
|
||||
# binaries instead of building everything from source, resulting in faster
|
||||
# updates, once updates work again that is...
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
pull_request:
|
||||
branches:
|
||||
- dev
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: neils-server-runner
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Test
|
||||
run: |
|
||||
. /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh
|
||||
nix develop --command go test ./internal/mixer/ -v -timeout 30s
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
. /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh
|
||||
nix build -L
|
||||
echo "Working dir: $(pwd)"
|
||||
echo "Result symlink: $(readlink -f result)"
|
||||
find result/ -type f
|
||||
|
||||
- name: Notify
|
||||
if: always()
|
||||
uses: https://github.com/dawidd6/action-send-mail@v3
|
||||
with:
|
||||
server_address: smtp.gmail.com
|
||||
server_port: 465
|
||||
username: ${{ secrets.MAIL_USERNAME }}
|
||||
password: ${{ secrets.MAIL_PASSWORD }}
|
||||
subject: "RadiantWave build ${{ job.status }}"
|
||||
to: cnclack@pm.me
|
||||
from: ci@radiantwavetech.com
|
||||
body: "Build ${{ job.status }} for commit ${{ github.sha }} on branch dev"
|
||||
|
||||
# - name: Trigger KioskConfig dev build
|
||||
# if: success()
|
||||
# run: |
|
||||
# curl -X POST \
|
||||
# -H "Authorization: token ${{ secrets.TOKEN }}" \
|
||||
# -H "Content-Type: application/json" \
|
||||
# -d '{"ref": "main"}' \
|
||||
# https://codeberg.org/api/v1/repos/RhemaWave-Technologies/KioskConfig/actions/workflows/update-dev.yaml/dispatches
|
||||
|
||||
# - name: Trigger KioskConfig dev build
|
||||
# if: success()
|
||||
# run: |
|
||||
# curl -X POST \
|
||||
# -H "Authorization: token ${{ secrets.FORGEJO_TOKEN }}" \
|
||||
# -H "Accept: application/json" \
|
||||
# -H "Content-Type: application/json" \
|
||||
# https://codeberg.org/api/v1/repos/RhemaWave-Technologies/KioskConfig/dispatches \
|
||||
# -d '{"type": "radiantwave-dev-built"}'
|
||||
@@ -1,63 +0,0 @@
|
||||
# This action builds the `dev` branch any time a Pull Request is submitted to dev.
|
||||
# The purpose of this is two fold:
|
||||
# - Prevent breaking changes from being merged to dev
|
||||
# - Automates the building of RadiantWave-Dev which can then be uploaded
|
||||
# to the Attic server, and the development machines can pull prebuilt
|
||||
# binaries instead of building everything from source, resulting in faster
|
||||
# updates, once updates work again that is...
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: neils-server-runner
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Test
|
||||
run: |
|
||||
. /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh
|
||||
nix develop --command go test ./internal/mixer/ -v -timeout 30s
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
. /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh
|
||||
nix build -L
|
||||
echo "Working dir: $(pwd)"
|
||||
echo "Result symlink: $(readlink -f result)"
|
||||
find result/ -type f
|
||||
|
||||
- name: Notify
|
||||
if: always()
|
||||
uses: https://github.com/dawidd6/action-send-mail@v3
|
||||
with:
|
||||
server_address: smtp.gmail.com
|
||||
server_port: 465
|
||||
username: ${{ secrets.MAIL_USERNAME }}
|
||||
password: ${{ secrets.MAIL_PASSWORD }}
|
||||
subject: "RadiantWave build ${{ job.status }}"
|
||||
to: cnclack@pm.me
|
||||
from: ci@radiantwavetech.com
|
||||
body: "Build ${{ job.status }} for commit ${{ github.sha }} on branch main"
|
||||
|
||||
# - name: Trigger KioskConfig dev build
|
||||
# if: success()
|
||||
# run: |
|
||||
# curl -X POST \
|
||||
# -H "Authorization: token ${{ secrets.TOKEN }}" \
|
||||
# -H "Content-Type: application/json" \
|
||||
# -d '{"ref": "main"}' \
|
||||
# https://codeberg.org/api/v1/repos/RhemaWave-Technologies/KioskConfig/actions/workflows/update-dev.yaml/dispatches
|
||||
|
||||
# - name: Trigger KioskConfig main build
|
||||
# if: success()
|
||||
# run: |
|
||||
# curl -X POST \
|
||||
# -H "Authorization: token ${{ secrets.FORGEJO_TOKEN }}" \
|
||||
# -H "Accept: application/json" \
|
||||
# -H "Content-Type: application/json" \
|
||||
# https://codeberg.org/api/v1/repos/RhemaWave-Technologies/KioskConfig/dispatches \
|
||||
# -d '{"type": "radiantwave-dev-built"}'
|
||||
@@ -1,261 +0,0 @@
|
||||
# RadiantWave — Codebase Architecture
|
||||
|
||||
## What This Is
|
||||
|
||||
A kiosk application for "RadiantWave" wellness technology. It displays scrolling
|
||||
proclamation text rendered with custom shaders, plays audio at solfeggio
|
||||
frequencies, and manages WiFi/provisioning on a headless Linux kiosk.
|
||||
|
||||
**Core libraries:** SDL2, OpenGL 3.3, SQLite (GORM), D-Bus (NetworkManager).
|
||||
**Build system:** Nix flake. **CI:** Forgejo on a self-hosted runner.
|
||||
|
||||
---
|
||||
|
||||
## Application Lifecycle
|
||||
|
||||
```
|
||||
rhemawave.go → internal/application.Run()
|
||||
```
|
||||
|
||||
`Run()` performs:
|
||||
1. SDL2 init (video, audio, events)
|
||||
2. Create window with `SDL_WINDOW_OPENGL | SDL_WINDOW_SHOWN`
|
||||
3. OpenGL 3.3 context
|
||||
4. ShaderManager singleton init
|
||||
5. FontManager singleton init
|
||||
6. Mixer singleton init
|
||||
7. SQLite connect + GORM auto-migrate
|
||||
8. Sync proclamations from remote API
|
||||
9. Start audio download goroutine
|
||||
10. Enter SDL event loop
|
||||
|
||||
The event loop polls SDL events, calls `Router.Update()` + `Router.Render()`,
|
||||
and runs `checkState()` to drive the provisioning state machine.
|
||||
|
||||
---
|
||||
|
||||
## Provisioning State Machine
|
||||
|
||||
Defined in `internal/application/application.go`:
|
||||
|
||||
```
|
||||
FIRST_RUN → NEEDS_INTERNET → NEEDS_LICENSE_KEY → NEEDS_EMAIL_ADDRESS → DOWNLOADING_ASSETS → READY
|
||||
```
|
||||
|
||||
Each state maps to a page. `checkState()` evaluates DB values and network
|
||||
status to decide which page to show.
|
||||
|
||||
---
|
||||
|
||||
## Page System (`internal/page/`)
|
||||
|
||||
All pages implement the `Page` interface:
|
||||
|
||||
```go
|
||||
type Page interface {
|
||||
Init(ctx *PageCtx)
|
||||
HandleEvent(event sdl.Event) error
|
||||
Update(dt float64)
|
||||
Render()
|
||||
Destroy()
|
||||
}
|
||||
```
|
||||
|
||||
`PageCtx` provides: `*gorm.DB`, screen dimensions, `DownloadDone` channel,
|
||||
`NavigateTo(pageType)` callback.
|
||||
|
||||
`Router` (`router.go`) maps `pageType` enum → concrete page struct, manages
|
||||
lifecycle (Destroy old → Init new).
|
||||
|
||||
| Page | Purpose |
|
||||
|------|---------|
|
||||
| `Home` | Version info, download progress, license status, F2/F3 keybinds |
|
||||
| `ScrollerPage` | **Main screen:** scrolling proclamation text with shaders, line patterns, color patterns, audio playback, velocity control |
|
||||
| `SettingsPage` | Menu: audio, WiFi, proclamations, license, email, logs, volume |
|
||||
| `AudioOptions` | Audio device selection (via PipeWire pactl), on/off, volume |
|
||||
| `ProclamationOptions` | Browse/search/select proclamations from DB |
|
||||
| `WiFiSetupPage` | WiFi scan, WPA connect, password entry |
|
||||
| `LicenseKeyPage` | License key text input, saves to DB |
|
||||
| `EmailAddressPage` | Email text input with regex validation |
|
||||
| `LogViewerPage` | Scrollable log viewer from DB |
|
||||
| `DownloadingAssets` | Stub during download phase |
|
||||
| `Message` | Generic message page (stub) |
|
||||
|
||||
---
|
||||
|
||||
## OpenGL Rendering
|
||||
|
||||
### Virtual Framebuffer (`internal/graphics/virtual_framebuffer.go`)
|
||||
Renders to a 1920x1080 FBO, then `glBlitFramebuffer()` to the native window
|
||||
resolution. This decouples layout from display size.
|
||||
|
||||
### Shaders (`share/rhemawave/shaders/`)
|
||||
| Shader | Purpose |
|
||||
|--------|---------|
|
||||
| `scroller.vert/frag` | Text with font texture; uniform `u_textColor` for per-word coloring |
|
||||
| `text.vert/frag` | Non-scrolling text elements |
|
||||
| `solid_color.vert/frag` | Flat color (backgrounds, UI) |
|
||||
|
||||
---
|
||||
|
||||
## Font System (`internal/fontManager/`)
|
||||
|
||||
**FontManager** (singleton) wraps SDL_ttf + OpenGL textures:
|
||||
- Loads TTF/OTF fonts, creates GL texture atlas per font.
|
||||
- `GetGlyph(fontName, rune)` → glyph metrics (advance, bearing, texture coords).
|
||||
- `RenderString(fontName, text, x, y, color, scale)` → renders to GL.
|
||||
- **Scrambled font mode:** Characters are spatially rearranged in the atlas, creating visual "frequency signatures" when rendered.
|
||||
|
||||
### Fonts (`share/rhemawave/fonts/`)
|
||||
| File | Use |
|
||||
|------|-----|
|
||||
| `Phibonacci_regular.otf` | Decorative display font |
|
||||
| `Roboto-Regular.ttf` | Clean sans-serif (UI) |
|
||||
| `SpaceMonoNerdFont-Regular.ttf` | Monospace with icons |
|
||||
|
||||
---
|
||||
|
||||
## Pattern System (`internal/pattern/`)
|
||||
|
||||
### Line Patterns (`internal/pattern/line/`)
|
||||
`LinePattern` interface: `GetNextLine(words, wordIndex, ctx) []string`.
|
||||
Registered via `init()` (plugin-style Registry map).
|
||||
|
||||
| Pattern | Behavior |
|
||||
|---------|----------|
|
||||
| Fibonacci | Oscillating sequence (1,2,3,5,8,13,21) for words-per-line; creates wave in 2D |
|
||||
| Sine Wave | Sine function → line width as % of screen; 5 cycles over 67 lines |
|
||||
|
||||
### Color Patterns (`internal/pattern/color/`)
|
||||
Distributes colors across words using Fibonacci-oscillating durations
|
||||
(1,1,2,3,5,8,13,21,34,55 words per color, then reverses).
|
||||
|
||||
---
|
||||
|
||||
## Audio System
|
||||
|
||||
### Mixer (`internal/mixer/`)
|
||||
Singleton wrapping SDL_mixer:
|
||||
- PipeWire sink enumeration via `pactl list sinks short`
|
||||
- Volume persistence to DB
|
||||
- Device switching with volume restore
|
||||
- `Play(wavPath)`, `Stop()`, `SwitchDevice(deviceName)`
|
||||
|
||||
### Audio Files
|
||||
`.wav` files are gitignored. Downloaded at runtime from
|
||||
`files.radiantwavetech.com`, verified against `checksums.md5`, stored in
|
||||
`~/.radiantwave/audio/`. A goroutine handles async download; a channel
|
||||
signals completion.
|
||||
|
||||
### Solfeggio Frequencies
|
||||
9 presets (174Hz–963Hz), each mapped to a "healing intention". Stored in DB,
|
||||
selectable in AudioOptions page.
|
||||
|
||||
---
|
||||
|
||||
## Database (`internal/db/`)
|
||||
|
||||
SQLite at `~/.local/share/rhemawave/database.sqlite` (WAL mode).
|
||||
|
||||
| Model | Purpose |
|
||||
|-------|---------|
|
||||
| `AppConfig` | Key-value store: license key, email, audio device, volume, download state |
|
||||
| `Log` | Application logs (level, message, timestamp) |
|
||||
| `Proclamation` | Synced proclamation text (title, content, category, intent colors) |
|
||||
| `ProclamationCategory` | Category metadata |
|
||||
| `SolfeggioFrequency` | 9 frequency presets |
|
||||
| `AudioFile` | Downloaded audio files (path, checksum, status) |
|
||||
|
||||
---
|
||||
|
||||
## Network (`internal/network/`)
|
||||
|
||||
D-Bus integration via `gonetworkmanager`:
|
||||
- Async WiFi scan (channel-based results)
|
||||
- WPA/WPA2 connection support
|
||||
- Captive portal detection
|
||||
|
||||
### Remote APIs
|
||||
| Endpoint | Purpose |
|
||||
|----------|---------|
|
||||
| `portal.radiantwavetech.com/api/v1/proclamations` | Sync proclamations |
|
||||
| `files.radiantwavetech.com/files/` | Audio file downloads |
|
||||
| HTTP API | License validation |
|
||||
|
||||
Generic HTTP client in `internal/netreq/netReq.go`: typed `GetJSON[T]()` and
|
||||
`Download()` with progress callback.
|
||||
|
||||
---
|
||||
|
||||
## Assets (`share/rhemawave/`)
|
||||
|
||||
Copied into nix store at build time:
|
||||
- `fonts/` — 3 font files (see above)
|
||||
- `shaders/` — 3 GLSL shader pairs
|
||||
- `proclamations/home/` — 16 text files (healing, wellness, productivity)
|
||||
- `proclamations/commercial/` — 1 text file
|
||||
- `tos/` — 3 HTML files (Welcome, Notice, Getting Started)
|
||||
|
||||
---
|
||||
|
||||
## C++ Rebuild Map
|
||||
|
||||
These are the components that need to be reimplemented in C++ with CMake/Ninja:
|
||||
|
||||
| Component | Go Package | C++ Equivalent | External Deps |
|
||||
|-----------|-----------|----------------|---------------|
|
||||
| Entry / Event Loop | `rhemawave.go`, `application.go` | `main.cpp` | SDL2 |
|
||||
| Provisioning FSM | `application.go` | State machine class | — |
|
||||
| Page Router | `page/router.go` | Router class | — |
|
||||
| Page (11 types) | `page/*.go` | 11 page classes implementing Page interface | — |
|
||||
| OpenGL Context | `application.go` | GL context init | SDL2, OpenGL 3.3 |
|
||||
| Virtual Framebuffer | `graphics/virtual_framebuffer.go` | FBO + blit | OpenGL |
|
||||
| Shader Manager | `graphics/shaderManager.go` | Shader class | OpenGL |
|
||||
| Font Manager | `fontManager/fontManager.go` | Font atlas class | SDL_ttf, OpenGL |
|
||||
| Line Patterns | `pattern/line/*.go` | Pattern classes + registry | — |
|
||||
| Color Patterns | `pattern/color/*.go` | Color pattern class | — |
|
||||
| Mixer | `mixer/mixer.go` | Mixer class | SDL_mixer |
|
||||
| Network / WiFi | `network/network.go` | NetworkManager wrapper | D-Bus (sdbus-c++) |
|
||||
| Database | `db/*.go` | SQLite wrapper | SQLite3 (direct) or ORM |
|
||||
| HTTP Client | `netreq/netReq.go` | HTTP client | libcurl or cpp-httplib |
|
||||
| Logging | `logger/logger.go` | Logger class | — |
|
||||
| Config / App State | `db/models.go` | Config struct + DB | SQLite3 |
|
||||
|
||||
---
|
||||
|
||||
## Build & Run
|
||||
|
||||
Dev shell via direnv (`use flake` in `.envrc`). Inside nix dev shell:
|
||||
|
||||
```sh
|
||||
go build -o bin/rhemawave rhemawave.go # Build
|
||||
./bin/rhemawave # Run
|
||||
nix build -L # Nix build (CI)
|
||||
```
|
||||
|
||||
CGO required (`CGO_ENABLED=1`). The nix shell sets `CGO_CFLAGS`/`CGO_LDFLAGS`
|
||||
for SDL2. Do not `go build` outside the nix shell.
|
||||
|
||||
## Tests
|
||||
|
||||
```sh
|
||||
go test ./internal/mixer/
|
||||
```
|
||||
|
||||
CI runs `go test` then `nix build -L`.
|
||||
|
||||
## Branch Model
|
||||
|
||||
`dev` → `main` via PRs. CI in `.forgejo/workflows/`:
|
||||
- `build-dev.yml`: PRs to `dev`
|
||||
- `build-prod.yml`: PRs to `main`
|
||||
|
||||
Both run `nix build -L` on self-hosted `neils-server-runner`.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- `dev_scripts/reset.sh` referenced in nix devShell hook does not exist.
|
||||
- `share/rhemawave/audio/*.wav` is gitignored — never commit audio assets.
|
||||
- `vendor/` is committed — run `go mod vendor` after `go mod tidy`.
|
||||
- CI uses a self-hosted Forgejo runner, not GitHub-hosted runners.
|
||||
- `packaging/` contains a kiosk-specific updater, not part of the main build.
|
||||
@@ -1,24 +0,0 @@
|
||||
FROM ubuntu:24.04
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
ENV USER=root
|
||||
ENV NIX_CONFIG="experimental-features = nix-command flakes"
|
||||
ENV PATH="/nix/var/nix/profiles/default/bin:${PATH}"
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
xz-utils \
|
||||
ca-certificates \
|
||||
git \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
rsync \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Nix
|
||||
RUN curl -L https://nixos.org/nix/install | sh -s -- --daemon
|
||||
|
||||
# Install Go
|
||||
RUN nix profile install nixpkgs#go
|
||||
|
||||
WORKDIR /workspace
|
||||
@@ -1,90 +0,0 @@
|
||||
#+TITLE: Radiantwave & KioskConfig Development & Release Pipeline
|
||||
#+AUTHOR: Neil C.
|
||||
#+DATE: 2026-06-23
|
||||
#+OPTIONS: toc:nil num:nil
|
||||
#+TAGS: development nixos radiantwave kiosk automation
|
||||
|
||||
* Radiant Wave
|
||||
|
||||
* Full Pipeline Workflow (Detailed)
|
||||
|
||||
** Development Phase
|
||||
1. Create a new branch for the issue
|
||||
2. Implement the changes for the issue
|
||||
|
||||
** Integration to =dev=
|
||||
3. Open a pull request to =dev=
|
||||
4. CI: Run =nix build= to verify no errors
|
||||
5. CI: Run linting and unit tests
|
||||
6. CI: Auto-update =rhemawave-dev= flake input
|
||||
7. CI: Build all =dev= VMs in parallel (Kiosk, Commercial, RPi)
|
||||
8. Merge the pull request to =dev= after CI passes
|
||||
|
||||
** Testing Phase
|
||||
9. Run all VMs (Kiosk, Commercial, RPi) and test all functionality of the application on each VM
|
||||
10. Install =dev-iso= to a test machine and verify installation and application functionality
|
||||
|
||||
** Release to =main=
|
||||
11. Open a pull request from =dev= to =main=
|
||||
12. CI: Build all =main= VMs in parallel (Kiosk, Commercial, RPi)
|
||||
13. CI: Build all ISO images in parallel
|
||||
14. CI: Auto-rename ISO images to format =radiantwave-{system}-{YYYY-MM-DD}-{git-hash}.iso=
|
||||
15. CI: Upload ISO images to file server
|
||||
16. CI: Move old ISO images to =archives/= on file server
|
||||
17. Merge the pull request to =main= after CI passes
|
||||
18. Tag the release (optional, for traceability)
|
||||
|
||||
* Automation Opportunities
|
||||
|
||||
This section describes which parts of the workflow can be automated via CI/CD, and how.
|
||||
|
||||
** Group 1: Development Validation (Steps 3-8)
|
||||
- =What to automate=: PR triggering, =nix build=, linting, unit tests, flake updates, dev VM builds
|
||||
- =How=: GitHub Actions workflow triggered on PR to =dev=
|
||||
- =Why=: Catch integration errors before manual testing; ensure =dev= is always buildable
|
||||
|
||||
** Group 2: Testing (Steps 9-10)
|
||||
- =What to automate=: VM functionality testing, ISO installation verification
|
||||
- =How=: Scripted test suites, automated ISO installation in VMs
|
||||
- =Why=: Reduce manual toil; increase test coverage and consistency
|
||||
|
||||
** Group 3: Release Pipeline (Steps 11-16)
|
||||
- =What to automate=: Main VM/ISO builds, ISO renaming, file server uploads, archiving
|
||||
- =How=: GitHub Actions workflow triggered on PR to =main=
|
||||
- =Why=: Ensure consistent releases; eliminate human error in naming/uploading
|
||||
|
||||
* Preflight Checklist
|
||||
|
||||
- [ ] Create a new branch for the issue
|
||||
- [ ] Implement the changes for the issue
|
||||
- [ ] Open a pull request to =dev=
|
||||
- [ ] Merge the pull request to =dev= after CI passes
|
||||
- [ ] Run all VMs (Kiosk, Commercial, RPi) and test all functionality of the application on each VM
|
||||
- [ ] Install =dev-iso= to a test machine and verify installation and application functionality
|
||||
- [ ] Open a pull request from =dev= to =main=
|
||||
- [ ] Merge the pull request to =main= after CI passes
|
||||
- [ ] Tag the release (optional, for traceability)
|
||||
|
||||
* Automation Task List
|
||||
|
||||
** Group 1: Development Validation
|
||||
- [ ] Set up GitHub Actions workflow for PRs to =dev=
|
||||
- [ ] Configure =nix build= step
|
||||
- [ ] Add linting and unit test steps
|
||||
- [ ] Add flake auto-update step
|
||||
- [ ] Configure parallel VM builds (Kiosk, Commercial, RPi)
|
||||
- [ ] Verify workflow blocks PR merge on failure
|
||||
|
||||
** Group 2: Testing
|
||||
- [ ] Create automated test scripts for VM functionality
|
||||
- [ ] Set up automated ISO installation testing
|
||||
- [ ] Integrate with CI for optional automated testing
|
||||
|
||||
** Group 3: Release Pipeline
|
||||
- [ ] Set up GitHub Actions workflow for PRs to =main=
|
||||
- [ ] Configure parallel VM builds
|
||||
- [ ] Configure parallel ISO builds
|
||||
- [ ] Add ISO auto-renaming step (using =git rev-parse HEAD= and =date +%Y-%m-%d=)
|
||||
- [ ] Add file server upload step (=rsync= or similar)
|
||||
- [ ] Add archive old ISOs step
|
||||
- [ ] Verify workflow blocks PR merge on failure
|
||||
@@ -1,19 +0,0 @@
|
||||
* Virtual Machine Testing
|
||||
To be completed after updating rhemawave-dev, deleting nixos.qcow2, and building rhemawavekiosk-dev
|
||||
|
||||
- [ ] Closure builds
|
||||
- [ ] VM Starts
|
||||
- [ ] RadiantWave starts
|
||||
- [ ] WiFi screen does NOT appear (no wifi nic in vm)
|
||||
- [ ] Email Address scren appears
|
||||
- [ ] I can type an email address
|
||||
- [ ] I cannot submit an incomplete email
|
||||
- [ ] I can submit a complete email
|
||||
- [ ] License Key screen appears
|
||||
- [ ] I can type a license key
|
||||
- [ ] I cannot submit an incomplete license
|
||||
- [ ] I can submit a complete license
|
||||
- [ ] Home screen appears
|
||||
- [ ] Version string is correct
|
||||
- [ ] Status: Downloading files
|
||||
-
|
||||
@@ -1,44 +0,0 @@
|
||||
- [X] Version string is correct
|
||||
- [X] Status shows READY or Downloading Assets
|
||||
- [X] Email address is present and correct
|
||||
- [X] License key is present and correct
|
||||
- [X] Settings menu opens
|
||||
- [X] Logs open
|
||||
- [X] No proprietary data is exposed in logs
|
||||
- [X] Logs are colored correctly
|
||||
- [X] WiFi Setup opens
|
||||
- [X] WiFi page shows "Something went wrong" because there are no WiFi nics available
|
||||
- [X] License key opens
|
||||
- [X] License key displays current license key
|
||||
- [X] Backspace deletes characters
|
||||
- [X] I can type in a new license key
|
||||
- [X] I can submit a license key
|
||||
- [X] License Key shows new license key
|
||||
- [X] I cannot submit an incomplete license key
|
||||
- [X] Email address opens
|
||||
- [X] Email addrss displays current email address
|
||||
- [X] Backspace deletes characters
|
||||
- [X] I can type a new email address
|
||||
- [X] I can submit an email address
|
||||
- [X] Email address shows new email address
|
||||
- [X] I cannot submit an invalid email address
|
||||
- [X] Audio opens
|
||||
- [X] I can toggle audio
|
||||
- [X] At least one audio device is listed
|
||||
- [X] Volume is at 100%
|
||||
- [X] I can change the volume
|
||||
- [X] Returning to the Audio page shows toggle and volume updated values
|
||||
- [X] Proclamation Browser opens
|
||||
- [X] Proclamation Browser is populated
|
||||
- [X] Space bar opens and closes categories
|
||||
- [X] Enter opens and closes categories
|
||||
- [X] Space toggles a proclamation
|
||||
- [X] Enter toggles a proclamation
|
||||
- [X] Typing characters starts a search
|
||||
- [X] I can search for Confidence and ADHD
|
||||
- [X] Enter during search toggles selection
|
||||
- [X] Space during search toggles selection
|
||||
- [X] Returning to the Proclamation browser shows my saved proclamations
|
||||
- [X] Scroller opens and starts playing
|
||||
- [X] Audio is playing
|
||||
- [X] Colors appear correct
|
||||
@@ -1,42 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
### Changed (Major Refactors)
|
||||
- Mixer package: extract wpctl calls behind an interface
|
||||
|
||||
## [07112026-1710]
|
||||
|
||||
### Fixed
|
||||
- Play() deadlocks when called before device is open (recursive SwitchDevice -> Play)
|
||||
- Audio volume silently zeroed after switching output devices (Init used hardcoded 0.20; SwitchDevice did not restore saved volume)
|
||||
- SwitchDevice not persisted to database across restarts (caller now saves to AppConfig)
|
||||
- Device restore path passes invalid device name "system default" to SDL (now passes "" for default)
|
||||
- Stop() no-op when audio toggled off mid-playback (removed AudioEnabled guard from Stop)
|
||||
- App shows "READY" before audio files finish downloading (state machine now gates READY on download completion via DOWNLOADING_ASSETS state)
|
||||
- Download failure no longer silently allows READY (downloadFailed flag prevents premature READY)
|
||||
- App restarts repeatedly after downloads complete (checkState re-navigated to HOME, closing DownloadDone channel twice -> panic)
|
||||
- F2/F3 keybinds now work during DOWNLOADING_ASSETS (blocked only during early prerequisites: no wifi, no email, no license key)
|
||||
- Active device highlight incorrect when using default device (default device now maps to first SDL device)
|
||||
|
||||
### Added
|
||||
- Human-readable audio device names (Built-in Speakers, HDMI/TV/Monitor)
|
||||
- Unit tests for mixer package (parseSinks, parseSinkLine, ConvertVolume)
|
||||
- Regression tests for state machine (download gating, failure handling, prerequisite priority, non-blocking channel read)
|
||||
- Regression tests for mixer (Stop with AudioEnabled=false, SwitchDevice edge cases)
|
||||
- Regression tests for Home page (double-close guard, download failure path)
|
||||
|
||||
### Changed (Major Refactors)
|
||||
- Mixer package: inject audio backend dependencies for testability
|
||||
- SwitchDevice now takes *db.AppConfig to access saved volume
|
||||
- Audio files moved from ~/.local/share/rhemawave/audio to ~/.radiantwave/audio
|
||||
- State machine uses DOWNLOADING_ASSETS state before READY
|
||||
- checkState() skips re-navigation when transitioning between HOME-mapped states
|
||||
- network.init() logs warning instead of crashing when D-Bus is unavailable
|
||||
- CI now runs go test before nix build
|
||||
@@ -1,22 +0,0 @@
|
||||
services:
|
||||
builder:
|
||||
build: .
|
||||
working_dir: /workspace
|
||||
init: true
|
||||
|
||||
volumes:
|
||||
- .:/workspace
|
||||
- nix-store:/nix
|
||||
|
||||
command: >
|
||||
bash -lc '
|
||||
set -euo pipefail
|
||||
rm -rf build
|
||||
mkdir -p build
|
||||
OUT=$$(nix build . --print-out-paths)
|
||||
echo "Build output: $$OUT"
|
||||
rsync -a --chmod=ugo=rwX --no-owner --no-group "$$OUT"/ build/
|
||||
'
|
||||
|
||||
volumes:
|
||||
nix-store:
|
||||
Generated
-61
@@ -1,61 +0,0 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1776877367,
|
||||
"narHash": "sha256-EHq1/OX139R1RvBzOJ0aMRT3xnWyqtHBRUBuO1gFzjI=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "0726a0ecb6d4e08f6adced58726b95db924cef57",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
{
|
||||
description = "RhemaWave";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
||||
outputs =
|
||||
{
|
||||
self,
|
||||
nixpkgs,
|
||||
flake-utils,
|
||||
}:
|
||||
nixpkgs.lib.recursiveUpdate
|
||||
(flake-utils.lib.eachDefaultSystem (
|
||||
system:
|
||||
let
|
||||
pkgs = import nixpkgs {
|
||||
inherit system;
|
||||
config.allowUnfreePredicate = pkg: (nixpkgs.lib.getName pkg) == "rhemawave";
|
||||
};
|
||||
|
||||
buildInputs = with pkgs; [
|
||||
SDL2
|
||||
SDL2_ttf
|
||||
SDL2_image
|
||||
SDL2_mixer
|
||||
libGL
|
||||
sqlite
|
||||
dbus
|
||||
] ++ pkgs.lib.optionals pkgs.stdenv.isLinux [
|
||||
wayland
|
||||
wayland-protocols
|
||||
libxkbcommon
|
||||
libdecor
|
||||
libx11
|
||||
libxext
|
||||
];
|
||||
|
||||
nativeBuildInputs = with pkgs; [
|
||||
go
|
||||
pkg-config
|
||||
];
|
||||
|
||||
in
|
||||
{
|
||||
packages.default = pkgs.buildGoModule {
|
||||
pname = "rhemawave";
|
||||
version = "v1.1.1";
|
||||
src = ./.;
|
||||
|
||||
vendorHash = null;
|
||||
|
||||
inherit buildInputs nativeBuildInputs;
|
||||
|
||||
env.CGO_ENABLED = "1";
|
||||
|
||||
preBuild = ''
|
||||
export CGO_CFLAGS="$(pkg-config --cflags SDL2_mixer SDL2_ttf SDL2_image) $CGO_CFLAGS"
|
||||
export CGO_LDFLAGS="$(pkg-config --libs SDL2_mixer SDL2_ttf SDL2_image) $CGO_LDFLAGS"
|
||||
'';
|
||||
|
||||
ldflags = [
|
||||
"-s"
|
||||
"-w"
|
||||
];
|
||||
|
||||
postInstall = ''
|
||||
mkdir -p $out/share/rhemawave
|
||||
cp -r share/rhemawave/* $out/share/rhemawave/
|
||||
'';
|
||||
|
||||
meta = with pkgs.lib; {
|
||||
description = "RhemaWave kiosk application";
|
||||
license = licenses.unfree;
|
||||
platforms = platforms.linux;
|
||||
};
|
||||
};
|
||||
|
||||
devShells.default = pkgs.mkShell {
|
||||
inherit buildInputs nativeBuildInputs;
|
||||
|
||||
shellHook = ''
|
||||
export OLD_PS1="$PS1"
|
||||
export PS1="[nix-env] $OLD_PS1"
|
||||
|
||||
export CGO_CFLAGS="$(pkg-config --cflags SDL2_mixer SDL2_ttf SDL2_image) $CGO_CFLAGS"
|
||||
export CGO_LDFLAGS="$(pkg-config --libs SDL2_mixer SDL2_ttf SDL2_image) $CGO_LDFLAGS"
|
||||
|
||||
echo "RhemaWave dev shell"
|
||||
echo " go version: $(go version)"
|
||||
echo " CGO_ENABLED=1"
|
||||
echo ""
|
||||
echo "Build: go build -o bin/rhemawave rhemawave.go"
|
||||
echo "Run: ./bin/rhemawave"
|
||||
'';
|
||||
};
|
||||
}
|
||||
))
|
||||
{
|
||||
nixosModules.default = import ./nixos/module.nix self;
|
||||
|
||||
nixosConfigurations.test-vm = nixpkgs.lib.nixosSystem {
|
||||
system = "x86_64-linux";
|
||||
modules = [
|
||||
"${nixpkgs}/nixos/modules/virtualisation/qemu-vm.nix"
|
||||
self.nixosModules.default
|
||||
./nixos/vm.nix
|
||||
];
|
||||
};
|
||||
|
||||
packages.x86_64-linux.test-vm =
|
||||
self.nixosConfigurations.test-vm.config.system.build.vm;
|
||||
};
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
module rhemawavetech.com/rhemawave
|
||||
|
||||
go 1.24.0
|
||||
|
||||
toolchain go1.24.2
|
||||
|
||||
require github.com/veandco/go-sdl2 v0.4.40
|
||||
|
||||
require github.com/go-gl/mathgl v1.2.0
|
||||
|
||||
require github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71
|
||||
|
||||
require (
|
||||
github.com/Wifx/gonetworkmanager/v2 v2.2.0
|
||||
github.com/google/uuid v1.3.0
|
||||
)
|
||||
|
||||
require github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||
|
||||
require (
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.32 // indirect
|
||||
golang.org/x/text v0.31.0 // indirect
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
)
|
||||
@@ -1,24 +0,0 @@
|
||||
github.com/Wifx/gonetworkmanager/v2 v2.2.0 h1:kPstgsQtY8CmDOOFZd81ytM9Gi3f6ImzPCKF7nNhQ2U=
|
||||
github.com/Wifx/gonetworkmanager/v2 v2.2.0/go.mod h1:fMDb//SHsKWxyDUAwXvCqurV3npbIyyaQWenGpZ/uXg=
|
||||
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA=
|
||||
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw=
|
||||
github.com/go-gl/mathgl v1.2.0 h1:v2eOj/y1B2afDxF6URV1qCYmo1KW08lAMtTbOn3KXCY=
|
||||
github.com/go-gl/mathgl v1.2.0/go.mod h1:pf9+b5J3LFP7iZ4XXaVzZrCle0Q/vNpB/vDe5+3ulRE=
|
||||
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
|
||||
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/veandco/go-sdl2 v0.4.40 h1:fZv6wC3zz1Xt167P09gazawnpa0KY5LM7JAvKpX9d/U=
|
||||
github.com/veandco/go-sdl2 v0.4.40/go.mod h1:OROqMhHD43nT4/i9crJukyVecjPNYYuCofep6SNiAjY=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
@@ -1,548 +0,0 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/go-gl/gl/v3.3-core/gl"
|
||||
"github.com/veandco/go-sdl2/sdl"
|
||||
"github.com/veandco/go-sdl2/ttf"
|
||||
"gorm.io/gorm"
|
||||
"rhemawavetech.com/rhemawave/internal/db"
|
||||
"rhemawavetech.com/rhemawave/internal/download"
|
||||
"rhemawavetech.com/rhemawave/internal/fontManager"
|
||||
"rhemawavetech.com/rhemawave/internal/graphics"
|
||||
"rhemawavetech.com/rhemawave/internal/keybinds"
|
||||
"rhemawavetech.com/rhemawave/internal/license"
|
||||
"rhemawavetech.com/rhemawave/internal/logger"
|
||||
"rhemawavetech.com/rhemawave/internal/mixer"
|
||||
"rhemawavetech.com/rhemawave/internal/network"
|
||||
"rhemawavetech.com/rhemawave/internal/page"
|
||||
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
||||
"rhemawavetech.com/rhemawave/internal/sync"
|
||||
)
|
||||
|
||||
const (
|
||||
targetFPS = 60.0
|
||||
openGLMajorVersion = 3
|
||||
openGLMinorVersion = 3
|
||||
// volumeStepSize = 16
|
||||
// apiURL = "https://portal.rhemawavetech.com/api/v1"
|
||||
)
|
||||
|
||||
type State int
|
||||
|
||||
const (
|
||||
FIRST_RUN State = iota
|
||||
NEEDS_INTERNET
|
||||
NEEDS_LICENSE_KEY
|
||||
NEEDS_EMAIL_ADDRESS
|
||||
DOWNLOADING_ASSETS
|
||||
READY
|
||||
)
|
||||
|
||||
type Application struct {
|
||||
Window *sdl.Window
|
||||
License *license.Response
|
||||
pageStack []page.Page
|
||||
pendingAction func()
|
||||
running bool
|
||||
DB *gorm.DB
|
||||
PageRouter *page.Router
|
||||
state State
|
||||
downloadsComplete bool
|
||||
downloadFailed bool
|
||||
}
|
||||
|
||||
var lastNetworkCheckTime time.Time
|
||||
var networkIsUp bool
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Main Entry Point
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
func Run() {
|
||||
var err error
|
||||
var homeDir string
|
||||
var dataDir string
|
||||
var DB *gorm.DB
|
||||
|
||||
networkIsUp = false
|
||||
|
||||
logger.Info("Starting Application")
|
||||
|
||||
homeDir, err = os.UserHomeDir()
|
||||
if err != nil {
|
||||
logger.Error("unable to fetch user $HOME directory: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
dataDir = filepath.Join(homeDir, ".local", "share", "rhemawave")
|
||||
if err := os.MkdirAll(dataDir, 0755); err != nil {
|
||||
logger.Error("unable to create data directory %s %v", dataDir, err)
|
||||
return
|
||||
}
|
||||
|
||||
testFile := filepath.Join(dataDir, ".access_test")
|
||||
f, err := os.Create(testFile)
|
||||
if err != nil {
|
||||
logger.Error("data %s is not writeable: %v", dataDir, err)
|
||||
return
|
||||
}
|
||||
f.Close()
|
||||
os.Remove(testFile)
|
||||
|
||||
logger.AddFile(filepath.Join(dataDir, "log.txt"))
|
||||
logger.SetLevel = logger.LevelInfo
|
||||
|
||||
DB, err = db.Connect(filepath.Join(dataDir, "database.sqlite"))
|
||||
if err != nil {
|
||||
logger.Error("application failed to connect to the database: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err = db.Migrate(DB); err != nil {
|
||||
logger.Error("application failed to perform database migrations: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
firstRun := false
|
||||
|
||||
// Populate the initial Config cache OR create defaults
|
||||
cfg, err := db.GetConfig(DB)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
firstRun = true
|
||||
cfg = &db.AppConfig{
|
||||
Model: gorm.Model{ID: 1},
|
||||
DataDir: dataDir,
|
||||
AssetsDir: dataDir,
|
||||
SystemLevel: "BASIC",
|
||||
SelectedAudioDevice: "default",
|
||||
InitialFontSize: 128,
|
||||
StandardFontSize: 32,
|
||||
LinePattern: "fibonacci",
|
||||
AudioEnabled: true,
|
||||
SavedVolume: 1.0,
|
||||
}
|
||||
|
||||
if err := DB.Create(cfg).Error; err != nil {
|
||||
logger.Error("failed to create config: %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.AddDatabase(DB)
|
||||
|
||||
app := &Application{
|
||||
DB: DB,
|
||||
}
|
||||
|
||||
logger.Debug("==== Debug Statement ====")
|
||||
|
||||
if firstRun {
|
||||
app.state = FIRST_RUN
|
||||
}
|
||||
|
||||
w, h := app.initializeSDLAndOpenGL()
|
||||
defer app.cleanup()
|
||||
|
||||
app.initializeManagers()
|
||||
app.initializeAudio()
|
||||
// Create a buffered channel so log writes don't slow down downloads.
|
||||
downloadProgressChan := make(chan download.DownloadStatus, 1000)
|
||||
downloadDone := make(chan struct{})
|
||||
|
||||
app.PageRouter = page.NewRouter(page.PageCtx{
|
||||
DbPtr: app.DB,
|
||||
W: w,
|
||||
H: h,
|
||||
MessageChannel: downloadProgressChan,
|
||||
DownloadDone: downloadDone,
|
||||
})
|
||||
|
||||
// Sync the proclamations from the API to the Database
|
||||
sync.SyncProclamations(DB)
|
||||
sync.SyncCategories(DB)
|
||||
sync.SyncFrequencies(DB)
|
||||
|
||||
// audioFileNames := []string{
|
||||
// "OriginalAudio.wav",
|
||||
// "174Hz.wav",
|
||||
// "396Hz.wav",
|
||||
// "417Hz.wav",
|
||||
// "432Hz.wav",
|
||||
// "639Hz.wav",
|
||||
// "741Hz.wav",
|
||||
// "852Hz.wav",
|
||||
// "963Hz.wav",
|
||||
// "1111Hz.wav",
|
||||
// "14Hz_Binaural.wav",
|
||||
// "40Hz_Binaural.wav",
|
||||
// "AlphaWaves.wav",
|
||||
// "PinealGland.wav",
|
||||
// }
|
||||
|
||||
audioDir := filepath.Join(homeDir, ".radiantwave", "audio")
|
||||
|
||||
_, err = os.Stat(audioDir)
|
||||
if os.IsNotExist(err) {
|
||||
if err = os.MkdirAll(audioDir, 0755); err != nil {
|
||||
logger.Error("unable to create audio directory: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
go download.DownloadAudioFiles(app.PageRouter.Context.MessageChannel, audioDir, "https://files.radiantwavetech.com/audio/")
|
||||
|
||||
app.runEventLoop()
|
||||
}
|
||||
|
||||
// computeState determines what state the application should be in based on
|
||||
// current conditions, without performing any navigation. It returns the first
|
||||
// unmet prerequisite in priority order: network > email > license > downloads > ready.
|
||||
func (app *Application) computeState() State {
|
||||
if app.state == READY {
|
||||
return READY
|
||||
}
|
||||
|
||||
// Check if downloads have finished (non-blocking)
|
||||
if !app.downloadsComplete {
|
||||
select {
|
||||
case <-app.PageRouter.Context.DownloadDone:
|
||||
app.downloadsComplete = true
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// Check if there is an internet connection
|
||||
if !networkIsUp {
|
||||
if lastNetworkCheckTime.IsZero() || time.Since(lastNetworkCheckTime) >= time.Second {
|
||||
lastNetworkCheckTime = time.Now()
|
||||
if !network.HasInternet() {
|
||||
return NEEDS_INTERNET
|
||||
}
|
||||
networkIsUp = true // internet confirmed, don't check again
|
||||
} else {
|
||||
return app.state // throttled, can't determine yet
|
||||
}
|
||||
}
|
||||
|
||||
// Check for email address
|
||||
config, err := db.GetConfig(app.DB)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// A rather annoying, insecure, but sometimes necessary "print the config" statement
|
||||
// data, err := json.MarshalIndent(config, "", " ")
|
||||
// if err != nil {
|
||||
// logger.Error("failed to marshal config: %v", err)
|
||||
// } else {
|
||||
// logger.Debug("AppConfig: %s", data)
|
||||
// }
|
||||
|
||||
if config.EmailAddress == "" {
|
||||
return NEEDS_EMAIL_ADDRESS
|
||||
}
|
||||
|
||||
// Check for license key
|
||||
config, err = db.GetConfig(app.DB)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if config.LicenseKey == "" {
|
||||
return NEEDS_LICENSE_KEY
|
||||
}
|
||||
|
||||
// All config prerequisites met -- now wait for downloads to finish
|
||||
if !app.downloadsComplete {
|
||||
return DOWNLOADING_ASSETS
|
||||
}
|
||||
|
||||
return READY
|
||||
}
|
||||
|
||||
// checkState keeps watch of the application state and sets pages
|
||||
// accordingly. This function runs once per frame.
|
||||
func (app *Application) checkState() {
|
||||
newState := app.computeState()
|
||||
|
||||
if newState == app.state {
|
||||
return
|
||||
}
|
||||
|
||||
oldState := app.state
|
||||
app.state = newState
|
||||
|
||||
// Both DOWNLOADING_ASSETS and READY map to the HOME page.
|
||||
// Skip re-navigation when transitioning between them to avoid
|
||||
// destroying and recreating the Home page (which would close
|
||||
// the already-closed DownloadDone channel).
|
||||
oldIsHome := oldState == DOWNLOADING_ASSETS || oldState == READY
|
||||
newIsHome := newState == DOWNLOADING_ASSETS || newState == READY
|
||||
if oldIsHome && newIsHome {
|
||||
return
|
||||
}
|
||||
|
||||
switch newState {
|
||||
case NEEDS_INTERNET:
|
||||
app.PageRouter.NavigateTo(page.WIFI)
|
||||
case NEEDS_EMAIL_ADDRESS:
|
||||
logger.Warning("No email address detected")
|
||||
app.PageRouter.NavigateTo(page.EMAIL_ADDRESS)
|
||||
case NEEDS_LICENSE_KEY:
|
||||
app.PageRouter.NavigateTo(page.LICENSE_KEY)
|
||||
case DOWNLOADING_ASSETS, READY:
|
||||
app.PageRouter.NavigateTo(page.HOME)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *Application) initializeSDLAndOpenGL() (W, H int32) {
|
||||
logger.Info("Initializing SDL")
|
||||
if err := sdl.Init(sdl.INIT_VIDEO | sdl.INIT_AUDIO); err != nil {
|
||||
logger.Fatal("SDL init failed: %v", err)
|
||||
}
|
||||
|
||||
logger.Info("Initializing TTF")
|
||||
if err := ttf.Init(); err != nil {
|
||||
logger.Fatal("TTF init failed: %v", err)
|
||||
}
|
||||
|
||||
sdl.GLSetAttribute(sdl.GL_CONTEXT_MAJOR_VERSION, openGLMajorVersion)
|
||||
sdl.GLSetAttribute(sdl.GL_CONTEXT_MINOR_VERSION, openGLMinorVersion)
|
||||
sdl.GLSetAttribute(sdl.GL_CONTEXT_PROFILE_MASK, sdl.GL_CONTEXT_PROFILE_CORE)
|
||||
sdl.ShowCursor(sdl.DISABLE)
|
||||
sdl.SetRelativeMouseMode(true)
|
||||
|
||||
logger.Info("Querying display mode")
|
||||
displayMode, err := sdl.GetDisplayMode(0, 0)
|
||||
if err != nil {
|
||||
logger.Fatal("Display mode query failed: %v", err)
|
||||
}
|
||||
if displayMode.W == 0 || displayMode.H == 0 {
|
||||
logger.Fatal("Invalid display dimensions: %dx%d", displayMode.W, displayMode.H)
|
||||
}
|
||||
logger.Info("Display: %dx%d @ %dHz", displayMode.W, displayMode.H, displayMode.RefreshRate)
|
||||
|
||||
logger.Info("Creating window")
|
||||
window, err := sdl.CreateWindow(
|
||||
"Rhema Wave",
|
||||
sdl.WINDOWPOS_UNDEFINED,
|
||||
sdl.WINDOWPOS_UNDEFINED,
|
||||
displayMode.W,
|
||||
displayMode.H,
|
||||
sdl.WINDOW_OPENGL|sdl.WINDOW_FULLSCREEN_DESKTOP|sdl.WINDOW_SHOWN,
|
||||
)
|
||||
if err != nil {
|
||||
logger.Fatal("Window creation failed: %v", err)
|
||||
}
|
||||
app.Window = window
|
||||
|
||||
logger.Info("Creating OpenGL context")
|
||||
if _, err := app.Window.GLCreateContext(); err != nil {
|
||||
logger.Fatal("OpenGL context creation failed: %v", err)
|
||||
}
|
||||
|
||||
logger.Info("Initializing go-gl")
|
||||
if err := gl.InitWithProcAddrFunc(sdl.GLGetProcAddress); err != nil {
|
||||
logger.Fatal("go-gl initialization failed: %v", err)
|
||||
}
|
||||
|
||||
logger.Info("OpenGL version: %s", gl.GoStr(gl.GetString(gl.VERSION)))
|
||||
logger.Info("OpenGL renderer: %s", gl.GoStr(gl.GetString(gl.RENDERER)))
|
||||
|
||||
if err := sdl.GLSetSwapInterval(1); err != nil {
|
||||
logger.Warning("Failed to enable vsync: %v", err)
|
||||
}
|
||||
|
||||
w, h := app.Window.GLGetDrawableSize()
|
||||
logger.Info("Drawable size: %dx%d", w, h)
|
||||
|
||||
logger.Info("Initializing virtual framebuffer (%dx%d -> %dx%d)",
|
||||
graphics.VirtualWidth, graphics.VirtualHeight, w, h)
|
||||
if err := graphics.InitVirtualFramebuffer(w, h); err != nil {
|
||||
logger.Fatal("Virtual framebuffer init failed: %v", err)
|
||||
}
|
||||
|
||||
app.initKeybinds()
|
||||
logger.Info("SDL and OpenGL initialization complete")
|
||||
return graphics.VirtualWidth, graphics.VirtualHeight
|
||||
}
|
||||
|
||||
func (app *Application) initializeManagers() {
|
||||
logger.Info("Initializing ShaderManager")
|
||||
config, err := db.GetConfig(app.DB)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
shaderDir := filepath.Join(config.AssetsDir, "shaders")
|
||||
logger.Info("Loading shaders from %s", shaderDir)
|
||||
if err := shaderManager.InitShaderManager(shaderDir); err != nil {
|
||||
logger.Fatal("ShaderManager initialization failed: %v", err)
|
||||
}
|
||||
|
||||
logger.Info("Initializing FontManager")
|
||||
if err := fontManager.InitFontManager(config); err != nil {
|
||||
logger.Fatal("FontManager initialization failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *Application) initializeAudio() {
|
||||
logger.Info("Initializing audio")
|
||||
config, err := db.GetConfig(app.DB)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := mixer.Init(config, app.refreshCache); err != nil {
|
||||
logger.Error("Unable to initialize audio device '%s': %v", config.SelectedAudioDevice, err)
|
||||
logger.Error("Audio will be unavailable until a device is configured")
|
||||
} else {
|
||||
logger.Info("Audio initialized with device: '%s'", config.SelectedAudioDevice)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *Application) refreshCache() {}
|
||||
|
||||
func (app *Application) cleanup() {
|
||||
logger.Info("Cleaning up")
|
||||
|
||||
if app.PageRouter != nil && app.PageRouter.ActivePage != nil {
|
||||
app.PageRouter.ActivePage.Destroy()
|
||||
}
|
||||
|
||||
graphics.DestroyVirtualFramebuffer()
|
||||
|
||||
if app.Window != nil {
|
||||
app.Window.Destroy()
|
||||
}
|
||||
|
||||
ttf.Quit()
|
||||
sdl.Quit()
|
||||
logger.Info("Cleanup complete")
|
||||
logger.Close()
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Event Loop
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
func (app *Application) runEventLoop() {
|
||||
if app.PageRouter == nil || app.PageRouter.ActivePage == nil {
|
||||
logger.Fatal("No initial page loaded, Router is empty")
|
||||
}
|
||||
const frameDelay = time.Second / targetFPS
|
||||
app.running = true
|
||||
lastTime := time.Now()
|
||||
logger.Info("Entering event loop")
|
||||
|
||||
for app.running {
|
||||
frameStart := time.Now()
|
||||
|
||||
app.checkState()
|
||||
app.handleEvents()
|
||||
if !app.running {
|
||||
break
|
||||
}
|
||||
|
||||
deltaTime := float32(time.Since(lastTime).Seconds())
|
||||
lastTime = time.Now()
|
||||
|
||||
app.updateCurrentPage(deltaTime)
|
||||
app.renderFrame()
|
||||
|
||||
frameTime := time.Since(frameStart)
|
||||
if frameTime < frameDelay {
|
||||
time.Sleep(frameDelay - frameTime)
|
||||
}
|
||||
}
|
||||
|
||||
logger.Info("Event loop exited")
|
||||
}
|
||||
|
||||
func (app *Application) handleEvents() {
|
||||
for event := sdl.PollEvent(); event != nil; event = sdl.PollEvent() {
|
||||
switch e := event.(type) {
|
||||
case *sdl.QuitEvent:
|
||||
app.running = false
|
||||
return
|
||||
|
||||
case *sdl.KeyboardEvent:
|
||||
if e.Type == sdl.KEYDOWN && e.Repeat == 0 {
|
||||
isCtrlPressed := (e.Keysym.Mod&sdl.KMOD_CTRL) != 0 || (e.Keysym.Mod&sdl.KMOD_GUI) != 0
|
||||
if e.Keysym.Sym == sdl.K_c && isCtrlPressed {
|
||||
continue
|
||||
}
|
||||
keybinds.PerformAction(e)
|
||||
}
|
||||
}
|
||||
|
||||
if currentPage := app.PageRouter.ActivePage; currentPage != nil {
|
||||
if err := currentPage.HandleEvent(&event); err != nil {
|
||||
logger.Error("Page event handler error: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (app *Application) updateCurrentPage(deltaTime float32) {
|
||||
if currentPage := app.PageRouter.ActivePage; currentPage != nil {
|
||||
currentPage.Update(deltaTime)
|
||||
}
|
||||
}
|
||||
|
||||
func (app *Application) renderFrame() {
|
||||
graphics.BeginVirtualRender()
|
||||
gl.ClearColor(0.0, 0.0, 0.0, 1.0)
|
||||
gl.Clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)
|
||||
|
||||
if currentPage := app.PageRouter.ActivePage; currentPage != nil {
|
||||
currentPage.Render()
|
||||
}
|
||||
|
||||
graphics.BlitToScreen()
|
||||
app.Window.GLSwap()
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// Keybind Registration
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
func (app *Application) initKeybinds() {
|
||||
keybinds.Register(sdl.K_F3, sdl.KMOD_NONE, func() {
|
||||
if app.state != READY && app.state != DOWNLOADING_ASSETS {
|
||||
return
|
||||
}
|
||||
app.PageRouter.NavigateTo(page.SETTINGS)
|
||||
})
|
||||
|
||||
keybinds.Register(sdl.K_F2, sdl.KMOD_NONE, func() {
|
||||
if app.state != READY && app.state != DOWNLOADING_ASSETS {
|
||||
return
|
||||
}
|
||||
app.PageRouter.NavigateTo(page.SCROLLER)
|
||||
})
|
||||
|
||||
keybinds.Register(sdl.K_F1, sdl.KMOD_NONE, func() {
|
||||
if app.state != READY && app.state != DOWNLOADING_ASSETS {
|
||||
return
|
||||
}
|
||||
app.PageRouter.NavigateTo(page.HOME)
|
||||
|
||||
})
|
||||
|
||||
keybinds.Register(sdl.K_q, sdl.KMOD_RCTRL, func() {
|
||||
app.Stop()
|
||||
})
|
||||
}
|
||||
|
||||
func (app *Application) Stop() {
|
||||
app.running = false
|
||||
}
|
||||
|
||||
func (app *Application) Quit() {
|
||||
logger.Info("User has quit the application")
|
||||
app.running = false
|
||||
}
|
||||
@@ -1,475 +0,0 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"rhemawavetech.com/rhemawave/internal/db"
|
||||
"rhemawavetech.com/rhemawave/internal/page"
|
||||
)
|
||||
|
||||
// Regression: The app used to reach READY before audio downloads completed,
|
||||
// showing "Status: READY" while "0 / 14 Files Downloaded" was still in progress.
|
||||
// The fix gates READY on downloadsComplete being true.
|
||||
|
||||
// TestStateTransitionsToDownloadingAssets verifies that when all config
|
||||
// prerequisites are met but downloads are not complete, the state transitions
|
||||
// to DOWNLOADING_ASSETS instead of READY.
|
||||
func TestStateTransitionsToDownloadingAssets(t *testing.T) {
|
||||
app := &Application{
|
||||
state: FIRST_RUN,
|
||||
DB: setupTestDB(t),
|
||||
}
|
||||
|
||||
networkIsUp = true
|
||||
config, err := db.GetConfig(app.DB)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config.EmailAddress = "test@example.com"
|
||||
config.LicenseKey = "test-key-123"
|
||||
app.DB.Save(config)
|
||||
|
||||
app.downloadsComplete = false
|
||||
|
||||
downloadDone := make(chan struct{})
|
||||
app.PageRouter = &page.Router{
|
||||
Context: page.PageCtx{
|
||||
DbPtr: app.DB,
|
||||
DownloadDone: downloadDone,
|
||||
},
|
||||
}
|
||||
|
||||
result := app.computeState()
|
||||
|
||||
if result != DOWNLOADING_ASSETS {
|
||||
t.Errorf("state = %v, want DOWNLOADING_ASSETS (downloads not complete)", result)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStateTransitionsToReadyWhenDownloadsComplete verifies that the state
|
||||
// transitions to READY only after downloads are complete.
|
||||
func TestStateTransitionsToReadyWhenDownloadsComplete(t *testing.T) {
|
||||
app := &Application{
|
||||
state: FIRST_RUN,
|
||||
DB: setupTestDB(t),
|
||||
}
|
||||
|
||||
networkIsUp = true
|
||||
config, err := db.GetConfig(app.DB)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config.EmailAddress = "test@example.com"
|
||||
config.LicenseKey = "test-key-123"
|
||||
app.DB.Save(config)
|
||||
|
||||
app.downloadsComplete = true
|
||||
|
||||
downloadDone := make(chan struct{})
|
||||
app.PageRouter = &page.Router{
|
||||
Context: page.PageCtx{
|
||||
DbPtr: app.DB,
|
||||
DownloadDone: downloadDone,
|
||||
},
|
||||
}
|
||||
|
||||
result := app.computeState()
|
||||
|
||||
if result != READY {
|
||||
t.Errorf("state = %v, want READY", result)
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: Download failure should prevent the app from reaching READY.
|
||||
// If downloads fail, the user sees an error and must restart to retry.
|
||||
func TestDownloadFailurePreventsReady(t *testing.T) {
|
||||
app := &Application{
|
||||
state: FIRST_RUN,
|
||||
DB: setupTestDB(t),
|
||||
}
|
||||
|
||||
networkIsUp = true
|
||||
config, err := db.GetConfig(app.DB)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config.EmailAddress = "test@example.com"
|
||||
config.LicenseKey = "test-key-123"
|
||||
app.DB.Save(config)
|
||||
|
||||
app.downloadsComplete = false
|
||||
app.downloadFailed = true
|
||||
|
||||
downloadDone := make(chan struct{})
|
||||
app.PageRouter = &page.Router{
|
||||
Context: page.PageCtx{
|
||||
DbPtr: app.DB,
|
||||
DownloadDone: downloadDone,
|
||||
},
|
||||
}
|
||||
|
||||
result := app.computeState()
|
||||
|
||||
if result == READY {
|
||||
t.Error("state should not be READY when download has failed")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStateStaysReadyOnceSet verifies that once the state reaches READY,
|
||||
// computeState() returns READY without checking other prerequisites.
|
||||
func TestStateStaysReadyOnceSet(t *testing.T) {
|
||||
app := &Application{
|
||||
state: READY,
|
||||
DB: setupTestDB(t),
|
||||
}
|
||||
|
||||
downloadDone := make(chan struct{})
|
||||
app.PageRouter = &page.Router{
|
||||
Context: page.PageCtx{
|
||||
DbPtr: app.DB,
|
||||
DownloadDone: downloadDone,
|
||||
},
|
||||
}
|
||||
|
||||
result := app.computeState()
|
||||
|
||||
if result != READY {
|
||||
t.Errorf("state = %v, want READY (should not change once set)", result)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmailMissingPreventsReady verifies that missing email blocks progress
|
||||
// even when network is up and downloads are complete.
|
||||
func TestEmailMissingPreventsReady(t *testing.T) {
|
||||
app := &Application{
|
||||
state: FIRST_RUN,
|
||||
DB: setupTestDB(t),
|
||||
}
|
||||
|
||||
networkIsUp = true
|
||||
config, err := db.GetConfig(app.DB)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config.EmailAddress = ""
|
||||
config.LicenseKey = "test-key-123"
|
||||
app.DB.Save(config)
|
||||
|
||||
app.downloadsComplete = true
|
||||
|
||||
downloadDone := make(chan struct{})
|
||||
app.PageRouter = &page.Router{
|
||||
Context: page.PageCtx{
|
||||
DbPtr: app.DB,
|
||||
DownloadDone: downloadDone,
|
||||
},
|
||||
}
|
||||
|
||||
result := app.computeState()
|
||||
|
||||
if result != NEEDS_EMAIL_ADDRESS {
|
||||
t.Errorf("state = %v, want NEEDS_EMAIL_ADDRESS", result)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLicenseMissingPreventsReady verifies that missing license key blocks
|
||||
// progress even when email and network are present.
|
||||
func TestLicenseMissingPreventsReady(t *testing.T) {
|
||||
app := &Application{
|
||||
state: FIRST_RUN,
|
||||
DB: setupTestDB(t),
|
||||
}
|
||||
|
||||
networkIsUp = true
|
||||
config, err := db.GetConfig(app.DB)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config.EmailAddress = "test@example.com"
|
||||
config.LicenseKey = ""
|
||||
app.DB.Save(config)
|
||||
|
||||
app.downloadsComplete = true
|
||||
|
||||
downloadDone := make(chan struct{})
|
||||
app.PageRouter = &page.Router{
|
||||
Context: page.PageCtx{
|
||||
DbPtr: app.DB,
|
||||
DownloadDone: downloadDone,
|
||||
},
|
||||
}
|
||||
|
||||
result := app.computeState()
|
||||
|
||||
if result != NEEDS_LICENSE_KEY {
|
||||
t.Errorf("state = %v, want NEEDS_LICENSE_KEY", result)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDownloadDoneChannelSetsDownloadsComplete verifies that receiving on
|
||||
// the DownloadDone channel sets downloadsComplete to true.
|
||||
func TestDownloadDoneChannelSetsDownloadsComplete(t *testing.T) {
|
||||
app := &Application{
|
||||
state: FIRST_RUN,
|
||||
DB: setupTestDB(t),
|
||||
}
|
||||
|
||||
networkIsUp = true
|
||||
config, err := db.GetConfig(app.DB)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config.EmailAddress = "test@example.com"
|
||||
config.LicenseKey = "test-key-123"
|
||||
app.DB.Save(config)
|
||||
|
||||
app.downloadsComplete = false
|
||||
|
||||
downloadDone := make(chan struct{})
|
||||
close(downloadDone)
|
||||
app.PageRouter = &page.Router{
|
||||
Context: page.PageCtx{
|
||||
DbPtr: app.DB,
|
||||
DownloadDone: downloadDone,
|
||||
},
|
||||
}
|
||||
|
||||
result := app.computeState()
|
||||
|
||||
if !app.downloadsComplete {
|
||||
t.Error("downloadsComplete should be true after DownloadDone channel is closed")
|
||||
}
|
||||
if result != READY {
|
||||
t.Errorf("state = %v, want READY after downloads complete", result)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrerequisitePriorityEmailBeforeLicense verifies that missing email
|
||||
// is reported before missing license key, even when both are absent.
|
||||
func TestPrerequisitePriorityEmailBeforeLicense(t *testing.T) {
|
||||
app := &Application{
|
||||
state: FIRST_RUN,
|
||||
DB: setupTestDB(t),
|
||||
}
|
||||
|
||||
networkIsUp = true
|
||||
config, err := db.GetConfig(app.DB)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config.EmailAddress = ""
|
||||
config.LicenseKey = ""
|
||||
app.DB.Save(config)
|
||||
|
||||
app.downloadsComplete = true
|
||||
|
||||
downloadDone := make(chan struct{})
|
||||
app.PageRouter = &page.Router{
|
||||
Context: page.PageCtx{
|
||||
DbPtr: app.DB,
|
||||
DownloadDone: downloadDone,
|
||||
},
|
||||
}
|
||||
|
||||
result := app.computeState()
|
||||
|
||||
if result != NEEDS_EMAIL_ADDRESS {
|
||||
t.Errorf("state = %v, want NEEDS_EMAIL_ADDRESS (email should be checked before license)", result)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPrerequisitePriorityLicenseAfterEmail verifies that when email is
|
||||
// present but license is missing, the state is NEEDS_LICENSE_KEY.
|
||||
func TestPrerequisitePriorityLicenseAfterEmail(t *testing.T) {
|
||||
app := &Application{
|
||||
state: FIRST_RUN,
|
||||
DB: setupTestDB(t),
|
||||
}
|
||||
|
||||
networkIsUp = true
|
||||
config, err := db.GetConfig(app.DB)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config.EmailAddress = "test@example.com"
|
||||
config.LicenseKey = ""
|
||||
app.DB.Save(config)
|
||||
|
||||
app.downloadsComplete = true
|
||||
|
||||
downloadDone := make(chan struct{})
|
||||
app.PageRouter = &page.Router{
|
||||
Context: page.PageCtx{
|
||||
DbPtr: app.DB,
|
||||
DownloadDone: downloadDone,
|
||||
},
|
||||
}
|
||||
|
||||
result := app.computeState()
|
||||
|
||||
if result != NEEDS_LICENSE_KEY {
|
||||
t.Errorf("state = %v, want NEEDS_LICENSE_KEY", result)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStateBlocksOnDownloadUntilChannelClosed verifies that computeState
|
||||
// returns DOWNLOADING_ASSETS repeatedly until DownloadDone is closed.
|
||||
func TestStateBlocksOnDownloadUntilChannelClosed(t *testing.T) {
|
||||
app := &Application{
|
||||
state: FIRST_RUN,
|
||||
DB: setupTestDB(t),
|
||||
}
|
||||
|
||||
networkIsUp = true
|
||||
config, err := db.GetConfig(app.DB)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config.EmailAddress = "test@example.com"
|
||||
config.LicenseKey = "test-key-123"
|
||||
app.DB.Save(config)
|
||||
|
||||
app.downloadsComplete = false
|
||||
|
||||
downloadDone := make(chan struct{})
|
||||
app.PageRouter = &page.Router{
|
||||
Context: page.PageCtx{
|
||||
DbPtr: app.DB,
|
||||
DownloadDone: downloadDone,
|
||||
},
|
||||
}
|
||||
|
||||
// Should return DOWNLOADING_ASSETS multiple times before channel closes.
|
||||
for i := 0; i < 5; i++ {
|
||||
result := app.computeState()
|
||||
if result != DOWNLOADING_ASSETS {
|
||||
t.Fatalf("iteration %d: state = %v, want DOWNLOADING_ASSETS", i, result)
|
||||
}
|
||||
}
|
||||
|
||||
// Now close the channel.
|
||||
close(downloadDone)
|
||||
|
||||
result := app.computeState()
|
||||
if result != READY {
|
||||
t.Errorf("after close: state = %v, want READY", result)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDownloadDoneNonBlocking verifies that computeState does not block
|
||||
// when DownloadDone channel is open (non-blocking select).
|
||||
func TestDownloadDoneNonBlocking(t *testing.T) {
|
||||
app := &Application{
|
||||
state: FIRST_RUN,
|
||||
DB: setupTestDB(t),
|
||||
}
|
||||
|
||||
networkIsUp = true
|
||||
config, err := db.GetConfig(app.DB)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config.EmailAddress = "test@example.com"
|
||||
config.LicenseKey = "test-key-123"
|
||||
app.DB.Save(config)
|
||||
|
||||
app.downloadsComplete = false
|
||||
|
||||
downloadDone := make(chan struct{})
|
||||
app.PageRouter = &page.Router{
|
||||
Context: page.PageCtx{
|
||||
DbPtr: app.DB,
|
||||
DownloadDone: downloadDone,
|
||||
},
|
||||
}
|
||||
|
||||
// computeState must return promptly even though DownloadDone is not closed.
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
result := app.computeState()
|
||||
if result != DOWNLOADING_ASSETS {
|
||||
t.Errorf("state = %v, want DOWNLOADING_ASSETS", result)
|
||||
}
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
// Good: returned promptly.
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("computeState blocked on DownloadDone channel (should be non-blocking)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMultipleComputeStateCallsAfterDownloadComplete verifies that calling
|
||||
// computeState repeatedly after downloads finish does not panic or change state.
|
||||
func TestMultipleComputeStateCallsAfterDownloadComplete(t *testing.T) {
|
||||
app := &Application{
|
||||
state: FIRST_RUN,
|
||||
DB: setupTestDB(t),
|
||||
}
|
||||
|
||||
networkIsUp = true
|
||||
config, err := db.GetConfig(app.DB)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config.EmailAddress = "test@example.com"
|
||||
config.LicenseKey = "test-key-123"
|
||||
app.DB.Save(config)
|
||||
|
||||
downloadDone := make(chan struct{})
|
||||
close(downloadDone)
|
||||
app.PageRouter = &page.Router{
|
||||
Context: page.PageCtx{
|
||||
DbPtr: app.DB,
|
||||
DownloadDone: downloadDone,
|
||||
},
|
||||
}
|
||||
|
||||
// First call transitions to READY.
|
||||
result := app.computeState()
|
||||
if result != READY {
|
||||
t.Fatalf("first call: state = %v, want READY", result)
|
||||
}
|
||||
app.state = result
|
||||
|
||||
// Subsequent calls should return READY without panic.
|
||||
for i := 0; i < 10; i++ {
|
||||
result = app.computeState()
|
||||
if result != READY {
|
||||
t.Fatalf("iteration %d: state = %v, want READY", i, result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setupTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
d, err := db.Connect(":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Migrate(d); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := &db.AppConfig{
|
||||
Model: gorm.Model{ID: 1},
|
||||
DataDir: t.TempDir(),
|
||||
AssetsDir: t.TempDir(),
|
||||
SystemLevel: "BASIC",
|
||||
SelectedAudioDevice: "default",
|
||||
InitialFontSize: 128,
|
||||
StandardFontSize: 32,
|
||||
LinePattern: "fibonacci",
|
||||
AudioEnabled: true,
|
||||
SavedVolume: 1.0,
|
||||
EmailAddress: "",
|
||||
LicenseKey: "",
|
||||
}
|
||||
if err := d.Create(cfg).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
// Package sdlcolors provides a comprehensive palette of 256 standard colors
|
||||
// for use with the Go SDL2 bindings. Each color is a public variable of
|
||||
// type sdl.Color, making them easily accessible for rendering tasks.
|
||||
package colors
|
||||
|
||||
import "github.com/veandco/go-sdl2/sdl"
|
||||
|
||||
// Basic Colors
|
||||
var (
|
||||
White = sdl.Color{R: 255, G: 255, B: 255, A: 255}
|
||||
Silver = sdl.Color{R: 192, G: 192, B: 192, A: 255}
|
||||
Gray = sdl.Color{R: 128, G: 128, B: 128, A: 255}
|
||||
Black = sdl.Color{R: 0, G: 0, B: 0, A: 255}
|
||||
Red = sdl.Color{R: 255, G: 0, B: 0, A: 255}
|
||||
Maroon = sdl.Color{R: 128, G: 0, B: 0, A: 255}
|
||||
Yellow = sdl.Color{R: 255, G: 255, B: 0, A: 255}
|
||||
Olive = sdl.Color{R: 128, G: 128, B: 0, A: 255}
|
||||
Lime = sdl.Color{R: 0, G: 255, B: 0, A: 255}
|
||||
Green = sdl.Color{R: 0, G: 128, B: 0, A: 255}
|
||||
Aqua = sdl.Color{R: 0, G: 255, B: 255, A: 255}
|
||||
Teal = sdl.Color{R: 0, G: 128, B: 128, A: 255}
|
||||
Blue = sdl.Color{R: 0, G: 0, B: 255, A: 255}
|
||||
Navy = sdl.Color{R: 0, G: 0, B: 128, A: 255}
|
||||
Fuchsia = sdl.Color{R: 255, G: 0, B: 255, A: 255}
|
||||
Purple = sdl.Color{R: 128, G: 0, B: 128, A: 255}
|
||||
)
|
||||
|
||||
// Grayscale
|
||||
var (
|
||||
Gainsboro = sdl.Color{R: 220, G: 220, B: 220, A: 255}
|
||||
LightGray = sdl.Color{R: 211, G: 211, B: 211, A: 255}
|
||||
DarkGray = sdl.Color{R: 169, G: 169, B: 169, A: 255}
|
||||
DimGray = sdl.Color{R: 105, G: 105, B: 105, A: 255}
|
||||
LightSlateGray = sdl.Color{R: 119, G: 136, B: 153, A: 255}
|
||||
SlateGray = sdl.Color{R: 112, G: 128, B: 144, A: 255}
|
||||
DarkSlateGray = sdl.Color{R: 47, G: 79, B: 79, A: 255}
|
||||
)
|
||||
|
||||
// Error Colors
|
||||
var (
|
||||
ErrorRed = sdl.Color{R: 255, G: 80, B: 80, A: 255}
|
||||
)
|
||||
|
||||
// Pinks & Reds
|
||||
var (
|
||||
Pink = sdl.Color{R: 255, G: 192, B: 203, A: 255}
|
||||
LightPink = sdl.Color{R: 255, G: 182, B: 193, A: 255}
|
||||
HotPink = sdl.Color{R: 255, G: 105, B: 180, A: 255}
|
||||
DeepPink = sdl.Color{R: 255, G: 20, B: 147, A: 255}
|
||||
PaleVioletRed = sdl.Color{R: 219, G: 112, B: 147, A: 255}
|
||||
MediumVioletRed = sdl.Color{R: 199, G: 21, B: 133, A: 255}
|
||||
LightSalmon = sdl.Color{R: 255, G: 160, B: 122, A: 255}
|
||||
Salmon = sdl.Color{R: 250, G: 128, B: 114, A: 255}
|
||||
DarkSalmon = sdl.Color{R: 233, G: 150, B: 122, A: 255}
|
||||
LightCoral = sdl.Color{R: 240, G: 128, B: 128, A: 255}
|
||||
IndianRed = sdl.Color{R: 205, G: 92, B: 92, A: 255}
|
||||
Crimson = sdl.Color{R: 220, G: 20, B: 60, A: 255}
|
||||
Firebrick = sdl.Color{R: 178, G: 34, B: 34, A: 255}
|
||||
DarkRed = sdl.Color{R: 139, G: 0, B: 0, A: 255}
|
||||
DeepBloodRed = sdl.Color{R: 139, G: 0, B: 0, A: 255}
|
||||
)
|
||||
|
||||
// Oranges
|
||||
var (
|
||||
LightOrange = sdl.Color{R: 255, G: 200, B: 127, A: 255}
|
||||
Orange = sdl.Color{R: 255, G: 165, B: 0, A: 255}
|
||||
OrangeRed = sdl.Color{R: 255, G: 69, B: 0, A: 255}
|
||||
DarkOrange = sdl.Color{R: 255, G: 140, B: 0, A: 255}
|
||||
DarkOrangeRed = sdl.Color{R: 255, G: 140, B: 105, A: 255}
|
||||
Tomato = sdl.Color{R: 255, G: 99, B: 71, A: 255}
|
||||
Coral = sdl.Color{R: 255, G: 127, B: 80, A: 255}
|
||||
PaleOrange = sdl.Color{R: 255, G: 223, B: 186, A: 255}
|
||||
RedOrange = sdl.Color{R: 255, G: 69, B: 0, A: 255}
|
||||
)
|
||||
|
||||
// Yellows
|
||||
var (
|
||||
Gold = sdl.Color{R: 255, G: 215, B: 0, A: 255}
|
||||
LightYellow = sdl.Color{R: 255, G: 255, B: 224, A: 255}
|
||||
LemonChiffon = sdl.Color{R: 255, G: 250, B: 205, A: 255}
|
||||
LightGoldenrodYellow = sdl.Color{R: 250, G: 250, B: 210, A: 255}
|
||||
PapayaWhip = sdl.Color{R: 255, G: 239, B: 213, A: 255}
|
||||
Moccasin = sdl.Color{R: 255, G: 228, B: 181, A: 255}
|
||||
PeachPuff = sdl.Color{R: 255, G: 218, B: 185, A: 255}
|
||||
PaleGoldenrod = sdl.Color{R: 238, G: 232, B: 170, A: 255}
|
||||
Khaki = sdl.Color{R: 240, G: 230, B: 140, A: 255}
|
||||
DarkKhaki = sdl.Color{R: 189, G: 183, B: 107, A: 255}
|
||||
)
|
||||
|
||||
// Purples & Violets
|
||||
var (
|
||||
Lavender = sdl.Color{R: 230, G: 230, B: 250, A: 255}
|
||||
Thistle = sdl.Color{R: 216, G: 191, B: 216, A: 255}
|
||||
Plum = sdl.Color{R: 221, G: 160, B: 221, A: 255}
|
||||
Violet = sdl.Color{R: 238, G: 130, B: 238, A: 255}
|
||||
Orchid = sdl.Color{R: 218, G: 112, B: 214, A: 255}
|
||||
Magenta = sdl.Color{R: 255, G: 0, B: 255, A: 255}
|
||||
MediumOrchid = sdl.Color{R: 186, G: 85, B: 211, A: 255}
|
||||
MediumPurple = sdl.Color{R: 147, G: 112, B: 219, A: 255}
|
||||
RebeccaPurple = sdl.Color{R: 102, G: 51, B: 153, A: 255}
|
||||
BlueViolet = sdl.Color{R: 138, G: 43, B: 226, A: 255}
|
||||
DarkViolet = sdl.Color{R: 148, G: 0, B: 211, A: 255}
|
||||
DarkOrchid = sdl.Color{R: 153, G: 50, B: 204, A: 255}
|
||||
DarkMagenta = sdl.Color{R: 139, G: 0, B: 139, A: 255}
|
||||
Indigo = sdl.Color{R: 75, G: 0, B: 130, A: 255}
|
||||
SlateBlue = sdl.Color{R: 106, G: 90, B: 205, A: 255}
|
||||
DarkSlateBlue = sdl.Color{R: 72, G: 61, B: 139, A: 255}
|
||||
MediumSlateBlue = sdl.Color{R: 123, G: 104, B: 238, A: 255}
|
||||
LavenderBlush = sdl.Color{R: 255, G: 240, B: 245, A: 255}
|
||||
LavenderGray = sdl.Color{R: 250, G: 240, B: 250, A: 255}
|
||||
)
|
||||
|
||||
// Greens
|
||||
var (
|
||||
GreenYellow = sdl.Color{R: 173, G: 255, B: 47, A: 255}
|
||||
Chartreuse = sdl.Color{R: 127, G: 255, B: 0, A: 255}
|
||||
LawnGreen = sdl.Color{R: 124, G: 252, B: 0, A: 255}
|
||||
LimeGreen = sdl.Color{R: 50, G: 205, B: 50, A: 255}
|
||||
PaleGreen = sdl.Color{R: 152, G: 251, B: 152, A: 255}
|
||||
LightGreen = sdl.Color{R: 144, G: 238, B: 144, A: 255}
|
||||
MediumSpringGreen = sdl.Color{R: 0, G: 250, B: 154, A: 255}
|
||||
SpringGreen = sdl.Color{R: 0, G: 255, B: 127, A: 255}
|
||||
MediumSeaGreen = sdl.Color{R: 60, G: 179, B: 113, A: 255}
|
||||
SeaGreen = sdl.Color{R: 46, G: 139, B: 87, A: 255}
|
||||
ForestGreen = sdl.Color{R: 34, G: 139, B: 34, A: 255}
|
||||
DarkGreen = sdl.Color{R: 0, G: 100, B: 0, A: 255}
|
||||
YellowGreen = sdl.Color{R: 154, G: 205, B: 50, A: 255}
|
||||
OliveDrab = sdl.Color{R: 107, G: 142, B: 35, A: 255}
|
||||
DarkOliveGreen = sdl.Color{R: 85, G: 107, B: 47, A: 255}
|
||||
MediumAquamarine = sdl.Color{R: 102, G: 205, B: 170, A: 255}
|
||||
DarkSeaGreen = sdl.Color{R: 143, G: 188, B: 143, A: 255}
|
||||
LightSeaGreen = sdl.Color{R: 32, G: 178, B: 170, A: 255}
|
||||
DarkCyan = sdl.Color{R: 0, G: 139, B: 139, A: 255}
|
||||
MintGreen = sdl.Color{R: 173, G: 255, B: 169, A: 255}
|
||||
VeryPaleGreen = sdl.Color{R: 200, G: 255, B: 210, A: 255}
|
||||
Honeydew = sdl.Color{R: 240, G: 255, B: 240, A: 255}
|
||||
)
|
||||
|
||||
// Blues & Cyans
|
||||
var (
|
||||
LightCyan = sdl.Color{R: 224, G: 255, B: 255, A: 255}
|
||||
Cyan = sdl.Color{R: 0, G: 255, B: 255, A: 255}
|
||||
AquaMarine = sdl.Color{R: 127, G: 255, B: 212, A: 255}
|
||||
PaleTurquoise = sdl.Color{R: 175, G: 238, B: 238, A: 255}
|
||||
Turquoise = sdl.Color{R: 64, G: 224, B: 208, A: 255}
|
||||
MediumTurquoise = sdl.Color{R: 72, G: 209, B: 204, A: 255}
|
||||
DarkTurquoise = sdl.Color{R: 0, G: 206, B: 209, A: 255}
|
||||
CadetBlue = sdl.Color{R: 95, G: 158, B: 160, A: 255}
|
||||
SteelBlue = sdl.Color{R: 70, G: 130, B: 180, A: 255}
|
||||
LightSteelBlue = sdl.Color{R: 176, G: 196, B: 222, A: 255}
|
||||
PowderBlue = sdl.Color{R: 176, G: 224, B: 230, A: 255}
|
||||
LightBlue = sdl.Color{R: 173, G: 216, B: 230, A: 255}
|
||||
SkyBlue = sdl.Color{R: 135, G: 206, B: 235, A: 255}
|
||||
LightSkyBlue = sdl.Color{R: 135, G: 206, B: 250, A: 255}
|
||||
DeepSkyBlue = sdl.Color{R: 0, G: 191, B: 255, A: 255}
|
||||
DodgerBlue = sdl.Color{R: 30, G: 144, B: 255, A: 255}
|
||||
CornflowerBlue = sdl.Color{R: 100, G: 149, B: 237, A: 255}
|
||||
MediumBlue = sdl.Color{R: 0, G: 0, B: 205, A: 255}
|
||||
DarkBlue = sdl.Color{R: 0, G: 0, B: 139, A: 255}
|
||||
MidnightBlue = sdl.Color{R: 25, G: 25, B: 112, A: 255}
|
||||
RoyalBlue = sdl.Color{R: 65, G: 105, B: 225, A: 255}
|
||||
)
|
||||
|
||||
// Browns
|
||||
var (
|
||||
Cornsilk = sdl.Color{R: 255, G: 248, B: 220, A: 255}
|
||||
BlanchedAlmond = sdl.Color{R: 255, G: 235, B: 205, A: 255}
|
||||
Bisque = sdl.Color{R: 255, G: 228, B: 196, A: 255}
|
||||
NavajoWhite = sdl.Color{R: 255, G: 222, B: 173, A: 255}
|
||||
Wheat = sdl.Color{R: 245, G: 222, B: 179, A: 255}
|
||||
BurlyWood = sdl.Color{R: 222, G: 184, B: 135, A: 255}
|
||||
Tan = sdl.Color{R: 210, G: 180, B: 140, A: 255}
|
||||
RosyBrown = sdl.Color{R: 188, G: 143, B: 143, A: 255}
|
||||
SandyBrown = sdl.Color{R: 244, G: 164, B: 96, A: 255}
|
||||
Goldenrod = sdl.Color{R: 218, G: 165, B: 32, A: 255}
|
||||
DarkGoldenrod = sdl.Color{R: 184, G: 134, B: 11, A: 255}
|
||||
Peru = sdl.Color{R: 205, G: 133, B: 63, A: 255}
|
||||
Chocolate = sdl.Color{R: 210, G: 105, B: 30, A: 255}
|
||||
SaddleBrown = sdl.Color{R: 139, G: 69, B: 19, A: 255}
|
||||
Sienna = sdl.Color{R: 160, G: 82, B: 45, A: 255}
|
||||
Brown = sdl.Color{R: 165, G: 42, B: 42, A: 255}
|
||||
)
|
||||
|
||||
// Whites
|
||||
var (
|
||||
Snow = sdl.Color{R: 255, G: 250, B: 250, A: 255}
|
||||
MintCream = sdl.Color{R: 245, G: 255, B: 250, A: 255}
|
||||
Azure = sdl.Color{R: 240, G: 255, B: 255, A: 255}
|
||||
AliceBlue = sdl.Color{R: 240, G: 248, B: 255, A: 255}
|
||||
GhostWhite = sdl.Color{R: 248, G: 248, B: 255, A: 255}
|
||||
WhiteSmoke = sdl.Color{R: 245, G: 245, B: 245, A: 255}
|
||||
Seashell = sdl.Color{R: 255, G: 245, B: 238, A: 255}
|
||||
Beige = sdl.Color{R: 245, G: 245, B: 220, A: 255}
|
||||
OldLace = sdl.Color{R: 253, G: 245, B: 230, A: 255}
|
||||
FloralWhite = sdl.Color{R: 255, G: 250, B: 240, A: 255}
|
||||
Ivory = sdl.Color{R: 255, G: 255, B: 240, A: 255}
|
||||
AntiqueWhite = sdl.Color{R: 250, G: 235, B: 215, A: 255}
|
||||
Linen = sdl.Color{R: 250, G: 240, B: 230, A: 255}
|
||||
MistyRose = sdl.Color{R: 255, G: 228, B: 225, A: 255}
|
||||
)
|
||||
|
||||
// Groups
|
||||
|
||||
// Based on Solfeggio Frequencies
|
||||
var (
|
||||
Freq_396_Colors = [7]sdl.Color{DeepBloodRed, Firebrick, Crimson, Tomato, DarkOrangeRed, Orange, PeachPuff}
|
||||
Freq_417_Colors = [7]sdl.Color{RedOrange, Coral, DarkOrange, Orange, LightOrange, PaleOrange, BlanchedAlmond}
|
||||
// Default proclamation needs to be using this
|
||||
Freq_528_Colors = [7]sdl.Color{LimeGreen, MediumSeaGreen, LightGreen, PaleGreen, MintGreen, VeryPaleGreen, Honeydew}
|
||||
Freq_639_Colors = [7]sdl.Color{SpringGreen, DarkTurquoise, Turquoise, CadetBlue, AquaMarine, PaleTurquoise, LightCyan}
|
||||
Freq_741_Colors = [7]sdl.Color{DeepSkyBlue, DodgerBlue, RoyalBlue, CornflowerBlue, SkyBlue, LightBlue, AliceBlue}
|
||||
Freq_852_Colors = [7]sdl.Color{Indigo, MediumPurple, BlueViolet, MediumSlateBlue, Orchid, Thistle, Lavender}
|
||||
Freq_963_Colors = [7]sdl.Color{DarkViolet, MediumOrchid, MediumVioletRed, Plum, Violet, LavenderBlush, LavenderGray}
|
||||
)
|
||||
@@ -1,112 +0,0 @@
|
||||
// Package db provides a database connection and functions for interacting with the database.
|
||||
// The DB object should be held and maintained by the application, not by this package.
|
||||
// The application should be responsible for connecting to, migrating, and initial population
|
||||
// of the database. This package should only provide the schema definitions and helper functions.
|
||||
// Schema definitions should clone API response structures as closely as possible.
|
||||
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
connectMaxAttempts = 5
|
||||
connectRetryDelay = 500 * time.Millisecond
|
||||
)
|
||||
|
||||
// Connect opens the SQLite database at dbpath with WAL mode and a hardened
|
||||
// lock-recovery strategy suitable for kiosk use where the process may have
|
||||
// crashed and left stale lock artefacts behind.
|
||||
func Connect(dbpath string) (*gorm.DB, error) {
|
||||
|
||||
// ── Stale lock recovery ───────────────────────────────────────────────
|
||||
// SQLite WAL mode leaves a .db-wal and .db-shm alongside the main file.
|
||||
// If the previous process crashed, the OS releases file locks but the
|
||||
// artefacts remain. SQLite recovers them automatically on the next open,
|
||||
// UNLESS an OS-level advisory lock is still held (rare but possible on
|
||||
// Linux with certain crash patterns). Removing them before opening is
|
||||
// safe: SQLite treats absent WAL/SHM files as a clean slate and will
|
||||
// recreate them. We only do this if the main DB file already exists
|
||||
// (i.e. this is not a first-run) to avoid masking legitimate errors.
|
||||
// if _, err := os.Stat(dbpath); err == nil {
|
||||
// for _, ext := range []string{"-wal", "-shm"} {
|
||||
// artefact := dbpath + ext
|
||||
// if _, err := os.Stat(artefact); err == nil {
|
||||
// if removeErr := os.Remove(artefact); removeErr != nil {
|
||||
// // Non-fatal: log and continue — SQLite may still recover.
|
||||
// fmt.Printf("[DB] warning: could not remove stale %s: %v\n", ext, removeErr)
|
||||
// } else {
|
||||
// fmt.Printf("[DB] removed stale lock artefact: %s\n", artefact)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// ── Connection with retry ─────────────────────────────────────────────
|
||||
// Even after removing WAL/SHM, the main DB file itself may still be
|
||||
// briefly locked if another process is shutting down. Retry with
|
||||
// backoff before giving up.
|
||||
var (
|
||||
db *gorm.DB
|
||||
err error
|
||||
)
|
||||
for attempt := 1; attempt <= connectMaxAttempts; attempt++ {
|
||||
db, err = gorm.Open(sqlite.Open(dbpath), &gorm.Config{})
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
if attempt == connectMaxAttempts {
|
||||
return nil, fmt.Errorf("database still locked after %d attempts: %w", connectMaxAttempts, err)
|
||||
}
|
||||
fmt.Printf("[DB] open attempt %d/%d failed (%v), retrying in %s\n",
|
||||
attempt, connectMaxAttempts, err, connectRetryDelay)
|
||||
time.Sleep(connectRetryDelay)
|
||||
}
|
||||
|
||||
// ── Connection pool ───────────────────────────────────────────────────
|
||||
// SQLite does not support concurrent writers. Setting MaxOpenConns(1)
|
||||
// forces gorm to serialise all queries through a single connection,
|
||||
// preventing "database is locked" errors from competing goroutines
|
||||
// within this process. This is the single most important setting for
|
||||
// SQLite in a multi-goroutine application.
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not get underlying sql.DB: %w", err)
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
sqlDB.SetMaxIdleConns(1)
|
||||
|
||||
// ── Pragmas ───────────────────────────────────────────────────────────
|
||||
pragmas := []string{
|
||||
"PRAGMA journal_mode=WAL",
|
||||
"PRAGMA synchronous=FULL",
|
||||
"PRAGMA foreign_keys=ON",
|
||||
// 5 s gives the serialised connection time to finish a long write
|
||||
// before returning SQLITE_BUSY to the application layer.
|
||||
"PRAGMA busy_timeout=5000",
|
||||
}
|
||||
for _, pragma := range pragmas {
|
||||
if _, err := sqlDB.Exec(pragma); err != nil {
|
||||
return nil, fmt.Errorf("failed to set %q: %w", pragma, err)
|
||||
}
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// Migrate the database schema.
|
||||
func Migrate(db *gorm.DB) error {
|
||||
return db.AutoMigrate(
|
||||
&AppConfig{},
|
||||
&Log{},
|
||||
&Proclamation{},
|
||||
&ProclamationCategory{},
|
||||
&SolfeggioFrequency{},
|
||||
&AudioFile{},
|
||||
)
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
// schema.go defines the database schema for the GORM models.
|
||||
package db
|
||||
|
||||
import "gorm.io/gorm"
|
||||
|
||||
type AppConfig struct {
|
||||
gorm.Model
|
||||
DataDir string
|
||||
AssetsDir string
|
||||
EmailAddress string
|
||||
LicenseKey string
|
||||
SystemLevel string // BASIC, ADVANCED, ELITE, COMMERCIAL
|
||||
SelectedAudioDevice string // The pipewire device name
|
||||
InitialFontSize int // How large to create the font atlas
|
||||
StandardFontSize int // The font size used for most text rendering
|
||||
LinePattern string // "fibonacci"
|
||||
AudioEnabled bool
|
||||
SavedVolume float64 // Last saved volume as a percentage
|
||||
FirstRun bool // Is this the first time the application has been run?
|
||||
}
|
||||
|
||||
type Log struct {
|
||||
gorm.Model
|
||||
Timestamp int64
|
||||
Level string
|
||||
Message string
|
||||
}
|
||||
|
||||
type Proclamation struct {
|
||||
ID string `gorm:"primaryKey"`
|
||||
Name string
|
||||
Content string
|
||||
SolfeggioFrequencyID int
|
||||
CategoryID int
|
||||
Selected bool
|
||||
Available bool
|
||||
Price float64
|
||||
}
|
||||
|
||||
type ProclamationCategory struct {
|
||||
ID int `gorm:"primaryKey"`
|
||||
Name string
|
||||
ParentId int
|
||||
}
|
||||
|
||||
type SolfeggioFrequency struct {
|
||||
ID int `gorm:"primaryKey"`
|
||||
Name string
|
||||
Value int
|
||||
}
|
||||
|
||||
type AudioFile struct {
|
||||
ID int `gorm:"primaryKey"`
|
||||
Name string
|
||||
Path string
|
||||
Selected bool
|
||||
Order int
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
package db
|
||||
|
||||
import "gorm.io/gorm"
|
||||
|
||||
// GetConfig fetches the application config from the database
|
||||
func GetConfig(db *gorm.DB) (*AppConfig, error) {
|
||||
var cfg AppConfig
|
||||
err := db.First(&cfg, 1).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/md5"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"rhemawavetech.com/rhemawave/internal/logger"
|
||||
)
|
||||
|
||||
// DownloadStatus holds information related to downloads and is
|
||||
// the container for information returned via channels within this package.
|
||||
type DownloadStatus struct {
|
||||
Max int64
|
||||
Cur int64
|
||||
Msg string
|
||||
Err error
|
||||
}
|
||||
|
||||
// manifest maps filename → expected MD5 hex string
|
||||
type manifest map[string]string
|
||||
|
||||
// ─── Server script to generate checksums.md5 ─────────────────────────────────
|
||||
//
|
||||
// Run this on your file server whenever you add or replace audio files:
|
||||
//
|
||||
// cd /path/to/audio && md5sum *.mp3 > checksums.md5
|
||||
//
|
||||
// That's it. The app will pick up changes automatically on next run.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const manifestFilename = "checksums.md5"
|
||||
|
||||
// DownloadAudioFiles downloads audio files, skipping any that already exist
|
||||
// locally with a matching MD5. Files with a wrong checksum are deleted and
|
||||
// re-downloaded. Progress is sent to progressChan, which is closed on return.
|
||||
func DownloadAudioFiles(progressChan chan<- DownloadStatus, destDir string, urlBase string) {
|
||||
defer close(progressChan)
|
||||
|
||||
send := func(cur, max int64, msg string, err error) {
|
||||
progressChan <- DownloadStatus{Cur: cur, Max: max, Msg: msg, Err: err}
|
||||
}
|
||||
|
||||
send(0, 0, "Fetching file manifest...", nil)
|
||||
m, err := fetchManifest(urlBase + manifestFilename)
|
||||
if err != nil {
|
||||
send(0, 0, "Failed to fetch manifest", err)
|
||||
return
|
||||
}
|
||||
logger.Info("download: manifest loaded, %d files listed", len(m))
|
||||
|
||||
send(0, 0, "Comparing checksums...", nil)
|
||||
|
||||
type work struct {
|
||||
filename string
|
||||
expectedMD5 string
|
||||
}
|
||||
var queue []work
|
||||
|
||||
for filename, expectedMD5 := range m {
|
||||
localPath := filepath.Join(destDir, filename)
|
||||
localMD5, err := fileMD5(localPath)
|
||||
if err != nil {
|
||||
logger.Debug("download: %s not found locally, queuing", filename)
|
||||
queue = append(queue, work{filename, expectedMD5})
|
||||
continue
|
||||
}
|
||||
if localMD5 != expectedMD5 {
|
||||
logger.Debug("download: %s checksum mismatch (got %s, want %s), re-downloading",
|
||||
filename, localMD5, expectedMD5)
|
||||
if err := os.Remove(localPath); err != nil {
|
||||
logger.Warning("download: failed to remove corrupt file %s: %v", localPath, err)
|
||||
}
|
||||
queue = append(queue, work{filename, expectedMD5})
|
||||
} else {
|
||||
logger.Debug("download: %s OK", filename)
|
||||
}
|
||||
}
|
||||
|
||||
if len(queue) == 0 {
|
||||
send(0, 0, "All files are up to date.", nil)
|
||||
logger.Info("download: nothing to do")
|
||||
return
|
||||
}
|
||||
|
||||
total := len(queue)
|
||||
send(0, 0, fmt.Sprintf("0 / %d Files Downloaded", total), nil)
|
||||
logger.Info("download: %d file(s) to download", total)
|
||||
|
||||
totalDownloaded := int64(0)
|
||||
filesDownloaded := 0
|
||||
|
||||
for _, w := range queue {
|
||||
url := urlBase + w.filename
|
||||
destPath := filepath.Join(destDir, w.filename)
|
||||
|
||||
logger.Debug("download: starting %s", w.filename)
|
||||
|
||||
downloaded, err := downloadFile(url, destPath, func(n int64) {
|
||||
totalDownloaded += n
|
||||
send(totalDownloaded, 0, fmt.Sprintf("%d / %d Files Downloaded", filesDownloaded, total), nil)
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
logger.Error("download: failed to download %s: %v", w.filename, err)
|
||||
send(totalDownloaded, 0, fmt.Sprintf("%d / %d Files Downloaded", filesDownloaded, total), err)
|
||||
return
|
||||
}
|
||||
|
||||
gotMD5, err := fileMD5(destPath)
|
||||
if err != nil {
|
||||
logger.Error("download: could not hash %s after download: %v", w.filename, err)
|
||||
os.Remove(destPath)
|
||||
send(totalDownloaded, 0, "Checksum verification failed", err)
|
||||
return
|
||||
}
|
||||
if gotMD5 != w.expectedMD5 {
|
||||
err := fmt.Errorf("checksum mismatch after download: got %s, want %s", gotMD5, w.expectedMD5)
|
||||
logger.Error("download: %s %v", w.filename, err)
|
||||
os.Remove(destPath)
|
||||
send(totalDownloaded, 0, "Checksum verification failed — file removed", err)
|
||||
return
|
||||
}
|
||||
|
||||
filesDownloaded++
|
||||
statusMsg := fmt.Sprintf("%d / %d Files Downloaded", filesDownloaded, total)
|
||||
logger.Info("download: %s (%d bytes)", statusMsg, downloaded)
|
||||
send(totalDownloaded, 0, statusMsg, nil)
|
||||
}
|
||||
|
||||
send(totalDownloaded, totalDownloaded, "All downloads complete!", nil)
|
||||
logger.Info("download: all files downloaded and verified")
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// fetchManifest downloads and parses a checksums.md5 file.
|
||||
// Format per line: "<md5hex> <filename>" (two spaces, same as md5sum output)
|
||||
func fetchManifest(url string) (manifest, error) {
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch manifest: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("fetch manifest: server returned %s", resp.Status)
|
||||
}
|
||||
|
||||
m := make(manifest)
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
// md5sum format: "hash filename" (two spaces)
|
||||
parts := strings.SplitN(line, " ", 2)
|
||||
if len(parts) != 2 {
|
||||
logger.Warning("download: skipping malformed manifest line: %q", line)
|
||||
continue
|
||||
}
|
||||
hash := strings.TrimSpace(parts[0])
|
||||
name := strings.TrimSpace(parts[1])
|
||||
m[name] = hash
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("parse manifest: %w", err)
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// fileMD5 computes the MD5 checksum of a local file.
|
||||
// Returns an error if the file doesn't exist or can't be read.
|
||||
func fileMD5(path string) (string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
h := md5.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return "", fmt.Errorf("hash %s: %w", path, err)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%x", h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
// downloadFile streams url to destPath, calling onChunk(n) after each write
|
||||
// with the number of bytes just written. Returns total bytes downloaded.
|
||||
func downloadFile(url, destPath string, onChunk func(int64)) (int64, error) {
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return 0, fmt.Errorf("GET %s: %s", url, resp.Status)
|
||||
}
|
||||
|
||||
out, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("create %s: %w", destPath, err)
|
||||
}
|
||||
defer out.Close()
|
||||
|
||||
var total int64
|
||||
buf := make([]byte, 32*1024) // 32KB chunks
|
||||
|
||||
for {
|
||||
n, err := resp.Body.Read(buf)
|
||||
if n > 0 {
|
||||
if _, werr := out.Write(buf[:n]); werr != nil {
|
||||
os.Remove(destPath)
|
||||
return total, fmt.Errorf("write %s: %w", destPath, werr)
|
||||
}
|
||||
total += int64(n)
|
||||
onChunk(int64(n))
|
||||
}
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
os.Remove(destPath)
|
||||
return total, fmt.Errorf("read %s: %w", url, err)
|
||||
}
|
||||
}
|
||||
|
||||
return total, nil
|
||||
}
|
||||
@@ -1,890 +0,0 @@
|
||||
package fontManager
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/go-gl/gl/v3.3-core/gl"
|
||||
"github.com/go-gl/mathgl/mgl32"
|
||||
"github.com/veandco/go-sdl2/sdl"
|
||||
"github.com/veandco/go-sdl2/ttf"
|
||||
"rhemawavetech.com/rhemawave/internal/db"
|
||||
"rhemawavetech.com/rhemawave/internal/graphics"
|
||||
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
||||
)
|
||||
|
||||
// FontManager handles the loading and management of fonts.
|
||||
type FontManager struct {
|
||||
Fonts map[string]*FontEntry
|
||||
|
||||
// Reusable GPU objects for string texture rendering
|
||||
stringFBO uint32
|
||||
stringVAO uint32
|
||||
stringVBO uint32
|
||||
}
|
||||
|
||||
var (
|
||||
fontManagerInstance *FontManager
|
||||
fontManagerOnce sync.Once
|
||||
initErr error // Package-level variable to capture error from within sync.Once
|
||||
)
|
||||
|
||||
// InitFontManager initializes the FontManager singleton.
|
||||
// It must be called once at startup after ShaderManager has been initialized.
|
||||
func InitFontManager(cfg *db.AppConfig) error {
|
||||
fontManagerOnce.Do(func() {
|
||||
|
||||
// Dependency check: Ensure ShaderManager is ready
|
||||
sm := shaderManager.Get()
|
||||
if sm == nil {
|
||||
initErr = fmt.Errorf("cannot initialize FontManager because ShaderManager is not initialized")
|
||||
return // Exit the anonymous function
|
||||
}
|
||||
|
||||
fm := &FontManager{
|
||||
Fonts: make(map[string]*FontEntry),
|
||||
}
|
||||
|
||||
fontPath := filepath.Join(cfg.AssetsDir, "fonts")
|
||||
if err := fm.loadFonts(fontPath, cfg); err != nil {
|
||||
initErr = fmt.Errorf("could not load fonts from '%s': %w", fontPath, err)
|
||||
return
|
||||
}
|
||||
|
||||
// --- NEW: Create the scrambled font after loading normal fonts ---
|
||||
// We will use "DejaVuSans" as the base. If you use a different default font,
|
||||
// change this string.
|
||||
baseFontForScramble := "SpaceMonoNerdFont-Regular"
|
||||
if _, ok := fm.Fonts[baseFontForScramble]; ok {
|
||||
if err := fm.CreateScrambledFont(baseFontForScramble, "Scrambled"); err != nil {
|
||||
// This is not a fatal error for the whole application, so we just log it.
|
||||
}
|
||||
} else {
|
||||
}
|
||||
// --- END NEW ---
|
||||
|
||||
// Create reusable GPU objects for string texture rendering
|
||||
gl.GenFramebuffers(1, &fm.stringFBO)
|
||||
gl.GenVertexArrays(1, &fm.stringVAO)
|
||||
gl.GenBuffers(1, &fm.stringVBO)
|
||||
|
||||
// Set up VAO attribute layout once
|
||||
gl.BindVertexArray(fm.stringVAO)
|
||||
gl.BindBuffer(gl.ARRAY_BUFFER, fm.stringVBO)
|
||||
gl.BufferData(gl.ARRAY_BUFFER, 6*4*4, nil, gl.DYNAMIC_DRAW) // 6 vertices * 4 floats * 4 bytes
|
||||
gl.EnableVertexAttribArray(0)
|
||||
gl.VertexAttribPointerWithOffset(0, 2, gl.FLOAT, false, 4*4, 0)
|
||||
gl.EnableVertexAttribArray(1)
|
||||
gl.VertexAttribPointerWithOffset(1, 2, gl.FLOAT, false, 4*4, 2*4)
|
||||
gl.BindVertexArray(0)
|
||||
gl.BindBuffer(gl.ARRAY_BUFFER, 0)
|
||||
|
||||
fontManagerInstance = fm
|
||||
})
|
||||
|
||||
// Return the error that might have been captured inside the sync.Once block.
|
||||
return initErr
|
||||
}
|
||||
|
||||
// Get returns the singleton instance of the FontManager.
|
||||
// It will panic if InitFontManager has not been called successfully first.
|
||||
func Get() *FontManager {
|
||||
if fontManagerInstance == nil {
|
||||
panic("FontManager has not been initialized or initialization failed. Call InitFontManager at application startup.")
|
||||
}
|
||||
return fontManagerInstance
|
||||
}
|
||||
|
||||
// FontEntry combines related Font information together
|
||||
type FontEntry struct {
|
||||
Name string
|
||||
Font *ttf.Font
|
||||
Map *FontMap
|
||||
}
|
||||
|
||||
// FontMap contains the character map for a specific font
|
||||
type FontMap struct {
|
||||
FontMapTextureID uint32 // Memory address of the FontMapTexture within the GPU
|
||||
Width int32 // The width of the font map texture
|
||||
Height int32 // The height of the font map FontMap Texture
|
||||
Glyphs map[rune]Glyph // A map of runes and their locations within the Fontmap
|
||||
padding int32
|
||||
}
|
||||
|
||||
// TempGlyph is a temporary struct used only during font map creation.
|
||||
type TempGlyph struct {
|
||||
Rune rune
|
||||
Advance int
|
||||
surface *sdl.Surface
|
||||
}
|
||||
|
||||
// Glyph contains the rendering information for a single character
|
||||
type Glyph struct {
|
||||
Rune rune // The character this glyph represents.
|
||||
SrcRect sdl.Rect // The source rectangle in the texture atlas.
|
||||
Advance int // How far to advance the cursor after drawing this glyph.
|
||||
TextureID uint32 // The ID of the texture atlas this glyph belongs to.
|
||||
}
|
||||
|
||||
// loadFonts scans the fonts directory, loads all .ttf/.otf files, and creates a font map for each.
|
||||
func (fm *FontManager) loadFonts(fontDir string, cfg *db.AppConfig) error {
|
||||
files, err := os.ReadDir(fontDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not read font directory '%s': %w", fontDir, err)
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
if file.IsDir() {
|
||||
continue
|
||||
}
|
||||
fileName := file.Name()
|
||||
if strings.HasSuffix(strings.ToLower(fileName), ".ttf") || strings.HasSuffix(strings.ToLower(fileName), ".otf") {
|
||||
fontPath := filepath.Join(fontDir, fileName)
|
||||
baseFontSize := cfg.StandardFontSize
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not convert init_font_size to int: %w", err)
|
||||
}
|
||||
font, err := ttf.OpenFont(fontPath, baseFontSize) // Using a higher resolution for better quality
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
fontName := strings.TrimSuffix(fileName, filepath.Ext(fileName))
|
||||
fm.Fonts[fontName] = &FontEntry{
|
||||
Name: fontName,
|
||||
Font: font,
|
||||
Map: nil,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, fontEntry := range fm.Fonts {
|
||||
if err := fm.createFontMap(fontEntry, false, nil); err != nil {
|
||||
return fmt.Errorf("unable to create Font Map for font: %s : %v", fontEntry.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateScrambledFont generates a new font entry by scrambling the glyphs of a base font.
|
||||
func (fm *FontManager) CreateScrambledFont(baseFontName string, newFontName string) error {
|
||||
baseFont, ok := fm.Fonts[baseFontName]
|
||||
if !ok {
|
||||
return fmt.Errorf("base font '%s' not found", baseFontName)
|
||||
}
|
||||
|
||||
scrambledFontEntry := &FontEntry{
|
||||
Name: newFontName,
|
||||
Font: baseFont.Font, // It uses the same underlying TTF font object for rendering
|
||||
Map: nil,
|
||||
}
|
||||
|
||||
if err := fm.createFontMap(scrambledFontEntry, true, baseFont); err != nil {
|
||||
return fmt.Errorf("unable to create scrambled font map: %w", err)
|
||||
}
|
||||
|
||||
fm.Fonts[newFontName] = scrambledFontEntry
|
||||
return nil
|
||||
}
|
||||
|
||||
// ATTENTION: TED
|
||||
//
|
||||
// # Deterministic Fibonacci-Based Visual Pattern Generation for Character Data
|
||||
//
|
||||
// This method transforms rendered text characters into structured visual patterns that produce
|
||||
// character-specific spatial frequency signatures while maintaining perfect reversibility.
|
||||
//
|
||||
// The technology addresses a fundamental limitation in subliminal text display: conventional
|
||||
// approaches either use unmodified text (consciously readable, defeating the subliminal purpose)
|
||||
// or simple blurring/masking (destroys information content and character-specific structure).
|
||||
// Our method preserves complete character information in a transformed visual state that remains
|
||||
// illegible to conscious observation while generating structured spatial patterns.
|
||||
//
|
||||
// The approach treats each rendered character as a two-dimensional grid and applies deterministic
|
||||
// geometric transformations parameterized by Fibonacci sequence values. The character's Unicode
|
||||
// value seeds all transformation parameters, ensuring identical characters produce identical
|
||||
// patterns while different characters generate distinct spatial signatures. Multiple transformation
|
||||
// stages compound to create high-entropy visual output with underlying mathematical structure.
|
||||
//
|
||||
// Key differentiators from existing solutions:
|
||||
// - Operates on rendered visual data rather than underlying text data or encrypted values
|
||||
// - Generates character-specific spatial frequency patterns rather than uniform noise
|
||||
// - Uses Fibonacci-sequence-derived parameters to create mathematically structured transformations
|
||||
// - Maintains pixel-perfect reversibility despite high visual entropy
|
||||
// - Produces deterministic, reproducible patterns for each character
|
||||
//
|
||||
// Within our Rhema Wave generation system, these transformed characters create spatial interference
|
||||
// patterns designed for subconscious processing. Each character contributes a unique frequency
|
||||
// signature to the overall visual field, with Fibonacci-based geometry intended to create harmonic
|
||||
// relationships between character patterns. The result is a consciously illegible display that
|
||||
// maintains structured information content in the spatial domain, forming a component of the
|
||||
// broader Rhema Wave output.
|
||||
func (fm *FontManager) scrambleSurface(surface *sdl.Surface, r rune) error {
|
||||
if surface.W < 8 || surface.H < 8 {
|
||||
return nil
|
||||
}
|
||||
if err := surface.Lock(); err != nil {
|
||||
return fmt.Errorf("could not lock surface for scrambling: %w", err)
|
||||
}
|
||||
defer surface.Unlock()
|
||||
|
||||
W := int(surface.W)
|
||||
H := int(surface.H)
|
||||
|
||||
// Use much finer grid for complete illegibility
|
||||
// Fibonacci number determines grid size: 5, 8, or 13
|
||||
fib := []int{5, 8, 13}
|
||||
fibIndex := int(r) % len(fib)
|
||||
gridSize := fib[fibIndex]
|
||||
|
||||
// Ensure we have at least 8x8 grid for strong scrambling
|
||||
if gridSize < 8 {
|
||||
gridSize = 8
|
||||
}
|
||||
|
||||
blockW := W / gridSize
|
||||
blockH := H / gridSize
|
||||
|
||||
if blockW == 0 || blockH == 0 {
|
||||
// Surface too small, fall back to 4x4
|
||||
gridSize = 4
|
||||
blockW = W / gridSize
|
||||
blockH = H / gridSize
|
||||
if blockW == 0 || blockH == 0 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
numBlocks := gridSize * gridSize
|
||||
|
||||
// Create complete permutation using Fibonacci linear congruential generator
|
||||
blockIndices := make([]int, numBlocks)
|
||||
|
||||
// Use Fibonacci numbers for the LCG parameters
|
||||
// F(7)=13, F(8)=21, F(9)=34, F(10)=55, F(11)=89
|
||||
fibSeq := []int{13, 21, 34, 55, 89, 144}
|
||||
multiplier := fibSeq[int(r)%len(fibSeq)]
|
||||
increment := fibSeq[(int(r)+1)%len(fibSeq)]
|
||||
|
||||
// Generate pseudo-random permutation
|
||||
for i := 0; i < numBlocks; i++ {
|
||||
blockIndices[i] = (multiplier*i + increment*int(r)) % numBlocks
|
||||
}
|
||||
|
||||
// Additional scrambling: apply multiple Fibonacci-based transformations
|
||||
numTransforms := 3 + (int(r) % 5) // 3-7 transformations
|
||||
|
||||
for t := 0; t < numTransforms; t++ {
|
||||
transformType := (int(r) + t*fibSeq[t%len(fibSeq)]) % 5
|
||||
|
||||
switch transformType {
|
||||
case 0: // Reverse sections
|
||||
sectionSize := gridSize / 2
|
||||
for i := 0; i < numBlocks; i += sectionSize {
|
||||
end := i + sectionSize
|
||||
if end > numBlocks {
|
||||
end = numBlocks
|
||||
}
|
||||
reverseSlice(blockIndices[i:end])
|
||||
}
|
||||
|
||||
case 1: // Spiral rotation
|
||||
// Rotate blocks in a spiral pattern
|
||||
for layer := 0; layer < gridSize/2; layer++ {
|
||||
rotateLayer(blockIndices, gridSize, layer)
|
||||
}
|
||||
|
||||
case 2: // Fibonacci walk swap
|
||||
step := fib[t%len(fib)]
|
||||
for i := 0; i < numBlocks-step; i += step {
|
||||
blockIndices[i], blockIndices[i+step] = blockIndices[i+step], blockIndices[i]
|
||||
}
|
||||
|
||||
case 3: // Checkerboard transpose
|
||||
for i := 0; i < gridSize; i++ {
|
||||
for j := 0; j < gridSize; j++ {
|
||||
if (i+j)%2 == 0 {
|
||||
idx1 := i*gridSize + j
|
||||
idx2 := j*gridSize + i
|
||||
if idx2 < numBlocks {
|
||||
blockIndices[idx1], blockIndices[idx2] = blockIndices[idx2], blockIndices[idx1]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case 4: // Random-walk permutation
|
||||
walkLen := fib[(t+1)%len(fib)]
|
||||
pos := int(r+rune(t)) % numBlocks
|
||||
for w := 0; w < walkLen && pos < numBlocks-1; w++ {
|
||||
nextPos := (pos + fib[w%len(fib)]) % numBlocks
|
||||
blockIndices[pos], blockIndices[nextPos] = blockIndices[nextPos], blockIndices[pos]
|
||||
pos = nextPos
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Copy pixels to perform the scrambling
|
||||
pixels := surface.Pixels()
|
||||
bytesPerPixel := int(surface.Format.BytesPerPixel)
|
||||
pitch := int(surface.Pitch)
|
||||
originalPixels := make([]byte, len(pixels))
|
||||
copy(originalPixels, pixels)
|
||||
|
||||
// Rearrange all blocks
|
||||
for dstIdx := 0; dstIdx < numBlocks; dstIdx++ {
|
||||
srcIdx := blockIndices[dstIdx]
|
||||
|
||||
srcCol := srcIdx % gridSize
|
||||
srcRow := srcIdx / gridSize
|
||||
dstCol := dstIdx % gridSize
|
||||
dstRow := dstIdx / gridSize
|
||||
|
||||
srcX := srcCol * blockW
|
||||
srcY := srcRow * blockH
|
||||
dstX := dstCol * blockW
|
||||
dstY := dstRow * blockH
|
||||
|
||||
// Copy block pixel by pixel
|
||||
for y := 0; y < blockH; y++ {
|
||||
if srcY+y >= H || dstY+y >= H {
|
||||
continue
|
||||
}
|
||||
|
||||
srcOffset := (srcY+y)*pitch + srcX*bytesPerPixel
|
||||
dstOffset := (dstY+y)*pitch + dstX*bytesPerPixel
|
||||
|
||||
rowLen := blockW * bytesPerPixel
|
||||
if srcX+blockW > W {
|
||||
rowLen = (W - srcX) * bytesPerPixel
|
||||
}
|
||||
if dstX+blockW > W {
|
||||
maxLen := (W - dstX) * bytesPerPixel
|
||||
if maxLen < rowLen {
|
||||
rowLen = maxLen
|
||||
}
|
||||
}
|
||||
|
||||
if srcOffset+rowLen <= len(originalPixels) && dstOffset+rowLen <= len(pixels) {
|
||||
copy(pixels[dstOffset:dstOffset+rowLen], originalPixels[srcOffset:srcOffset+rowLen])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helper function to reverse a slice
|
||||
func reverseSlice(arr []int) {
|
||||
for i, j := 0, len(arr)-1; i < j; i, j = i+1, j-1 {
|
||||
arr[i], arr[j] = arr[j], arr[i]
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to rotate a layer in spiral pattern
|
||||
func rotateLayer(arr []int, gridSize, layer int) {
|
||||
if layer >= gridSize/2 {
|
||||
return
|
||||
}
|
||||
|
||||
// Extract the layer elements
|
||||
var temp []int
|
||||
|
||||
// Top edge
|
||||
for i := layer; i < gridSize-layer; i++ {
|
||||
temp = append(temp, arr[layer*gridSize+i])
|
||||
}
|
||||
// Right edge
|
||||
for i := layer + 1; i < gridSize-layer; i++ {
|
||||
temp = append(temp, arr[i*gridSize+(gridSize-layer-1)])
|
||||
}
|
||||
// Bottom edge
|
||||
if gridSize-layer-1 > layer {
|
||||
for i := gridSize - layer - 2; i >= layer; i-- {
|
||||
temp = append(temp, arr[(gridSize-layer-1)*gridSize+i])
|
||||
}
|
||||
}
|
||||
// Left edge
|
||||
if gridSize-layer-1 > layer {
|
||||
for i := gridSize - layer - 2; i > layer; i-- {
|
||||
temp = append(temp, arr[i*gridSize+layer])
|
||||
}
|
||||
}
|
||||
|
||||
// Rotate the temp array by 1 position
|
||||
if len(temp) > 0 {
|
||||
last := temp[len(temp)-1]
|
||||
copy(temp[1:], temp[0:len(temp)-1])
|
||||
temp[0] = last
|
||||
}
|
||||
|
||||
// Put back the rotated elements
|
||||
idx := 0
|
||||
// Top edge
|
||||
for i := layer; i < gridSize-layer && idx < len(temp); i++ {
|
||||
arr[layer*gridSize+i] = temp[idx]
|
||||
idx++
|
||||
}
|
||||
// Right edge
|
||||
for i := layer + 1; i < gridSize-layer && idx < len(temp); i++ {
|
||||
arr[i*gridSize+(gridSize-layer-1)] = temp[idx]
|
||||
idx++
|
||||
}
|
||||
// Bottom edge
|
||||
if gridSize-layer-1 > layer {
|
||||
for i := gridSize - layer - 2; i >= layer && idx < len(temp); i-- {
|
||||
arr[(gridSize-layer-1)*gridSize+i] = temp[idx]
|
||||
idx++
|
||||
}
|
||||
}
|
||||
// Left edge
|
||||
if gridSize-layer-1 > layer {
|
||||
for i := gridSize - layer - 2; i > layer && idx < len(temp); i-- {
|
||||
arr[i*gridSize+layer] = temp[idx]
|
||||
idx++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// createFontMap generates a texture atlas for a given font entry.
|
||||
func (fm *FontManager) createFontMap(font *FontEntry, scramble bool, baseFont *FontEntry) error {
|
||||
// var chars []rune
|
||||
// for i := rune(32); i <= 126; i++ { // Common ASCII characters
|
||||
// chars = append(chars, i)
|
||||
// }
|
||||
|
||||
// Standard ASCII (A-Z, a-z, 0-9) + punctuation
|
||||
chars := []rune{
|
||||
// Numbers 0-9
|
||||
48, 49, 50, 51, 52, 53, 54, 55, 56, 57, // 0-9
|
||||
|
||||
// Uppercase A-Z
|
||||
65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, // A-O
|
||||
80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, // P-Z
|
||||
|
||||
// Lowercase a-z
|
||||
97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, // a-m
|
||||
110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, // n-z
|
||||
|
||||
// Basic punctuation (ASCII 32-47, 58-64, 91-96, 123-126)
|
||||
32, // Space
|
||||
33, // !
|
||||
34, // "
|
||||
35, // #
|
||||
36, // $
|
||||
37, // %
|
||||
38, // &
|
||||
39, // '
|
||||
40, // (
|
||||
41, // )
|
||||
42, // *
|
||||
43, // +
|
||||
44, // ,
|
||||
45, // -
|
||||
46, // .
|
||||
47, // /
|
||||
58, // :
|
||||
59, // ;
|
||||
60, // <
|
||||
61, // =
|
||||
62, // >
|
||||
63, // ?
|
||||
64, // @
|
||||
91, // [
|
||||
92, // \
|
||||
93, // ]
|
||||
94, // ^
|
||||
95, // _
|
||||
96, // `
|
||||
123, // {
|
||||
124, // |
|
||||
125, // }
|
||||
126, // ~
|
||||
|
||||
// Common Unicode punctuation
|
||||
161, // ¡
|
||||
191, // ¿
|
||||
8211, // –
|
||||
8212, // —
|
||||
8216, // ‘
|
||||
8217, // ’
|
||||
8220, // “
|
||||
8221, // ”
|
||||
8226, // •
|
||||
8230, // …
|
||||
8240, // ‰
|
||||
8364, // €
|
||||
162, // ¢
|
||||
163, // £
|
||||
165, // ¥
|
||||
169, // ©
|
||||
174, // ®
|
||||
8482, // ™
|
||||
232, // è
|
||||
233, // é
|
||||
10003, // checkmark
|
||||
}
|
||||
tempGlyphs := make([]TempGlyph, 0, len(chars))
|
||||
var totalWidth, maxHeight int32
|
||||
padding := int32(2) // 2 pixels of padding between glyphs
|
||||
|
||||
// If scrambling, use the base font's metrics but our new entry's name
|
||||
fontToRenderFrom := font
|
||||
if scramble && baseFont != nil {
|
||||
fontToRenderFrom = baseFont
|
||||
}
|
||||
|
||||
for _, char := range chars {
|
||||
tempGlyph, err := fm.createTempGlyph(fontToRenderFrom, char)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// --- NEW: Scramble the surface if requested ---
|
||||
if scramble {
|
||||
if err := fm.scrambleSurface(tempGlyph.surface, char); err != nil {
|
||||
// We can continue, it will just use the unscrambled glyph
|
||||
}
|
||||
}
|
||||
// --- END NEW ---
|
||||
|
||||
tempGlyphs = append(tempGlyphs, tempGlyph)
|
||||
totalWidth += tempGlyph.surface.W + padding
|
||||
if tempGlyph.surface.H > maxHeight {
|
||||
maxHeight = tempGlyph.surface.H
|
||||
}
|
||||
}
|
||||
|
||||
atlasSurface, err := sdl.CreateRGBSurface(0, totalWidth, maxHeight, 32, 0x00ff0000, 0x0000ff00, 0x000000ff, 0xff000000)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not create atlas surface: %w", err)
|
||||
}
|
||||
defer atlasSurface.Free()
|
||||
|
||||
fontMap := &FontMap{
|
||||
Glyphs: make(map[rune]Glyph),
|
||||
padding: padding,
|
||||
}
|
||||
|
||||
var currentX int32 = 0
|
||||
for _, tempGlyph := range tempGlyphs {
|
||||
dstRect := sdl.Rect{X: currentX, Y: 0, W: tempGlyph.surface.W, H: tempGlyph.surface.H}
|
||||
if err := tempGlyph.surface.Blit(nil, atlasSurface, &dstRect); err != nil {
|
||||
tempGlyph.surface.Free()
|
||||
continue
|
||||
}
|
||||
|
||||
fontMap.Glyphs[tempGlyph.Rune] = Glyph{
|
||||
Rune: tempGlyph.Rune,
|
||||
Advance: tempGlyph.Advance,
|
||||
SrcRect: dstRect,
|
||||
}
|
||||
|
||||
currentX += tempGlyph.surface.W + padding
|
||||
tempGlyph.surface.Free()
|
||||
}
|
||||
|
||||
textureID, err := graphics.CreateTextureFromSurface(atlasSurface)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not create texture from atlas surface: %w", err)
|
||||
}
|
||||
|
||||
fontMap.FontMapTextureID = textureID
|
||||
fontMap.Width = atlasSurface.W
|
||||
fontMap.Height = atlasSurface.H
|
||||
|
||||
for r, g := range fontMap.Glyphs {
|
||||
g.TextureID = textureID
|
||||
fontMap.Glyphs[r] = g
|
||||
}
|
||||
|
||||
font.Map = fontMap
|
||||
return nil
|
||||
}
|
||||
|
||||
// createTempGlyph renders a single character to a temporary SDL surface.
|
||||
func (fm *FontManager) createTempGlyph(font *FontEntry, char rune) (TempGlyph, error) {
|
||||
runeSurface, err := font.Font.RenderGlyphBlended(char, sdl.Color{R: 255, G: 255, B: 255, A: 255})
|
||||
if err != nil {
|
||||
return TempGlyph{}, fmt.Errorf("unable to create glyph for char '%c': %w", char, err)
|
||||
}
|
||||
|
||||
metrics, err := font.Font.GlyphMetrics(char)
|
||||
if err != nil {
|
||||
runeSurface.Free()
|
||||
return TempGlyph{}, fmt.Errorf("unable to fetch glyph metrics for char '%c': %w", char, err)
|
||||
}
|
||||
|
||||
return TempGlyph{
|
||||
Rune: char,
|
||||
Advance: metrics.Advance,
|
||||
surface: runeSurface,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetFont retrieves a loaded font by its name.
|
||||
func (fm *FontManager) GetFont(name string) (*FontEntry, bool) {
|
||||
fontEntry, ok := fm.Fonts[name]
|
||||
return fontEntry, ok
|
||||
}
|
||||
|
||||
// GetGlyph retrieves the rendering data for a specific character from a font's map.
|
||||
func (fm *FontManager) GetGlyph(fontName string, r rune) (Glyph, bool) {
|
||||
fontEntry, ok := fm.Fonts[fontName]
|
||||
if !ok || fontEntry.Map == nil {
|
||||
return Glyph{}, false
|
||||
}
|
||||
glyph, ok := fontEntry.Map.Glyphs[r]
|
||||
return glyph, ok
|
||||
}
|
||||
|
||||
// GetFontNames returns a slice of all loaded font names.
|
||||
func (fm *FontManager) GetFontNames() []string {
|
||||
names := make([]string, 0, len(fm.Fonts))
|
||||
for name := range fm.Fonts {
|
||||
names = append(names, name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// Close frees all loaded font resources, including the generated textures.
|
||||
func (fm *FontManager) Close() {
|
||||
for _, fontEntry := range fm.Fonts {
|
||||
if fontEntry.Map != nil && fontEntry.Map.FontMapTextureID != 0 {
|
||||
gl.DeleteTextures(1, &fontEntry.Map.FontMapTextureID)
|
||||
}
|
||||
if fontEntry.Font != nil {
|
||||
fontEntry.Font.Close()
|
||||
}
|
||||
}
|
||||
|
||||
if fm.stringFBO != 0 {
|
||||
gl.DeleteFramebuffers(1, &fm.stringFBO)
|
||||
}
|
||||
if fm.stringVAO != 0 {
|
||||
gl.DeleteVertexArrays(1, &fm.stringVAO)
|
||||
}
|
||||
if fm.stringVBO != 0 {
|
||||
gl.DeleteBuffers(1, &fm.stringVBO)
|
||||
}
|
||||
}
|
||||
|
||||
func (fm *FontManager) CreateStringTexture(fontName string, text string, shader *shaderManager.Shader) (textureID uint32, width int32, height int32, err error) {
|
||||
shaderID := shader.ProgramID
|
||||
if fontName == "" || text == "" {
|
||||
return 0, 0, 0, fmt.Errorf("font name and text cannot be blank")
|
||||
}
|
||||
|
||||
selectedFont, ok := fm.Fonts[fontName]
|
||||
if !ok || selectedFont.Map == nil {
|
||||
return 0, 0, 0, fmt.Errorf("font '%s' not found or its font map is not created", fontName)
|
||||
}
|
||||
|
||||
var totalWidth, maxHeight int32
|
||||
glyphsToRender := make([]Glyph, 0, len(text))
|
||||
|
||||
for _, r := range text {
|
||||
g, ok := selectedFont.Map.Glyphs[r]
|
||||
if !ok {
|
||||
g = selectedFont.Map.Glyphs['?'] // Fallback
|
||||
}
|
||||
totalWidth += g.SrcRect.W + selectedFont.Map.padding
|
||||
if g.SrcRect.H > maxHeight {
|
||||
maxHeight = g.SrcRect.H
|
||||
}
|
||||
glyphsToRender = append(glyphsToRender, g)
|
||||
}
|
||||
|
||||
if totalWidth <= 0 || maxHeight <= 0 {
|
||||
return 0, 0, 0, fmt.Errorf("calculated texture dimensions are invalid: %dx%d", totalWidth, maxHeight)
|
||||
}
|
||||
|
||||
var lastFBO int32
|
||||
gl.GetIntegerv(gl.FRAMEBUFFER_BINDING, &lastFBO)
|
||||
var lastViewport [4]int32
|
||||
gl.GetIntegerv(gl.VIEWPORT, &lastViewport[0])
|
||||
|
||||
gl.BindFramebuffer(gl.FRAMEBUFFER, fm.stringFBO)
|
||||
|
||||
var newTextureID uint32
|
||||
gl.GenTextures(1, &newTextureID)
|
||||
gl.BindTexture(gl.TEXTURE_2D, newTextureID)
|
||||
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
|
||||
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
|
||||
gl.PixelStorei(gl.UNPACK_ALIGNMENT, 1)
|
||||
|
||||
gl.TexImage2D(gl.TEXTURE_2D, 0, gl.RGBA, totalWidth, maxHeight, 0, gl.RGBA, gl.UNSIGNED_BYTE, nil)
|
||||
gl.FramebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, newTextureID, 0)
|
||||
|
||||
if gl.CheckFramebufferStatus(gl.FRAMEBUFFER) != gl.FRAMEBUFFER_COMPLETE {
|
||||
gl.BindFramebuffer(gl.FRAMEBUFFER, uint32(lastFBO))
|
||||
gl.DeleteTextures(1, &newTextureID)
|
||||
return 0, 0, 0, fmt.Errorf("framebuffer is not complete")
|
||||
}
|
||||
|
||||
gl.Viewport(0, 0, totalWidth, maxHeight)
|
||||
gl.ClearColor(0.0, 0.0, 0.0, 0.0)
|
||||
gl.Clear(gl.COLOR_BUFFER_BIT)
|
||||
gl.UseProgram(shaderID)
|
||||
|
||||
projection := mgl32.Ortho(0, float32(totalWidth), 0, float32(maxHeight), -1, 1)
|
||||
gl.UniformMatrix4fv(shader.LocMVPMatrix, 1, false, &projection[0])
|
||||
|
||||
if shader.LocTextColor != -1 {
|
||||
gl.Uniform4f(shader.LocTextColor, 1.0, 1.0, 1.0, 1.0)
|
||||
}
|
||||
|
||||
gl.ActiveTexture(gl.TEXTURE0)
|
||||
gl.Uniform1i(shader.LocTexture, 0)
|
||||
gl.Enable(gl.BLEND)
|
||||
gl.BlendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
|
||||
gl.BindTexture(gl.TEXTURE_2D, selectedFont.Map.FontMapTextureID)
|
||||
|
||||
gl.BindVertexArray(fm.stringVAO)
|
||||
gl.BindBuffer(gl.ARRAY_BUFFER, fm.stringVBO)
|
||||
|
||||
var currentX float32
|
||||
atlasW, atlasH := float32(selectedFont.Map.Width), float32(selectedFont.Map.Height)
|
||||
|
||||
for _, glyph := range glyphsToRender {
|
||||
xPos := currentX
|
||||
yPos := float32(0)
|
||||
w, h := float32(glyph.SrcRect.W), float32(glyph.SrcRect.H)
|
||||
u0 := float32(glyph.SrcRect.X) / atlasW
|
||||
v0 := float32(glyph.SrcRect.Y) / atlasH
|
||||
u1 := u0 + w/atlasW
|
||||
v1 := v0 + h/atlasH
|
||||
vertices := []float32{
|
||||
xPos, yPos + h, u0, v0,
|
||||
xPos, yPos, u0, v1,
|
||||
xPos + w, yPos, u1, v1,
|
||||
xPos, yPos + h, u0, v0,
|
||||
xPos + w, yPos, u1, v1,
|
||||
xPos + w, yPos + h, u1, v0,
|
||||
}
|
||||
gl.BufferSubData(gl.ARRAY_BUFFER, 0, len(vertices)*4, gl.Ptr(vertices))
|
||||
gl.DrawArrays(gl.TRIANGLES, 0, 6)
|
||||
currentX += float32(glyph.Advance)
|
||||
}
|
||||
|
||||
gl.BindVertexArray(0)
|
||||
gl.BindFramebuffer(gl.FRAMEBUFFER, uint32(lastFBO))
|
||||
gl.Viewport(lastViewport[0], lastViewport[1], lastViewport[2], lastViewport[3])
|
||||
gl.Disable(gl.BLEND)
|
||||
|
||||
return newTextureID, totalWidth, maxHeight, nil
|
||||
}
|
||||
|
||||
// NewCreateStringTexture does the same thing as CreateStringTexture, except that it returns the width in pixels used by the glyphs
|
||||
// in the texture, rather than the width of the texture itself. This can be used for trimming.
|
||||
func (fm *FontManager) NewCreateStringTexture(fontName string, text string, shader *shaderManager.Shader) (textureID uint32, width float32, height int32, err error) {
|
||||
shaderID := shader.ProgramID
|
||||
if fontName == "" || text == "" {
|
||||
return 0, 0, 0, fmt.Errorf("font name and text cannot be blank")
|
||||
}
|
||||
|
||||
selectedFont, ok := fm.Fonts[fontName]
|
||||
if !ok || selectedFont.Map == nil {
|
||||
return 0, 0, 0, fmt.Errorf("font '%s' not found or its font map is not created", fontName)
|
||||
}
|
||||
|
||||
var totalWidth, maxHeight int32
|
||||
glyphsToRender := make([]Glyph, 0, len(text))
|
||||
|
||||
for _, r := range text {
|
||||
g, ok := selectedFont.Map.Glyphs[r]
|
||||
if !ok {
|
||||
g = selectedFont.Map.Glyphs['?'] // Fallback
|
||||
}
|
||||
totalWidth += g.SrcRect.W + selectedFont.Map.padding
|
||||
if g.SrcRect.H > maxHeight {
|
||||
maxHeight = g.SrcRect.H
|
||||
}
|
||||
glyphsToRender = append(glyphsToRender, g)
|
||||
}
|
||||
|
||||
if totalWidth <= 0 || maxHeight <= 0 {
|
||||
return 0, 0, 0, fmt.Errorf("calculated texture dimensions are invalid: %dx%d", totalWidth, maxHeight)
|
||||
}
|
||||
|
||||
var lastFBO int32
|
||||
gl.GetIntegerv(gl.FRAMEBUFFER_BINDING, &lastFBO)
|
||||
var lastViewport [4]int32
|
||||
gl.GetIntegerv(gl.VIEWPORT, &lastViewport[0])
|
||||
|
||||
gl.BindFramebuffer(gl.FRAMEBUFFER, fm.stringFBO)
|
||||
|
||||
var newTextureID uint32
|
||||
gl.GenTextures(1, &newTextureID)
|
||||
gl.BindTexture(gl.TEXTURE_2D, newTextureID)
|
||||
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
|
||||
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
|
||||
gl.PixelStorei(gl.UNPACK_ALIGNMENT, 1)
|
||||
|
||||
gl.TexImage2D(gl.TEXTURE_2D, 0, gl.RGBA, totalWidth, maxHeight, 0, gl.RGBA, gl.UNSIGNED_BYTE, nil)
|
||||
gl.FramebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, newTextureID, 0)
|
||||
|
||||
if gl.CheckFramebufferStatus(gl.FRAMEBUFFER) != gl.FRAMEBUFFER_COMPLETE {
|
||||
gl.BindFramebuffer(gl.FRAMEBUFFER, uint32(lastFBO))
|
||||
gl.DeleteTextures(1, &newTextureID)
|
||||
return 0, 0, 0, fmt.Errorf("framebuffer is not complete")
|
||||
}
|
||||
|
||||
gl.Viewport(0, 0, totalWidth, maxHeight)
|
||||
gl.ClearColor(0.0, 0.0, 0.0, 0.0)
|
||||
gl.Clear(gl.COLOR_BUFFER_BIT)
|
||||
gl.UseProgram(shaderID)
|
||||
|
||||
projection := mgl32.Ortho(0, float32(totalWidth), 0, float32(maxHeight), -1, 1)
|
||||
gl.UniformMatrix4fv(shader.LocMVPMatrix, 1, false, &projection[0])
|
||||
|
||||
if shader.LocTextColor != -1 {
|
||||
gl.Uniform4f(shader.LocTextColor, 1.0, 1.0, 1.0, 1.0)
|
||||
}
|
||||
|
||||
gl.ActiveTexture(gl.TEXTURE0)
|
||||
gl.Uniform1i(shader.LocTexture, 0)
|
||||
gl.Enable(gl.BLEND)
|
||||
gl.BlendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
|
||||
gl.BindTexture(gl.TEXTURE_2D, selectedFont.Map.FontMapTextureID)
|
||||
|
||||
gl.BindVertexArray(fm.stringVAO)
|
||||
gl.BindBuffer(gl.ARRAY_BUFFER, fm.stringVBO)
|
||||
|
||||
var currentX float32
|
||||
atlasW, atlasH := float32(selectedFont.Map.Width), float32(selectedFont.Map.Height)
|
||||
|
||||
for _, glyph := range glyphsToRender {
|
||||
xPos := currentX
|
||||
yPos := float32(0)
|
||||
w, h := float32(glyph.SrcRect.W), float32(glyph.SrcRect.H)
|
||||
u0 := float32(glyph.SrcRect.X) / atlasW
|
||||
v0 := float32(glyph.SrcRect.Y) / atlasH
|
||||
u1 := u0 + w/atlasW
|
||||
v1 := v0 + h/atlasH
|
||||
vertices := []float32{
|
||||
xPos, yPos + h, u0, v0,
|
||||
xPos, yPos, u0, v1,
|
||||
xPos + w, yPos, u1, v1,
|
||||
xPos, yPos + h, u0, v0,
|
||||
xPos + w, yPos, u1, v1,
|
||||
xPos + w, yPos + h, u1, v0,
|
||||
}
|
||||
gl.BufferSubData(gl.ARRAY_BUFFER, 0, len(vertices)*4, gl.Ptr(vertices))
|
||||
gl.DrawArrays(gl.TRIANGLES, 0, 6)
|
||||
currentX += float32(glyph.Advance)
|
||||
}
|
||||
|
||||
usedWidth := float32(currentX)
|
||||
|
||||
gl.BindVertexArray(0)
|
||||
gl.BindFramebuffer(gl.FRAMEBUFFER, uint32(lastFBO))
|
||||
gl.Viewport(lastViewport[0], lastViewport[1], lastViewport[2], lastViewport[3])
|
||||
gl.Disable(gl.BLEND)
|
||||
|
||||
return newTextureID, usedWidth, maxHeight, nil
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
package graphics
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/go-gl/gl/v3.3-core/gl"
|
||||
"github.com/veandco/go-sdl2/sdl"
|
||||
)
|
||||
|
||||
// CreateTextureFromSurface uploads an SDL surface to an OpenGL texture and returns the texture ID.
|
||||
func CreateTextureFromSurface(surface *sdl.Surface) (uint32, error) {
|
||||
if surface == nil {
|
||||
return 0, fmt.Errorf("CreateTextureFromSurface: input surface is nil")
|
||||
}
|
||||
|
||||
var textureID uint32
|
||||
var err error
|
||||
|
||||
surfaceToUpload := surface
|
||||
var convertedSurface *sdl.Surface = nil
|
||||
|
||||
sdlFormatEnum := surface.Format.Format
|
||||
var dataFormat uint32
|
||||
var internalFormat int32 = gl.RGBA8
|
||||
|
||||
if sdlFormatEnum == sdl.PIXELFORMAT_INDEX8 {
|
||||
convertedSurface, err = surface.ConvertFormat(sdl.PIXELFORMAT_ARGB8888, 0)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to convert INDEX8 surface to ARGB8888: %w", err)
|
||||
}
|
||||
defer convertedSurface.Free()
|
||||
|
||||
surfaceToUpload = convertedSurface
|
||||
sdlFormatEnum = surfaceToUpload.Format.Format
|
||||
}
|
||||
|
||||
switch sdlFormatEnum {
|
||||
case sdl.PIXELFORMAT_ARGB8888:
|
||||
dataFormat = gl.BGRA
|
||||
case sdl.PIXELFORMAT_ABGR8888:
|
||||
dataFormat = gl.RGBA
|
||||
case sdl.PIXELFORMAT_RGBA8888:
|
||||
dataFormat = gl.RGBA
|
||||
case sdl.PIXELFORMAT_BGRA8888:
|
||||
dataFormat = gl.BGRA
|
||||
default:
|
||||
return 0, fmt.Errorf("unhandled SDL pixel format for OpenGL upload: %s", sdl.GetPixelFormatName(uint(sdlFormatEnum)))
|
||||
}
|
||||
|
||||
for errCode := gl.GetError(); errCode != gl.NO_ERROR; errCode = gl.GetError() {
|
||||
log.Printf("OpenGL error pending before GenTextures: 0x%X", errCode)
|
||||
}
|
||||
|
||||
gl.GenTextures(1, &textureID)
|
||||
if errCode := gl.GetError(); errCode != gl.NO_ERROR {
|
||||
return 0, fmt.Errorf("OpenGL error 0x%X after GenTextures", errCode)
|
||||
}
|
||||
if textureID == 0 {
|
||||
return 0, fmt.Errorf("gl.GenTextures returned textureID 0")
|
||||
}
|
||||
|
||||
gl.BindTexture(gl.TEXTURE_2D, textureID)
|
||||
|
||||
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST)
|
||||
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST)
|
||||
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
|
||||
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
|
||||
|
||||
gl.PixelStorei(gl.UNPACK_ALIGNMENT, 1)
|
||||
|
||||
expectedPitch := surfaceToUpload.W * int32(surfaceToUpload.Format.BytesPerPixel)
|
||||
if int32(surfaceToUpload.Pitch) != expectedPitch {
|
||||
gl.PixelStorei(gl.UNPACK_ROW_LENGTH, surfaceToUpload.Pitch/int32(surfaceToUpload.Format.BytesPerPixel))
|
||||
} else {
|
||||
gl.PixelStorei(gl.UNPACK_ROW_LENGTH, 0)
|
||||
}
|
||||
|
||||
gl.TexImage2D(
|
||||
gl.TEXTURE_2D,
|
||||
0,
|
||||
internalFormat,
|
||||
surfaceToUpload.W,
|
||||
surfaceToUpload.H,
|
||||
0,
|
||||
dataFormat,
|
||||
gl.UNSIGNED_BYTE,
|
||||
gl.Ptr(surfaceToUpload.Pixels()),
|
||||
)
|
||||
|
||||
gl.PixelStorei(gl.UNPACK_ALIGNMENT, 4)
|
||||
gl.PixelStorei(gl.UNPACK_ROW_LENGTH, 0)
|
||||
|
||||
if glErr := gl.GetError(); glErr != gl.NO_ERROR {
|
||||
gl.DeleteTextures(1, &textureID)
|
||||
return 0, fmt.Errorf("OpenGL error 0x%X after TexImage2D", glErr)
|
||||
}
|
||||
|
||||
gl.BindTexture(gl.TEXTURE_2D, 0)
|
||||
return textureID, nil
|
||||
}
|
||||
|
||||
// CheckGLError queries OpenGL for errors and returns the first one found.
|
||||
func CheckGLError() error {
|
||||
errCode := gl.GetError()
|
||||
if errCode == gl.NO_ERROR {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch errCode {
|
||||
case gl.INVALID_ENUM:
|
||||
return fmt.Errorf("INVALID_ENUM")
|
||||
case gl.INVALID_VALUE:
|
||||
return fmt.Errorf("INVALID_VALUE")
|
||||
case gl.INVALID_OPERATION:
|
||||
return fmt.Errorf("INVALID_OPERATION")
|
||||
case gl.STACK_OVERFLOW:
|
||||
return fmt.Errorf("STACK_OVERFLOW")
|
||||
case gl.STACK_UNDERFLOW:
|
||||
return fmt.Errorf("STACK_UNDERFLOW")
|
||||
case gl.OUT_OF_MEMORY:
|
||||
return fmt.Errorf("OUT_OF_MEMORY")
|
||||
case gl.INVALID_FRAMEBUFFER_OPERATION:
|
||||
return fmt.Errorf("INVALID_FRAMEBUFFER_OPERATION")
|
||||
default:
|
||||
return fmt.Errorf("Unknown GL error: 0x%X", errCode)
|
||||
}
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
package graphics
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/go-gl/gl/v3.3-core/gl"
|
||||
)
|
||||
|
||||
const (
|
||||
VirtualWidth int32 = 1920
|
||||
VirtualHeight int32 = 1080
|
||||
)
|
||||
|
||||
var (
|
||||
vfbo uint32
|
||||
vfboTexture uint32
|
||||
vfboDepthStencil uint32
|
||||
blitVAO uint32
|
||||
blitVBO uint32
|
||||
blitShaderProg uint32
|
||||
nativeW, nativeH int32
|
||||
)
|
||||
|
||||
// InitVirtualFramebuffer creates the FBO at virtual resolution and the blit shader.
|
||||
// Call after OpenGL context is created.
|
||||
func InitVirtualFramebuffer(displayW, displayH int32) error {
|
||||
nativeW = displayW
|
||||
nativeH = displayH
|
||||
|
||||
// --- FBO setup ---
|
||||
gl.GenFramebuffers(1, &vfbo)
|
||||
gl.BindFramebuffer(gl.FRAMEBUFFER, vfbo)
|
||||
|
||||
gl.GenTextures(1, &vfboTexture)
|
||||
gl.BindTexture(gl.TEXTURE_2D, vfboTexture)
|
||||
gl.TexImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, VirtualWidth, VirtualHeight, 0, gl.RGBA, gl.UNSIGNED_BYTE, nil)
|
||||
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
|
||||
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
|
||||
gl.FramebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, vfboTexture, 0)
|
||||
|
||||
// Depth/stencil renderbuffer
|
||||
gl.GenRenderbuffers(1, &vfboDepthStencil)
|
||||
gl.BindRenderbuffer(gl.RENDERBUFFER, vfboDepthStencil)
|
||||
gl.RenderbufferStorage(gl.RENDERBUFFER, gl.DEPTH24_STENCIL8, VirtualWidth, VirtualHeight)
|
||||
gl.FramebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_STENCIL_ATTACHMENT, gl.RENDERBUFFER, vfboDepthStencil)
|
||||
|
||||
if status := gl.CheckFramebufferStatus(gl.FRAMEBUFFER); status != gl.FRAMEBUFFER_COMPLETE {
|
||||
return fmt.Errorf("virtual framebuffer incomplete: 0x%X", status)
|
||||
}
|
||||
|
||||
gl.BindFramebuffer(gl.FRAMEBUFFER, 0)
|
||||
|
||||
// --- Fullscreen quad ---
|
||||
vertices := []float32{
|
||||
// pos (x,y) texcoord (u,v)
|
||||
-1, -1, 0, 0,
|
||||
1, -1, 1, 0,
|
||||
-1, 1, 0, 1,
|
||||
1, 1, 1, 1,
|
||||
}
|
||||
|
||||
gl.GenVertexArrays(1, &blitVAO)
|
||||
gl.GenBuffers(1, &blitVBO)
|
||||
gl.BindVertexArray(blitVAO)
|
||||
gl.BindBuffer(gl.ARRAY_BUFFER, blitVBO)
|
||||
gl.BufferData(gl.ARRAY_BUFFER, len(vertices)*4, gl.Ptr(vertices), gl.STATIC_DRAW)
|
||||
|
||||
gl.EnableVertexAttribArray(0)
|
||||
gl.VertexAttribPointerWithOffset(0, 2, gl.FLOAT, false, 4*4, 0)
|
||||
gl.EnableVertexAttribArray(1)
|
||||
gl.VertexAttribPointerWithOffset(1, 2, gl.FLOAT, false, 4*4, 2*4)
|
||||
|
||||
gl.BindVertexArray(0)
|
||||
|
||||
// --- Blit shader ---
|
||||
var err error
|
||||
blitShaderProg, err = compileBlitShader()
|
||||
if err != nil {
|
||||
return fmt.Errorf("blit shader compilation failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// BeginVirtualRender binds the FBO so all subsequent draws go to the virtual 1080p buffer.
|
||||
func BeginVirtualRender() {
|
||||
gl.BindFramebuffer(gl.FRAMEBUFFER, vfbo)
|
||||
gl.Viewport(0, 0, VirtualWidth, VirtualHeight)
|
||||
}
|
||||
|
||||
// BlitToScreen draws the virtual buffer to the actual screen at native resolution.
|
||||
func BlitToScreen() {
|
||||
gl.BindFramebuffer(gl.FRAMEBUFFER, 0)
|
||||
gl.Viewport(0, 0, nativeW, nativeH)
|
||||
gl.ClearColor(0, 0, 0, 1)
|
||||
gl.Clear(gl.COLOR_BUFFER_BIT)
|
||||
|
||||
gl.UseProgram(blitShaderProg)
|
||||
gl.ActiveTexture(gl.TEXTURE0)
|
||||
gl.BindTexture(gl.TEXTURE_2D, vfboTexture)
|
||||
|
||||
gl.BindVertexArray(blitVAO)
|
||||
gl.DrawArrays(gl.TRIANGLE_STRIP, 0, 4)
|
||||
gl.BindVertexArray(0)
|
||||
}
|
||||
|
||||
// DestroyVirtualFramebuffer cleans up all FBO resources.
|
||||
func DestroyVirtualFramebuffer() {
|
||||
if blitShaderProg != 0 {
|
||||
gl.DeleteProgram(blitShaderProg)
|
||||
}
|
||||
if blitVBO != 0 {
|
||||
gl.DeleteBuffers(1, &blitVBO)
|
||||
}
|
||||
if blitVAO != 0 {
|
||||
gl.DeleteVertexArrays(1, &blitVAO)
|
||||
}
|
||||
if vfboDepthStencil != 0 {
|
||||
gl.DeleteRenderbuffers(1, &vfboDepthStencil)
|
||||
}
|
||||
if vfboTexture != 0 {
|
||||
gl.DeleteTextures(1, &vfboTexture)
|
||||
}
|
||||
if vfbo != 0 {
|
||||
gl.DeleteFramebuffers(1, &vfbo)
|
||||
}
|
||||
}
|
||||
|
||||
func compileBlitShader() (uint32, error) {
|
||||
vertSrc := `#version 330 core
|
||||
layout(location = 0) in vec2 aPos;
|
||||
layout(location = 1) in vec2 aTexCoord;
|
||||
out vec2 vTexCoord;
|
||||
void main() {
|
||||
vTexCoord = aTexCoord;
|
||||
gl_Position = vec4(aPos, 0.0, 1.0);
|
||||
}` + "\x00"
|
||||
|
||||
fragSrc := `#version 330 core
|
||||
in vec2 vTexCoord;
|
||||
out vec4 FragColor;
|
||||
uniform sampler2D screenTexture;
|
||||
void main() {
|
||||
FragColor = texture(screenTexture, vTexCoord);
|
||||
}` + "\x00"
|
||||
|
||||
vertShader, err := compileShader(vertSrc, gl.VERTEX_SHADER)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
fragShader, err := compileShader(fragSrc, gl.FRAGMENT_SHADER)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
prog := gl.CreateProgram()
|
||||
gl.AttachShader(prog, vertShader)
|
||||
gl.AttachShader(prog, fragShader)
|
||||
gl.LinkProgram(prog)
|
||||
|
||||
var status int32
|
||||
gl.GetProgramiv(prog, gl.LINK_STATUS, &status)
|
||||
if status == gl.FALSE {
|
||||
var logLen int32
|
||||
gl.GetProgramiv(prog, gl.INFO_LOG_LENGTH, &logLen)
|
||||
logStr := make([]byte, logLen)
|
||||
gl.GetProgramInfoLog(prog, logLen, nil, &logStr[0])
|
||||
return 0, fmt.Errorf("shader link error: %s", string(logStr))
|
||||
}
|
||||
|
||||
gl.DeleteShader(vertShader)
|
||||
gl.DeleteShader(fragShader)
|
||||
return prog, nil
|
||||
}
|
||||
|
||||
func compileShader(src string, shaderType uint32) (uint32, error) {
|
||||
shader := gl.CreateShader(shaderType)
|
||||
csrc, free := gl.Strs(src)
|
||||
gl.ShaderSource(shader, 1, csrc, nil)
|
||||
free()
|
||||
gl.CompileShader(shader)
|
||||
|
||||
var status int32
|
||||
gl.GetShaderiv(shader, gl.COMPILE_STATUS, &status)
|
||||
if status == gl.FALSE {
|
||||
var logLen int32
|
||||
gl.GetShaderiv(shader, gl.INFO_LOG_LENGTH, &logLen)
|
||||
logStr := make([]byte, logLen)
|
||||
gl.GetShaderInfoLog(shader, logLen, nil, &logStr[0])
|
||||
return 0, fmt.Errorf("shader compile error: %s", string(logStr))
|
||||
}
|
||||
return shader, nil
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
// package keybinds provides keybind functionality for the entire application
|
||||
package keybinds
|
||||
|
||||
import (
|
||||
"github.com/veandco/go-sdl2/sdl"
|
||||
)
|
||||
|
||||
// KeyCombo represents a combination of a key and it's active modifier.
|
||||
type KeyCombo struct {
|
||||
Key sdl.Keycode
|
||||
Mod sdl.Keymod
|
||||
}
|
||||
|
||||
// Binds contains all currently registered Keybinds.
|
||||
var Binds = make(map[KeyCombo]func())
|
||||
|
||||
// Enabled controls whether PerformAction dispatches keybinds. Set to false
|
||||
// to temporarily suppress all global keybinds (e.g. during a modal page).
|
||||
var Enabled = true
|
||||
|
||||
// Regiser registers a new keybind in Binds
|
||||
func Register(k sdl.Keycode, m sdl.Keymod, action func()) {
|
||||
mod := NormalizeModifiers(m)
|
||||
newCombo := KeyCombo{k, mod}
|
||||
_, ok := Binds[newCombo]
|
||||
if ok {
|
||||
}
|
||||
Binds[newCombo] = action
|
||||
}
|
||||
|
||||
// BindKey adds a new KeyCombo and Function (the action to take) to the keybinds.Binds
|
||||
func BindKey(k KeyCombo, f func()) {
|
||||
k.Mod = NormalizeModifiers(k.Mod)
|
||||
Binds[k] = f
|
||||
}
|
||||
|
||||
// UnbindKey removes a KeyCombo from the available binds
|
||||
func UnbindKey(k KeyCombo) {
|
||||
k.Mod = NormalizeModifiers(k.Mod)
|
||||
delete(Binds, k)
|
||||
}
|
||||
|
||||
func PerformAction(event sdl.Event) {
|
||||
if !Enabled {
|
||||
return
|
||||
}
|
||||
switch e := event.(type) {
|
||||
case *sdl.KeyboardEvent:
|
||||
if e.Type == sdl.KEYDOWN {
|
||||
key := e.Keysym.Sym
|
||||
rawMod := e.Keysym.Mod
|
||||
mod := NormalizeModifiers(sdl.Keymod(rawMod))
|
||||
combo := KeyCombo{key, mod}
|
||||
action, ok := Binds[combo]
|
||||
if ok {
|
||||
action()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NormalizeModifiers converts a raw SDL modifier bitmask into a canonical form
|
||||
// focusing on common modifiers (SHIFT, CTRL, ALT, GUI) and ignoring others like CapsLock.
|
||||
func NormalizeModifiers(mod sdl.Keymod) sdl.Keymod {
|
||||
var normalized sdl.Keymod = sdl.KMOD_NONE
|
||||
if mod&sdl.KMOD_SHIFT != 0 {
|
||||
normalized |= sdl.KMOD_SHIFT
|
||||
}
|
||||
if mod&sdl.KMOD_CTRL != 0 {
|
||||
normalized |= sdl.KMOD_CTRL
|
||||
}
|
||||
if mod&sdl.KMOD_ALT != 0 {
|
||||
normalized |= sdl.KMOD_ALT
|
||||
}
|
||||
if mod&sdl.KMOD_GUI != 0 {
|
||||
normalized |= sdl.KMOD_GUI
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
package license
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"time"
|
||||
)
|
||||
|
||||
var Level int
|
||||
|
||||
type Validator struct {
|
||||
client *http.Client
|
||||
apiURL string
|
||||
apiPath string
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
Key string `json:"key"`
|
||||
Email string `json:"email"`
|
||||
Active bool `json:"active"`
|
||||
Type string `json:"type"`
|
||||
Level string `json:"level"`
|
||||
LastCheck time.Time `json:"lastCheck"`
|
||||
NewCheck time.Time `json:"newCheck"`
|
||||
}
|
||||
|
||||
func NewValidator(apiURL, apiPath string) *Validator {
|
||||
return &Validator{
|
||||
client: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: 10,
|
||||
IdleConnTimeout: 30 * time.Second,
|
||||
DisableCompression: true,
|
||||
},
|
||||
},
|
||||
apiURL: apiURL,
|
||||
apiPath: apiPath,
|
||||
}
|
||||
}
|
||||
|
||||
func (v *Validator) Validate(key, email string) (*Response, error) {
|
||||
u, err := url.Parse(v.apiURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.Path = path.Join(u.Path, v.apiPath)
|
||||
|
||||
q := u.Query()
|
||||
q.Set("licenseKey", key)
|
||||
q.Set("emailAddress", email)
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
resp, err := v.client.Get(u.String())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("unexpected status: %w", err)
|
||||
}
|
||||
|
||||
var result Response
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("failed ot decode response: %w", err)
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"rhemawavetech.com/rhemawave/internal/db"
|
||||
)
|
||||
|
||||
// ─── Level ───────────────────────────────────────────────────────────────────
|
||||
|
||||
type Level int
|
||||
|
||||
const (
|
||||
LevelDebug Level = iota
|
||||
LevelInfo
|
||||
LevelWarning
|
||||
LevelError
|
||||
LevelFatal
|
||||
)
|
||||
|
||||
func (l Level) String() string {
|
||||
return [...]string{"DEBUG", "INFO", "WARNING", "ERROR", "FATAL"}[l]
|
||||
}
|
||||
|
||||
// ─── Internal entry type ──────────────────────────────────────────────────────
|
||||
|
||||
type entry struct {
|
||||
level Level
|
||||
message string
|
||||
timestamp time.Time
|
||||
fatal bool
|
||||
}
|
||||
|
||||
// ─── Package state ────────────────────────────────────────────────────────────
|
||||
|
||||
var (
|
||||
SetLevel Level = LevelInfo
|
||||
console = log.New(os.Stderr, "", 0)
|
||||
|
||||
ch chan entry // unbuffered send is a bug; always buffered
|
||||
done chan struct{}
|
||||
db_ *gorm.DB
|
||||
file *os.File
|
||||
)
|
||||
|
||||
func init() {
|
||||
ch = make(chan entry, 4096)
|
||||
done = make(chan struct{})
|
||||
go drain()
|
||||
}
|
||||
|
||||
// ─── Configuration ────────────────────────────────────────────────────────────
|
||||
|
||||
// AddFile attaches or replaces the file backend. Safe to call at any time.
|
||||
func AddFile(path string) error {
|
||||
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("logger: open file: %w", err)
|
||||
}
|
||||
// Send a sentinel that carries the new file handle.
|
||||
// We do this through the channel so the drain goroutine owns
|
||||
// all file state — no mutex needed.
|
||||
fileSwapCh <- f
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddDatabase attaches or replaces the database backend. Safe to call at any time.
|
||||
func AddDatabase(d *gorm.DB) {
|
||||
dbSwapCh <- d
|
||||
}
|
||||
|
||||
// Close drains remaining entries and shuts down the logger.
|
||||
// Call this with defer in main().
|
||||
func Close() {
|
||||
close(done)
|
||||
<-drainDone // wait for drain goroutine to finish flushing
|
||||
if file != nil {
|
||||
file.Close()
|
||||
file = nil
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Swap channels (owned exclusively by drain goroutine) ────────────────────
|
||||
|
||||
var (
|
||||
fileSwapCh = make(chan *os.File, 1)
|
||||
dbSwapCh = make(chan *gorm.DB, 1)
|
||||
drainDone = make(chan struct{})
|
||||
)
|
||||
|
||||
// ─── Drain goroutine ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// This is the ONLY goroutine that touches file, db_, or does DB writes.
|
||||
// Everything else just sends to ch.
|
||||
|
||||
func drain() {
|
||||
defer close(drainDone)
|
||||
|
||||
batch := make([]entry, 0, 64)
|
||||
ticker := time.NewTicker(250 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
flush := func() {
|
||||
if len(batch) == 0 {
|
||||
return
|
||||
}
|
||||
writeBatchToDB(batch)
|
||||
batch = batch[:0]
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
|
||||
// New log entry
|
||||
case e := <-ch:
|
||||
writeToFile(e)
|
||||
if db_ != nil {
|
||||
batch = append(batch, e)
|
||||
if len(batch) >= 64 {
|
||||
flush()
|
||||
}
|
||||
}
|
||||
|
||||
// Periodic DB flush so low-volume logs don't sit in the batch forever
|
||||
case <-ticker.C:
|
||||
flush()
|
||||
|
||||
// Hot-swap the file backend
|
||||
case f := <-fileSwapCh:
|
||||
if file != nil {
|
||||
file.Close()
|
||||
}
|
||||
file = f
|
||||
|
||||
// Hot-swap the DB backend
|
||||
case d := <-dbSwapCh:
|
||||
flush() // flush any pending entries to the old db first
|
||||
db_ = d
|
||||
console.Println("logger: database backend attached")
|
||||
|
||||
// Shutdown: drain what's left, then exit
|
||||
case <-done:
|
||||
// Pull any entries still sitting in the channel
|
||||
for len(ch) > 0 {
|
||||
e := <-ch
|
||||
writeToFile(e)
|
||||
batch = append(batch, e)
|
||||
}
|
||||
flush()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Writers (called only from drain goroutine) ───────────────────────────────
|
||||
|
||||
func writeToFile(e entry) {
|
||||
line := formatLine(e)
|
||||
if file != nil {
|
||||
fmt.Fprintln(file, line)
|
||||
}
|
||||
}
|
||||
|
||||
func writeBatchToDB(batch []entry) {
|
||||
if db_ == nil || len(batch) == 0 {
|
||||
return
|
||||
}
|
||||
records := make([]db.Log, len(batch))
|
||||
for i, e := range batch {
|
||||
records[i] = db.Log{
|
||||
Timestamp: e.timestamp.UnixMilli(),
|
||||
Level: e.level.String(),
|
||||
Message: e.message,
|
||||
}
|
||||
}
|
||||
// Single INSERT ... VALUES (...), (...), (...) instead of N individual saves.
|
||||
if result := db_.CreateInBatches(records, 64); result.Error != nil {
|
||||
console.Printf("logger: db batch write failed: %v\n", result.Error)
|
||||
}
|
||||
}
|
||||
|
||||
func formatLine(e entry) string {
|
||||
return fmt.Sprintf("%s [%s] %s",
|
||||
e.timestamp.Format("2006-01-02 15:04:05.000"),
|
||||
e.level,
|
||||
e.message,
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Public API ───────────────────────────────────────────────────────────────
|
||||
|
||||
func logMsg(level Level, fatal bool, format string, args ...any) {
|
||||
if level == LevelDebug && SetLevel != LevelDebug {
|
||||
return
|
||||
}
|
||||
e := entry{
|
||||
level: level,
|
||||
message: fmt.Sprintf(format, args...),
|
||||
timestamp: time.Now(), // captured here, in the caller's goroutine
|
||||
fatal: fatal,
|
||||
}
|
||||
|
||||
// Console is synchronous — log.Logger is internally mutex-protected,
|
||||
// so this is safe from any goroutine and gives you immediate output.
|
||||
console.Println(formatLine(e))
|
||||
|
||||
// Non-blocking send: if the buffer is full, we drop rather than deadlock.
|
||||
// 4096 entries of headroom means this should never happen in practice.
|
||||
select {
|
||||
case ch <- e:
|
||||
default:
|
||||
console.Println("logger: channel full, entry dropped")
|
||||
}
|
||||
|
||||
if fatal {
|
||||
// Give the drain goroutine a moment to flush before we exit.
|
||||
// Close() is cleaner but Fatal has to exit immediately.
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func Debug(format string, args ...any) { logMsg(LevelDebug, false, format, args...) }
|
||||
func Info(format string, args ...any) { logMsg(LevelInfo, false, format, args...) }
|
||||
func Warning(format string, args ...any) { logMsg(LevelWarning, false, format, args...) }
|
||||
func Error(format string, args ...any) { logMsg(LevelError, false, format, args...) }
|
||||
func Fatal(format string, args ...any) { logMsg(LevelFatal, true, format, args...) }
|
||||
@@ -1,496 +0,0 @@
|
||||
// internal/audio/mixer/mixer.go
|
||||
package mixer
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/veandco/go-sdl2/mix"
|
||||
"github.com/veandco/go-sdl2/sdl"
|
||||
"rhemawavetech.com/rhemawave/internal/db"
|
||||
"rhemawavetech.com/rhemawave/internal/logger"
|
||||
)
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
inited bool
|
||||
deviceOpen bool
|
||||
deviceName string
|
||||
|
||||
currentMusic *mix.Music
|
||||
currentPath string
|
||||
currentLoops int // 0 = once, -1 = forever
|
||||
|
||||
// Config knobs (could be made public or read from your config)
|
||||
defaultFreq = 48000
|
||||
defaultFormat = uint16(sdl.AUDIO_F32)
|
||||
defaultChannels = 2
|
||||
defaultChunkSize = 1024
|
||||
defaultAllow = sdl.AUDIO_ALLOW_ANY_CHANGE
|
||||
|
||||
refreshCacheCallback func()
|
||||
)
|
||||
|
||||
type Sink struct {
|
||||
ID int
|
||||
Name string
|
||||
Volume float64
|
||||
IsDefault bool
|
||||
}
|
||||
|
||||
// Init initializes SDL audio + SDL_mixer. Safe to call multiple times.
|
||||
func Init(cfg *db.AppConfig, onUpdate func()) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if inited {
|
||||
return nil
|
||||
}
|
||||
|
||||
refreshCacheCallback = onUpdate
|
||||
|
||||
if err := sdl.InitSubSystem(sdl.INIT_AUDIO); err != nil {
|
||||
return fmt.Errorf("SDL audio init failed: %w", err)
|
||||
}
|
||||
|
||||
// FIX 1: Initialize MP3 and OGG decoders
|
||||
err := mix.Init(mix.INIT_MP3 | mix.INIT_OGG)
|
||||
if err != nil {
|
||||
logger.Error("Failed to initialize MP3/OGG support: %v", err)
|
||||
}
|
||||
|
||||
deviceName := cfg.SelectedAudioDevice
|
||||
if deviceName == "default" {
|
||||
deviceName = ""
|
||||
}
|
||||
if err := openDeviceLocked(deviceName); err != nil {
|
||||
sdl.QuitSubSystem(sdl.INIT_AUDIO)
|
||||
return err
|
||||
}
|
||||
|
||||
SetAllSinkVolumes(cfg.SavedVolume)
|
||||
|
||||
inited = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// openDeviceLocked tries to open the named device; if it fails and the name wasn't empty, falls back to default.
|
||||
func openDeviceLocked(name string) error {
|
||||
if err := mix.OpenAudioDevice(defaultFreq, defaultFormat, defaultChannels, defaultChunkSize, name, defaultAllow); err != nil {
|
||||
if name != "" {
|
||||
// Log the failure of the requested device
|
||||
|
||||
// Fallback to default device
|
||||
if err2 := mix.OpenAudioDevice(defaultFreq, defaultFormat, defaultChannels, defaultChunkSize, "", defaultAllow); err2 != nil {
|
||||
return fmt.Errorf("failed to open device %q and default device: %w", name, err2)
|
||||
}
|
||||
deviceName = "" // actually opened default
|
||||
} else {
|
||||
return fmt.Errorf("failed to open default audio device: %w", err)
|
||||
}
|
||||
} else {
|
||||
if name != "" {
|
||||
} else {
|
||||
}
|
||||
deviceName = name
|
||||
}
|
||||
deviceOpen = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// Play loads a WAV (as music) and plays it. If loopForever is true, loops forever.
|
||||
// If something is already playing, it stops and replaces it.
|
||||
func Play(path string, loopForever bool, cfg *db.AppConfig) error {
|
||||
// Check if audio is enabled or disabled
|
||||
if !cfg.AudioEnabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Lock the mutex
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
// Check if mixer has been initialized and that a device is open
|
||||
if !inited {
|
||||
logger.Error("audio mixer not initialized")
|
||||
return errors.New("audio mixer not initialized")
|
||||
}
|
||||
if !deviceOpen {
|
||||
if err := openDeviceLocked(""); err != nil {
|
||||
return fmt.Errorf("failed to open audio device: %w", err)
|
||||
}
|
||||
}
|
||||
if path == "" {
|
||||
logger.Error("empty audio path")
|
||||
return errors.New("empty audio path")
|
||||
}
|
||||
|
||||
// Stop and free any prior music
|
||||
mix.HaltMusic()
|
||||
if currentMusic != nil {
|
||||
currentMusic.Free()
|
||||
currentMusic = nil
|
||||
}
|
||||
|
||||
// Load the music file
|
||||
m, err := mix.LoadMUS(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("LoadMUS(%q): %w", path, err)
|
||||
}
|
||||
// Set current music to the loaded music file
|
||||
currentMusic = m
|
||||
currentPath = path
|
||||
if loopForever {
|
||||
currentLoops = -1
|
||||
} else {
|
||||
currentLoops = 0
|
||||
}
|
||||
|
||||
mix.VolumeMusic(mix.MAX_VOLUME)
|
||||
volume := mix.VolumeMusic(-1)
|
||||
logger.Info("Current volume is %d", volume)
|
||||
|
||||
if err := currentMusic.Play(currentLoops); err != nil {
|
||||
currentMusic.Free()
|
||||
currentMusic = nil
|
||||
logger.Error("music.Play failed: %v", err)
|
||||
return fmt.Errorf("music.Play: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop stops playback. If fadeMs > 0, fades out asynchronously; otherwise halts immediately.
|
||||
// It also frees the currently loaded music (so a new Play will reload).
|
||||
func Stop(fadeMs int, cfg *db.AppConfig) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if !inited {
|
||||
return
|
||||
}
|
||||
|
||||
if fadeMs > 0 && mix.PlayingMusic() {
|
||||
_ = mix.FadeOutMusic(fadeMs) // async fade; we’ll still free below
|
||||
}
|
||||
|
||||
mix.HaltMusic()
|
||||
if currentMusic != nil {
|
||||
currentMusic.Free()
|
||||
currentMusic = nil
|
||||
}
|
||||
currentPath = ""
|
||||
currentLoops = 0
|
||||
}
|
||||
|
||||
// IsPlaying reports whether music is currently playing (or fading).
|
||||
func IsPlaying() bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if !inited {
|
||||
return false
|
||||
}
|
||||
return mix.PlayingMusic()
|
||||
}
|
||||
|
||||
// SetDefaultSinkVolume sets volume 0.0–1.0 on the default sink
|
||||
func SetDefaultSinkVolume(level float64) error {
|
||||
if level < 0 || level > 1 {
|
||||
return fmt.Errorf("volume must be between 0.0 and 1.0, got %f", level)
|
||||
}
|
||||
pct := fmt.Sprintf("%.2f", level)
|
||||
cmd := exec.Command("wpctl", "set-volume", "@DEFAULT_AUDIO_SINK@", pct)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("wpctl failed: %w\noutput: %s", err, out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// listSinkIDs returns just the numeric IDs of all audio sinks.
|
||||
func listSinkIDs() ([]int, error) {
|
||||
sinks, err := listSinks()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids := make([]int, len(sinks))
|
||||
for i, s := range sinks {
|
||||
ids[i] = s.ID
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// listSinks returns full Sink info for all audio sinks.
|
||||
func listSinks() ([]Sink, error) {
|
||||
out, err := exec.Command("wpctl", "status").Output()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("wpctl status: %w", err)
|
||||
}
|
||||
return parseSinks(out)
|
||||
}
|
||||
|
||||
// SetAllSinkVolumes sets volume on every audio sink found via wpctl status
|
||||
func SetAllSinkVolumes(level float64) error {
|
||||
ids, err := listSinkIDs()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pct := fmt.Sprintf("%.2f", level)
|
||||
for _, id := range ids {
|
||||
cmd := exec.Command("wpctl", "set-volume", strconv.Itoa(id), pct)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("wpctl set-volume %d failed: %w\noutput: %s", id, err, out)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ConvertVolume converts a desired volume percentage (0%, 25%, 33%, 90%, etc) to
|
||||
// the float64 value needed to pass to SetAllSinkVolumes 0..1.0
|
||||
// Percentages should be passed as integers. 25% -> 25
|
||||
func ConvertVolume(volPerc int) float64 {
|
||||
if volPerc == 0 {
|
||||
return 0.0
|
||||
}
|
||||
return float64(volPerc) / 100.0
|
||||
}
|
||||
|
||||
// parseSinks parses the output of `wpctl status` and extracts audio sinks.
|
||||
func parseSinks(data []byte) ([]Sink, error) {
|
||||
var sinks []Sink
|
||||
|
||||
scanner := bufio.NewScanner(bytes.NewReader(data))
|
||||
|
||||
inAudioSection := false
|
||||
inSinksSection := false
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
stripped := strings.TrimLeft(line, " │├└─")
|
||||
|
||||
// Detect top-level sections: "Audio", "Video", "Settings", etc.
|
||||
// These are lines with no leading tree characters.
|
||||
if !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "│") {
|
||||
inAudioSection = strings.TrimSpace(line) == "Audio"
|
||||
inSinksSection = false
|
||||
continue
|
||||
}
|
||||
|
||||
if !inAudioSection {
|
||||
continue
|
||||
}
|
||||
|
||||
// Detect subsection headers within Audio
|
||||
if strings.Contains(stripped, "Sinks:") {
|
||||
inSinksSection = true
|
||||
continue
|
||||
}
|
||||
// Any other subsection header resets sink parsing
|
||||
if strings.HasSuffix(strings.TrimSpace(stripped), ":") {
|
||||
inSinksSection = false
|
||||
continue
|
||||
}
|
||||
|
||||
if !inSinksSection {
|
||||
continue
|
||||
}
|
||||
|
||||
sink, ok := parseSinkLine(stripped)
|
||||
if ok {
|
||||
sinks = append(sinks, sink)
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, fmt.Errorf("scanning wpctl output: %w", err)
|
||||
}
|
||||
|
||||
return sinks, nil
|
||||
}
|
||||
|
||||
// parseSinkLine parses a single sink entry line like:
|
||||
//
|
||||
// "*51. USB Audio [vol: 1.00]"
|
||||
// "33. Built-in Audio Analog Stereo [vol: 0.65]"
|
||||
func parseSinkLine(s string) (Sink, bool) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return Sink{}, false
|
||||
}
|
||||
|
||||
var sink Sink
|
||||
|
||||
// Leading '*' marks the default sink
|
||||
if strings.HasPrefix(s, "*") {
|
||||
sink.IsDefault = true
|
||||
s = strings.TrimPrefix(s, "*")
|
||||
s = strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
// Expect "<id>. <name> ..."
|
||||
dotIdx := strings.Index(s, ".")
|
||||
if dotIdx < 0 {
|
||||
return Sink{}, false
|
||||
}
|
||||
|
||||
idStr := strings.TrimSpace(s[:dotIdx])
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
return Sink{}, false
|
||||
}
|
||||
sink.ID = id
|
||||
|
||||
rest := strings.TrimSpace(s[dotIdx+1:])
|
||||
|
||||
// Extract [vol: X.XX] if present
|
||||
volStart := strings.Index(rest, "[vol:")
|
||||
if volStart >= 0 {
|
||||
sink.Name = strings.TrimSpace(rest[:volStart])
|
||||
volPart := rest[volStart:]
|
||||
// "[vol: 0.65]" or "[vol: 0.65 MUTED]"
|
||||
volPart = strings.TrimPrefix(volPart, "[vol:")
|
||||
volPart = strings.TrimRight(volPart, "]")
|
||||
volPart = strings.TrimSpace(volPart)
|
||||
fields := strings.Fields(volPart)
|
||||
if len(fields) > 0 {
|
||||
v, err := strconv.ParseFloat(fields[0], 64)
|
||||
if err == nil {
|
||||
sink.Volume = v
|
||||
}
|
||||
}
|
||||
} else {
|
||||
sink.Name = strings.TrimSpace(rest)
|
||||
}
|
||||
|
||||
return sink, true
|
||||
}
|
||||
|
||||
// ListDevices returns the list of available output device names (from core SDL).
|
||||
func ListDevices() []string {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
n := sdl.GetNumAudioDevices(false)
|
||||
if n <= 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, n)
|
||||
for i := 0; i < n; i++ {
|
||||
if name := sdl.GetAudioDeviceName(i, false); name != "" {
|
||||
out = append(out, name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// CurrentDevice returns the last requested/opened device name ("" means default).
|
||||
func CurrentDevice() string {
|
||||
return deviceName
|
||||
}
|
||||
|
||||
// SwitchDevice switches output devices, preserving volume and resuming playback of the current track/loop state if any.
|
||||
func SwitchDevice(newName string, cfg *db.AppConfig) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
if !inited {
|
||||
return errors.New("audio mixer not initialized")
|
||||
}
|
||||
|
||||
// Log device switch attempt
|
||||
// Automatically defaults to "system default"
|
||||
oldDevice := CurrentDevice() // Which device is to be closed
|
||||
// SDL expects "" for default device, not "system default"
|
||||
if newName == "" { // Which device is being switched to?
|
||||
newName = ""
|
||||
}
|
||||
|
||||
// Capture current state
|
||||
wasPlaying := mix.PlayingMusic()
|
||||
savedPath := currentPath
|
||||
savedLoops := currentLoops
|
||||
|
||||
// Stop and release current music before reopening device
|
||||
mix.HaltMusic()
|
||||
if currentMusic != nil {
|
||||
currentMusic.Free()
|
||||
currentMusic = nil
|
||||
}
|
||||
currentPath = ""
|
||||
currentLoops = 0
|
||||
|
||||
// Close and reopen device
|
||||
if deviceOpen {
|
||||
mix.CloseAudio()
|
||||
deviceOpen = false
|
||||
}
|
||||
|
||||
// Attempt to open the newName device
|
||||
if err := openDeviceLocked(newName); err != nil {
|
||||
// If error switching to new device, switch back to the old device
|
||||
// oldDevice is already "" for default (which openDeviceLocked expects)
|
||||
if restoreErr := openDeviceLocked(oldDevice); restoreErr != nil {
|
||||
// We should not hit an error when switching back, but if we do...
|
||||
logger.Error("Failed to switch to device: %s", oldDevice)
|
||||
}
|
||||
// Could not switch to the new device, return the error
|
||||
return err
|
||||
}
|
||||
|
||||
// Restore saved volume after reopening device
|
||||
if cfg != nil {
|
||||
SetAllSinkVolumes(cfg.SavedVolume)
|
||||
}
|
||||
|
||||
// Optionally resume previous track if there was one
|
||||
if wasPlaying && savedPath != "" {
|
||||
m, err := mix.LoadMUS(savedPath)
|
||||
if err != nil {
|
||||
// Don't fail the device switch just because reload failed
|
||||
return fmt.Errorf("device switched, but reloading %q failed: %w", savedPath, err)
|
||||
}
|
||||
currentMusic = m
|
||||
currentPath = savedPath
|
||||
currentLoops = savedLoops
|
||||
if err := currentMusic.Play(currentLoops); err != nil {
|
||||
currentMusic.Free()
|
||||
currentMusic = nil
|
||||
currentPath = ""
|
||||
currentLoops = 0
|
||||
return fmt.Errorf("device switched, but restarting playback failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
logger.Info("Audio device switched to %s", newName)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetVolume128() int {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return mix.VolumeMusic(-1)
|
||||
}
|
||||
|
||||
// Shutdown fully tears everything down. Call at app exit.
|
||||
func Shutdown() {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
mix.HaltMusic()
|
||||
if currentMusic != nil {
|
||||
currentMusic.Free()
|
||||
currentMusic = nil
|
||||
}
|
||||
if deviceOpen {
|
||||
mix.CloseAudio()
|
||||
deviceOpen = false
|
||||
}
|
||||
|
||||
if inited {
|
||||
mix.Quit() // Add this to clean up the decoders
|
||||
sdl.QuitSubSystem(sdl.INIT_AUDIO)
|
||||
inited = false
|
||||
}
|
||||
}
|
||||
@@ -1,312 +0,0 @@
|
||||
package mixer
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"rhemawavetech.com/rhemawave/internal/db"
|
||||
)
|
||||
|
||||
// Regression tests for critical audio bugs (v0.5)
|
||||
|
||||
// Regression: Stop() used to return early when AudioEnabled was false,
|
||||
// leaving music playing after the user toggled audio off mid-playback.
|
||||
func TestStopCleansUpWhenAudioDisabled(t *testing.T) {
|
||||
mu.Lock()
|
||||
wasInited := inited
|
||||
inited = true
|
||||
deviceOpen = false
|
||||
currentPath = "/some/track.wav"
|
||||
currentLoops = -1
|
||||
mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
mu.Lock()
|
||||
inited = wasInited
|
||||
currentPath = ""
|
||||
currentLoops = 0
|
||||
mu.Unlock()
|
||||
}()
|
||||
|
||||
cfg := &db.AppConfig{AudioEnabled: false}
|
||||
Stop(0, cfg)
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if currentPath != "" {
|
||||
t.Errorf("currentPath = %q, want empty after Stop with AudioEnabled=false", currentPath)
|
||||
}
|
||||
if currentLoops != 0 {
|
||||
t.Errorf("currentLoops = %d, want 0 after Stop with AudioEnabled=false", currentLoops)
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: SwitchDevice used to convert oldDevice="" to "system default"
|
||||
// and pass that string to openDeviceLocked, which SDL does not recognize.
|
||||
func TestSwitchDeviceReturnsErrorWhenUninitialized(t *testing.T) {
|
||||
mu.Lock()
|
||||
wasInited := inited
|
||||
inited = false
|
||||
deviceOpen = false
|
||||
mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
mu.Lock()
|
||||
inited = wasInited
|
||||
mu.Unlock()
|
||||
}()
|
||||
|
||||
cfg := &db.AppConfig{AudioEnabled: true, SavedVolume: 0.5}
|
||||
err := SwitchDevice("Some Device", cfg)
|
||||
if err == nil {
|
||||
t.Fatal("SwitchDevice on uninitialized mixer should return error")
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: SwitchDevice with nil config must not panic when attempting
|
||||
// to restore volume after reopening the device.
|
||||
func TestSwitchDeviceNilConfigDoesNotPanic(t *testing.T) {
|
||||
mu.Lock()
|
||||
wasInited := inited
|
||||
inited = false
|
||||
deviceOpen = false
|
||||
mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
mu.Lock()
|
||||
inited = wasInited
|
||||
mu.Unlock()
|
||||
}()
|
||||
|
||||
err := SwitchDevice("Some Device", nil)
|
||||
if err == nil {
|
||||
t.Fatal("SwitchDevice on uninitialized mixer should return error")
|
||||
}
|
||||
}
|
||||
|
||||
// Regression: SwitchDevice with empty device name (system default) should
|
||||
// not be converted to "system default" before passing to openDeviceLocked.
|
||||
func TestSwitchDeviceAcceptsEmptyDeviceName(t *testing.T) {
|
||||
mu.Lock()
|
||||
wasInited := inited
|
||||
inited = false
|
||||
deviceOpen = false
|
||||
mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
mu.Lock()
|
||||
inited = wasInited
|
||||
mu.Unlock()
|
||||
}()
|
||||
|
||||
cfg := &db.AppConfig{SavedVolume: 0.75}
|
||||
err := SwitchDevice("", cfg)
|
||||
// Returns error because mixer is not initialized, but should not panic
|
||||
// and should handle the empty name gracefully.
|
||||
if err == nil {
|
||||
t.Fatal("SwitchDevice on uninitialized mixer should return error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlayDoesNotDeadlockWhenUninitialized verifies that Play() returns an error
|
||||
// (not a deadlock) when the mixer has not been initialized.
|
||||
// The old code would deadlock because Play() held mu then called SwitchDevice()
|
||||
// which also tried to acquire mu.
|
||||
func TestPlayDoesNotDeadlockWhenUninitialized(t *testing.T) {
|
||||
// Ensure mixer is in uninitialized state
|
||||
mu.Lock()
|
||||
wasInited := inited
|
||||
inited = false
|
||||
deviceOpen = false
|
||||
mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
mu.Lock()
|
||||
inited = wasInited
|
||||
mu.Unlock()
|
||||
}()
|
||||
|
||||
cfg := &db.AppConfig{AudioEnabled: true}
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- Play("nonexistent.wav", false, cfg)
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if err == nil {
|
||||
t.Fatal("Play() returned nil error on uninitialized mixer, want error")
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("Play() deadlocked on uninitialized mixer (did not return within 5s)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSinkLine(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantOK bool
|
||||
wantID int
|
||||
wantName string
|
||||
wantVol float64
|
||||
wantDef bool
|
||||
}{
|
||||
{
|
||||
name: "default sink with volume",
|
||||
input: "*51. USB Audio [vol: 1.00]",
|
||||
wantOK: true,
|
||||
wantID: 51,
|
||||
wantName: "USB Audio",
|
||||
wantVol: 1.00,
|
||||
wantDef: true,
|
||||
},
|
||||
{
|
||||
name: "non-default sink with volume",
|
||||
input: "33. Built-in Audio Analog Stereo [vol: 0.65]",
|
||||
wantOK: true,
|
||||
wantID: 33,
|
||||
wantName: "Built-in Audio Analog Stereo",
|
||||
wantVol: 0.65,
|
||||
wantDef: false,
|
||||
},
|
||||
{
|
||||
name: "default sink with muted tag",
|
||||
input: "*51. USB Audio [vol: 0.50 MUTED]",
|
||||
wantOK: true,
|
||||
wantID: 51,
|
||||
wantName: "USB Audio",
|
||||
wantVol: 0.50,
|
||||
wantDef: true,
|
||||
},
|
||||
{
|
||||
name: "sink without volume field",
|
||||
input: "33. Built-in Audio Analog Stereo",
|
||||
wantOK: true,
|
||||
wantID: 33,
|
||||
wantName: "Built-in Audio Analog Stereo",
|
||||
wantVol: 0.0,
|
||||
wantDef: false,
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
input: "",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "no dot separator",
|
||||
input: "no dot here",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "non-numeric ID",
|
||||
input: "abc. Some Device [vol: 0.80]",
|
||||
wantOK: false,
|
||||
},
|
||||
{
|
||||
name: "zero volume",
|
||||
input: "1. Muted Device [vol: 0.00]",
|
||||
wantOK: true,
|
||||
wantID: 1,
|
||||
wantName: "Muted Device",
|
||||
wantVol: 0.0,
|
||||
wantDef: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
sink, ok := parseSinkLine(tt.input)
|
||||
if ok != tt.wantOK {
|
||||
t.Fatalf("parseSinkLine(%q) ok = %v, want %v", tt.input, ok, tt.wantOK)
|
||||
}
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if sink.ID != tt.wantID {
|
||||
t.Errorf("ID = %d, want %d", sink.ID, tt.wantID)
|
||||
}
|
||||
if sink.Name != tt.wantName {
|
||||
t.Errorf("Name = %q, want %q", sink.Name, tt.wantName)
|
||||
}
|
||||
if sink.Volume != tt.wantVol {
|
||||
t.Errorf("Volume = %f, want %f", sink.Volume, tt.wantVol)
|
||||
}
|
||||
if sink.IsDefault != tt.wantDef {
|
||||
t.Errorf("IsDefault = %v, want %v", sink.IsDefault, tt.wantDef)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSinks(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantCount int
|
||||
wantNames []string
|
||||
}{
|
||||
{
|
||||
name: "two sinks",
|
||||
input: `Audio
|
||||
Sinks:
|
||||
*51. USB Audio [vol: 1.00]
|
||||
33. Built-in Audio Analog Stereo [vol: 0.65]
|
||||
Sources:
|
||||
52. Microphone [vol: 0.80]`,
|
||||
wantCount: 2,
|
||||
wantNames: []string{"USB Audio", "Built-in Audio Analog Stereo"},
|
||||
},
|
||||
{
|
||||
name: "no sinks section",
|
||||
input: `Audio
|
||||
Sources:
|
||||
52. Microphone [vol: 0.80]`,
|
||||
wantCount: 0,
|
||||
},
|
||||
{
|
||||
name: "sinks with tree characters",
|
||||
input: `Audio
|
||||
├─Sinks:
|
||||
│ ├─*51. USB Audio [vol: 1.00]
|
||||
│ └─33. Built-in Audio Analog Stereo [vol: 0.65]
|
||||
└─Sources:
|
||||
└─52. Microphone [vol: 0.80]`,
|
||||
wantCount: 2,
|
||||
wantNames: []string{"USB Audio", "Built-in Audio Analog Stereo"},
|
||||
},
|
||||
{
|
||||
name: "empty input",
|
||||
input: ``,
|
||||
wantCount: 0,
|
||||
},
|
||||
{
|
||||
name: "video section ignored",
|
||||
input: `Video
|
||||
Sinks:
|
||||
100. HDMI Output [vol: 1.00]
|
||||
Audio
|
||||
Sinks:
|
||||
51. USB Audio [vol: 1.00]`,
|
||||
wantCount: 1,
|
||||
wantNames: []string{"USB Audio"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
sinks, err := parseSinks([]byte(tt.input))
|
||||
if err != nil {
|
||||
t.Fatalf("parseSinks returned error: %v", err)
|
||||
}
|
||||
if len(sinks) != tt.wantCount {
|
||||
t.Fatalf("got %d sinks, want %d", len(sinks), tt.wantCount)
|
||||
}
|
||||
for i, name := range tt.wantNames {
|
||||
if sinks[i].Name != name {
|
||||
t.Errorf("sink[%d].Name = %q, want %q", i, sinks[i].Name, name)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package mixer_test
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"rhemawavetech.com/rhemawave/internal/mixer"
|
||||
)
|
||||
|
||||
func TestConvertVolume(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input int
|
||||
expected float64
|
||||
}{
|
||||
{"0 percent", 0, 0.0},
|
||||
{"25 percent", 25, 0.25},
|
||||
{"33 percent", 33, 0.33},
|
||||
{"50 percent", 50, 0.50},
|
||||
{"90 percent", 90, 0.90},
|
||||
{"100 percent", 100, 1.0},
|
||||
{"1 percent", 1, 0.01},
|
||||
{"99 percent", 99, 0.99},
|
||||
{"negative 10", -10, -0.10},
|
||||
{"over 100", 150, 1.50},
|
||||
}
|
||||
|
||||
const epsilon = 1e-9
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := mixer.ConvertVolume(tt.input)
|
||||
if math.Abs(got-tt.expected) > epsilon {
|
||||
t.Errorf("ConvertVolume(%d) = %v, want %v", tt.input, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
package netreq
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Makes a GET request to the specified URL and returns the JSON response
|
||||
// as a byte slice. If the request fails, it returns an error.
|
||||
func GetJSON[T any](url string) ([]T, error) {
|
||||
fmt.Println("Fetching:", url)
|
||||
res, err := http.Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if res.StatusCode > 299 {
|
||||
return nil, fmt.Errorf("status %d: %s", res.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// 1. Trim whitespace to find the first actual JSON character
|
||||
trimmed := bytes.TrimSpace(body)
|
||||
if len(trimmed) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var result []T
|
||||
|
||||
// 2. Determine if we are looking at an Array [ ] or an Object { }
|
||||
if trimmed[0] == '[' {
|
||||
// It's already an array, unmarshal directly
|
||||
err = json.Unmarshal(body, &result)
|
||||
} else {
|
||||
// It's a single object, unmarshal into a single instance and wrap it
|
||||
var single T
|
||||
err = json.Unmarshal(body, &single)
|
||||
result = []T{single}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse JSON: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Download downloads a specified object at a given url and places it in
|
||||
// the provided dest directory.
|
||||
func Download(name string, path string, url string) error {
|
||||
fullFilePath := filepath.Join(path, name)
|
||||
if err := os.MkdirAll(path, os.ModeDir); err != nil {
|
||||
return err
|
||||
}
|
||||
file, err := os.Create(fullFilePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
_, err = io.Copy(file, resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,577 +0,0 @@
|
||||
package network
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
gonm "github.com/Wifx/gonetworkmanager/v2"
|
||||
"github.com/google/uuid"
|
||||
"rhemawavetech.com/rhemawave/internal/logger"
|
||||
)
|
||||
|
||||
// ─── Public types ─────────────────────────────────────────────────────────────
|
||||
|
||||
type SecurityType string
|
||||
|
||||
const (
|
||||
SecurityOpen SecurityType = "Open"
|
||||
SecurityWEP SecurityType = "WEP"
|
||||
SecurityWPA SecurityType = "WPA"
|
||||
SecurityWPA2 SecurityType = "WPA2"
|
||||
SecurityWPA3 SecurityType = "WPA3"
|
||||
)
|
||||
|
||||
type Network struct {
|
||||
SSID string
|
||||
SignalStrength uint8
|
||||
Security SecurityType
|
||||
Saved bool
|
||||
Connected bool
|
||||
}
|
||||
|
||||
// ─── Package init ─────────────────────────────────────────────────────────────
|
||||
|
||||
var nm gonm.NetworkManager
|
||||
|
||||
func init() {
|
||||
var err error
|
||||
nm, err = gonm.NewNetworkManager()
|
||||
if err != nil {
|
||||
logger.Warning("network: NetworkManager unavailable, network features disabled: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── ScanForNetworks ──────────────────────────────────────────────────────────
|
||||
|
||||
func ScanForNetworks(ctx context.Context) (<-chan []Network, error) {
|
||||
dev, err := wifiDevice()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ch := make(chan []Network, 1)
|
||||
|
||||
go func() {
|
||||
defer close(ch)
|
||||
|
||||
ticker := time.NewTicker(15 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
scan := func() {
|
||||
if err := dev.RequestScan(); err != nil {
|
||||
logger.Warning("network: scan request failed: %v", err)
|
||||
return
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
nets, err := buildNetworkList(dev)
|
||||
if err != nil {
|
||||
logger.Warning("network: building network list failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
select {
|
||||
case ch <- nets:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}
|
||||
|
||||
scan()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
scan()
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// ─── ConnectToNetwork ─────────────────────────────────────────────────────────
|
||||
|
||||
func ConnectToNetwork(ssid, password string) error {
|
||||
dev, err := wifiDevice()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ifaceName, err := dev.GetPropertyInterface()
|
||||
if err != nil {
|
||||
return fmt.Errorf("network: cannot get interface name: %w", err)
|
||||
}
|
||||
|
||||
ap, err := findAccessPoint(dev, ssid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("network: access point %q not found: %w", ssid, err)
|
||||
}
|
||||
|
||||
// Delete any existing profiles for this SSID before creating a new one.
|
||||
// Stale/duplicate profiles cause NM to fail silently.
|
||||
if err := deleteExistingConnections(ssid); err != nil {
|
||||
logger.Warning("network: could not clean up old profiles for %q: %v", ssid, err)
|
||||
}
|
||||
|
||||
security := detectSecurity(ap)
|
||||
settings, err := buildConnectionSettings(ssid, password, security, ifaceName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Info("network: connecting to %q (%s)", ssid, security)
|
||||
|
||||
_, err = nm.AddAndActivateConnection(settings, dev)
|
||||
if err != nil {
|
||||
return fmt.Errorf("network: failed to connect to %q: %w", ssid, err)
|
||||
}
|
||||
|
||||
return waitForConnection(dev, ssid, 20*time.Second)
|
||||
}
|
||||
|
||||
// deleteExistingConnections removes all stored NM profiles for the given SSID.
|
||||
func deleteExistingConnections(ssid string) error {
|
||||
s, err := gonm.NewSettings()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
connections, err := s.ListConnections()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, conn := range connections {
|
||||
cfg, err := conn.GetSettings()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
wireless, ok := cfg["802-11-wireless"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
rawSSID, ok := wireless["ssid"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if string(rawSSID.([]byte)) != ssid {
|
||||
continue
|
||||
}
|
||||
logger.Debug("network: removing existing profile for %q", ssid)
|
||||
if err := conn.Delete(); err != nil {
|
||||
logger.Warning("network: failed to delete profile for %q: %v", ssid, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ─── ForgetNetwork ────────────────────────────────────────────────────────────
|
||||
|
||||
func ForgetNetwork(ssid string) error {
|
||||
s, err := gonm.NewSettings()
|
||||
if err != nil {
|
||||
return fmt.Errorf("network: cannot access NM settings: %w", err)
|
||||
}
|
||||
|
||||
connections, err := s.ListConnections()
|
||||
if err != nil {
|
||||
return fmt.Errorf("network: cannot list connections: %w", err)
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, conn := range connections {
|
||||
settings, err := conn.GetSettings()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
wireless, ok := settings["802-11-wireless"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
rawSSID, ok := wireless["ssid"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if string(rawSSID.([]byte)) != ssid {
|
||||
continue
|
||||
}
|
||||
|
||||
found = true
|
||||
logger.Info("network: forgetting %q", ssid)
|
||||
if err := conn.Delete(); err != nil {
|
||||
return fmt.Errorf("network: failed to delete connection %q: %w", ssid, err)
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
return fmt.Errorf("network: no saved connection found for %q", ssid)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ─── CurrentNetwork ───────────────────────────────────────────────────────────
|
||||
|
||||
func CurrentNetwork() (string, error) {
|
||||
dev, err := wifiDevice()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
ap, err := dev.GetPropertyActiveAccessPoint()
|
||||
if err != nil || ap == nil {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
ssid, err := ap.GetPropertySSID()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return ssid, nil
|
||||
}
|
||||
|
||||
// ─── ListSavedNetworks ────────────────────────────────────────────────────────
|
||||
|
||||
func ListSavedNetworks() ([]string, error) {
|
||||
s, err := gonm.NewSettings()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("network: cannot access NM settings: %w", err)
|
||||
}
|
||||
|
||||
connections, err := s.ListConnections()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("network: cannot list connections: %w", err)
|
||||
}
|
||||
|
||||
var ssids []string
|
||||
for _, conn := range connections {
|
||||
settings, err := conn.GetSettings()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
wireless, ok := settings["802-11-wireless"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
rawSSID, ok := wireless["ssid"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
ssids = append(ssids, string(rawSSID.([]byte)))
|
||||
}
|
||||
|
||||
return ssids, nil
|
||||
}
|
||||
|
||||
// ─── IsConnected ──────────────────────────────────────────────────────────────
|
||||
|
||||
func IsConnected() (bool, error) {
|
||||
if nm == nil {
|
||||
return false, fmt.Errorf("network: NetworkManager not available")
|
||||
}
|
||||
|
||||
state, err := nm.GetPropertyState()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("network: cannot get NM state: %w", err)
|
||||
}
|
||||
|
||||
return state >= gonm.NmStateConnectedLocal, nil
|
||||
}
|
||||
|
||||
// ─── HasInternet ──────────────────────────────────────────────────────────────
|
||||
|
||||
func HasInternet() bool {
|
||||
targets := []string{
|
||||
"1.1.1.1:53",
|
||||
"1.0.0.1:53",
|
||||
"8.8.8.8:53",
|
||||
"9.9.9.9:53",
|
||||
"8.8.4.4:53",
|
||||
"google.com:443",
|
||||
"cloudflare.com:443",
|
||||
}
|
||||
|
||||
results := make(chan bool, len(targets))
|
||||
|
||||
for _, target := range targets {
|
||||
go func(addr string) {
|
||||
conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
|
||||
if err == nil {
|
||||
conn.Close()
|
||||
results <- true
|
||||
} else {
|
||||
results <- false
|
||||
}
|
||||
}(target)
|
||||
}
|
||||
|
||||
for range targets {
|
||||
if <-results {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ─── WatchState ───────────────────────────────────────────────────────────────
|
||||
|
||||
func WatchState(ctx context.Context) (<-chan bool, error) {
|
||||
if nm == nil {
|
||||
return nil, fmt.Errorf("network: NetworkManager not available")
|
||||
}
|
||||
|
||||
ch := make(chan bool, 1)
|
||||
|
||||
go func() {
|
||||
defer close(ch)
|
||||
|
||||
ticker := time.NewTicker(3 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
var last *bool
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
connected, err := IsConnected()
|
||||
if err != nil {
|
||||
logger.Warning("network: WatchState poll failed: %v", err)
|
||||
continue
|
||||
}
|
||||
if last == nil || *last != connected {
|
||||
last = &connected
|
||||
select {
|
||||
case ch <- connected:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
// ─── Internal helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
func wifiDevice() (gonm.DeviceWireless, error) {
|
||||
if nm == nil {
|
||||
return nil, fmt.Errorf("network: NetworkManager not available")
|
||||
}
|
||||
|
||||
devices, err := nm.GetPropertyAllDevices()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("network: cannot list devices: %w", err)
|
||||
}
|
||||
|
||||
for _, dev := range devices {
|
||||
devType, err := dev.GetPropertyDeviceType()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if devType == gonm.NmDeviceTypeWifi {
|
||||
return gonm.NewDeviceWireless(dev.GetPath())
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("network: no WiFi device found")
|
||||
}
|
||||
|
||||
func findAccessPoint(dev gonm.DeviceWireless, ssid string) (gonm.AccessPoint, error) {
|
||||
aps, err := dev.GetPropertyAccessPoints()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, ap := range aps {
|
||||
apSSID, err := ap.GetPropertySSID()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if apSSID == ssid {
|
||||
return ap, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("not found")
|
||||
}
|
||||
|
||||
func detectSecurity(ap gonm.AccessPoint) SecurityType {
|
||||
flags, _ := ap.GetPropertyFlags()
|
||||
wpaFlags, _ := ap.GetPropertyWPAFlags()
|
||||
rsnFlags, _ := ap.GetPropertyRSNFlags()
|
||||
|
||||
// WPA3 uses SAE (0x400)
|
||||
if rsnFlags&0x400 != 0 {
|
||||
return SecurityWPA3
|
||||
}
|
||||
// WPA2 uses PSK (0x100)
|
||||
if rsnFlags&0x100 != 0 {
|
||||
return SecurityWPA2
|
||||
}
|
||||
// WPA uses WPA flags (not RSN)
|
||||
if wpaFlags != 0 {
|
||||
return SecurityWPA
|
||||
}
|
||||
// WEP uses privacy flag
|
||||
if flags&0x1 != 0 {
|
||||
return SecurityWEP
|
||||
}
|
||||
|
||||
return SecurityOpen
|
||||
}
|
||||
|
||||
func buildConnectionSettings(ssid, password string, security SecurityType, interfaceName string) (map[string]map[string]interface{}, error) {
|
||||
settings := map[string]map[string]interface{}{
|
||||
"connection": {
|
||||
"id": ssid,
|
||||
"type": "802-11-wireless",
|
||||
"uuid": uuid.NewString(),
|
||||
"interface-name": interfaceName,
|
||||
"autoconnect": true,
|
||||
"permissions": []string{},
|
||||
},
|
||||
"802-11-wireless": {
|
||||
"ssid": []byte(ssid),
|
||||
"mode": "infrastructure",
|
||||
},
|
||||
"ipv4": {"method": "auto"},
|
||||
"ipv6": {"method": "auto"},
|
||||
}
|
||||
|
||||
switch security {
|
||||
case SecurityOpen:
|
||||
// nothing
|
||||
|
||||
case SecurityWEP:
|
||||
if password == "" {
|
||||
return nil, fmt.Errorf("network: WEP network requires a password")
|
||||
}
|
||||
settings["802-11-wireless-security"] = map[string]interface{}{
|
||||
"key-mgmt": "none",
|
||||
"wep-key0": password,
|
||||
"wep-key-type": uint32(1),
|
||||
}
|
||||
|
||||
case SecurityWPA, SecurityWPA2:
|
||||
if password == "" {
|
||||
return nil, fmt.Errorf("network: WPA/WPA2 network requires a password")
|
||||
}
|
||||
settings["802-11-wireless-security"] = map[string]interface{}{
|
||||
"key-mgmt": "wpa-psk",
|
||||
"auth-alg": "open",
|
||||
"psk": password,
|
||||
"psk-flags": uint32(0),
|
||||
}
|
||||
|
||||
case SecurityWPA3:
|
||||
if password == "" {
|
||||
return nil, fmt.Errorf("network: WPA3 network requires a password")
|
||||
}
|
||||
settings["802-11-wireless-security"] = map[string]interface{}{
|
||||
"key-mgmt": "sae",
|
||||
"psk": password,
|
||||
}
|
||||
}
|
||||
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
func buildNetworkList(dev gonm.DeviceWireless) ([]Network, error) {
|
||||
aps, err := dev.GetPropertyAccessPoints()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
activeAP, _ := dev.GetPropertyActiveAccessPoint()
|
||||
var activeSSID string
|
||||
if activeAP != nil {
|
||||
activeSSID, _ = activeAP.GetPropertySSID()
|
||||
}
|
||||
|
||||
saved, _ := ListSavedNetworks()
|
||||
savedSet := make(map[string]bool, len(saved))
|
||||
for _, s := range saved {
|
||||
savedSet[s] = true
|
||||
}
|
||||
|
||||
var networks []Network
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for _, ap := range aps {
|
||||
ssid, err := ap.GetPropertySSID()
|
||||
if err != nil || ssid == "" {
|
||||
continue
|
||||
}
|
||||
if seen[ssid] {
|
||||
continue
|
||||
}
|
||||
seen[ssid] = true
|
||||
|
||||
strength, _ := ap.GetPropertyStrength()
|
||||
|
||||
networks = append(networks, Network{
|
||||
SSID: ssid,
|
||||
SignalStrength: strength,
|
||||
Security: detectSecurity(ap),
|
||||
Saved: savedSet[ssid],
|
||||
Connected: ssid == activeSSID,
|
||||
})
|
||||
}
|
||||
|
||||
return networks, nil
|
||||
}
|
||||
|
||||
// waitForConnection waits until the wifi device reaches the Activated state,
|
||||
// meaning DHCP has completed and the connection is fully up.
|
||||
func waitForConnection(dev gonm.DeviceWireless, ssid string, timeout time.Duration) error {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
state, err := dev.GetPropertyState()
|
||||
if err != nil {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
|
||||
logger.Debug("network: device state %d, waiting...", state)
|
||||
|
||||
switch state {
|
||||
case gonm.NmDeviceStateActivated:
|
||||
current, err := CurrentNetwork()
|
||||
if err == nil && current == ssid {
|
||||
logger.Info("network: fully connected to %q (DHCP complete)", ssid)
|
||||
return nil
|
||||
}
|
||||
|
||||
case gonm.NmDeviceStateFailed:
|
||||
return fmt.Errorf("network: connection to %q failed (wrong password?)", ssid)
|
||||
|
||||
case gonm.NmDeviceStateDisconnected:
|
||||
return fmt.Errorf("network: connection to %q was rejected by the network", ssid)
|
||||
}
|
||||
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
return fmt.Errorf("network: timed out waiting for connection to %q", ssid)
|
||||
}
|
||||
|
||||
var internetCheckURL = "https://clients3.google.com/generate_204"
|
||||
|
||||
func httpCheck() bool {
|
||||
client := http.Client{Timeout: 3 * time.Second}
|
||||
resp, err := client.Get(internetCheckURL)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
resp.Body.Close()
|
||||
return resp.StatusCode == 204
|
||||
}
|
||||
|
||||
var _ = httpCheck
|
||||
@@ -1,626 +0,0 @@
|
||||
package page
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"github.com/go-gl/gl/v3.3-core/gl"
|
||||
"github.com/go-gl/mathgl/mgl32"
|
||||
"github.com/veandco/go-sdl2/sdl"
|
||||
"rhemawavetech.com/rhemawave/internal/colors"
|
||||
"rhemawavetech.com/rhemawave/internal/db"
|
||||
"rhemawavetech.com/rhemawave/internal/fontManager"
|
||||
"rhemawavetech.com/rhemawave/internal/logger"
|
||||
"rhemawavetech.com/rhemawave/internal/mixer"
|
||||
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
||||
)
|
||||
|
||||
// focusToggle is the sentinel focusState value meaning the audio on/off toggle
|
||||
// row has keyboard focus. Device indices (0..n-1) are used for everything below.
|
||||
const focusToggle = -1
|
||||
|
||||
// AudioFileList is kept as the canonical seed data for the audio_files table.
|
||||
// Track selection UI no longer lives on this page, but other parts of the
|
||||
// app still read these rows from the database.
|
||||
var AudioFileList = []db.AudioFile{
|
||||
{ID: 14, Name: "Original Audio", Path: "OriginalAudio.wav", Selected: false, Order: 1},
|
||||
{ID: 1, Name: "174 Hz", Path: "174Hz.wav", Selected: false, Order: 2},
|
||||
{ID: 2, Name: "396 Hz", Path: "396Hz.wav", Selected: false, Order: 3},
|
||||
{ID: 3, Name: "417 Hz", Path: "417Hz.wav", Selected: false, Order: 4},
|
||||
{ID: 4, Name: "432 Hz", Path: "432Hz.wav", Selected: false, Order: 5},
|
||||
{ID: 5, Name: "639 Hz", Path: "639Hz.wav", Selected: false, Order: 6},
|
||||
{ID: 6, Name: "741 Hz", Path: "741Hz.wav", Selected: false, Order: 7},
|
||||
{ID: 7, Name: "852 Hz", Path: "852Hz.wav", Selected: false, Order: 8},
|
||||
{ID: 8, Name: "963 Hz", Path: "963Hz.wav", Selected: false, Order: 9},
|
||||
{ID: 9, Name: "1111 Hz", Path: "1111Hz.wav", Selected: false, Order: 10},
|
||||
{ID: 10, Name: "14 Hz Binaural Beat", Path: "14Hz_Binaural.wav", Selected: false, Order: 11},
|
||||
{ID: 11, Name: "40 Hz Binaural Beat", Path: "40Hz_Binaural.wav", Selected: false, Order: 12},
|
||||
{ID: 12, Name: "Alpha Waves", Path: "AlphaWaves.wav", Selected: false, Order: 13},
|
||||
{ID: 13, Name: "Pineal Gland", Path: "PinealGland.wav", Selected: false, Order: 14},
|
||||
}
|
||||
|
||||
const triangleSize = float32(16)
|
||||
|
||||
type AudioDevice struct {
|
||||
Label string
|
||||
ID uint32
|
||||
index int
|
||||
}
|
||||
|
||||
type AudioOptions struct {
|
||||
// Page Attributes
|
||||
Base
|
||||
font *fontManager.FontEntry
|
||||
textShader *shaderManager.Shader
|
||||
solidShader *shaderManager.Shader
|
||||
CfgPtr *db.AppConfig
|
||||
|
||||
// Page Textures
|
||||
title StringItem
|
||||
prompt StringItem
|
||||
|
||||
// Layout
|
||||
verticalMargin int32
|
||||
ItemScale float32
|
||||
|
||||
// Audio toggle
|
||||
AudioStatusToggle StringItem
|
||||
|
||||
// Audio Devices
|
||||
activeDeviceIndex int
|
||||
NumAudioDevices int
|
||||
AudioDevices []AudioDevice
|
||||
AudioDeviceTextures []StringItem
|
||||
|
||||
// Volume row
|
||||
VolumeLabel StringItem
|
||||
volumePercent StringItem
|
||||
volumeBarX int32
|
||||
volumeBarY int32
|
||||
volumeBarW int32
|
||||
volumeBarH int32
|
||||
|
||||
// Triangle cursor
|
||||
triVAO uint32
|
||||
triVBO uint32
|
||||
triX float32
|
||||
triCenterY float32
|
||||
spinElapsed float32
|
||||
|
||||
// Focus: focusToggle (-1) = toggle row, 0..n-1 = device at that index
|
||||
focusState int
|
||||
}
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AudioOptions) Init(ctx *PageCtx) error {
|
||||
var err error
|
||||
p.verticalMargin = int32(20)
|
||||
p.focusState = focusToggle
|
||||
|
||||
// Font
|
||||
fm := fontManager.Get()
|
||||
font, ok := fm.GetFont("SpaceMonoNerdFont-Regular")
|
||||
if !ok {
|
||||
return fmt.Errorf("failed to get font: %w", sdl.GetError())
|
||||
}
|
||||
p.font = font
|
||||
|
||||
// Shaders
|
||||
sm := shaderManager.Get()
|
||||
p.textShader, ok = sm.Get("text")
|
||||
if !ok {
|
||||
return fmt.Errorf("failed to get text shader: %w", sdl.GetError())
|
||||
}
|
||||
p.solidShader, ok = sm.Get("solid_color")
|
||||
if !ok {
|
||||
return fmt.Errorf("failed to get solid_color shader: %w", sdl.GetError())
|
||||
}
|
||||
|
||||
if err = p.Base.Init(ctx); err != nil {
|
||||
return fmt.Errorf("base init failed: %w", err)
|
||||
}
|
||||
|
||||
config, err := db.GetConfig(p.Context.DbPtr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
baseFontSize := config.StandardFontSize
|
||||
titleScale := float32(64) / float32(baseFontSize)
|
||||
p.ItemScale = float32(32) / float32(baseFontSize)
|
||||
|
||||
// Title
|
||||
p.title, err = NewStringItem("Audio Options", p.font, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating title: %w", err)
|
||||
}
|
||||
p.title.Scale(titleScale)
|
||||
|
||||
// Prompt
|
||||
p.prompt, err = NewStringItem(
|
||||
"Enter to select -- Up/Down to navigate -- Left/Right to change volume -- Backspace to go back",
|
||||
p.font, colors.White,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating prompt: %w", err)
|
||||
}
|
||||
p.prompt.Scale(p.ItemScale)
|
||||
|
||||
// Volume row
|
||||
p.VolumeLabel, err = NewStringItem("Volume", p.font, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating volume label: %w", err)
|
||||
}
|
||||
p.VolumeLabel.Scale(p.ItemScale)
|
||||
if err = p.CreateVolumePercentItem(); err != nil {
|
||||
logger.Error("failed to create VolumePercentItem in AudioOptions.Init()")
|
||||
}
|
||||
|
||||
// Audio toggle
|
||||
if err = p.CreateAudioStatusItem(); err != nil {
|
||||
logger.Error("failed to create AudioStatusItem in AudioOptions.Init()")
|
||||
}
|
||||
|
||||
// Audio output devices
|
||||
if err = p.GetAudioDevices(); err != nil {
|
||||
logger.Error("failed to enumerate audio devices: %v", err)
|
||||
}
|
||||
|
||||
// Set active device index based on mixer's current device
|
||||
currentName := mixer.CurrentDevice()
|
||||
config, err = db.GetConfig(p.Context.DbPtr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
p.activeDeviceIndex = -1 // default to not found
|
||||
for i, dev := range p.AudioDevices {
|
||||
if dev.Label == currentName {
|
||||
p.activeDeviceIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
// If not found and current device is default (""), check saved config
|
||||
if p.activeDeviceIndex == -1 && currentName == "" && config.SelectedAudioDevice == "default" {
|
||||
// Default device is selected - first device in SDL list is typically the default
|
||||
if len(p.AudioDevices) > 0 {
|
||||
p.activeDeviceIndex = 0
|
||||
}
|
||||
}
|
||||
|
||||
// Make sure the audio_files table is populated.
|
||||
if err = p.seedAudioFiles(); err != nil {
|
||||
logger.Error("failed to seed audio files: %v", err)
|
||||
}
|
||||
|
||||
// Triangle cursor VAO/VBO — stride is vec2 only, no tex coords
|
||||
triVerts := []float32{
|
||||
0.5, 0.0,
|
||||
-0.5, 0.5,
|
||||
-0.5, -0.5,
|
||||
}
|
||||
gl.GenVertexArrays(1, &p.triVAO)
|
||||
gl.GenBuffers(1, &p.triVBO)
|
||||
gl.BindVertexArray(p.triVAO)
|
||||
gl.BindBuffer(gl.ARRAY_BUFFER, p.triVBO)
|
||||
gl.BufferData(gl.ARRAY_BUFFER, len(triVerts)*4, gl.Ptr(triVerts), gl.STATIC_DRAW)
|
||||
gl.EnableVertexAttribArray(0)
|
||||
gl.VertexAttribPointerWithOffset(0, 2, gl.FLOAT, false, 2*4, 0)
|
||||
gl.BindBuffer(gl.ARRAY_BUFFER, 0)
|
||||
gl.BindVertexArray(0)
|
||||
|
||||
p.layout()
|
||||
return nil
|
||||
}
|
||||
|
||||
// seedAudioFiles ensures the audio_files table has its default rows.
|
||||
func (p *AudioOptions) seedAudioFiles() error {
|
||||
var existing []db.AudioFile
|
||||
if err := p.Context.DbPtr.Find(&existing).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(existing) == 0 {
|
||||
return p.Context.DbPtr.Save(&AudioFileList).Error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Audio status toggle ───────────────────────────────────────────────────────
|
||||
|
||||
func (p *AudioOptions) CreateAudioStatusItem() error {
|
||||
config, err := db.GetConfig(p.Context.DbPtr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
on := config.AudioEnabled
|
||||
statusString := "Turn Audio On"
|
||||
color := colors.Green
|
||||
if on {
|
||||
statusString = "Turn Audio Off"
|
||||
color = colors.Red
|
||||
}
|
||||
item, err := NewStringItem(statusString, p.font, color)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create '%s' string item: %v", statusString, err)
|
||||
}
|
||||
item.Scale(p.ItemScale)
|
||||
p.AudioStatusToggle = item
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Audio Output Devices ──────────────────────────────────────────────────────
|
||||
|
||||
func (p *AudioOptions) GetAudioDevices() error {
|
||||
numAudioDevices := sdl.GetNumAudioDevices(false)
|
||||
if numAudioDevices == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Pre-allocate with zero length so append doesn't produce leading zero-value
|
||||
// elements (the old code used make([]AudioDevice, n) then appended, which
|
||||
// doubled the slice length with n zero-value entries at the front).
|
||||
audioDevices := make([]AudioDevice, 0, numAudioDevices)
|
||||
for i := range numAudioDevices {
|
||||
label := sdl.GetAudioDeviceName(i, false)
|
||||
audioDevices = append(audioDevices, AudioDevice{Label: label, ID: uint32(i)})
|
||||
}
|
||||
p.AudioDevices = audioDevices
|
||||
|
||||
for i := range audioDevices {
|
||||
item, err := NewStringItem(p.AudioDevices[i].Label, p.font, colors.White)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
item.Scale(p.ItemScale)
|
||||
p.AudioDeviceTextures = append(p.AudioDeviceTextures, item)
|
||||
}
|
||||
p.NumAudioDevices = numAudioDevices
|
||||
return nil
|
||||
}
|
||||
|
||||
// SelectAudioDevice is called when the user confirms a device from the list.
|
||||
// The device-switching implementation goes here; for now we just log the choice.
|
||||
func (p *AudioOptions) SelectAudioDevice(deviceIndex int) error {
|
||||
if deviceIndex < 0 || deviceIndex >= len(p.AudioDevices) {
|
||||
return fmt.Errorf("invalid device index: %d", deviceIndex)
|
||||
}
|
||||
device := p.AudioDevices[deviceIndex]
|
||||
|
||||
config, err := db.GetConfig(p.Context.DbPtr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get config for device switch: %w", err)
|
||||
}
|
||||
|
||||
if err := mixer.SwitchDevice(device.Label, config); err != nil {
|
||||
return fmt.Errorf("failed to switch to device %s: %w", device.Label, err)
|
||||
}
|
||||
|
||||
// Persist the selected device to the database
|
||||
config.SelectedAudioDevice = device.Label
|
||||
if err := p.Context.DbPtr.Save(config).Error; err != nil {
|
||||
logger.Error("failed to persist device selection: %v", err)
|
||||
}
|
||||
|
||||
p.activeDeviceIndex = deviceIndex
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Volume ────────────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AudioOptions) CreateVolumePercentItem() error {
|
||||
if p.volumePercent.ID != 0 {
|
||||
gl.DeleteTextures(1, &p.volumePercent.ID)
|
||||
}
|
||||
config, err := db.GetConfig(p.Context.DbPtr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
pct := int(config.SavedVolume * 100)
|
||||
item, err := NewStringItem(fmt.Sprintf("%d%%", pct), p.font, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create volume percent item: %v", err)
|
||||
}
|
||||
item.Scale(p.ItemScale)
|
||||
p.volumePercent = item
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *AudioOptions) UpdateVolume(level int) {
|
||||
config, err := db.GetConfig(p.Context.DbPtr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
newVol := mixer.ConvertVolume(level) + config.SavedVolume
|
||||
if newVol < 0.0 || newVol > 1.0 {
|
||||
return
|
||||
}
|
||||
if newVol > .99 {
|
||||
newVol = 1.0
|
||||
}
|
||||
config.SavedVolume = newVol
|
||||
p.Context.DbPtr.Save(&config)
|
||||
if err := mixer.SetAllSinkVolumes(newVol); err != nil {
|
||||
logger.Error("Error setting allSinkVolumes: %v", err)
|
||||
}
|
||||
if err := p.CreateVolumePercentItem(); err != nil {
|
||||
logger.Error("failed to refresh volume percent item: %v", err)
|
||||
}
|
||||
gap := int32(12)
|
||||
p.volumePercent.X = p.volumeBarX + p.volumeBarW + gap
|
||||
p.volumePercent.Y = p.volumeBarY + (p.volumeBarH-p.volumePercent.H)/2
|
||||
}
|
||||
|
||||
// ── Layout ────────────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AudioOptions) layout() {
|
||||
config, err := db.GetConfig(p.Context.DbPtr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Title
|
||||
p.title.X = p.ScreenCenterX - p.title.W/2
|
||||
p.title.Y = p.ScreenHeight - p.title.H - p.verticalMargin*2
|
||||
|
||||
// Prompt
|
||||
p.prompt.X = p.ScreenCenterX - p.prompt.W/2
|
||||
p.prompt.Y = p.title.Y - p.verticalMargin - p.prompt.H
|
||||
|
||||
// Volume row
|
||||
barH := int32(float32(config.StandardFontSize) * p.ItemScale)
|
||||
barW := int32(float32(p.ScreenWidth) * 0.30)
|
||||
barX := p.ScreenCenterX - barW/2
|
||||
barY := p.prompt.Y - p.verticalMargin*2 - barH
|
||||
|
||||
p.volumeBarX = barX
|
||||
p.volumeBarY = barY
|
||||
p.volumeBarW = barW
|
||||
p.volumeBarH = barH
|
||||
|
||||
p.VolumeLabel.X = barX - p.VolumeLabel.W - 20
|
||||
p.VolumeLabel.Y = barY + (barH-p.VolumeLabel.H)/2
|
||||
|
||||
gap := int32(12)
|
||||
p.volumePercent.X = barX + barW + gap
|
||||
p.volumePercent.Y = barY + (barH-p.volumePercent.H)/2
|
||||
|
||||
// Audio toggle — centred below the volume row
|
||||
p.AudioStatusToggle.X = p.ScreenCenterX - p.AudioStatusToggle.W/2
|
||||
p.AudioStatusToggle.Y = barY - p.verticalMargin*3 - p.AudioStatusToggle.H
|
||||
|
||||
// Device list — each item centred and stacked below the toggle
|
||||
nextY := p.AudioStatusToggle.Y - p.verticalMargin
|
||||
for i := range p.AudioDeviceTextures {
|
||||
item := &p.AudioDeviceTextures[i]
|
||||
item.X = p.ScreenCenterX - item.W/2
|
||||
item.Y = nextY - item.H
|
||||
nextY = item.Y - p.verticalMargin
|
||||
}
|
||||
|
||||
// Reposition the triangle cursor for whatever row currently has focus
|
||||
p.updateCursorPosition()
|
||||
}
|
||||
|
||||
// updateCursorPosition moves triX / triCenterY to sit just left of the
|
||||
// focused row. Call this any time focusState changes or layout() reruns.
|
||||
func (p *AudioOptions) updateCursorPosition() {
|
||||
cursorGap := int32(12)
|
||||
|
||||
var item *StringItem
|
||||
switch {
|
||||
case p.focusState == focusToggle:
|
||||
item = &p.AudioStatusToggle
|
||||
case p.focusState >= 0 && p.focusState < len(p.AudioDeviceTextures):
|
||||
item = &p.AudioDeviceTextures[p.focusState]
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
p.triX = float32(item.X) - triangleSize - float32(cursorGap)
|
||||
p.triCenterY = float32(item.Y) + float32(item.H)/2
|
||||
}
|
||||
|
||||
// ── Toggle ────────────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AudioOptions) ToggleAudio() {
|
||||
config, err := db.GetConfig(p.Context.DbPtr)
|
||||
if err != nil {
|
||||
logger.Error("panic! Cannot query database for configuration")
|
||||
panic(err)
|
||||
}
|
||||
config.AudioEnabled = !config.AudioEnabled
|
||||
p.Context.DbPtr.Save(config)
|
||||
p.CreateAudioStatusItem()
|
||||
p.layout()
|
||||
}
|
||||
|
||||
// ── Events ────────────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AudioOptions) HandleEvent(event *sdl.Event) error {
|
||||
e, ok := (*event).(*sdl.KeyboardEvent)
|
||||
if !ok || e.Type != sdl.KEYDOWN {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch e.Keysym.Sym {
|
||||
case sdl.K_RETURN:
|
||||
if p.focusState == focusToggle {
|
||||
p.ToggleAudio()
|
||||
} else if p.focusState >= 0 && p.focusState < len(p.AudioDevices) {
|
||||
if err := p.SelectAudioDevice(p.focusState); err != nil {
|
||||
logger.Error("SelectAudioDevice: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
case sdl.K_DOWN:
|
||||
if p.focusState == focusToggle {
|
||||
if len(p.AudioDeviceTextures) > 0 {
|
||||
p.focusState = 0
|
||||
p.updateCursorPosition()
|
||||
}
|
||||
} else if p.focusState < len(p.AudioDeviceTextures)-1 {
|
||||
p.focusState++
|
||||
p.updateCursorPosition()
|
||||
}
|
||||
|
||||
case sdl.K_UP:
|
||||
if p.focusState == 0 {
|
||||
p.focusState = focusToggle
|
||||
p.updateCursorPosition()
|
||||
} else if p.focusState > 0 {
|
||||
p.focusState--
|
||||
p.updateCursorPosition()
|
||||
}
|
||||
|
||||
case sdl.K_RIGHT:
|
||||
p.UpdateVolume(10)
|
||||
|
||||
case sdl.K_LEFT:
|
||||
p.UpdateVolume(-10)
|
||||
|
||||
case sdl.K_BACKSPACE:
|
||||
p.Context.NavigateTo(SETTINGS)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Update ────────────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AudioOptions) Update(dt float32) error {
|
||||
p.spinElapsed += dt
|
||||
if p.spinElapsed >= 1.0 {
|
||||
p.spinElapsed -= 1.0
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Render helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AudioOptions) renderVolumeBar() error {
|
||||
config, err := db.GetConfig(p.Context.DbPtr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
vol := config.SavedVolume
|
||||
|
||||
fillW := float32(p.volumeBarW) * float32(vol)
|
||||
if fillW > 0 {
|
||||
if err := p.RenderSolidColorQuad(
|
||||
p.solidShader,
|
||||
mgl32.Vec2{float32(p.volumeBarX), float32(p.volumeBarY)},
|
||||
mgl32.Vec2{fillW, float32(p.volumeBarH)},
|
||||
colors.White,
|
||||
); err != nil {
|
||||
return fmt.Errorf("rendering volume fill: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
thickness := float32(2)
|
||||
bx := float32(p.volumeBarX)
|
||||
by := float32(p.volumeBarY)
|
||||
bw := float32(p.volumeBarW)
|
||||
bh := float32(p.volumeBarH)
|
||||
|
||||
outlineRects := [4][2]mgl32.Vec2{
|
||||
{{bx, by}, {bw, thickness}},
|
||||
{{bx, by + bh - thickness}, {bw, thickness}},
|
||||
{{bx, by}, {thickness, bh}},
|
||||
{{bx + bw - thickness, by}, {thickness, bh}}, // fix: was missing `by` as Y component
|
||||
}
|
||||
for _, r := range outlineRects {
|
||||
if err := p.RenderSolidColorQuad(p.solidShader, r[0], r[1], colors.Green); err != nil {
|
||||
return fmt.Errorf("rendering volume outline: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// renderCursor draws the spinning triangle cursor next to the focused row.
|
||||
func (p *AudioOptions) renderCursor() error {
|
||||
scaleY := float32(math.Cos(2 * math.Pi * float64(p.spinElapsed)))
|
||||
|
||||
model := mgl32.Ident4()
|
||||
model = model.Mul4(mgl32.Translate3D(p.triX+triangleSize/2, p.triCenterY, 0))
|
||||
model = model.Mul4(mgl32.Scale3D(triangleSize, triangleSize*scaleY, 1))
|
||||
mvp := p.Projection.Mul4(model)
|
||||
|
||||
gl.UseProgram(p.solidShader.ProgramID)
|
||||
gl.UniformMatrix4fv(p.solidShader.LocMVPMatrix, 1, false, &mvp[0])
|
||||
gl.Uniform4f(
|
||||
p.solidShader.LocColor,
|
||||
float32(colors.White.R)/255.0,
|
||||
float32(colors.White.G)/255.0,
|
||||
float32(colors.White.B)/255.0,
|
||||
float32(colors.White.A)/255.0,
|
||||
)
|
||||
|
||||
gl.BindVertexArray(p.triVAO)
|
||||
gl.DrawArrays(gl.TRIANGLES, 0, 3)
|
||||
gl.BindVertexArray(0)
|
||||
gl.UseProgram(0)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AudioOptions) Render() error {
|
||||
if err := p.RenderTexture(p.textShader, p.title.ID, p.title.Position(), p.title.RawDimensions(), p.title.Color); err != nil {
|
||||
return fmt.Errorf("render title: %w", err)
|
||||
}
|
||||
if err := p.RenderTexture(p.textShader, p.prompt.ID, p.prompt.Position(), p.prompt.RawDimensions(), p.prompt.Color); err != nil {
|
||||
return fmt.Errorf("render prompt: %w", err)
|
||||
}
|
||||
|
||||
// Volume row
|
||||
if err := p.RenderTexture(p.textShader, p.VolumeLabel.ID, p.VolumeLabel.Position(), p.VolumeLabel.RawDimensions(), p.VolumeLabel.Color); err != nil {
|
||||
return fmt.Errorf("render volume label: %w", err)
|
||||
}
|
||||
if err := p.renderVolumeBar(); err != nil {
|
||||
return fmt.Errorf("render volume bar: %w", err)
|
||||
}
|
||||
if err := p.RenderTexture(p.textShader, p.volumePercent.ID, p.volumePercent.Position(), p.volumePercent.RawDimensions(), p.volumePercent.Color); err != nil {
|
||||
return fmt.Errorf("render volume percent: %w", err)
|
||||
}
|
||||
|
||||
// Toggle
|
||||
if err := p.RenderTexture(p.textShader, p.AudioStatusToggle.ID, p.AudioStatusToggle.Position(), p.AudioStatusToggle.RawDimensions(), p.AudioStatusToggle.Color); err != nil {
|
||||
return fmt.Errorf("render audio toggle: %w", err)
|
||||
}
|
||||
|
||||
// Device list
|
||||
for i := range p.AudioDeviceTextures {
|
||||
item := &p.AudioDeviceTextures[i]
|
||||
color := colors.White
|
||||
if i == p.activeDeviceIndex {
|
||||
color = colors.Green
|
||||
}
|
||||
if err := p.RenderTexture(p.textShader, item.ID, item.Position(), item.RawDimensions(), color); err != nil {
|
||||
return fmt.Errorf("render audio device %d: %w", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Spinning triangle cursor — always on top
|
||||
if err := p.renderCursor(); err != nil {
|
||||
return fmt.Errorf("render cursor: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Destroy ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *AudioOptions) Destroy() error {
|
||||
gl.DeleteTextures(1, &p.title.ID)
|
||||
gl.DeleteTextures(1, &p.prompt.ID)
|
||||
gl.DeleteTextures(1, &p.VolumeLabel.ID)
|
||||
gl.DeleteTextures(1, &p.volumePercent.ID)
|
||||
gl.DeleteTextures(1, &p.AudioStatusToggle.ID)
|
||||
for i := range p.AudioDeviceTextures {
|
||||
gl.DeleteTextures(1, &p.AudioDeviceTextures[i].ID)
|
||||
}
|
||||
gl.DeleteBuffers(1, &p.triVBO)
|
||||
gl.DeleteVertexArrays(1, &p.triVAO)
|
||||
return nil
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package page
|
||||
|
||||
import (
|
||||
"github.com/veandco/go-sdl2/sdl"
|
||||
)
|
||||
|
||||
type DownloadingAssets struct {
|
||||
}
|
||||
|
||||
func (p *DownloadingAssets) Init(ctx *PageCtx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *DownloadingAssets) HandleEvent(event *sdl.Event) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *DownloadingAssets) Update(dt float32) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *DownloadingAssets) Render() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *DownloadingAssets) Destroy() error {
|
||||
return nil
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
package page
|
||||
|
||||
import (
|
||||
"github.com/veandco/go-sdl2/sdl"
|
||||
)
|
||||
|
||||
type MessagePage struct {
|
||||
Msg string
|
||||
}
|
||||
|
||||
func (p *MessagePage) Init(ctx *PageCtx) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *MessagePage) HandleEvent(event *sdl.Event) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *MessagePage) Update(dt float32) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *MessagePage) Render() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *MessagePage) Destroy() error {
|
||||
return nil
|
||||
}
|
||||
@@ -1,407 +0,0 @@
|
||||
package page
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
|
||||
"github.com/go-gl/gl/v3.3-core/gl"
|
||||
"github.com/go-gl/mathgl/mgl32"
|
||||
"github.com/veandco/go-sdl2/sdl"
|
||||
"rhemawavetech.com/rhemawave/internal/colors"
|
||||
"rhemawavetech.com/rhemawave/internal/db"
|
||||
"rhemawavetech.com/rhemawave/internal/fontManager"
|
||||
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
||||
)
|
||||
|
||||
// emailRegex validates email format
|
||||
var emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
|
||||
|
||||
type EmailAddressPage struct {
|
||||
Base
|
||||
|
||||
title string
|
||||
titleTextureID uint32
|
||||
titleTextureWidth int32
|
||||
titleTextureHeight int32
|
||||
|
||||
currentEmailDisplayTextureID uint32
|
||||
currentEmailDisplayTextureWidth int32
|
||||
currentEmailDisplayTextureHeight int32
|
||||
|
||||
inputText string
|
||||
inputTextTextureID uint32
|
||||
inputTextTextureWidth int32
|
||||
inputTextTextureHeight int32
|
||||
|
||||
// Error message handling
|
||||
errorMessage string
|
||||
errorTextureID uint32
|
||||
errorTextureWidth int32
|
||||
errorTextureHeight int32
|
||||
|
||||
// isFirstTimeSetup tracks whether page was loaded empty (first-time setup)
|
||||
isFirstTimeSetup bool
|
||||
|
||||
// Text input deduplication
|
||||
textInputSupported bool
|
||||
}
|
||||
|
||||
// isValidEmail checks if the given string is a valid email format
|
||||
func isValidEmail(email string) bool {
|
||||
return emailRegex.MatchString(email)
|
||||
}
|
||||
|
||||
// Init performs basic page initialization and creation of static assets.
|
||||
func (p *EmailAddressPage) Init(ctx *PageCtx) error {
|
||||
|
||||
if err := p.Base.Init(ctx); err != nil {
|
||||
return fmt.Errorf("failed base page initialization: %w", err)
|
||||
}
|
||||
|
||||
p.title = "Your Email Address"
|
||||
|
||||
applicationFont := "SpaceMonoNerdFont-Regular"
|
||||
|
||||
fm := fontManager.Get()
|
||||
textShader, ok := shaderManager.Get().Get("text")
|
||||
if !ok {
|
||||
return fmt.Errorf("unable to fetch text shader")
|
||||
}
|
||||
|
||||
// Create title texture
|
||||
titleTextureID, titleWidth, titleHeight, err := fm.CreateStringTexture(applicationFont, p.title, textShader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create title texture: %w", err)
|
||||
}
|
||||
if titleTextureID == 0 {
|
||||
return fmt.Errorf("CreateStringTexture returned a nil texture ID")
|
||||
}
|
||||
|
||||
p.titleTextureID = titleTextureID
|
||||
p.titleTextureWidth = titleWidth
|
||||
p.titleTextureHeight = titleHeight
|
||||
|
||||
// Load existing email address from database
|
||||
config, err := db.GetConfig(p.Context.DbPtr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
emailAddress := config.EmailAddress
|
||||
|
||||
if emailAddress != "" {
|
||||
p.inputText = emailAddress
|
||||
p.isFirstTimeSetup = false
|
||||
|
||||
// Create current email display texture
|
||||
currentEmailText := fmt.Sprintf("Current Email: %s", emailAddress)
|
||||
currentEmailTextureID, currentEmailWidth, currentEmailHeight, err := fm.CreateStringTexture(applicationFont, currentEmailText, textShader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create current email display texture: %w", err)
|
||||
}
|
||||
if currentEmailTextureID == 0 {
|
||||
return fmt.Errorf("CreateStringTexture returned a nil texture ID for current email display")
|
||||
}
|
||||
|
||||
p.currentEmailDisplayTextureID = currentEmailTextureID
|
||||
p.currentEmailDisplayTextureWidth = currentEmailWidth
|
||||
p.currentEmailDisplayTextureHeight = currentEmailHeight
|
||||
} else {
|
||||
p.inputText = ""
|
||||
p.isFirstTimeSetup = true
|
||||
}
|
||||
|
||||
sdl.StartTextInput()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleEvent processes SDL events and handles them appropriately for the EmailAddressPage.
|
||||
func (p *EmailAddressPage) HandleEvent(event *sdl.Event) error {
|
||||
actualEvent := *event
|
||||
if !sdl.IsTextInputActive() {
|
||||
sdl.StartTextInput()
|
||||
}
|
||||
|
||||
switch e := actualEvent.(type) {
|
||||
case *sdl.TextInputEvent:
|
||||
if !p.textInputSupported {
|
||||
// First TextInputEvent: the keyboard fallback already
|
||||
// handled this character, so just record that text input
|
||||
// is supported and skip the duplicate append.
|
||||
p.textInputSupported = true
|
||||
} else {
|
||||
p.inputText += e.GetText()
|
||||
// Clear error when user starts typing
|
||||
p.errorMessage = ""
|
||||
}
|
||||
case *sdl.KeyboardEvent:
|
||||
if e.Type == sdl.KEYDOWN {
|
||||
switch e.Keysym.Sym {
|
||||
case sdl.K_ESCAPE:
|
||||
// Block escape during first-time setup
|
||||
if p.isFirstTimeSetup {
|
||||
return nil
|
||||
}
|
||||
// Allow escape back to settings if coming from settings
|
||||
sdl.StopTextInput()
|
||||
p.Context.NavigateTo(SETTINGS)
|
||||
case sdl.K_RETURN:
|
||||
// Validate email - check empty first, then format
|
||||
if p.inputText == "" {
|
||||
p.errorMessage = "Email address cannot be empty"
|
||||
return nil
|
||||
}
|
||||
if !isValidEmail(p.inputText) {
|
||||
p.errorMessage = "Please enter a valid email address"
|
||||
return nil
|
||||
}
|
||||
|
||||
sdl.StopTextInput()
|
||||
|
||||
// Save email address to database
|
||||
config, err := db.GetConfig(p.Context.DbPtr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
config.EmailAddress = p.inputText
|
||||
p.Context.DbPtr.Save(config)
|
||||
|
||||
if p.isFirstTimeSetup {
|
||||
// First-time setup: navigate to License Key page
|
||||
p.Context.NavigateTo(LICENSE_KEY)
|
||||
} else {
|
||||
p.Context.NavigateTo(SETTINGS)
|
||||
}
|
||||
case sdl.K_BACKSPACE:
|
||||
if len(p.inputText) > 0 {
|
||||
p.inputText = p.inputText[:len(p.inputText)-1]
|
||||
// Clear error when user edits input
|
||||
p.errorMessage = ""
|
||||
}
|
||||
default:
|
||||
// Keyboard fallback: if TextInputEvent is not supported,
|
||||
// convert printable keycodes to characters directly.
|
||||
// This handles kiosk systems without an input method framework.
|
||||
if !p.textInputSupported {
|
||||
if ch := keycodeToChar(e.Keysym.Sym, e.Keysym.Mod); ch != 0 {
|
||||
p.inputText += string(ch)
|
||||
p.errorMessage = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update updates animations and changing textures and other changes using `dt` which represents a time delta in float32.
|
||||
func (p *EmailAddressPage) Update(dt float32) error {
|
||||
// Get application font from database
|
||||
applicationFont := "SpaceMonoNerdFont-Regular"
|
||||
|
||||
fm := fontManager.Get()
|
||||
sm := shaderManager.Get()
|
||||
shader, ok := sm.Get("text")
|
||||
if !ok {
|
||||
return fmt.Errorf("unable to fetch text shader")
|
||||
}
|
||||
|
||||
// Update input text texture
|
||||
if len(p.inputText) > 0 {
|
||||
// Create the new inputText texture
|
||||
inputTextTextureID, inputTextTextureWidth, inputTextTextureHeight, err := fm.CreateStringTexture(
|
||||
applicationFont,
|
||||
p.inputText,
|
||||
shader,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to create new input text texture: %w", err)
|
||||
}
|
||||
|
||||
// Delete old texture if it exists
|
||||
if p.inputTextTextureID != 0 {
|
||||
gl.DeleteTextures(1, &p.inputTextTextureID)
|
||||
}
|
||||
|
||||
p.inputTextTextureID = inputTextTextureID
|
||||
p.inputTextTextureWidth = inputTextTextureWidth
|
||||
p.inputTextTextureHeight = inputTextTextureHeight
|
||||
}
|
||||
|
||||
// Update error message texture
|
||||
if p.errorMessage != "" {
|
||||
errorTextureID, errorWidth, errorHeight, err := fm.CreateStringTexture(
|
||||
applicationFont,
|
||||
p.errorMessage,
|
||||
shader,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to create error text texture: %w", err)
|
||||
}
|
||||
|
||||
// Delete old error texture if it exists
|
||||
if p.errorTextureID != 0 {
|
||||
gl.DeleteTextures(1, &p.errorTextureID)
|
||||
}
|
||||
|
||||
p.errorTextureID = errorTextureID
|
||||
p.errorTextureWidth = errorWidth
|
||||
p.errorTextureHeight = errorHeight
|
||||
} else {
|
||||
// Clear error texture if no error message
|
||||
if p.errorTextureID != 0 {
|
||||
gl.DeleteTextures(1, &p.errorTextureID)
|
||||
p.errorTextureID = 0
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Render performs page drawing logic.
|
||||
func (p *EmailAddressPage) Render() error {
|
||||
config, err := db.GetConfig(p.Context.DbPtr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
// Get base font size from database
|
||||
baseFontSize := config.StandardFontSize
|
||||
|
||||
sm := shaderManager.Get()
|
||||
|
||||
// 1. Calculate Scale
|
||||
titleFontSize := int32(64)
|
||||
titleScale := float32(titleFontSize) / float32(baseFontSize)
|
||||
currentEmailFontSize := int32(28)
|
||||
currentEmailScale := float32(currentEmailFontSize) / float32(baseFontSize)
|
||||
padding := int32(4)
|
||||
verticalMargin := int32(20)
|
||||
|
||||
// 2. Define dimensions
|
||||
targetFontSize := int32(36)
|
||||
scale := float32(targetFontSize) / float32(baseFontSize)
|
||||
border := int32(3)
|
||||
boxH := targetFontSize + (padding * 2)
|
||||
boxW := int32(600) + (padding * 2)
|
||||
boxDimensions := mgl32.Vec2{float32(boxW), float32(boxH)}
|
||||
borderBoxH := boxH + (border * 2)
|
||||
borderBoxW := boxW + (border * 2)
|
||||
borderBoxDimensions := mgl32.Vec2{float32(borderBoxW), float32(borderBoxH)}
|
||||
titleH := float32(p.titleTextureHeight) * titleScale
|
||||
titleW := float32(p.titleTextureWidth) * titleScale
|
||||
titleDimensions := mgl32.Vec2{titleW, titleH}
|
||||
inputTextH := float32(p.inputTextTextureHeight) * scale
|
||||
inputTextW := float32(p.inputTextTextureWidth) * scale
|
||||
inputTextDimensions := mgl32.Vec2{inputTextW, inputTextH}
|
||||
|
||||
var currentEmailDisplayH, currentEmailDisplayW float32
|
||||
var currentEmailDisplayDimensions mgl32.Vec2
|
||||
if p.currentEmailDisplayTextureID != 0 {
|
||||
currentEmailDisplayH = float32(p.currentEmailDisplayTextureHeight) * currentEmailScale
|
||||
currentEmailDisplayW = float32(p.currentEmailDisplayTextureWidth) * currentEmailScale
|
||||
currentEmailDisplayDimensions = mgl32.Vec2{currentEmailDisplayW, currentEmailDisplayH}
|
||||
}
|
||||
|
||||
// Error text dimensions
|
||||
errorFontSize := int32(20)
|
||||
errorScale := float32(errorFontSize) / float32(baseFontSize)
|
||||
var errorTextH, errorTextW float32
|
||||
var errorTextDimensions mgl32.Vec2
|
||||
if p.errorTextureID != 0 {
|
||||
errorTextH = float32(p.errorTextureHeight) * errorScale
|
||||
errorTextW = float32(p.errorTextureWidth) * errorScale
|
||||
errorTextDimensions = mgl32.Vec2{errorTextW, errorTextH}
|
||||
}
|
||||
|
||||
// 3. Define positions
|
||||
borderBoxY := p.Base.ScreenCenterY - (borderBoxH / 2)
|
||||
borderBoxX := p.Base.ScreenCenterX - (borderBoxW / 2)
|
||||
borderBoxPosition := mgl32.Vec2{float32(borderBoxX), float32(borderBoxY)}
|
||||
boxY := p.Base.ScreenCenterY - (boxH / 2)
|
||||
boxX := p.Base.ScreenCenterX - (boxW / 2)
|
||||
boxPosition := mgl32.Vec2{float32(boxX), float32(boxY)}
|
||||
titleY := borderBoxY + borderBoxH + verticalMargin
|
||||
titleX := p.Base.ScreenCenterX - int32(titleW/2)
|
||||
titlePosition := mgl32.Vec2{float32(titleX), float32(titleY)}
|
||||
inputTextY := boxY + padding
|
||||
inputTextX := boxX + padding
|
||||
inputTextPosition := mgl32.Vec2{float32(inputTextX), float32(inputTextY)}
|
||||
|
||||
var currentEmailDisplayPosition mgl32.Vec2
|
||||
if p.currentEmailDisplayTextureID != 0 {
|
||||
currentEmailDisplayY := borderBoxY - int32(currentEmailDisplayH) - verticalMargin
|
||||
currentEmailDisplayX := p.Base.ScreenCenterX - int32(currentEmailDisplayW/2)
|
||||
currentEmailDisplayPosition = mgl32.Vec2{float32(currentEmailDisplayX), float32(currentEmailDisplayY)}
|
||||
}
|
||||
|
||||
// Error text position (below current email display to avoid overlap)
|
||||
var errorTextPosition mgl32.Vec2
|
||||
if p.errorTextureID != 0 {
|
||||
var errorTextY int32
|
||||
if p.currentEmailDisplayTextureID != 0 {
|
||||
// Position below the current email display
|
||||
errorTextY = borderBoxY - int32(currentEmailDisplayH) - verticalMargin - int32(errorTextH) - (verticalMargin / 2)
|
||||
} else {
|
||||
// No current email display, position below the input box
|
||||
errorTextY = borderBoxY - int32(errorTextH) - (verticalMargin / 2)
|
||||
}
|
||||
errorTextX := p.Base.ScreenCenterX - int32(errorTextW/2)
|
||||
errorTextPosition = mgl32.Vec2{float32(errorTextX), float32(errorTextY)}
|
||||
}
|
||||
|
||||
// 4. Define Colors
|
||||
white := sdl.Color{R: 255, G: 255, B: 255, A: 255}
|
||||
grey := sdl.Color{R: 120, G: 120, B: 120, A: 255}
|
||||
darkGrey := sdl.Color{R: 30, G: 30, B: 30, A: 255}
|
||||
|
||||
// 5. Get Shaders
|
||||
textShader, _ := sm.Get("text")
|
||||
solidShader, _ := sm.Get("solid_color")
|
||||
|
||||
// 6. Draw everything
|
||||
p.Base.RenderSolidColorQuad(solidShader, borderBoxPosition, borderBoxDimensions, grey)
|
||||
p.Base.RenderSolidColorQuad(solidShader, boxPosition, boxDimensions, darkGrey)
|
||||
p.Base.RenderTexture(textShader, p.titleTextureID, titlePosition, titleDimensions, white)
|
||||
|
||||
// Draw current email display if it exists
|
||||
if p.currentEmailDisplayTextureID != 0 {
|
||||
p.Base.RenderTexture(textShader, p.currentEmailDisplayTextureID, currentEmailDisplayPosition, currentEmailDisplayDimensions, white)
|
||||
}
|
||||
|
||||
if len(p.inputText) > 0 {
|
||||
p.Base.RenderTexture(textShader, p.inputTextTextureID, inputTextPosition, inputTextDimensions, white)
|
||||
}
|
||||
|
||||
// Draw error message if it exists
|
||||
if p.errorTextureID != 0 {
|
||||
p.Base.RenderTexture(textShader, p.errorTextureID, errorTextPosition, errorTextDimensions, colors.ErrorRed)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Destroy cleans up page-specific resources.
|
||||
func (p *EmailAddressPage) Destroy() error {
|
||||
|
||||
gl.DeleteTextures(1, &p.titleTextureID)
|
||||
|
||||
// Delete current email display texture if it exists
|
||||
if p.currentEmailDisplayTextureID != 0 {
|
||||
gl.DeleteTextures(1, &p.currentEmailDisplayTextureID)
|
||||
p.currentEmailDisplayTextureID = 0
|
||||
}
|
||||
|
||||
// Delete input text texture if it exists
|
||||
if p.inputTextTextureID != 0 {
|
||||
gl.DeleteTextures(1, &p.inputTextTextureID)
|
||||
p.inputTextTextureID = 0
|
||||
}
|
||||
|
||||
// Delete error texture if it exists
|
||||
if p.errorTextureID != 0 {
|
||||
gl.DeleteTextures(1, &p.errorTextureID)
|
||||
p.errorTextureID = 0
|
||||
}
|
||||
|
||||
sdl.StopTextInput()
|
||||
return p.Base.Destroy()
|
||||
}
|
||||
@@ -1,419 +0,0 @@
|
||||
package page
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/go-gl/gl/v3.3-core/gl"
|
||||
"github.com/go-gl/mathgl/mgl32"
|
||||
"github.com/veandco/go-sdl2/sdl"
|
||||
"rhemawavetech.com/rhemawave/internal/colors"
|
||||
"rhemawavetech.com/rhemawave/internal/db"
|
||||
"rhemawavetech.com/rhemawave/internal/download"
|
||||
"rhemawavetech.com/rhemawave/internal/fontManager"
|
||||
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
||||
)
|
||||
|
||||
var GitVersion = "07112026-1710"
|
||||
|
||||
type Home struct {
|
||||
Base
|
||||
fm *fontManager.FontManager
|
||||
font *fontManager.FontEntry
|
||||
title StringItem
|
||||
version StringItem
|
||||
prompt StringItem
|
||||
note StringItem
|
||||
message StringItem
|
||||
downloadProgressChan chan download.DownloadStatus
|
||||
licenseInfoHeading StringItem
|
||||
emailDisplay StringItem
|
||||
licenseKeyDisplay StringItem
|
||||
hasLicenseInfo bool
|
||||
baseFontSize int
|
||||
isDownloadComplete bool
|
||||
downloadFailed bool
|
||||
downloadDone bool
|
||||
}
|
||||
|
||||
// Init performs basic page initialization and creation of static assets.
|
||||
func (p *Home) Init(ctx *PageCtx) error {
|
||||
title := "Radiant Wave"
|
||||
version := GitVersion
|
||||
prompt := "Press F2 to Start, or F3 for Settings"
|
||||
note := "You can always press F1 to return here"
|
||||
|
||||
if err := p.Base.Init(ctx); err != nil {
|
||||
return fmt.Errorf("failed base page initialization: %w", err)
|
||||
}
|
||||
|
||||
// Get font
|
||||
p.fm = fontManager.Get()
|
||||
font, ok := p.fm.GetFont("SpaceMonoNerdFont-Regular")
|
||||
if !ok {
|
||||
return fmt.Errorf("unable to fetch font")
|
||||
}
|
||||
p.font = font
|
||||
|
||||
// Create title texture
|
||||
var err error
|
||||
p.title, err = NewStringItem(title, font, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to create title string texture: %w", err)
|
||||
}
|
||||
|
||||
// Create version texture
|
||||
versionString := "Version: " + version
|
||||
p.version, err = NewStringItem(versionString, font, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to create version string texture: %w", err)
|
||||
}
|
||||
|
||||
// Create prompt texture
|
||||
p.prompt, err = NewStringItem(prompt, font, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to create prompt string texture: %w", err)
|
||||
}
|
||||
|
||||
// Create note texture
|
||||
p.note, err = NewStringItem(note, font, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to create note string texture: %w", err)
|
||||
}
|
||||
|
||||
// Create download channel and message
|
||||
p.downloadProgressChan = make(chan download.DownloadStatus)
|
||||
|
||||
// Initialize message
|
||||
p.message, err = NewStringItem("Status: Initializing...", font, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to create initial message texture: %w", err)
|
||||
}
|
||||
|
||||
config, err := db.GetConfig(p.Context.DbPtr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Create license info textures if data exists
|
||||
if config.EmailAddress != "" || config.LicenseKey != "" {
|
||||
p.hasLicenseInfo = true
|
||||
|
||||
// Create heading
|
||||
p.licenseInfoHeading, err = NewStringItem("License Info", font, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to create license info heading texture: %w", err)
|
||||
}
|
||||
|
||||
// Create email display if exists
|
||||
if config.EmailAddress != "" {
|
||||
emailText := fmt.Sprintf("Email: %s", config.EmailAddress)
|
||||
p.emailDisplay, err = NewStringItem(emailText, font, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to create email display texture: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create license key display if exists
|
||||
if config.LicenseKey != "" {
|
||||
licenseKeyText := fmt.Sprintf("License Key: %s", config.LicenseKey)
|
||||
p.licenseKeyDisplay, err = NewStringItem(licenseKeyText, font, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to create license key display texture: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get base font size from database
|
||||
baseFontSize := config.StandardFontSize
|
||||
p.baseFontSize = baseFontSize
|
||||
|
||||
// Scale the note text
|
||||
fontSize := int32(32)
|
||||
scale := float32(fontSize) / float32(baseFontSize)
|
||||
p.note.W = int32(float32(p.note.W) * scale * 0.75)
|
||||
p.note.H = int32(float32(p.note.H) * scale * 0.75)
|
||||
|
||||
// Scale license info texts
|
||||
if p.hasLicenseInfo {
|
||||
licenseInfoScale := float32(28) / float32(baseFontSize)
|
||||
p.licenseInfoHeading.W = int32(float32(p.licenseInfoHeading.W) * licenseInfoScale)
|
||||
p.licenseInfoHeading.H = int32(float32(p.licenseInfoHeading.H) * licenseInfoScale)
|
||||
if p.emailDisplay.ID != 0 {
|
||||
p.emailDisplay.W = int32(float32(p.emailDisplay.W) * licenseInfoScale * 0.85)
|
||||
p.emailDisplay.H = int32(float32(p.emailDisplay.H) * licenseInfoScale * 0.85)
|
||||
}
|
||||
if p.licenseKeyDisplay.ID != 0 {
|
||||
p.licenseKeyDisplay.W = int32(float32(p.licenseKeyDisplay.W) * licenseInfoScale * 0.85)
|
||||
p.licenseKeyDisplay.H = int32(float32(p.licenseKeyDisplay.H) * licenseInfoScale * 0.85)
|
||||
}
|
||||
}
|
||||
|
||||
// Scale message text
|
||||
messageScale := float32(28) / float32(baseFontSize)
|
||||
p.message.W = int32(float32(p.message.W) * messageScale)
|
||||
p.message.H = int32(float32(p.message.H) * messageScale)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleEvent processes SDL events and handles them appropriately for the Home page.
|
||||
func (p *Home) HandleEvent(event *sdl.Event) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Home) Update(dt float32) error {
|
||||
var (
|
||||
progressUpdate download.DownloadStatus
|
||||
ok bool
|
||||
gotUpdate bool
|
||||
)
|
||||
|
||||
// Drain the channel, keeping only the newest update.
|
||||
for {
|
||||
select {
|
||||
case progressUpdate, ok = <-p.Context.MessageChannel:
|
||||
gotUpdate = true
|
||||
if !ok {
|
||||
if p.downloadFailed {
|
||||
p.setMessage("Download failed — restart to retry", colors.Red)
|
||||
} else {
|
||||
p.setMessage("Status: READY", colors.Green)
|
||||
p.isDownloadComplete = true
|
||||
if !p.downloadDone {
|
||||
p.downloadDone = true
|
||||
close(p.Context.DownloadDone)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
default:
|
||||
goto drained
|
||||
}
|
||||
}
|
||||
|
||||
drained:
|
||||
if !gotUpdate {
|
||||
return nil
|
||||
}
|
||||
|
||||
if progressUpdate.Err != nil {
|
||||
p.downloadFailed = true
|
||||
p.setMessage(fmt.Sprintf("Error: %s", progressUpdate.Err.Error()), colors.Red)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build the display string based on what the download package tells us.
|
||||
var msg string
|
||||
if progressUpdate.Max <= 0 {
|
||||
// No total known yet — show the descriptive message from the package.
|
||||
// This covers: "Fetching file manifest", "Checking local files",
|
||||
// "Comparing checksums", "Building download list", etc.
|
||||
msg = fmt.Sprintf("Status: %s", progressUpdate.Msg)
|
||||
} else {
|
||||
// We have a known total — show percentage.
|
||||
pct := float64(progressUpdate.Cur) / float64(progressUpdate.Max) * 100
|
||||
msg = fmt.Sprintf("Status: Downloading Assets — %.1f%%", pct)
|
||||
}
|
||||
|
||||
p.setMessage(msg, colors.White)
|
||||
return nil
|
||||
}
|
||||
|
||||
// setMessage deletes the old message texture and builds a new one.
|
||||
func (p *Home) setMessage(text string, color sdl.Color) {
|
||||
if p.message.ID != 0 {
|
||||
gl.DeleteTextures(1, &p.message.ID)
|
||||
p.message.ID = 0
|
||||
}
|
||||
|
||||
newMsg, err := NewStringItem(text, p.font, color)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
messageScale := float32(28) / float32(p.baseFontSize)
|
||||
newMsg.W = int32(float32(newMsg.W) * messageScale)
|
||||
newMsg.H = int32(float32(newMsg.H) * messageScale)
|
||||
p.message = newMsg
|
||||
}
|
||||
|
||||
func (p *Home) Render() error {
|
||||
baseFontSize := p.baseFontSize
|
||||
sm := shaderManager.Get()
|
||||
verticalMargin := int32(20)
|
||||
licenseInfoMargin := int32(10)
|
||||
|
||||
// 1) Calculate scales
|
||||
titleFontSize := int32(64)
|
||||
bodyFontSize := int32(32)
|
||||
|
||||
titleScale := float32(titleFontSize) / float32(baseFontSize)
|
||||
bodyScale := float32(bodyFontSize) / float32(baseFontSize)
|
||||
|
||||
// 2) Calculate dimensions (scaled)
|
||||
titleW := float32(p.title.W) * titleScale
|
||||
titleH := float32(p.title.H) * titleScale
|
||||
titleDim := mgl32.Vec2{titleW, titleH}
|
||||
|
||||
versionW := float32(p.version.W) * bodyScale
|
||||
versionH := float32(p.version.H) * bodyScale
|
||||
versionDim := mgl32.Vec2{versionW, versionH}
|
||||
|
||||
promptW := float32(p.prompt.W) * bodyScale
|
||||
promptH := float32(p.prompt.H) * bodyScale
|
||||
promptDim := mgl32.Vec2{promptW, promptH}
|
||||
|
||||
// Note dimensions (already scaled in Init)
|
||||
noteW := float32(p.note.W)
|
||||
noteH := float32(p.note.H)
|
||||
|
||||
// Message dimensions (already scaled in Init/Update)
|
||||
messageW := float32(p.message.W)
|
||||
messageH := float32(p.message.H)
|
||||
|
||||
// License info dimensions (already scaled in Init)
|
||||
licenseHeadingW := float32(p.licenseInfoHeading.W)
|
||||
licenseHeadingH := float32(p.licenseInfoHeading.H)
|
||||
var emailDisplayW, emailDisplayH, licenseKeyDisplayW, licenseKeyDisplayH float32
|
||||
if p.emailDisplay.ID != 0 {
|
||||
emailDisplayW = float32(p.emailDisplay.W)
|
||||
emailDisplayH = float32(p.emailDisplay.H)
|
||||
}
|
||||
if p.licenseKeyDisplay.ID != 0 {
|
||||
licenseKeyDisplayW = float32(p.licenseKeyDisplay.W)
|
||||
licenseKeyDisplayH = float32(p.licenseKeyDisplay.H)
|
||||
}
|
||||
|
||||
// 3) Calculate total height of the vertical stack
|
||||
totalH := int32(titleH) + verticalMargin +
|
||||
int32(versionH) + verticalMargin +
|
||||
int32(promptH) + verticalMargin +
|
||||
int32(noteH)
|
||||
|
||||
// 4) Y-up layout: start from the TOP of the centered stack and step DOWN
|
||||
top := p.Base.ScreenCenterY + totalH/2
|
||||
|
||||
titleY := top - int32(titleH)
|
||||
versionY := titleY - verticalMargin - int32(versionH)
|
||||
p.prompt.Y = versionY - verticalMargin - int32(promptH)
|
||||
p.note.Y = p.prompt.Y - verticalMargin - int32(noteH)
|
||||
|
||||
// 5) Center X for each item
|
||||
titleX := p.Base.ScreenCenterX - int32(titleW/2)
|
||||
versionX := p.Base.ScreenCenterX - int32(versionW/2)
|
||||
p.prompt.X = p.Base.ScreenCenterX - int32(promptW/2)
|
||||
p.note.X = p.Base.ScreenCenterX - int32(noteW/2)
|
||||
|
||||
titlePos := mgl32.Vec2{float32(titleX), float32(titleY)}
|
||||
versionPos := mgl32.Vec2{float32(versionX), float32(versionY)}
|
||||
|
||||
// 6) Position license info at bottom center
|
||||
if p.hasLicenseInfo {
|
||||
bottomMargin := int32(20)
|
||||
|
||||
// Calculate total height of license info block
|
||||
licenseInfoTotalH := int32(licenseHeadingH)
|
||||
if p.emailDisplay.ID != 0 {
|
||||
licenseInfoTotalH += int32(emailDisplayH) + licenseInfoMargin
|
||||
}
|
||||
if p.licenseKeyDisplay.ID != 0 {
|
||||
licenseInfoTotalH += int32(licenseKeyDisplayH) + licenseInfoMargin
|
||||
}
|
||||
|
||||
// Start from bottom and work up
|
||||
currentY := bottomMargin
|
||||
|
||||
// License key (bottom-most if it exists)
|
||||
if p.licenseKeyDisplay.ID != 0 {
|
||||
p.licenseKeyDisplay.X = p.Base.ScreenCenterX - int32(licenseKeyDisplayW/2)
|
||||
p.licenseKeyDisplay.Y = currentY
|
||||
currentY += int32(licenseKeyDisplayH) + licenseInfoMargin
|
||||
}
|
||||
|
||||
// Email
|
||||
if p.emailDisplay.ID != 0 {
|
||||
p.emailDisplay.X = p.Base.ScreenCenterX - int32(emailDisplayW/2)
|
||||
p.emailDisplay.Y = currentY
|
||||
currentY += int32(emailDisplayH) + licenseInfoMargin
|
||||
}
|
||||
|
||||
// Heading (top-most)
|
||||
p.licenseInfoHeading.X = p.Base.ScreenCenterX - int32(licenseHeadingW/2)
|
||||
p.licenseInfoHeading.Y = currentY
|
||||
}
|
||||
|
||||
// 7) Position message between license info and note/prompt
|
||||
if p.hasLicenseInfo {
|
||||
// Calculate the top of the license info block
|
||||
licenseInfoTopY := p.licenseInfoHeading.Y + int32(licenseHeadingH)
|
||||
if p.emailDisplay.ID != 0 {
|
||||
licenseInfoTopY += int32(emailDisplayH) + licenseInfoMargin
|
||||
}
|
||||
if p.licenseKeyDisplay.ID != 0 {
|
||||
licenseInfoTopY += int32(licenseKeyDisplayH) + licenseInfoMargin
|
||||
}
|
||||
|
||||
// Center the message vertically between license info and note
|
||||
availableSpace := p.note.Y - licenseInfoTopY - verticalMargin
|
||||
p.message.Y = licenseInfoTopY + availableSpace/2 - int32(messageH/2)
|
||||
} else {
|
||||
// If no license info, place message below note
|
||||
p.message.Y = p.note.Y - verticalMargin - int32(messageH)
|
||||
}
|
||||
p.message.X = p.Base.ScreenCenterX - int32(messageW/2)
|
||||
|
||||
// 8) Get shader and render
|
||||
textShader, ok := sm.Get("text")
|
||||
if !ok {
|
||||
return fmt.Errorf("failed to fetch text shader")
|
||||
}
|
||||
color := colors.White
|
||||
|
||||
p.Base.RenderTexture(textShader, p.title.ID, titlePos, titleDim, color)
|
||||
p.Base.RenderTexture(textShader, p.version.ID, versionPos, versionDim, color)
|
||||
p.Base.RenderTexture(textShader, p.prompt.ID, mgl32.Vec2{float32(p.prompt.X), float32(p.prompt.Y)}, promptDim, color)
|
||||
p.Base.RenderTexture(textShader, p.note.ID, mgl32.Vec2{float32(p.note.X), float32(p.note.Y)}, mgl32.Vec2{noteW, noteH}, color)
|
||||
|
||||
// Render message
|
||||
if p.message.ID != 0 {
|
||||
msgColor := colors.White
|
||||
if p.isDownloadComplete {
|
||||
msgColor = colors.Green
|
||||
}
|
||||
p.Base.RenderTexture(textShader, p.message.ID, mgl32.Vec2{float32(p.message.X), float32(p.message.Y)}, mgl32.Vec2{messageW, messageH}, msgColor)
|
||||
}
|
||||
|
||||
// Render license info if it exists
|
||||
if p.hasLicenseInfo {
|
||||
p.Base.RenderTexture(textShader, p.licenseInfoHeading.ID, mgl32.Vec2{float32(p.licenseInfoHeading.X), float32(p.licenseInfoHeading.Y)}, mgl32.Vec2{licenseHeadingW, licenseHeadingH}, color)
|
||||
if p.emailDisplay.ID != 0 {
|
||||
p.Base.RenderTexture(textShader, p.emailDisplay.ID, mgl32.Vec2{float32(p.emailDisplay.X), float32(p.emailDisplay.Y)}, mgl32.Vec2{emailDisplayW, emailDisplayH}, color)
|
||||
}
|
||||
if p.licenseKeyDisplay.ID != 0 {
|
||||
p.Base.RenderTexture(textShader, p.licenseKeyDisplay.ID, mgl32.Vec2{float32(p.licenseKeyDisplay.X), float32(p.licenseKeyDisplay.Y)}, mgl32.Vec2{licenseKeyDisplayW, licenseKeyDisplayH}, color)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Destroy cleans up page-specific resources.
|
||||
func (p *Home) Destroy() error {
|
||||
gl.DeleteTextures(1, &p.title.ID)
|
||||
gl.DeleteTextures(1, &p.version.ID)
|
||||
gl.DeleteTextures(1, &p.prompt.ID)
|
||||
gl.DeleteTextures(1, &p.note.ID)
|
||||
gl.DeleteTextures(1, &p.message.ID)
|
||||
|
||||
if p.hasLicenseInfo {
|
||||
gl.DeleteTextures(1, &p.licenseInfoHeading.ID)
|
||||
if p.emailDisplay.ID != 0 {
|
||||
gl.DeleteTextures(1, &p.emailDisplay.ID)
|
||||
}
|
||||
if p.licenseKeyDisplay.ID != 0 {
|
||||
gl.DeleteTextures(1, &p.licenseKeyDisplay.ID)
|
||||
}
|
||||
}
|
||||
|
||||
return p.Base.Destroy()
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
package page
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"rhemawavetech.com/rhemawave/internal/download"
|
||||
)
|
||||
|
||||
// Regression: DownloadDone was closed twice when checkState() re-navigated
|
||||
// to HOME on the DOWNLOADING_ASSETS -> READY transition. The new Home page
|
||||
// saw the already-closed MessageChannel and tried to close DownloadDone
|
||||
// again, causing panic: close of closed channel.
|
||||
//
|
||||
// Primary fix: checkState() now skips navigation when transitioning between
|
||||
// states that both map to the HOME page. The downloadDone flag on Home is
|
||||
// defense-in-depth.
|
||||
|
||||
// TestHomeDownloadDoneGuardPreventsDoubleClose verifies that the per-instance
|
||||
// downloadDone flag prevents closing DownloadDone more than once, even if a
|
||||
// new Home page somehow reaches the close path.
|
||||
func TestHomeDownloadDoneGuardPreventsDoubleClose(t *testing.T) {
|
||||
downloadDone := make(chan struct{})
|
||||
|
||||
// First Home closes DownloadDone.
|
||||
h1 := &Home{downloadDone: false}
|
||||
h1.Context = &PageCtx{DownloadDone: downloadDone}
|
||||
if !h1.downloadDone {
|
||||
h1.downloadDone = true
|
||||
close(h1.Context.DownloadDone)
|
||||
}
|
||||
|
||||
// Second Home with guard already set (simulates defense-in-depth).
|
||||
h2 := &Home{downloadDone: true}
|
||||
h2.Context = &PageCtx{DownloadDone: downloadDone}
|
||||
if !h2.downloadDone {
|
||||
h2.downloadDone = true
|
||||
close(h2.Context.DownloadDone) // would panic without guard
|
||||
}
|
||||
}
|
||||
|
||||
// TestHomeDownloadDoneGuardUnsetWouldPanic proves that without the
|
||||
// downloadDone guard, closing DownloadDone a second time panics.
|
||||
func TestHomeDownloadDoneGuardUnsetWouldPanic(t *testing.T) {
|
||||
downloadDone := make(chan struct{})
|
||||
close(downloadDone) // pre-close
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r == nil {
|
||||
t.Error("expected panic when closing already-closed channel")
|
||||
}
|
||||
}()
|
||||
|
||||
// New Home with downloadDone=false tries to close an already-closed channel.
|
||||
h := &Home{downloadDone: false}
|
||||
h.Context = &PageCtx{DownloadDone: downloadDone}
|
||||
if !h.downloadDone {
|
||||
h.downloadDone = true
|
||||
close(h.Context.DownloadDone) // panics
|
||||
}
|
||||
}
|
||||
|
||||
// TestHomeDownloadFailedDoesNotClose verifies that when a download fails,
|
||||
// DownloadDone is NOT closed (the error path should not signal success).
|
||||
func TestHomeDownloadFailedDoesNotClose(t *testing.T) {
|
||||
downloadDone := make(chan struct{})
|
||||
|
||||
h := &Home{
|
||||
downloadFailed: true,
|
||||
downloadDone: false,
|
||||
}
|
||||
h.Context = &PageCtx{
|
||||
MessageChannel: make(chan download.DownloadStatus),
|
||||
DownloadDone: downloadDone,
|
||||
}
|
||||
|
||||
// Simulate the Update logic: on failure, downloadDone should not be set
|
||||
// and DownloadDone should not be closed.
|
||||
if h.downloadFailed {
|
||||
// Error path: do not close DownloadDone.
|
||||
} else if !h.downloadDone {
|
||||
h.downloadDone = true
|
||||
close(h.Context.DownloadDone)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-downloadDone:
|
||||
t.Error("DownloadDone should not be closed on download failure")
|
||||
default:
|
||||
// Correct: channel is still open.
|
||||
}
|
||||
}
|
||||
|
||||
// TestHomeDownloadDoneAlreadyClosed verifies that a Home page with
|
||||
// downloadDone=true never attempts to close the channel.
|
||||
func TestHomeDownloadDoneAlreadyClosed(t *testing.T) {
|
||||
downloadDone := make(chan struct{})
|
||||
close(downloadDone)
|
||||
|
||||
h := &Home{downloadDone: true}
|
||||
h.Context = &PageCtx{DownloadDone: downloadDone}
|
||||
|
||||
// Guard should prevent any close attempt.
|
||||
if !h.downloadDone {
|
||||
h.downloadDone = true
|
||||
close(h.Context.DownloadDone)
|
||||
}
|
||||
}
|
||||
@@ -1,459 +0,0 @@
|
||||
package page
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/go-gl/gl/v3.3-core/gl"
|
||||
"github.com/go-gl/mathgl/mgl32"
|
||||
"github.com/veandco/go-sdl2/sdl"
|
||||
"rhemawavetech.com/rhemawave/internal/colors"
|
||||
"rhemawavetech.com/rhemawave/internal/db"
|
||||
"rhemawavetech.com/rhemawave/internal/fontManager"
|
||||
"rhemawavetech.com/rhemawave/internal/logger"
|
||||
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
||||
)
|
||||
|
||||
type LicenseKeyPage struct {
|
||||
Base
|
||||
|
||||
title string
|
||||
titleTextureID uint32
|
||||
titleTextureWidth int32
|
||||
titleTextureHeight int32
|
||||
|
||||
currentLicenseDisplayTextureID uint32
|
||||
currentLicenseDisplayTextureWidth int32
|
||||
currentLicenseDisplayTextureHeight int32
|
||||
|
||||
inputText string
|
||||
inputTextTextureID uint32
|
||||
inputTextTextureWidth int32
|
||||
inputTextTextureHeight int32
|
||||
|
||||
// Error message handling
|
||||
errorMessage string
|
||||
errorTextureID uint32
|
||||
errorTextureWidth int32
|
||||
errorTextureHeight int32
|
||||
|
||||
// isFirstTimeSetup tracks whether page was loaded empty (first-time setup)
|
||||
isFirstTimeSetup bool
|
||||
|
||||
// Text input deduplication
|
||||
textInputSupported bool
|
||||
}
|
||||
|
||||
// Init performs basic page initialization and creation of static assets.
|
||||
func (p *LicenseKeyPage) Init(ctx *PageCtx) error {
|
||||
|
||||
if err := p.Base.Init(ctx); err != nil {
|
||||
return fmt.Errorf("failed base page initialization: %w", err)
|
||||
}
|
||||
|
||||
p.title = "License Key"
|
||||
|
||||
// Get application font from database
|
||||
applicationFont := "SpaceMonoNerdFont-Regular"
|
||||
|
||||
fm := fontManager.Get()
|
||||
textShader, ok := shaderManager.Get().Get("text")
|
||||
if !ok {
|
||||
return fmt.Errorf("unable to fetch text shader")
|
||||
}
|
||||
|
||||
// Create title texture
|
||||
titleTextureID, titleWidth, titleHeight, err := fm.CreateStringTexture(applicationFont, p.title, textShader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create title texture: %w", err)
|
||||
}
|
||||
if titleTextureID == 0 {
|
||||
return fmt.Errorf("CreateStringTexture returned a nil texture ID")
|
||||
}
|
||||
|
||||
p.titleTextureID = titleTextureID
|
||||
p.titleTextureWidth = titleWidth
|
||||
p.titleTextureHeight = titleHeight
|
||||
|
||||
// Load existing license key from database
|
||||
config, err := db.GetConfig(p.Context.DbPtr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
licenseKey := config.LicenseKey
|
||||
|
||||
if licenseKey != "" {
|
||||
p.inputText = licenseKey
|
||||
p.isFirstTimeSetup = false
|
||||
|
||||
// Create current license key display texture
|
||||
currentLicenseText := fmt.Sprintf("Current License Key: %s", licenseKey)
|
||||
currentLicenseTextureID, currentLicenseWidth, currentLicenseHeight, err := fm.CreateStringTexture(applicationFont, currentLicenseText, textShader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create current license display texture: %w", err)
|
||||
}
|
||||
if currentLicenseTextureID == 0 {
|
||||
return fmt.Errorf("CreateStringTexture returned a nil texture ID for current license display")
|
||||
}
|
||||
|
||||
p.currentLicenseDisplayTextureID = currentLicenseTextureID
|
||||
p.currentLicenseDisplayTextureWidth = currentLicenseWidth
|
||||
p.currentLicenseDisplayTextureHeight = currentLicenseHeight
|
||||
} else {
|
||||
p.inputText = ""
|
||||
p.isFirstTimeSetup = true
|
||||
}
|
||||
|
||||
sdl.StartTextInput()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// formatLicenseKey formats the input string with dashes every 4 characters
|
||||
// and limits to 16 alphanumeric characters (not counting dashes)
|
||||
func formatLicenseKey(input string) string {
|
||||
// Remove all dashes and non-alphanumeric characters
|
||||
cleaned := strings.Map(func(r rune) rune {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
|
||||
return r
|
||||
}
|
||||
return -1
|
||||
}, input)
|
||||
|
||||
// Limit to 16 characters
|
||||
if len(cleaned) > 16 {
|
||||
cleaned = cleaned[:16]
|
||||
}
|
||||
|
||||
// Insert dashes every 4 characters
|
||||
var result strings.Builder
|
||||
for i, char := range cleaned {
|
||||
if i > 0 && i%4 == 0 {
|
||||
result.WriteRune('-')
|
||||
}
|
||||
result.WriteRune(char)
|
||||
}
|
||||
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// HandleEvent processes SDL events and handles them appropriately for the LicenseKeyPage.
|
||||
func (p *LicenseKeyPage) HandleEvent(event *sdl.Event) error {
|
||||
actualEvent := *event
|
||||
if !sdl.IsTextInputActive() {
|
||||
sdl.StartTextInput()
|
||||
}
|
||||
|
||||
switch e := actualEvent.(type) {
|
||||
case *sdl.TextInputEvent:
|
||||
if !p.textInputSupported {
|
||||
// First TextInputEvent: the keyboard fallback already
|
||||
// handled this character, so just record that text input
|
||||
// is supported and skip the duplicate append.
|
||||
p.textInputSupported = true
|
||||
} else {
|
||||
// Add the new character and reformat
|
||||
p.inputText += e.GetText()
|
||||
p.inputText = formatLicenseKey(p.inputText)
|
||||
// Clear error when user starts typing
|
||||
p.errorMessage = ""
|
||||
}
|
||||
case *sdl.KeyboardEvent:
|
||||
if e.Type == sdl.KEYDOWN {
|
||||
switch e.Keysym.Sym {
|
||||
case sdl.K_ESCAPE:
|
||||
// Block escape during first-time setup
|
||||
if p.isFirstTimeSetup {
|
||||
return nil
|
||||
}
|
||||
// Allow escape back to settings if coming from settings
|
||||
sdl.StopTextInput()
|
||||
p.Context.NavigateTo(SETTINGS)
|
||||
case sdl.K_RETURN:
|
||||
// Validate license key - check empty first, then length, then characters
|
||||
cleaned := strings.ReplaceAll(p.inputText, "-", "")
|
||||
if cleaned == "" {
|
||||
p.errorMessage = "License key cannot be empty"
|
||||
return nil
|
||||
}
|
||||
if len(cleaned) != 16 {
|
||||
p.errorMessage = fmt.Sprintf("License key must be 16 characters (currently %d)", len(cleaned))
|
||||
return nil
|
||||
}
|
||||
// Check for invalid characters
|
||||
for _, r := range cleaned {
|
||||
if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')) {
|
||||
p.errorMessage = "License key must contain only letters and numbers"
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
sdl.StopTextInput()
|
||||
|
||||
// Save license key to database
|
||||
config, err := db.GetConfig(p.Context.DbPtr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
config.LicenseKey = p.inputText
|
||||
p.Context.DbPtr.Save(config)
|
||||
|
||||
// Write license key to plain text file for the updater
|
||||
if homeDir, err := os.UserHomeDir(); err == nil {
|
||||
keyPath := filepath.Join(homeDir, ".local", "share", "rhemawave", "license_key")
|
||||
if err := os.WriteFile(keyPath, []byte(p.inputText), 0644); err != nil {
|
||||
logger.Error("Failed to write license key file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if p.isFirstTimeSetup {
|
||||
// First-time setup: rewind to Welcome page
|
||||
p.Context.NavigateTo(HOME)
|
||||
} else {
|
||||
// Coming from settings: rewind back to settings
|
||||
p.Context.NavigateTo(SETTINGS)
|
||||
}
|
||||
case sdl.K_BACKSPACE:
|
||||
if len(p.inputText) > 0 {
|
||||
// Remove last character and reformat
|
||||
// Strip all dashes first to get raw characters
|
||||
cleaned := strings.ReplaceAll(p.inputText, "-", "")
|
||||
if len(cleaned) > 0 {
|
||||
cleaned = cleaned[:len(cleaned)-1]
|
||||
}
|
||||
p.inputText = formatLicenseKey(cleaned)
|
||||
// Clear error when user edits input
|
||||
p.errorMessage = ""
|
||||
}
|
||||
default:
|
||||
// Keyboard fallback: if TextInputEvent is not supported,
|
||||
// convert printable keycodes to characters directly.
|
||||
// This handles kiosk systems without an input method framework.
|
||||
if !p.textInputSupported {
|
||||
if ch := keycodeToChar(e.Keysym.Sym, e.Keysym.Mod); ch != 0 {
|
||||
p.inputText += string(ch)
|
||||
p.inputText = formatLicenseKey(p.inputText)
|
||||
p.errorMessage = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update updates animations and changing textures and other changes using `dt` which represents a time delta in float32.
|
||||
func (p *LicenseKeyPage) Update(dt float32) error {
|
||||
// Get application font from database
|
||||
applicationFont := "SpaceMonoNerdFont-Regular"
|
||||
|
||||
fm := fontManager.Get()
|
||||
sm := shaderManager.Get()
|
||||
shader, ok := sm.Get("text")
|
||||
if !ok {
|
||||
return fmt.Errorf("unable to fetch text shader")
|
||||
}
|
||||
|
||||
// Update input text texture
|
||||
if len(p.inputText) > 0 {
|
||||
// Create the new inputText texture
|
||||
inputTextTextureID, inputTextTextureWidth, inputTextTextureHeight, err := fm.CreateStringTexture(
|
||||
applicationFont,
|
||||
p.inputText,
|
||||
shader,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to create new input text texture: %w", err)
|
||||
}
|
||||
|
||||
// Delete old texture if it exists
|
||||
if p.inputTextTextureID != 0 {
|
||||
gl.DeleteTextures(1, &p.inputTextTextureID)
|
||||
}
|
||||
|
||||
p.inputTextTextureID = inputTextTextureID
|
||||
p.inputTextTextureWidth = inputTextTextureWidth
|
||||
p.inputTextTextureHeight = inputTextTextureHeight
|
||||
}
|
||||
|
||||
// Update error message texture
|
||||
if p.errorMessage != "" {
|
||||
errorTextureID, errorWidth, errorHeight, err := fm.CreateStringTexture(
|
||||
applicationFont,
|
||||
p.errorMessage,
|
||||
shader,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to create error text texture: %w", err)
|
||||
}
|
||||
|
||||
// Delete old error texture if it exists
|
||||
if p.errorTextureID != 0 {
|
||||
gl.DeleteTextures(1, &p.errorTextureID)
|
||||
}
|
||||
|
||||
p.errorTextureID = errorTextureID
|
||||
p.errorTextureWidth = errorWidth
|
||||
p.errorTextureHeight = errorHeight
|
||||
} else {
|
||||
// Clear error texture if no error message
|
||||
if p.errorTextureID != 0 {
|
||||
gl.DeleteTextures(1, &p.errorTextureID)
|
||||
p.errorTextureID = 0
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Render performs page drawing logic.
|
||||
func (p *LicenseKeyPage) Render() error {
|
||||
config, err := db.GetConfig(p.Context.DbPtr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
// Get base font size from database
|
||||
baseFontSize := config.StandardFontSize
|
||||
|
||||
sm := shaderManager.Get()
|
||||
|
||||
// 1. Calculate Scale
|
||||
titleFontSize := int32(64)
|
||||
titleScale := float32(titleFontSize) / float32(baseFontSize)
|
||||
currentLicenseFontSize := int32(28)
|
||||
currentLicenseScale := float32(currentLicenseFontSize) / float32(baseFontSize)
|
||||
padding := int32(4)
|
||||
verticalMargin := int32(20)
|
||||
|
||||
// 2. Define dimensions
|
||||
targetFontSize := int32(36)
|
||||
scale := float32(targetFontSize) / float32(baseFontSize)
|
||||
border := int32(3)
|
||||
|
||||
boxH := targetFontSize + (padding * 2)
|
||||
boxW := int32(600) + (padding * 2)
|
||||
boxDimensions := mgl32.Vec2{float32(boxW), float32(boxH)}
|
||||
|
||||
borderBoxH := boxH + (border * 2)
|
||||
borderBoxW := boxW + (border * 2)
|
||||
borderBoxDimensions := mgl32.Vec2{float32(borderBoxW), float32(borderBoxH)}
|
||||
|
||||
titleH := float32(p.titleTextureHeight) * titleScale
|
||||
titleW := float32(p.titleTextureWidth) * titleScale
|
||||
titleDimensions := mgl32.Vec2{titleW, titleH}
|
||||
|
||||
inputTextH := float32(p.inputTextTextureHeight) * scale
|
||||
inputTextW := float32(p.inputTextTextureWidth) * scale
|
||||
inputTextDimensions := mgl32.Vec2{inputTextW, inputTextH}
|
||||
|
||||
var currentLicenseDisplayH, currentLicenseDisplayW float32
|
||||
var currentLicenseDisplayDimensions mgl32.Vec2
|
||||
if p.currentLicenseDisplayTextureID != 0 {
|
||||
currentLicenseDisplayH = float32(p.currentLicenseDisplayTextureHeight) * currentLicenseScale
|
||||
currentLicenseDisplayW = float32(p.currentLicenseDisplayTextureWidth) * currentLicenseScale
|
||||
currentLicenseDisplayDimensions = mgl32.Vec2{currentLicenseDisplayW, currentLicenseDisplayH}
|
||||
}
|
||||
|
||||
// Error text dimensions
|
||||
errorFontSize := int32(20)
|
||||
errorScale := float32(errorFontSize) / float32(baseFontSize)
|
||||
var errorTextH, errorTextW float32
|
||||
var errorTextDimensions mgl32.Vec2
|
||||
if p.errorTextureID != 0 {
|
||||
errorTextH = float32(p.errorTextureHeight) * errorScale
|
||||
errorTextW = float32(p.errorTextureWidth) * errorScale
|
||||
errorTextDimensions = mgl32.Vec2{errorTextW, errorTextH}
|
||||
}
|
||||
|
||||
// 3. Define positions
|
||||
borderBoxY := p.Base.ScreenCenterY - (borderBoxH / 2)
|
||||
borderBoxX := p.Base.ScreenCenterX - (borderBoxW / 2)
|
||||
borderBoxPosition := mgl32.Vec2{float32(borderBoxX), float32(borderBoxY)}
|
||||
|
||||
boxY := p.Base.ScreenCenterY - (boxH / 2)
|
||||
boxX := p.Base.ScreenCenterX - (boxW / 2)
|
||||
boxPosition := mgl32.Vec2{float32(boxX), float32(boxY)}
|
||||
|
||||
titleY := borderBoxY + borderBoxH + verticalMargin
|
||||
titleX := p.Base.ScreenCenterX - int32(titleW/2)
|
||||
titlePosition := mgl32.Vec2{float32(titleX), float32(titleY)}
|
||||
|
||||
inputTextY := boxY + padding
|
||||
inputTextX := boxX + padding
|
||||
inputTextPosition := mgl32.Vec2{float32(inputTextX), float32(inputTextY)}
|
||||
|
||||
var currentLicenseDisplayPosition mgl32.Vec2
|
||||
if p.currentLicenseDisplayTextureID != 0 {
|
||||
currentLicenseDisplayY := borderBoxY - int32(currentLicenseDisplayH) - verticalMargin
|
||||
currentLicenseDisplayX := p.Base.ScreenCenterX - int32(currentLicenseDisplayW/2)
|
||||
currentLicenseDisplayPosition = mgl32.Vec2{float32(currentLicenseDisplayX), float32(currentLicenseDisplayY)}
|
||||
}
|
||||
|
||||
// Error text position (below current license display to avoid overlap)
|
||||
var errorTextPosition mgl32.Vec2
|
||||
if p.errorTextureID != 0 {
|
||||
var errorTextY int32
|
||||
if p.currentLicenseDisplayTextureID != 0 {
|
||||
// Position below the current license display
|
||||
errorTextY = borderBoxY - int32(currentLicenseDisplayH) - verticalMargin - int32(errorTextH) - (verticalMargin / 2)
|
||||
} else {
|
||||
// No current license display, position below the input box
|
||||
errorTextY = borderBoxY - int32(errorTextH) - (verticalMargin / 2)
|
||||
}
|
||||
errorTextX := p.Base.ScreenCenterX - int32(errorTextW/2)
|
||||
errorTextPosition = mgl32.Vec2{float32(errorTextX), float32(errorTextY)}
|
||||
}
|
||||
|
||||
// 4. Get Shaders
|
||||
textShader, _ := sm.Get("text")
|
||||
solidShader, _ := sm.Get("solid_color")
|
||||
|
||||
// 5. Draw everything
|
||||
p.Base.RenderSolidColorQuad(solidShader, borderBoxPosition, borderBoxDimensions, colors.Gray)
|
||||
p.Base.RenderSolidColorQuad(solidShader, boxPosition, boxDimensions, colors.DarkGray)
|
||||
p.Base.RenderTexture(textShader, p.titleTextureID, titlePosition, titleDimensions, colors.White)
|
||||
|
||||
// Draw current license display if it exists
|
||||
if p.currentLicenseDisplayTextureID != 0 {
|
||||
p.Base.RenderTexture(textShader, p.currentLicenseDisplayTextureID, currentLicenseDisplayPosition, currentLicenseDisplayDimensions, colors.White)
|
||||
}
|
||||
|
||||
if len(p.inputText) > 0 {
|
||||
p.Base.RenderTexture(textShader, p.inputTextTextureID, inputTextPosition, inputTextDimensions, colors.White)
|
||||
}
|
||||
|
||||
// Draw error message if it exists
|
||||
if p.errorTextureID != 0 {
|
||||
p.Base.RenderTexture(textShader, p.errorTextureID, errorTextPosition, errorTextDimensions, colors.ErrorRed)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Destroy cleans up page-specific resources.
|
||||
func (p *LicenseKeyPage) Destroy() error {
|
||||
|
||||
gl.DeleteTextures(1, &p.titleTextureID)
|
||||
|
||||
// Delete current license display texture if it exists
|
||||
if p.currentLicenseDisplayTextureID != 0 {
|
||||
gl.DeleteTextures(1, &p.currentLicenseDisplayTextureID)
|
||||
p.currentLicenseDisplayTextureID = 0
|
||||
}
|
||||
|
||||
// Delete input text texture if it exists
|
||||
if p.inputTextTextureID != 0 {
|
||||
gl.DeleteTextures(1, &p.inputTextTextureID)
|
||||
p.inputTextTextureID = 0
|
||||
}
|
||||
|
||||
// Delete error texture if it exists
|
||||
if p.errorTextureID != 0 {
|
||||
gl.DeleteTextures(1, &p.errorTextureID)
|
||||
p.errorTextureID = 0
|
||||
}
|
||||
|
||||
sdl.StopTextInput()
|
||||
return p.Base.Destroy()
|
||||
}
|
||||
@@ -1,273 +0,0 @@
|
||||
package page
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-gl/gl/v3.3-core/gl"
|
||||
"github.com/go-gl/mathgl/mgl32"
|
||||
"github.com/veandco/go-sdl2/sdl"
|
||||
"rhemawavetech.com/rhemawave/internal/colors"
|
||||
"rhemawavetech.com/rhemawave/internal/db"
|
||||
"rhemawavetech.com/rhemawave/internal/fontManager"
|
||||
"rhemawavetech.com/rhemawave/internal/logger"
|
||||
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
||||
)
|
||||
|
||||
type LogViewerPage struct {
|
||||
Base
|
||||
|
||||
titleTextureID uint32
|
||||
titleTextureWidth int32
|
||||
titleTextureHeight int32
|
||||
|
||||
lines []string
|
||||
lineTextureIDs []uint32
|
||||
lineTextureWidths []int32
|
||||
lineTextureHeights []int32
|
||||
lineColors []sdl.Color
|
||||
|
||||
hintTextureID uint32
|
||||
hintTextureWidth int32
|
||||
hintTextureHeight int32
|
||||
|
||||
scrollOffset int32
|
||||
maxScrollOffset int32
|
||||
lineHeight int32
|
||||
visibleHeight int32
|
||||
}
|
||||
|
||||
func (p *LogViewerPage) Init(ctx *PageCtx) error {
|
||||
var logs []db.Log
|
||||
if err := p.Base.Init(ctx); err != nil {
|
||||
return fmt.Errorf("failed base page initialization: %w", err)
|
||||
}
|
||||
|
||||
if result := p.Context.DbPtr.Order("timestamp desc").Limit(250).Find(&logs); result.Error != nil {
|
||||
logger.Error("error querying database for logs: %s", result.Error)
|
||||
return fmt.Errorf("%s", result.Error)
|
||||
}
|
||||
|
||||
// // Format and word-wrap each log entry
|
||||
// for _, entry := range logs {
|
||||
// formatted := fmt.Sprintf("[%s] [%s] %s", entry.Timestamp, entry.Level, entry.Message)
|
||||
// wrapped := wrapText(formatted, 100)
|
||||
// p.lines = append(p.lines, strings.Split(wrapped, "\n")...)
|
||||
// }
|
||||
|
||||
levelColors := map[string]sdl.Color{
|
||||
"DEBUG": colors.SkyBlue,
|
||||
"INFO": colors.White,
|
||||
"WARNING": colors.Yellow,
|
||||
"ERROR": colors.RedOrange,
|
||||
}
|
||||
|
||||
fallbackColor := colors.White
|
||||
|
||||
p.lineColors = make([]sdl.Color, len(p.lines))
|
||||
|
||||
for _, entry := range logs {
|
||||
color, ok := levelColors[entry.Level]
|
||||
if !ok {
|
||||
color = fallbackColor
|
||||
}
|
||||
logTime := time.UnixMilli(entry.Timestamp)
|
||||
timeStr := logTime.Format("2006-01-02 15:04")
|
||||
line := fmt.Sprintf("[%s] [%s] %s", timeStr, entry.Level, entry.Message)
|
||||
p.lines = append(p.lines, line)
|
||||
p.lineColors = append(p.lineColors, color)
|
||||
}
|
||||
|
||||
applicationFont := "SpaceMonoNerdFont-Regular"
|
||||
|
||||
fm := fontManager.Get()
|
||||
textShader, ok := shaderManager.Get().Get("text")
|
||||
if !ok {
|
||||
return fmt.Errorf("unable to fetch text shader")
|
||||
}
|
||||
|
||||
// Title texture
|
||||
titleID, titleW, titleH, err := fm.CreateStringTexture(applicationFont, "Application Logs", textShader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create title texture: %w", err)
|
||||
}
|
||||
p.titleTextureID = titleID
|
||||
p.titleTextureWidth = titleW
|
||||
p.titleTextureHeight = titleH
|
||||
|
||||
// Line textures
|
||||
p.lineTextureIDs = make([]uint32, len(p.lines))
|
||||
p.lineTextureWidths = make([]int32, len(p.lines))
|
||||
p.lineTextureHeights = make([]int32, len(p.lines))
|
||||
|
||||
for i, line := range p.lines {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
texID, w, h, err := fm.CreateStringTexture(applicationFont, line, textShader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create line texture: %w", err)
|
||||
}
|
||||
p.lineTextureIDs[i] = texID
|
||||
p.lineTextureWidths[i] = w
|
||||
p.lineTextureHeights[i] = h
|
||||
}
|
||||
|
||||
// Hint texture
|
||||
hintText := "Arrow Keys / PgUp/PgDn to scroll | F1|2|3 to Exit"
|
||||
hintID, hintW, hintH, err := fm.CreateStringTexture(applicationFont, hintText, textShader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create hint texture: %w", err)
|
||||
}
|
||||
p.hintTextureID = hintID
|
||||
p.hintTextureWidth = hintW
|
||||
p.hintTextureHeight = hintH
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *LogViewerPage) HandleEvent(event *sdl.Event) error {
|
||||
switch e := (*event).(type) {
|
||||
case *sdl.KeyboardEvent:
|
||||
if e.Type == sdl.KEYDOWN {
|
||||
switch e.Keysym.Sym {
|
||||
case sdl.K_BACKSPACE:
|
||||
p.Context.NavigateTo(SETTINGS)
|
||||
case sdl.K_UP:
|
||||
p.scrollOffset -= p.lineHeight
|
||||
if p.scrollOffset < 0 {
|
||||
p.scrollOffset = 0
|
||||
}
|
||||
case sdl.K_DOWN:
|
||||
p.scrollOffset += p.lineHeight
|
||||
if p.scrollOffset > p.maxScrollOffset {
|
||||
p.scrollOffset = p.maxScrollOffset
|
||||
}
|
||||
case sdl.K_PAGEUP:
|
||||
p.scrollOffset -= p.visibleHeight
|
||||
if p.scrollOffset < 0 {
|
||||
p.scrollOffset = 0
|
||||
}
|
||||
case sdl.K_PAGEDOWN:
|
||||
p.scrollOffset += p.visibleHeight
|
||||
if p.scrollOffset > p.maxScrollOffset {
|
||||
p.scrollOffset = p.maxScrollOffset
|
||||
}
|
||||
case sdl.K_HOME:
|
||||
p.scrollOffset = 0
|
||||
case sdl.K_END:
|
||||
p.scrollOffset = p.maxScrollOffset
|
||||
}
|
||||
}
|
||||
case *sdl.MouseWheelEvent:
|
||||
p.scrollOffset -= e.Y * p.lineHeight * 3
|
||||
if p.scrollOffset < 0 {
|
||||
p.scrollOffset = 0
|
||||
}
|
||||
if p.scrollOffset > p.maxScrollOffset {
|
||||
p.scrollOffset = p.maxScrollOffset
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *LogViewerPage) Update(dt float32) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *LogViewerPage) Render() error {
|
||||
config, err := db.GetConfig(p.Context.DbPtr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
baseFontSize := config.StandardFontSize
|
||||
|
||||
sm := shaderManager.Get()
|
||||
textShader, _ := sm.Get("text")
|
||||
|
||||
titleFontSize := int32(48)
|
||||
titleScale := float32(titleFontSize) / float32(baseFontSize)
|
||||
contentFontSize := int32(24)
|
||||
contentScale := float32(contentFontSize) / float32(baseFontSize)
|
||||
hintFontSize := int32(20)
|
||||
hintScale := float32(hintFontSize) / float32(baseFontSize)
|
||||
|
||||
margin := int32(40)
|
||||
lineSpacing := int32(4)
|
||||
p.lineHeight = contentFontSize + lineSpacing
|
||||
|
||||
contentLeftX := 25
|
||||
|
||||
// Title
|
||||
titleH := float32(p.titleTextureHeight) * titleScale
|
||||
titleW := float32(p.titleTextureWidth) * titleScale
|
||||
titleX := p.Base.ScreenCenterX - int32(titleW/2)
|
||||
titleY := p.Base.ScreenHeight - margin - int32(titleH)
|
||||
p.Base.RenderTexture(textShader, p.titleTextureID, mgl32.Vec2{float32(titleX), float32(titleY)}, mgl32.Vec2{titleW, titleH}, colors.White)
|
||||
|
||||
// Hint at bottom
|
||||
hintH := float32(p.hintTextureHeight) * hintScale
|
||||
hintW := float32(p.hintTextureWidth) * hintScale
|
||||
hintX := p.Base.ScreenCenterX - int32(hintW/2)
|
||||
hintY := margin
|
||||
p.Base.RenderTexture(textShader, p.hintTextureID, mgl32.Vec2{float32(hintX), float32(hintY)}, mgl32.Vec2{hintW, hintH}, colors.Gray)
|
||||
|
||||
// Content area bounds
|
||||
contentTop := titleY - margin
|
||||
contentBottom := hintY + int32(hintH) + margin
|
||||
p.visibleHeight = contentTop - contentBottom
|
||||
|
||||
// Total content height
|
||||
totalContentHeight := int32(len(p.lines)) * p.lineHeight
|
||||
p.maxScrollOffset = totalContentHeight - p.visibleHeight
|
||||
if p.maxScrollOffset < 0 {
|
||||
p.maxScrollOffset = 0
|
||||
}
|
||||
|
||||
// Render visible lines
|
||||
currentY := contentTop - p.lineHeight + p.scrollOffset
|
||||
|
||||
for i, line := range p.lines {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
currentY -= p.lineHeight
|
||||
continue
|
||||
}
|
||||
|
||||
if p.lineTextureIDs[i] == 0 {
|
||||
currentY -= p.lineHeight
|
||||
continue
|
||||
}
|
||||
|
||||
if currentY > contentTop || currentY+p.lineHeight < contentBottom {
|
||||
currentY -= p.lineHeight
|
||||
continue
|
||||
}
|
||||
|
||||
lineW := float32(p.lineTextureWidths[i]) * contentScale
|
||||
lineH := float32(p.lineTextureHeights[i]) * contentScale
|
||||
|
||||
color := colors.White
|
||||
if i < len(p.lineColors) {
|
||||
color = p.lineColors[i]
|
||||
}
|
||||
|
||||
p.Base.RenderTexture(textShader, p.lineTextureIDs[i], mgl32.Vec2{float32(contentLeftX), float32(currentY)}, mgl32.Vec2{lineW, lineH}, color)
|
||||
currentY -= p.lineHeight
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *LogViewerPage) Destroy() error {
|
||||
gl.DeleteTextures(1, &p.titleTextureID)
|
||||
gl.DeleteTextures(1, &p.hintTextureID)
|
||||
|
||||
for _, texID := range p.lineTextureIDs {
|
||||
if texID != 0 {
|
||||
gl.DeleteTextures(1, &texID)
|
||||
}
|
||||
}
|
||||
|
||||
return p.Base.Destroy()
|
||||
}
|
||||
@@ -1,236 +0,0 @@
|
||||
// Package page provides the interface for pages
|
||||
package page
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/go-gl/gl/v3.3-core/gl"
|
||||
"github.com/go-gl/mathgl/mgl32"
|
||||
"github.com/veandco/go-sdl2/sdl"
|
||||
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
||||
)
|
||||
|
||||
// Page defines the contract for all application screens.
|
||||
type Page interface {
|
||||
Init(ctx *PageCtx) error
|
||||
HandleEvent(event *sdl.Event) error
|
||||
Update(dt float32) error
|
||||
Render() error
|
||||
Destroy() error
|
||||
}
|
||||
|
||||
// Base is a helper struct that pages can embed to get common functionality.
|
||||
type Base struct {
|
||||
Context *PageCtx
|
||||
QuadVAO uint32
|
||||
QuadVBO uint32
|
||||
Projection mgl32.Mat4
|
||||
ScreenWidth int32
|
||||
ScreenHeight int32
|
||||
ScreenCenterX int32
|
||||
ScreenCenterY int32
|
||||
}
|
||||
|
||||
// Init stores the application context and sets up common resources.
|
||||
func (p *Base) Init(ctx *PageCtx) error {
|
||||
p.Context = ctx
|
||||
vertices := []float32{
|
||||
// pos(x,y) tex(u,v)
|
||||
0.0, 1.0, 0.0, 1.0,
|
||||
1.0, 0.0, 1.0, 0.0,
|
||||
0.0, 0.0, 0.0, 0.0,
|
||||
|
||||
0.0, 1.0, 0.0, 1.0,
|
||||
1.0, 1.0, 1.0, 1.0,
|
||||
1.0, 0.0, 1.0, 0.0,
|
||||
}
|
||||
|
||||
W := p.Context.W
|
||||
H := p.Context.H
|
||||
|
||||
p.Projection = mgl32.Ortho(0, float32(W), 0, float32(H), -1, 1)
|
||||
p.ScreenHeight = H
|
||||
p.ScreenWidth = W
|
||||
p.ScreenCenterY = H / 2
|
||||
p.ScreenCenterX = W / 2
|
||||
|
||||
gl.GenVertexArrays(1, &p.QuadVAO)
|
||||
gl.GenBuffers(1, &p.QuadVBO)
|
||||
gl.BindVertexArray(p.QuadVAO)
|
||||
gl.BindBuffer(gl.ARRAY_BUFFER, p.QuadVBO)
|
||||
gl.BufferData(gl.ARRAY_BUFFER, len(vertices)*4, gl.Ptr(vertices), gl.STATIC_DRAW)
|
||||
|
||||
stride := int32(4 * 4) // Each vertex has 4 floats (pos.x, pos.y, tex.u, tex.v)
|
||||
|
||||
// Attribute 0: Vertex Position (vec2)
|
||||
gl.EnableVertexAttribArray(0)
|
||||
gl.VertexAttribPointerWithOffset(0, 2, gl.FLOAT, false, stride, 0)
|
||||
|
||||
// Attribute 1: Texture Coordinate (vec2)
|
||||
gl.EnableVertexAttribArray(1)
|
||||
gl.VertexAttribPointerWithOffset(1, 2, gl.FLOAT, false, stride, 2*4) // Offset by 2 floats
|
||||
|
||||
gl.BindBuffer(gl.ARRAY_BUFFER, 0)
|
||||
gl.BindVertexArray(0)
|
||||
gl.Disable(gl.BLEND)
|
||||
gl.UseProgram(0)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Destroy cleans up the common OpenGL resources.
|
||||
func (p *Base) Destroy() error {
|
||||
gl.DeleteBuffers(1, &p.QuadVBO)
|
||||
gl.DeleteVertexArrays(1, &p.QuadVAO)
|
||||
return nil
|
||||
}
|
||||
|
||||
// BeginTextureRendering sets up shared GL state for a batch of RenderTexture calls.
|
||||
// Call this once before a series of RenderTexture calls, then EndTextureRendering after.
|
||||
func (p *Base) BeginTextureRendering(shader *shaderManager.Shader) {
|
||||
gl.UseProgram(shader.ProgramID)
|
||||
gl.Uniform1i(shader.LocTexture, 0)
|
||||
gl.ActiveTexture(gl.TEXTURE0)
|
||||
gl.Enable(gl.BLEND)
|
||||
gl.BlendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
|
||||
gl.BindVertexArray(p.QuadVAO)
|
||||
}
|
||||
|
||||
// EndTextureRendering restores GL state after a batch of RenderTexture calls.
|
||||
func (p *Base) EndTextureRendering() {
|
||||
gl.BindVertexArray(0)
|
||||
gl.Disable(gl.BLEND)
|
||||
gl.UseProgram(0)
|
||||
}
|
||||
|
||||
// RenderTexture draws a given texture to the screen at a specified position and size.
|
||||
// For best performance, wrap multiple calls with BeginTextureRendering/EndTextureRendering.
|
||||
// This method also works standalone for one-off renders.
|
||||
func (p *Base) RenderTexture(shader *shaderManager.Shader, textureID uint32, position, size mgl32.Vec2, color sdl.Color) error {
|
||||
gl.UseProgram(shader.ProgramID)
|
||||
gl.Uniform1i(shader.LocTexture, 0)
|
||||
|
||||
if shader.LocTextColor == -1 {
|
||||
return fmt.Errorf("could not find 'u_textColor' uniform in shader program %d", shader.ProgramID)
|
||||
}
|
||||
gl.Uniform4f(shader.LocTextColor, float32(color.R)/255.0, float32(color.G)/255.0, float32(color.B)/255.0, float32(color.A)/255.0)
|
||||
|
||||
model := mgl32.Ident4()
|
||||
model = model.Mul4(mgl32.Translate3D(position.X(), position.Y(), 0))
|
||||
model = model.Mul4(mgl32.Scale3D(size.X(), size.Y(), 1))
|
||||
mvp := p.Projection.Mul4(model)
|
||||
gl.UniformMatrix4fv(shader.LocMVPMatrix, 1, false, &mvp[0])
|
||||
|
||||
gl.ActiveTexture(gl.TEXTURE0)
|
||||
gl.BindTexture(gl.TEXTURE_2D, textureID)
|
||||
gl.Enable(gl.BLEND)
|
||||
gl.BlendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
|
||||
|
||||
gl.BindVertexArray(p.QuadVAO)
|
||||
gl.DrawArrays(gl.TRIANGLES, 0, 6)
|
||||
|
||||
gl.BindVertexArray(0)
|
||||
gl.Disable(gl.BLEND)
|
||||
gl.UseProgram(0)
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenderTextureBatched is a lightweight version of RenderTexture for use inside
|
||||
// a BeginTextureRendering/EndTextureRendering block. It skips redundant state setup.
|
||||
func (p *Base) RenderTextureBatched(shader *shaderManager.Shader, textureID uint32, position, size mgl32.Vec2, color sdl.Color) {
|
||||
gl.Uniform4f(shader.LocTextColor, float32(color.R)/255.0, float32(color.G)/255.0, float32(color.B)/255.0, float32(color.A)/255.0)
|
||||
|
||||
model := mgl32.Ident4()
|
||||
model = model.Mul4(mgl32.Translate3D(position.X(), position.Y(), 0))
|
||||
model = model.Mul4(mgl32.Scale3D(size.X(), size.Y(), 1))
|
||||
mvp := p.Projection.Mul4(model)
|
||||
gl.UniformMatrix4fv(shader.LocMVPMatrix, 1, false, &mvp[0])
|
||||
|
||||
gl.BindTexture(gl.TEXTURE_2D, textureID)
|
||||
gl.DrawArrays(gl.TRIANGLES, 0, 6)
|
||||
}
|
||||
|
||||
// RenderSolidColorQuad draws a rectangle of a solid color to the screen.
|
||||
func (p *Base) RenderSolidColorQuad(shader *shaderManager.Shader, position, size mgl32.Vec2, color sdl.Color) error {
|
||||
gl.UseProgram(shader.ProgramID)
|
||||
|
||||
model := mgl32.Ident4()
|
||||
model = model.Mul4(mgl32.Translate3D(position.X(), position.Y(), 0))
|
||||
model = model.Mul4(mgl32.Scale3D(size.X(), size.Y(), 1))
|
||||
mvp := p.Projection.Mul4(model)
|
||||
|
||||
if shader.LocMVPMatrix == -1 {
|
||||
return fmt.Errorf("RenderSolidColorQuad: could not find 'u_mvpMatrix' uniform")
|
||||
}
|
||||
gl.UniformMatrix4fv(shader.LocMVPMatrix, 1, false, &mvp[0])
|
||||
|
||||
if shader.LocColor == -1 {
|
||||
return fmt.Errorf("RenderSolidColorQuad: could not find 'u_color' uniform")
|
||||
}
|
||||
gl.Uniform4f(shader.LocColor, float32(color.R)/255.0, float32(color.G)/255.0, float32(color.B)/255.0, float32(color.A)/255.0)
|
||||
|
||||
gl.Enable(gl.BLEND)
|
||||
gl.BlendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
|
||||
|
||||
gl.BindVertexArray(p.QuadVAO)
|
||||
gl.DrawArrays(gl.TRIANGLES, 0, 6)
|
||||
|
||||
gl.BindVertexArray(0)
|
||||
gl.Disable(gl.BLEND)
|
||||
gl.UseProgram(0)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Provide default implementations for the Page interface.
|
||||
func (p *Base) HandleEvent(event sdl.Event) error { return nil }
|
||||
func (p *Base) Update(dt float32) error { return nil }
|
||||
func (p *Base) Render() error { return nil }
|
||||
|
||||
// --- Helper Functions ---
|
||||
// keycodeToChar converts an SDL keycode to a rune, respecting SHIFT state.
|
||||
// Returns 0 for non-printable keys.
|
||||
func keycodeToChar(sym sdl.Keycode, mod uint16) rune {
|
||||
shifted := mod&sdl.KMOD_SHIFT != 0
|
||||
|
||||
// Letters
|
||||
if sym >= sdl.K_a && sym <= sdl.K_z {
|
||||
ch := rune(sym)
|
||||
if shifted {
|
||||
ch -= 'a' - 'A'
|
||||
}
|
||||
return ch
|
||||
}
|
||||
|
||||
// Digits and their shifted symbols
|
||||
if sym >= sdl.K_0 && sym <= sdl.K_9 {
|
||||
if shifted {
|
||||
shiftDigits := []rune{')', '!', '@', '#', '$', '%', '^', '&', '*', '('}
|
||||
return shiftDigits[sym-sdl.K_0]
|
||||
}
|
||||
return rune(sym)
|
||||
}
|
||||
|
||||
// Common punctuation / symbols
|
||||
type charPair struct{ normal, shifted rune }
|
||||
punctuation := map[sdl.Keycode]charPair{
|
||||
sdl.K_SPACE: {' ', ' '},
|
||||
sdl.K_MINUS: {'-', '_'},
|
||||
sdl.K_EQUALS: {'=', '+'},
|
||||
sdl.K_LEFTBRACKET: {'[', '{'},
|
||||
sdl.K_RIGHTBRACKET: {']', '}'},
|
||||
sdl.K_BACKSLASH: {'\\', '|'},
|
||||
sdl.K_SEMICOLON: {';', ':'},
|
||||
sdl.K_QUOTE: {'\'', '"'},
|
||||
sdl.K_BACKQUOTE: {'`', '~'},
|
||||
sdl.K_COMMA: {',', '<'},
|
||||
sdl.K_PERIOD: {'.', '>'},
|
||||
sdl.K_SLASH: {'/', '?'},
|
||||
}
|
||||
if pair, ok := punctuation[sym]; ok {
|
||||
if shifted {
|
||||
return pair.shifted
|
||||
}
|
||||
return pair.normal
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
package page
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/go-gl/mathgl/mgl32"
|
||||
"github.com/veandco/go-sdl2/sdl"
|
||||
"rhemawavetech.com/rhemawave/internal/fontManager"
|
||||
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
||||
)
|
||||
|
||||
// PageItem defines the basic features of any item on any page
|
||||
// This coupling makes creating, updating and managing pages easier
|
||||
type PageItem struct {
|
||||
ID uint32
|
||||
W int32
|
||||
H int32
|
||||
X int32 // Horizontal position
|
||||
Y int32 // Vertical position
|
||||
Color sdl.Color
|
||||
// index int
|
||||
}
|
||||
|
||||
// Scale updates the W and H of an item based on a scale factor (usually determined by base font size / desired font size)
|
||||
func (i *PageItem) Scale(scale float32) {
|
||||
i.W = int32(float32(i.W) * scale)
|
||||
i.H = int32(float32(i.H) * scale)
|
||||
}
|
||||
|
||||
// Dimensions returns an mgl32.Vec2 containing the W and H of an item, respectively
|
||||
func (i *PageItem) Dimensions(scale float32) mgl32.Vec2 {
|
||||
return mgl32.Vec2{float32(float32(i.W) * scale), float32(float32(i.H) * scale)}
|
||||
}
|
||||
|
||||
// RawDimensions returns a mgl32.Vec2 containing the W and H of an item, without scaling
|
||||
// This method will replace Dimensions in the future. Future PageItems should first call Scale(), then RawDimensions()
|
||||
func (i *PageItem) RawDimensions() mgl32.Vec2 {
|
||||
return mgl32.Vec2{float32(i.W), float32(i.H)}
|
||||
}
|
||||
|
||||
// Position returns an mgl32.Vec2 contianing the X and Y positions of an item, respectively
|
||||
func (i *PageItem) Position() mgl32.Vec2 {
|
||||
return mgl32.Vec2{float32(i.X), float32(i.Y)}
|
||||
}
|
||||
|
||||
// StringItem is an extended PageItem that contains a string content and string specific methods
|
||||
type StringItem struct {
|
||||
PageItem
|
||||
content string
|
||||
}
|
||||
|
||||
// NewStringItem creates a new StringItem with the given content, font, and color.
|
||||
// It initializes the texture ID, width, and height by creating a string texture using the font
|
||||
// and shader manager.
|
||||
func NewStringItem(content string, font *fontManager.FontEntry, color sdl.Color) (StringItem, error) {
|
||||
var err error
|
||||
|
||||
fm := fontManager.Get()
|
||||
sm := shaderManager.Get()
|
||||
|
||||
shader, ok := sm.Get("text")
|
||||
if !ok {
|
||||
return StringItem{}, fmt.Errorf("failed to fetch shader")
|
||||
}
|
||||
|
||||
id, width, height, err := fm.CreateStringTexture(font.Name, content, shader)
|
||||
if err != nil {
|
||||
return StringItem{}, err
|
||||
}
|
||||
|
||||
newStringItem := StringItem{
|
||||
PageItem: PageItem{
|
||||
ID: id,
|
||||
W: width,
|
||||
H: height,
|
||||
Color: color,
|
||||
},
|
||||
content: content,
|
||||
}
|
||||
|
||||
return newStringItem, err
|
||||
|
||||
}
|
||||
@@ -1,793 +0,0 @@
|
||||
package page
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/go-gl/gl/v3.3-core/gl"
|
||||
"github.com/go-gl/mathgl/mgl32"
|
||||
"github.com/veandco/go-sdl2/sdl"
|
||||
"gorm.io/gorm"
|
||||
"rhemawavetech.com/rhemawave/internal/colors"
|
||||
"rhemawavetech.com/rhemawave/internal/db"
|
||||
"rhemawavetech.com/rhemawave/internal/fontManager"
|
||||
"rhemawavetech.com/rhemawave/internal/logger"
|
||||
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
||||
"rhemawavetech.com/rhemawave/internal/sync"
|
||||
)
|
||||
|
||||
// Scrollbar Visuals
|
||||
var (
|
||||
colorScrollTrack = sdl.Color{R: 255, G: 255, B: 255, A: 30}
|
||||
colorScrollThumb = sdl.Color{R: 255, G: 255, B: 255, A: 100}
|
||||
)
|
||||
|
||||
type FlatItem struct {
|
||||
Label StringItem
|
||||
ID string
|
||||
IsCategory bool
|
||||
IsSubCategory bool
|
||||
Depth int
|
||||
Expanded bool
|
||||
SearchText string // lowercase name + content, pre-built at load time
|
||||
}
|
||||
|
||||
type ProclamationOptions struct {
|
||||
Base
|
||||
font *fontManager.FontEntry
|
||||
textShader *shaderManager.Shader
|
||||
solidShader *shaderManager.Shader
|
||||
title StringItem
|
||||
prompt StringItem
|
||||
|
||||
// Browse Tree State
|
||||
allItems []*FlatItem
|
||||
activeMap map[string]bool
|
||||
highlightIndex int
|
||||
|
||||
// Browse Scrolling State
|
||||
scrollOffset int
|
||||
maxVisible int
|
||||
|
||||
// Scrollbar Dimens
|
||||
scrollTrackWidth int32
|
||||
scrollTrackX int32
|
||||
scrollTrackHeight int32
|
||||
scrollTrackY int32
|
||||
|
||||
indentSize float32
|
||||
verticalMargin int32
|
||||
|
||||
// Search State
|
||||
isSearchActive bool
|
||||
searchQuery string
|
||||
searchInput StringItem
|
||||
searchItems []*FlatItem // filtered view — pointers into allProclamations
|
||||
searchHighlight int
|
||||
searchScrollOff int
|
||||
|
||||
// Full flat proclamation pool (pointers shared with allItems leaves)
|
||||
allProclamations []*FlatItem
|
||||
|
||||
// Spinning triangle cursor — same shape/animation as AudioOptions —
|
||||
// that points at whichever row is currently highlighted.
|
||||
triVAO uint32
|
||||
triVBO uint32
|
||||
triX float32
|
||||
triCenterY float32
|
||||
triVisible bool
|
||||
spinElapsed float32
|
||||
}
|
||||
|
||||
func (p *ProclamationOptions) Init(ctx *PageCtx) error {
|
||||
p.verticalMargin = 25
|
||||
p.indentSize = 40.0
|
||||
p.activeMap = make(map[string]bool)
|
||||
p.allItems = []*FlatItem{}
|
||||
p.scrollOffset = 0
|
||||
p.isSearchActive = false
|
||||
p.scrollTrackWidth = 30
|
||||
p.scrollTrackX = p.ScreenWidth - p.scrollTrackWidth
|
||||
var err error
|
||||
|
||||
// Syncronize proclamations first
|
||||
var allProcs []db.Proclamation
|
||||
allProcs, err = sync.SyncProclamations(ctx.DbPtr)
|
||||
if err != nil {
|
||||
// sync failed, fallback to database
|
||||
ctx.DbPtr.Find(&allProcs)
|
||||
}
|
||||
|
||||
sync.SyncFrequencies(ctx.DbPtr)
|
||||
|
||||
fm := fontManager.Get()
|
||||
font, _ := fm.GetFont("SpaceMonoNerdFont-Regular")
|
||||
p.font = font
|
||||
|
||||
sm := shaderManager.Get()
|
||||
p.textShader, _ = sm.Get("text")
|
||||
p.solidShader, _ = sm.Get("solid_color")
|
||||
|
||||
p.Base.Init(ctx)
|
||||
|
||||
config, err := db.GetConfig(p.Context.DbPtr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
itemScale := float32(32) / float32(config.StandardFontSize)
|
||||
p.title, _ = NewStringItem("Proclamation Browser", p.font, colors.White)
|
||||
p.title.Scale(float32(64) / float32(config.StandardFontSize))
|
||||
|
||||
p.prompt, _ = NewStringItem("Type to Search Space to Select Enter to Save Escape to Exit", p.font, colors.White)
|
||||
p.prompt.Scale(itemScale * 0.8)
|
||||
|
||||
// Calculation for visible area
|
||||
areaHeight := float32(p.ScreenHeight) - 250
|
||||
p.maxVisible = int(areaHeight / 47)
|
||||
|
||||
// Load current selections
|
||||
var selected []db.Proclamation
|
||||
ctx.DbPtr.Where("selected = ?", true).Where("id != ?", defaultProclamationID).Find(&selected)
|
||||
for _, s := range selected {
|
||||
if s.ID == defaultProclamationID {
|
||||
logger.Debug("Found default proclamation in selected list: %s", s.ID)
|
||||
}
|
||||
p.activeMap[s.ID] = true
|
||||
}
|
||||
|
||||
procContentMap := make(map[string]string, len(allProcs))
|
||||
for _, pr := range allProcs {
|
||||
if pr.ID == defaultProclamationID {
|
||||
logger.Debug("Found default proclamation in allProcs list: %s", pr.ID)
|
||||
}
|
||||
procContentMap[pr.ID] = pr.Content
|
||||
}
|
||||
p.allProclamations = []*FlatItem{}
|
||||
|
||||
for _, pr := range allProcs {
|
||||
fi := p.createFlatItem(pr.Name, pr.ID, false, 0)
|
||||
fi.SearchText = strings.ToLower(pr.Name)
|
||||
p.allProclamations = append(p.allProclamations, fi)
|
||||
}
|
||||
|
||||
// Load Root Categories
|
||||
var parentCategories []db.ProclamationCategory
|
||||
p.Context.DbPtr.Where("parent_id IS NULL OR parent_id = 0").Find(&parentCategories)
|
||||
for _, c := range parentCategories {
|
||||
p.allItems = append(p.allItems, p.createFlatItem(c.Name, fmt.Sprint(c.ID), true, 0))
|
||||
}
|
||||
|
||||
// Triangle cursor VAO/VBO — stride is vec2 only, no tex coords.
|
||||
// Mirrors the spinning cursor used in AudioOptions.
|
||||
triVerts := []float32{
|
||||
0.5, 0.0,
|
||||
-0.5, 0.5,
|
||||
-0.5, -0.5,
|
||||
}
|
||||
gl.GenVertexArrays(1, &p.triVAO)
|
||||
gl.GenBuffers(1, &p.triVBO)
|
||||
gl.BindVertexArray(p.triVAO)
|
||||
gl.BindBuffer(gl.ARRAY_BUFFER, p.triVBO)
|
||||
gl.BufferData(gl.ARRAY_BUFFER, len(triVerts)*4, gl.Ptr(triVerts), gl.STATIC_DRAW)
|
||||
gl.EnableVertexAttribArray(0)
|
||||
gl.VertexAttribPointerWithOffset(0, 2, gl.FLOAT, false, 2*4, 0)
|
||||
gl.BindBuffer(gl.ARRAY_BUFFER, 0)
|
||||
gl.BindVertexArray(0)
|
||||
|
||||
p.layout()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *ProclamationOptions) createFlatItem(name, id string, isCat bool, depth int) *FlatItem {
|
||||
text := name
|
||||
if isCat && depth == 0 {
|
||||
text = "[+] " + name
|
||||
}
|
||||
config, err := db.GetConfig(p.Context.DbPtr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
itemScale := float32(32) / float32(config.StandardFontSize)
|
||||
lbl, _ := NewStringItem(text, p.font, colors.White)
|
||||
lbl.Scale(itemScale)
|
||||
return &FlatItem{
|
||||
Label: lbl,
|
||||
ID: id,
|
||||
IsCategory: isCat,
|
||||
IsSubCategory: isCat && depth > 0,
|
||||
Depth: depth,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ProclamationOptions) layout() {
|
||||
p.title.X = p.ScreenCenterX - p.title.W/2
|
||||
p.title.Y = 40
|
||||
p.prompt.X = p.ScreenCenterX - p.prompt.W/2
|
||||
p.prompt.Y = p.ScreenHeight - 60
|
||||
|
||||
startY := float32(p.ScreenHeight - 150)
|
||||
p.scrollTrackY = 130
|
||||
p.scrollTrackHeight = int32(startY) - 130 + 50
|
||||
|
||||
// Reserve room on the left of the list for the spinning cursor
|
||||
// (triangleSize wide, cursorGap away from the text it points at),
|
||||
// ahead of the original left margin used for list rows.
|
||||
cursorGap := int32(12)
|
||||
listBaseX := int32(60) + int32(triangleSize) + cursorGap
|
||||
|
||||
if p.isSearchActive {
|
||||
// Position search bar label
|
||||
p.searchInput.X = 60
|
||||
p.searchInput.Y = 100
|
||||
|
||||
// Hide all browse items
|
||||
for _, item := range p.allItems {
|
||||
item.Label.X = -5000
|
||||
}
|
||||
|
||||
// Position filtered search results
|
||||
for i, item := range p.searchItems {
|
||||
item.Label.X = -5000
|
||||
if i >= p.searchScrollOff && i < p.searchScrollOff+p.maxVisible {
|
||||
visibleIdx := i - p.searchScrollOff
|
||||
item.Label.X = listBaseX
|
||||
item.Label.Y = int32(startY - float32(visibleIdx)*47.0)
|
||||
}
|
||||
}
|
||||
|
||||
// Point the cursor at the highlighted search result, if it's
|
||||
// currently scrolled into view.
|
||||
p.triVisible = false
|
||||
if len(p.searchItems) > 0 && p.searchHighlight >= 0 && p.searchHighlight < len(p.searchItems) &&
|
||||
p.searchHighlight >= p.searchScrollOff && p.searchHighlight < p.searchScrollOff+p.maxVisible {
|
||||
target := p.searchItems[p.searchHighlight].Label
|
||||
p.triX = float32(target.X) - triangleSize - float32(cursorGap)
|
||||
p.triCenterY = float32(target.Y) + float32(target.H)/2
|
||||
p.triVisible = true
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Browse mode
|
||||
for i, item := range p.allItems {
|
||||
item.Label.X = -5000
|
||||
if i >= p.scrollOffset && i < p.scrollOffset+p.maxVisible {
|
||||
visibleIdx := i - p.scrollOffset
|
||||
item.Label.X = listBaseX + int32(float32(item.Depth)*p.indentSize)
|
||||
item.Label.Y = int32(startY - float32(visibleIdx)*47.0)
|
||||
}
|
||||
}
|
||||
|
||||
// Point the cursor at the highlighted browse row, if it's currently
|
||||
// scrolled into view. Since target.X already includes the row's depth
|
||||
// indentation, the cursor shifts in and out with it automatically —
|
||||
// the gap between cursor and text stays constant regardless of depth.
|
||||
p.triVisible = false
|
||||
if len(p.allItems) > 0 && p.highlightIndex >= 0 && p.highlightIndex < len(p.allItems) &&
|
||||
p.highlightIndex >= p.scrollOffset && p.highlightIndex < p.scrollOffset+p.maxVisible {
|
||||
target := p.allItems[p.highlightIndex].Label
|
||||
p.triX = float32(target.X) - triangleSize - float32(cursorGap)
|
||||
p.triCenterY = float32(target.Y) + float32(target.H)/2
|
||||
p.triVisible = true
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ProclamationOptions) toggleExpansion(index int) {
|
||||
item := p.allItems[index]
|
||||
if !item.IsCategory || item.IsSubCategory {
|
||||
return
|
||||
}
|
||||
|
||||
pureName := item.Label.content[4:]
|
||||
gl.DeleteTextures(1, &item.Label.ID)
|
||||
|
||||
config, err := db.GetConfig(p.Context.DbPtr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
itemScale := float32(32) / float32(config.StandardFontSize)
|
||||
if item.Expanded {
|
||||
p.collapse(index)
|
||||
item.Label, _ = NewStringItem("[+] "+pureName, p.font, colors.White)
|
||||
} else {
|
||||
p.expand(index)
|
||||
item.Label, _ = NewStringItem("[-] "+pureName, p.font, colors.White)
|
||||
}
|
||||
item.Label.Scale(itemScale)
|
||||
item.Expanded = !item.Expanded
|
||||
p.layout()
|
||||
}
|
||||
|
||||
func (p *ProclamationOptions) expand(index int) {
|
||||
parent := p.allItems[index]
|
||||
parentID, _ := strconv.Atoi(parent.ID)
|
||||
var newItems []*FlatItem
|
||||
|
||||
// Load subcategories
|
||||
var cats []db.ProclamationCategory
|
||||
p.Context.DbPtr.Where("parent_id = ?", parentID).Find(&cats)
|
||||
for _, c := range cats {
|
||||
fi := p.createFlatItem(c.Name, fmt.Sprint(c.ID), true, parent.Depth+1)
|
||||
fi.Expanded = true
|
||||
newItems = append(newItems, fi)
|
||||
}
|
||||
|
||||
// Load proclamations for this category
|
||||
var procs []db.Proclamation
|
||||
p.Context.DbPtr.Where("category_id = ?", parentID).Where("ID != ?", defaultProclamationID).Find(&procs)
|
||||
|
||||
// Sort proclamations alphabetically by name
|
||||
sort.Slice(procs, func(i, j int) bool {
|
||||
return strings.ToLower(procs[i].Name) < strings.ToLower(procs[j].Name)
|
||||
})
|
||||
|
||||
for _, pr := range procs {
|
||||
var fi *FlatItem
|
||||
// Reuse existing proclamation from allProclamations if available
|
||||
for _, existing := range p.allProclamations {
|
||||
if existing.ID == pr.ID {
|
||||
fi = existing
|
||||
break
|
||||
}
|
||||
}
|
||||
if fi == nil {
|
||||
// Safety fallback
|
||||
fi = p.createFlatItem(pr.Name, pr.ID, false, parent.Depth+1)
|
||||
fi.SearchText = strings.ToLower(pr.Name + " " + pr.Content)
|
||||
p.allProclamations = append(p.allProclamations, fi)
|
||||
}
|
||||
fi.Depth = parent.Depth + 1
|
||||
newItems = append(newItems, fi)
|
||||
}
|
||||
|
||||
// Insert new items after parent
|
||||
tail := append([]*FlatItem{}, p.allItems[index+1:]...)
|
||||
p.allItems = append(p.allItems[:index+1], append(newItems, tail...)...)
|
||||
|
||||
// Recurse into subcategories
|
||||
i := index + 1
|
||||
for i < len(p.allItems) && p.allItems[i].Depth > parent.Depth {
|
||||
if p.allItems[i].IsCategory && p.allItems[i].Depth > 0 {
|
||||
p.expand(i)
|
||||
}
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ProclamationOptions) collapse(index int) {
|
||||
depth := p.allItems[index].Depth
|
||||
i := index + 1
|
||||
for i < len(p.allItems) && p.allItems[i].Depth > depth {
|
||||
// Do NOT delete textures here — leaves are shared with allProclamations.
|
||||
// Only delete textures for category items that aren't in the proclamation pool.
|
||||
if p.allItems[i].IsCategory {
|
||||
gl.DeleteTextures(1, &p.allItems[i].Label.ID)
|
||||
}
|
||||
i++
|
||||
}
|
||||
p.allItems = append(p.allItems[:index+1], p.allItems[i:]...)
|
||||
}
|
||||
|
||||
// matchesWordPrefix returns true if any word in text starts with query (case-insensitive).
|
||||
func matchesWordPrefix(text, query string) bool {
|
||||
query = strings.ToLower(query)
|
||||
text = strings.ToLower(text)
|
||||
|
||||
// Split text into words (non-alphanumeric boundaries)
|
||||
words := strings.FieldsFunc(text, func(r rune) bool {
|
||||
return !unicode.IsLetter(r) && !unicode.IsNumber(r)
|
||||
})
|
||||
|
||||
for _, word := range words {
|
||||
if strings.HasPrefix(word, query) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (p *ProclamationOptions) buildSearchList() {
|
||||
p.searchItems = nil
|
||||
if p.searchQuery == "" {
|
||||
return
|
||||
}
|
||||
for _, item := range p.allProclamations {
|
||||
if matchesWordPrefix(item.SearchText, p.searchQuery) {
|
||||
p.searchItems = append(p.searchItems, item)
|
||||
}
|
||||
}
|
||||
if p.searchHighlight >= len(p.searchItems) {
|
||||
p.searchHighlight = 0
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ProclamationOptions) rebuildSearchLabel() {
|
||||
if p.searchInput.ID != 0 {
|
||||
gl.DeleteTextures(1, &p.searchInput.ID)
|
||||
p.searchInput.ID = 0
|
||||
}
|
||||
if p.searchQuery == "" {
|
||||
return
|
||||
}
|
||||
config, err := db.GetConfig(p.Context.DbPtr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
itemScale := float32(32) / float32(config.StandardFontSize)
|
||||
p.searchInput, _ = NewStringItem("Search: "+p.searchQuery, p.font, colors.Yellow)
|
||||
p.searchInput.Scale(itemScale * 1.1)
|
||||
}
|
||||
|
||||
func (p *ProclamationOptions) enterSearchMode(firstRune rune) {
|
||||
p.searchQuery = string(firstRune)
|
||||
p.isSearchActive = true
|
||||
p.searchScrollOff = 0
|
||||
p.searchHighlight = 0
|
||||
p.buildSearchList()
|
||||
p.rebuildSearchLabel()
|
||||
}
|
||||
|
||||
func (p *ProclamationOptions) exitSearchMode() {
|
||||
p.searchQuery = ""
|
||||
p.isSearchActive = false
|
||||
p.searchItems = nil
|
||||
p.searchHighlight = 0
|
||||
p.searchScrollOff = 0
|
||||
if p.searchInput.ID != 0 {
|
||||
gl.DeleteTextures(1, &p.searchInput.ID)
|
||||
p.searchInput.ID = 0
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ProclamationOptions) HandleEvent(event *sdl.Event) error {
|
||||
switch e := (*event).(type) {
|
||||
case *sdl.KeyboardEvent:
|
||||
if e.Type != sdl.KEYDOWN {
|
||||
return nil
|
||||
}
|
||||
|
||||
sym := e.Keysym.Sym
|
||||
|
||||
// --- Backspace: always handled specially ---
|
||||
if sym == sdl.K_BACKSPACE {
|
||||
if p.isSearchActive {
|
||||
if len(p.searchQuery) > 0 {
|
||||
p.searchQuery = p.searchQuery[:len(p.searchQuery)-1]
|
||||
}
|
||||
if p.searchQuery == "" {
|
||||
p.exitSearchMode()
|
||||
} else {
|
||||
p.buildSearchList()
|
||||
p.rebuildSearchLabel()
|
||||
}
|
||||
p.layout()
|
||||
return nil
|
||||
}
|
||||
// Browse mode backspace — navigate back
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Escape ---
|
||||
if sym == sdl.K_ESCAPE {
|
||||
if p.isSearchActive {
|
||||
p.exitSearchMode()
|
||||
p.layout()
|
||||
return nil
|
||||
}
|
||||
p.Context.NavigateTo(SETTINGS)
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Search mode input handling ---
|
||||
if p.isSearchActive {
|
||||
switch sym {
|
||||
case sdl.K_UP:
|
||||
if p.searchHighlight > 0 {
|
||||
p.searchHighlight--
|
||||
if p.searchHighlight < p.searchScrollOff {
|
||||
p.searchScrollOff--
|
||||
}
|
||||
}
|
||||
case sdl.K_DOWN:
|
||||
if p.searchHighlight < len(p.searchItems)-1 {
|
||||
p.searchHighlight++
|
||||
if p.searchHighlight >= p.searchScrollOff+p.maxVisible {
|
||||
p.searchScrollOff++
|
||||
}
|
||||
}
|
||||
case sdl.K_PAGEUP:
|
||||
p.searchHighlight = p.searchScrollOff
|
||||
p.searchScrollOff -= p.maxVisible - 1
|
||||
if p.searchScrollOff < 0 {
|
||||
p.searchScrollOff = 0
|
||||
p.searchHighlight = 0
|
||||
}
|
||||
case sdl.K_PAGEDOWN:
|
||||
bottom := p.searchScrollOff + p.maxVisible - 1
|
||||
if bottom >= len(p.searchItems) {
|
||||
bottom = len(p.searchItems) - 1
|
||||
}
|
||||
p.searchScrollOff = bottom
|
||||
p.searchHighlight = bottom
|
||||
case sdl.K_SPACE:
|
||||
if len(p.searchItems) > 0 {
|
||||
item := p.searchItems[p.searchHighlight]
|
||||
p.activeMap[item.ID] = !p.activeMap[item.ID]
|
||||
}
|
||||
p.acceptChanges()
|
||||
case sdl.K_RETURN:
|
||||
if len(p.searchItems) > 0 {
|
||||
item := p.searchItems[p.searchHighlight]
|
||||
p.activeMap[item.ID] = !p.activeMap[item.ID]
|
||||
}
|
||||
p.acceptChanges()
|
||||
default:
|
||||
// Append printable characters to search query
|
||||
r := rune(sym)
|
||||
if r >= 33 && r <= 126 {
|
||||
if e.Keysym.Mod&sdl.KMOD_SHIFT != 0 {
|
||||
r = unicode.ToUpper(r)
|
||||
}
|
||||
p.searchQuery += string(r)
|
||||
p.buildSearchList()
|
||||
p.rebuildSearchLabel()
|
||||
}
|
||||
}
|
||||
p.layout()
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Browse mode ---
|
||||
switch sym {
|
||||
case sdl.K_UP:
|
||||
if p.highlightIndex > 0 {
|
||||
p.highlightIndex--
|
||||
if p.highlightIndex < p.scrollOffset {
|
||||
p.scrollOffset--
|
||||
}
|
||||
}
|
||||
case sdl.K_DOWN:
|
||||
if p.highlightIndex < len(p.allItems)-1 {
|
||||
p.highlightIndex++
|
||||
if p.highlightIndex >= p.scrollOffset+p.maxVisible {
|
||||
p.scrollOffset++
|
||||
}
|
||||
}
|
||||
case sdl.K_PAGEUP:
|
||||
p.highlightIndex = p.scrollOffset
|
||||
p.scrollOffset -= p.maxVisible - 1
|
||||
if p.scrollOffset < 0 {
|
||||
p.scrollOffset = 0
|
||||
p.highlightIndex = 0
|
||||
}
|
||||
case sdl.K_PAGEDOWN:
|
||||
bottomIdx := p.scrollOffset + p.maxVisible - 1
|
||||
if bottomIdx >= len(p.allItems) {
|
||||
bottomIdx = len(p.allItems) - 1
|
||||
}
|
||||
p.scrollOffset = bottomIdx
|
||||
p.highlightIndex = bottomIdx
|
||||
case sdl.K_RETURN:
|
||||
item := p.allItems[p.highlightIndex]
|
||||
if !item.IsCategory { // If the item is NOT a Category
|
||||
p.activeMap[item.ID] = !p.activeMap[item.ID] // Toggle it from the 'selected' list
|
||||
p.acceptChanges()
|
||||
} else if !item.IsSubCategory {
|
||||
p.toggleExpansion(p.highlightIndex)
|
||||
}
|
||||
case sdl.K_SPACE:
|
||||
item := p.allItems[p.highlightIndex]
|
||||
if !item.IsCategory { // If the item is NOT a Category
|
||||
p.activeMap[item.ID] = !p.activeMap[item.ID] // Toggle it from the 'selected' list
|
||||
p.acceptChanges()
|
||||
} else if !item.IsSubCategory {
|
||||
p.toggleExpansion(p.highlightIndex)
|
||||
}
|
||||
default:
|
||||
// Any printable character enters search mode
|
||||
r := rune(sym)
|
||||
if r >= 33 && r <= 126 {
|
||||
if e.Keysym.Mod&sdl.KMOD_SHIFT != 0 {
|
||||
r = unicode.ToUpper(r)
|
||||
}
|
||||
p.enterSearchMode(r)
|
||||
}
|
||||
}
|
||||
|
||||
p.layout()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *ProclamationOptions) acceptChanges() {
|
||||
p.Context.DbPtr.Transaction(func(tx *gorm.DB) error {
|
||||
tx.Model(&db.Proclamation{}).Where("1 = 1").Update("selected", false)
|
||||
var ids []string
|
||||
for id, active := range p.activeMap {
|
||||
if active {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
if len(ids) > 0 {
|
||||
tx.Model(&db.Proclamation{}).Where("id IN ?", ids).Update("selected", true)
|
||||
logger.Debug("Saving selected state for %s\n", ids)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// Update advances the spinning-cursor animation. The cursor's *position* is
|
||||
// recomputed in layout() whenever the highlighted row changes; this only
|
||||
// drives the spin itself, frame to frame.
|
||||
func (p *ProclamationOptions) Update(dt float32) error {
|
||||
p.spinElapsed += dt
|
||||
if p.spinElapsed >= 1.0 {
|
||||
p.spinElapsed -= 1.0
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *ProclamationOptions) Render() error {
|
||||
p.RenderTexture(p.textShader, p.title.ID, p.title.Position(), p.title.RawDimensions(), p.title.Color)
|
||||
p.RenderTexture(p.textShader, p.prompt.ID, p.prompt.Position(), p.prompt.RawDimensions(), p.prompt.Color)
|
||||
|
||||
if p.isSearchActive {
|
||||
// Search bar label
|
||||
if p.searchInput.ID != 0 {
|
||||
p.RenderTexture(p.textShader, p.searchInput.ID, p.searchInput.Position(), p.searchInput.RawDimensions(), p.searchInput.Color)
|
||||
}
|
||||
|
||||
if len(p.searchItems) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
for i := p.searchScrollOff; i < p.searchScrollOff+p.maxVisible && i < len(p.searchItems); i++ {
|
||||
item := p.searchItems[i]
|
||||
if p.activeMap[item.ID] {
|
||||
item.Label.Color = colors.Orange
|
||||
} else {
|
||||
item.Label.Color = colors.White
|
||||
}
|
||||
p.RenderTexture(p.textShader, item.Label.ID, item.Label.Position(), item.Label.RawDimensions(), item.Label.Color)
|
||||
}
|
||||
|
||||
// Spinning triangle cursor — always on top, points at the highlighted result
|
||||
p.renderCursor()
|
||||
|
||||
p.renderSearchScrollbar()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Browse mode
|
||||
if len(p.allItems) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
for i := p.scrollOffset; i < p.scrollOffset+p.maxVisible && i < len(p.allItems); i++ {
|
||||
item := p.allItems[i]
|
||||
if item.IsCategory {
|
||||
item.Label.Color = colors.DodgerBlue
|
||||
} else if p.activeMap[item.ID] {
|
||||
item.Label.Color = colors.Orange
|
||||
} else {
|
||||
item.Label.Color = colors.White
|
||||
}
|
||||
p.RenderTexture(p.textShader, item.Label.ID, item.Label.Position(), item.Label.RawDimensions(), item.Label.Color)
|
||||
}
|
||||
|
||||
// Spinning triangle cursor — always on top, points at the highlighted row
|
||||
p.renderCursor()
|
||||
|
||||
p.renderScrollbar()
|
||||
return nil
|
||||
}
|
||||
|
||||
// renderCursor draws the spinning triangle cursor pointing at whichever row
|
||||
// is currently highlighted, in either browse or search mode. Identical
|
||||
// shape/animation to the cursor in AudioOptions.
|
||||
func (p *ProclamationOptions) renderCursor() error {
|
||||
if !p.triVisible {
|
||||
return nil
|
||||
}
|
||||
|
||||
scaleY := float32(math.Cos(2 * math.Pi * float64(p.spinElapsed)))
|
||||
|
||||
model := mgl32.Ident4()
|
||||
model = model.Mul4(mgl32.Translate3D(p.triX+triangleSize/2, p.triCenterY, 0))
|
||||
model = model.Mul4(mgl32.Scale3D(triangleSize, triangleSize*scaleY, 1))
|
||||
mvp := p.Projection.Mul4(model)
|
||||
|
||||
gl.UseProgram(p.solidShader.ProgramID)
|
||||
gl.UniformMatrix4fv(p.solidShader.LocMVPMatrix, 1, false, &mvp[0])
|
||||
gl.Uniform4f(
|
||||
p.solidShader.LocColor,
|
||||
float32(colors.White.R)/255.0,
|
||||
float32(colors.White.G)/255.0,
|
||||
float32(colors.White.B)/255.0,
|
||||
float32(colors.White.A)/255.0,
|
||||
)
|
||||
|
||||
gl.BindVertexArray(p.triVAO)
|
||||
gl.DrawArrays(gl.TRIANGLES, 0, 3)
|
||||
gl.BindVertexArray(0)
|
||||
gl.UseProgram(0)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *ProclamationOptions) renderScrollbar() {
|
||||
total := len(p.allItems)
|
||||
if total <= p.maxVisible {
|
||||
return
|
||||
}
|
||||
|
||||
p.RenderSolidColorQuad(p.solidShader,
|
||||
mgl32.Vec2{float32(p.scrollTrackX), float32(p.scrollTrackY)},
|
||||
mgl32.Vec2{float32(p.scrollTrackWidth), float32(p.scrollTrackHeight)},
|
||||
colorScrollTrack)
|
||||
|
||||
visibleRatio := float32(p.maxVisible) / float32(total)
|
||||
scrollRatio := float32(p.scrollOffset) / float32(total)
|
||||
thumbHeight := float32(p.scrollTrackHeight) * visibleRatio
|
||||
if thumbHeight < 20 {
|
||||
thumbHeight = 20
|
||||
}
|
||||
thumbY := float32(p.scrollTrackY) + float32(p.scrollTrackHeight)*scrollRatio
|
||||
|
||||
p.RenderSolidColorQuad(p.solidShader,
|
||||
mgl32.Vec2{float32(p.scrollTrackX + 2), thumbY},
|
||||
mgl32.Vec2{float32(p.scrollTrackWidth - 4), thumbHeight},
|
||||
colorScrollThumb)
|
||||
}
|
||||
|
||||
func (p *ProclamationOptions) renderSearchScrollbar() {
|
||||
total := len(p.searchItems)
|
||||
if total <= p.maxVisible {
|
||||
return
|
||||
}
|
||||
|
||||
p.RenderSolidColorQuad(p.solidShader,
|
||||
mgl32.Vec2{float32(p.scrollTrackX), float32(p.scrollTrackY)},
|
||||
mgl32.Vec2{float32(p.scrollTrackWidth), float32(p.scrollTrackHeight)},
|
||||
colorScrollTrack)
|
||||
|
||||
visibleRatio := float32(p.maxVisible) / float32(total)
|
||||
scrollRatio := float32(p.searchScrollOff) / float32(total)
|
||||
thumbHeight := float32(p.scrollTrackHeight) * visibleRatio
|
||||
if thumbHeight < 20 {
|
||||
thumbHeight = 20
|
||||
}
|
||||
thumbY := float32(p.scrollTrackY) + float32(p.scrollTrackHeight)*scrollRatio
|
||||
|
||||
p.RenderSolidColorQuad(p.solidShader,
|
||||
mgl32.Vec2{float32(p.scrollTrackX + 2), thumbY},
|
||||
mgl32.Vec2{float32(p.scrollTrackWidth - 4), thumbHeight},
|
||||
colorScrollThumb)
|
||||
}
|
||||
|
||||
func (p *ProclamationOptions) Destroy() error {
|
||||
gl.DeleteTextures(1, &p.title.ID)
|
||||
gl.DeleteTextures(1, &p.prompt.ID)
|
||||
if p.searchInput.ID != 0 {
|
||||
gl.DeleteTextures(1, &p.searchInput.ID)
|
||||
}
|
||||
// Delete browse category textures
|
||||
for _, item := range p.allItems {
|
||||
if item.IsCategory {
|
||||
gl.DeleteTextures(1, &item.Label.ID)
|
||||
}
|
||||
}
|
||||
// Delete proclamation textures via the pool (single source of truth)
|
||||
for _, item := range p.allProclamations {
|
||||
gl.DeleteTextures(1, &item.Label.ID)
|
||||
}
|
||||
gl.DeleteBuffers(1, &p.triVBO)
|
||||
gl.DeleteVertexArrays(1, &p.triVAO)
|
||||
return nil
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
package page
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
"rhemawavetech.com/rhemawave/internal/download"
|
||||
)
|
||||
|
||||
type pageType int
|
||||
|
||||
const (
|
||||
HOME pageType = iota
|
||||
SCROLLER
|
||||
SETTINGS
|
||||
WIFI
|
||||
AUDIO
|
||||
PROCLAMATIONS
|
||||
LICENSE_KEY
|
||||
EMAIL_ADDRESS
|
||||
LOGS
|
||||
MESSAGE
|
||||
DOWNLOADING_ASSETS
|
||||
)
|
||||
|
||||
type PageCtx struct {
|
||||
DbPtr *gorm.DB
|
||||
W, H int32
|
||||
MessageChannel chan download.DownloadStatus
|
||||
DownloadDone chan struct{} // closed when downloads finish (success or failure)
|
||||
NavigateTo func(pageType)
|
||||
Msg string // Message to be displayed by MessagePage
|
||||
Inst string // Instructions to be displayed on MessagePage
|
||||
}
|
||||
|
||||
type Router struct {
|
||||
ActivePage Page
|
||||
Context PageCtx
|
||||
}
|
||||
|
||||
func NewRouter(pgCtx PageCtx) *Router {
|
||||
r := &Router{
|
||||
Context: pgCtx,
|
||||
}
|
||||
r.Context.NavigateTo = r.NavigateTo
|
||||
r.NavigateTo(HOME)
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *Router) NavigateTo(p pageType) {
|
||||
if r.ActivePage != nil {
|
||||
r.ActivePage.Destroy()
|
||||
}
|
||||
|
||||
switch p {
|
||||
case HOME:
|
||||
r.ActivePage = &Home{}
|
||||
case SCROLLER:
|
||||
r.ActivePage = &ScrollerPage{}
|
||||
case SETTINGS:
|
||||
r.ActivePage = &Settings{}
|
||||
case WIFI:
|
||||
r.ActivePage = &WiFiSetupPage{}
|
||||
case AUDIO:
|
||||
r.ActivePage = &AudioOptions{}
|
||||
case PROCLAMATIONS:
|
||||
r.ActivePage = &ProclamationOptions{}
|
||||
case LICENSE_KEY:
|
||||
r.ActivePage = &LicenseKeyPage{}
|
||||
case EMAIL_ADDRESS:
|
||||
r.ActivePage = &EmailAddressPage{}
|
||||
case LOGS:
|
||||
r.ActivePage = &LogViewerPage{}
|
||||
case MESSAGE:
|
||||
r.ActivePage = &MessagePage{}
|
||||
case DOWNLOADING_ASSETS:
|
||||
r.ActivePage = &DownloadingAssets{}
|
||||
}
|
||||
|
||||
r.ActivePage.Init(&r.Context)
|
||||
}
|
||||
@@ -1,829 +0,0 @@
|
||||
package page
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/go-gl/gl/v3.3-core/gl"
|
||||
"github.com/veandco/go-sdl2/sdl"
|
||||
"rhemawavetech.com/rhemawave/internal/colors"
|
||||
"rhemawavetech.com/rhemawave/internal/db"
|
||||
"rhemawavetech.com/rhemawave/internal/fontManager"
|
||||
"rhemawavetech.com/rhemawave/internal/logger"
|
||||
"rhemawavetech.com/rhemawave/internal/mixer"
|
||||
colorPattern "rhemawavetech.com/rhemawave/internal/pattern/color"
|
||||
linePattern "rhemawavetech.com/rhemawave/internal/pattern/line"
|
||||
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultProclamationID string = "f3d42354-3197-45e4-9b08-be447f14386d"
|
||||
proclamationPlayCount int = 20
|
||||
// defaultProclamationPlayCount int = 1
|
||||
)
|
||||
|
||||
type ScrollerPage struct {
|
||||
Base
|
||||
|
||||
streams []proclamationStream
|
||||
streamIndex int
|
||||
|
||||
// currentAudioFreq is the solfeggio Hz value of whatever track is
|
||||
// currently playing. 0 means "no specific frequency" (either the
|
||||
// legacy Selected-AudioFile track, or nothing yet).
|
||||
currentAudioFreq int
|
||||
|
||||
IsDownloadingAudio bool
|
||||
|
||||
lines *LineDeque
|
||||
lineHeight int32
|
||||
shader *shaderManager.Shader
|
||||
activePattern linePattern.LinePattern
|
||||
font *fontManager.FontEntry
|
||||
spaceWidth int32
|
||||
lineCtx *linePattern.LineRenderContext
|
||||
colorPattern *colorPattern.ColorPattern
|
||||
scrollOffset float32
|
||||
scrollY float32
|
||||
fontScale float32
|
||||
|
||||
// Reuse one GL texture per unique word.
|
||||
wordTextureCache map[string]StringItem
|
||||
|
||||
increment float32
|
||||
velocity float32
|
||||
|
||||
// Time-based velocity control
|
||||
velocityFloor float32
|
||||
velocityCeiling float32
|
||||
velocityStep float32
|
||||
velocityDelay float32
|
||||
velocityDirection int
|
||||
velocityTimer float32
|
||||
velocityState int
|
||||
autoVelocity bool
|
||||
hold float32
|
||||
|
||||
// Color palette settings
|
||||
useColorPalettes bool
|
||||
}
|
||||
|
||||
type ScrollerProclamation struct {
|
||||
db.Proclamation
|
||||
colorPattern.ColorPattern
|
||||
ColorPalette []sdl.Color
|
||||
}
|
||||
|
||||
type Line struct {
|
||||
words []StringItem
|
||||
width int32
|
||||
color sdl.Color // Color for the entire line
|
||||
}
|
||||
|
||||
// proclamationStream tracks one proclamation and how many full loops it should play.
|
||||
type proclamationStream struct {
|
||||
name string
|
||||
words []string
|
||||
repeat int
|
||||
completed int
|
||||
wordIndex int
|
||||
palette []sdl.Color // Color palette for this proclamation
|
||||
colorIndex int // Track which color to use next
|
||||
|
||||
// frequencyValue is the solfeggio Hz value that should be audible while
|
||||
// this stream plays. 0 means "no specific frequency" (the default
|
||||
// proclamation) — entering this stream never changes the audio.
|
||||
frequencyValue int
|
||||
}
|
||||
|
||||
// LineDeque is a double-ended queue for Line values backed by a circular buffer.
|
||||
// It avoids O(n) prepend and prevents capacity leaks from head-of-slice removal.
|
||||
type LineDeque struct {
|
||||
buf []Line
|
||||
head, tail int
|
||||
count int
|
||||
}
|
||||
|
||||
func NewLineDeque(cap int) *LineDeque {
|
||||
if cap < 4 {
|
||||
cap = 4
|
||||
}
|
||||
return &LineDeque{buf: make([]Line, cap)}
|
||||
}
|
||||
|
||||
func (d *LineDeque) Len() int { return d.count }
|
||||
|
||||
func (d *LineDeque) grow() {
|
||||
newCap := len(d.buf) * 2
|
||||
newBuf := make([]Line, newCap)
|
||||
for i := 0; i < d.count; i++ {
|
||||
newBuf[i] = d.buf[(d.head+i)%len(d.buf)]
|
||||
}
|
||||
d.buf = newBuf
|
||||
d.head = 0
|
||||
d.tail = d.count
|
||||
}
|
||||
|
||||
func (d *LineDeque) PushBack(l Line) {
|
||||
if d.count == len(d.buf) {
|
||||
d.grow()
|
||||
}
|
||||
d.buf[d.tail] = l
|
||||
d.tail = (d.tail + 1) % len(d.buf)
|
||||
d.count++
|
||||
}
|
||||
|
||||
func (d *LineDeque) PushFront(l Line) {
|
||||
if d.count == len(d.buf) {
|
||||
d.grow()
|
||||
}
|
||||
d.head = (d.head - 1 + len(d.buf)) % len(d.buf)
|
||||
d.buf[d.head] = l
|
||||
d.count++
|
||||
}
|
||||
|
||||
func (d *LineDeque) PopFront() Line {
|
||||
l := d.buf[d.head]
|
||||
d.buf[d.head] = Line{} // clear for GC
|
||||
d.head = (d.head + 1) % len(d.buf)
|
||||
d.count--
|
||||
return l
|
||||
}
|
||||
|
||||
func (d *LineDeque) PopBack() Line {
|
||||
d.tail = (d.tail - 1 + len(d.buf)) % len(d.buf)
|
||||
l := d.buf[d.tail]
|
||||
d.buf[d.tail] = Line{} // clear for GC
|
||||
d.count--
|
||||
return l
|
||||
}
|
||||
|
||||
func (d *LineDeque) Front() Line { return d.buf[d.head] }
|
||||
|
||||
func (d *LineDeque) Back() Line {
|
||||
return d.buf[(d.tail-1+len(d.buf))%len(d.buf)]
|
||||
}
|
||||
|
||||
func (d *LineDeque) Get(i int) *Line {
|
||||
return &d.buf[(d.head+i)%len(d.buf)]
|
||||
}
|
||||
|
||||
func (d *LineDeque) Set(i int, l Line) {
|
||||
d.buf[(d.head+i)%len(d.buf)] = l
|
||||
}
|
||||
|
||||
func (d *LineDeque) Clear() {
|
||||
for i := 0; i < d.count; i++ {
|
||||
d.buf[(d.head+i)%len(d.buf)] = Line{}
|
||||
}
|
||||
d.head, d.tail, d.count = 0, 0, 0
|
||||
}
|
||||
|
||||
func (p *ScrollerPage) Init(ctx *PageCtx) error {
|
||||
p.increment = 100.0
|
||||
p.velocity = 800.0
|
||||
p.SetAutoVelocity(true, -4000.0, 4000.0, 100.0, 2.5, 6.0)
|
||||
p.useColorPalettes = true // Default to enabled
|
||||
|
||||
sm := shaderManager.Get()
|
||||
fm := fontManager.Get()
|
||||
|
||||
// Get font
|
||||
font, ok := fm.GetFont("Scrambled")
|
||||
if !ok {
|
||||
return fmt.Errorf("unable to fetch font 'Scrambled'")
|
||||
}
|
||||
p.font = font
|
||||
logger.Debug("Font name: %s", font.Name)
|
||||
|
||||
p.Base.Init(ctx)
|
||||
|
||||
// Get text shader
|
||||
textShader, ok := sm.Get("text")
|
||||
if !ok {
|
||||
return fmt.Errorf("text shader not found")
|
||||
}
|
||||
p.shader = textShader
|
||||
logger.Debug("Shader Program ID: %d", textShader.ProgramID)
|
||||
logger.Debug("p.shader Program ID: %d", p.shader.ProgramID)
|
||||
|
||||
p.wordTextureCache = make(map[string]StringItem, 256)
|
||||
|
||||
// Load proclamation streams from database
|
||||
if err := p.loadProclamations(); err != nil {
|
||||
logger.Error("Error loading proclamations: %v", err)
|
||||
p.Context.NavigateTo(PROCLAMATIONS)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get line pattern from database
|
||||
config, err := db.GetConfig(p.Context.DbPtr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
linePatternName := config.LinePattern
|
||||
|
||||
p.activePattern, err = linePattern.Get(linePatternName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load line pattern '%s': %w", linePatternName, err)
|
||||
}
|
||||
|
||||
// Initialize color pattern
|
||||
baseColors := []sdl.Color{
|
||||
colors.Tan,
|
||||
colors.PeachPuff,
|
||||
colors.LightCyan,
|
||||
colors.LightSeaGreen,
|
||||
colors.MediumTurquoise,
|
||||
colors.DarkCyan,
|
||||
colors.MediumPurple,
|
||||
}
|
||||
p.colorPattern = colorPattern.NewColorPattern(baseColors)
|
||||
|
||||
// Font setup
|
||||
p.fontScale = float32(32) / float32(config.StandardFontSize)
|
||||
nLines := int(float32(p.ScreenHeight) / 32.0)
|
||||
p.lines = NewLineDeque(nLines * 2)
|
||||
|
||||
// Calculate space width using the scale ratio.
|
||||
spaceGlyph, ok := fm.GetGlyph(font.Name, ' ')
|
||||
if !ok {
|
||||
return fmt.Errorf("failed to get space glyph")
|
||||
}
|
||||
p.spaceWidth = int32(float32(spaceGlyph.Advance) * p.fontScale)
|
||||
|
||||
// Get line height using the same scale ratio.
|
||||
tmpStrID, _, H, err := fm.NewCreateStringTexture(font.Name, "Mg", p.shader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create sample texture: %w", err)
|
||||
}
|
||||
p.lineHeight = int32(float32(H) * p.fontScale)
|
||||
gl.DeleteTextures(1, &tmpStrID)
|
||||
|
||||
p.lineCtx = &linePattern.LineRenderContext{
|
||||
ScreenWidth: p.ScreenWidth,
|
||||
Font: font,
|
||||
FontScale: p.fontScale,
|
||||
SpaceWidth: p.spaceWidth,
|
||||
}
|
||||
|
||||
// Create initial lines
|
||||
for i := range nLines {
|
||||
if err := p.createLine(i, true); err != nil {
|
||||
return fmt.Errorf("failed to create initial line %d: %w", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Start audio for the first proclamation in the stream. Falls back to
|
||||
// whatever track is marked Selected in the audio_files table if the
|
||||
// first stream has no frequency (e.g. only the default proclamation is
|
||||
// queued) or its matching file doesn't exist on disk yet.
|
||||
started := false
|
||||
if len(p.streams) > 0 {
|
||||
started = p.switchAudioForFrequency(p.streams[0].frequencyValue)
|
||||
}
|
||||
|
||||
if !started {
|
||||
var selectedAudio db.AudioFile
|
||||
if err := p.Context.DbPtr.Where("Selected = ?", true).First(&selectedAudio).Error; err != nil {
|
||||
selectedAudio = db.AudioFile{
|
||||
Name: "OriginalAudio",
|
||||
Path: filepath.Join(audioDir(), "OriginalAudio.wav"),
|
||||
Selected: true,
|
||||
Order: 0,
|
||||
}
|
||||
}
|
||||
|
||||
logger.Info("Playing %s", selectedAudio.Path)
|
||||
if err := mixer.Play(selectedAudio.Path, true, config); err != nil {
|
||||
logger.Error("Mixer returned an error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
currentDevice := mixer.CurrentDevice()
|
||||
if currentDevice == "" {
|
||||
currentDevice = "default"
|
||||
}
|
||||
_ = currentDevice
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadProclamations loads selected proclamations as stream units (not one giant word list).
|
||||
func (p *ScrollerPage) loadProclamations() error {
|
||||
p.streams = p.streams[:0]
|
||||
|
||||
// Load all solfeggio frequencies
|
||||
var frequencies []db.SolfeggioFrequency
|
||||
if err := p.Context.DbPtr.Find(&frequencies).Error; err != nil {
|
||||
return fmt.Errorf("failed to load solfeggio frequencies: %v", err)
|
||||
}
|
||||
|
||||
// Create a map of frequency ID to its value
|
||||
frequencyMap := make(map[int]int)
|
||||
for _, freq := range frequencies {
|
||||
frequencyMap[freq.ID] = freq.Value
|
||||
}
|
||||
|
||||
// Map frequency values to their color palettes
|
||||
frequencyPalettes := map[int][]sdl.Color{
|
||||
396: colors.Freq_396_Colors[:],
|
||||
417: colors.Freq_417_Colors[:],
|
||||
528: colors.Freq_528_Colors[:], // Default proclamation
|
||||
639: colors.Freq_639_Colors[:],
|
||||
741: colors.Freq_741_Colors[:],
|
||||
852: colors.Freq_852_Colors[:],
|
||||
963: colors.Freq_963_Colors[:],
|
||||
}
|
||||
|
||||
// Load selected proclamations
|
||||
var selected []db.Proclamation
|
||||
if err := p.Context.DbPtr.Where("selected = ?", true).Find(&selected).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Load default proclamation
|
||||
var defaultProclamation db.Proclamation
|
||||
if err := p.Context.DbPtr.Where("ID = ?", defaultProclamationID).First(&defaultProclamation).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
defaultWords := strings.Fields(defaultProclamation.Content)
|
||||
|
||||
// Build interleaved stream:
|
||||
// each selected proclamation repeats N times, then default repeats once.
|
||||
for _, proclamation := range selected {
|
||||
// Skip default proclamation if it's in the selected list
|
||||
if proclamation.ID == defaultProclamationID {
|
||||
continue
|
||||
}
|
||||
|
||||
words := strings.Fields(proclamation.Content)
|
||||
if len(words) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get the frequency value for this proclamation
|
||||
freqValue, ok := frequencyMap[proclamation.SolfeggioFrequencyID]
|
||||
if !ok {
|
||||
logger.Warning("No frequency found for proclamation %s, using default palette", proclamation.Name)
|
||||
freqValue = 528 // Default to 528 if frequency not found
|
||||
}
|
||||
|
||||
// Get the palette for this frequency
|
||||
palette, ok := frequencyPalettes[freqValue]
|
||||
if !ok {
|
||||
logger.Warning("No palette found for frequency %d, using default palette", freqValue)
|
||||
palette = colors.Freq_528_Colors[:] // Fallback to default palette
|
||||
}
|
||||
|
||||
p.streams = append(p.streams, proclamationStream{
|
||||
name: proclamation.Name,
|
||||
words: words,
|
||||
repeat: proclamationPlayCount,
|
||||
palette: palette,
|
||||
colorIndex: 0,
|
||||
frequencyValue: freqValue,
|
||||
})
|
||||
|
||||
if len(defaultWords) > 0 {
|
||||
p.streams = append(p.streams, proclamationStream{
|
||||
name: defaultProclamation.Name,
|
||||
words: defaultWords,
|
||||
repeat: 1,
|
||||
palette: colors.Freq_528_Colors[:], // Default palette for default proclamation
|
||||
colorIndex: 0,
|
||||
frequencyValue: 0, // default proclamation never changes audio
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: if no non-default selected proclamations, at least play default.
|
||||
if len(p.streams) == 0 && len(defaultWords) > 0 {
|
||||
p.streams = append(p.streams, proclamationStream{
|
||||
name: defaultProclamation.Name,
|
||||
words: defaultWords,
|
||||
repeat: 1,
|
||||
palette: colors.Freq_528_Colors[:],
|
||||
colorIndex: 0,
|
||||
frequencyValue: 0,
|
||||
})
|
||||
}
|
||||
|
||||
if len(p.streams) == 0 {
|
||||
return fmt.Errorf("failed to load proclamations: no proclamation words available")
|
||||
}
|
||||
|
||||
p.streamIndex = 0
|
||||
return nil
|
||||
}
|
||||
|
||||
// audioDir returns the path to the audio files directory (~/.radiantwave/audio).
|
||||
func audioDir() string {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return filepath.Join(home, ".radiantwave", "audio")
|
||||
}
|
||||
|
||||
// audioPathForFrequency builds the on-disk path for a solfeggio frequency's
|
||||
// audio file, e.g. 396 -> "~/.radiantwave/audio/396Hz.wav".
|
||||
func (p *ScrollerPage) audioPathForFrequency(freq int) string {
|
||||
return filepath.Join(audioDir(), fmt.Sprintf("%dHz.wav", freq))
|
||||
}
|
||||
|
||||
// switchAudioForFrequency swaps the currently playing track to match freq.
|
||||
//
|
||||
// - freq == 0 ("no specific frequency", i.e. the default proclamation) is
|
||||
// always a no-op — whatever is playing keeps playing.
|
||||
// - If freq already matches what's playing, nothing happens.
|
||||
// - If the matching file doesn't exist on disk yet, the current track
|
||||
// keeps playing untouched (so missing solfeggio audio never causes
|
||||
// silence — and starts working automatically once the file is added).
|
||||
// - Otherwise the current track is stopped immediately and freq's track
|
||||
// starts right away (hard cut, no fade).
|
||||
//
|
||||
// Returns true if freq's track is now playing (either just switched, or
|
||||
// already was).
|
||||
func (p *ScrollerPage) switchAudioForFrequency(freq int) bool {
|
||||
if freq == 0 {
|
||||
return false
|
||||
}
|
||||
if freq == p.currentAudioFreq {
|
||||
return true
|
||||
}
|
||||
|
||||
path := p.audioPathForFrequency(freq)
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
logger.Warning("No audio file for %dHz (%s), keeping current track", freq, path)
|
||||
return false
|
||||
}
|
||||
config, err := db.GetConfig(p.Context.DbPtr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
mixer.Stop(3000, config)
|
||||
if err := mixer.Play(path, true, config); err != nil {
|
||||
logger.Error("Mixer returned an error: %v", err)
|
||||
return false
|
||||
}
|
||||
p.currentAudioFreq = freq
|
||||
return true
|
||||
}
|
||||
|
||||
// nextLineWords gets words for the current line and advances proclamation stream when needed.
|
||||
func (p *ScrollerPage) nextLineWords() []string {
|
||||
if len(p.streams) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
s := &p.streams[p.streamIndex]
|
||||
if len(s.words) == 0 {
|
||||
p.streamIndex = (p.streamIndex + 1) % len(p.streams)
|
||||
return p.nextLineWords()
|
||||
}
|
||||
|
||||
prevIdx := s.wordIndex
|
||||
words := p.activePattern.GetNextLine(s.words, &s.wordIndex, p.lineCtx)
|
||||
|
||||
// If word index wraps, one full proclamation pass completed.
|
||||
if s.wordIndex < prevIdx {
|
||||
s.completed++
|
||||
targetRepeats := s.repeat
|
||||
if targetRepeats <= 0 {
|
||||
targetRepeats = 1
|
||||
}
|
||||
if s.completed >= targetRepeats {
|
||||
s.completed = 0
|
||||
s.wordIndex = 0
|
||||
s.colorIndex = 0 // Reset color index when proclamation restarts
|
||||
p.streamIndex = (p.streamIndex + 1) % len(p.streams)
|
||||
next := &p.streams[p.streamIndex]
|
||||
p.switchAudioForFrequency(next.frequencyValue)
|
||||
}
|
||||
}
|
||||
|
||||
return words
|
||||
}
|
||||
|
||||
// getCachedWord returns a reusable textured StringItem for a word.
|
||||
func (p *ScrollerPage) getCachedWord(word string) (StringItem, error) {
|
||||
if cached, ok := p.wordTextureCache[word]; ok {
|
||||
return cached, nil
|
||||
}
|
||||
|
||||
base, err := NewStringItem(word, p.font, sdl.Color{R: 255, G: 255, B: 255, A: 255})
|
||||
if err != nil {
|
||||
return StringItem{}, fmt.Errorf("unable to create cached string item for word '%s': %w", word, err)
|
||||
}
|
||||
base.Scale(p.fontScale)
|
||||
|
||||
p.wordTextureCache[word] = base
|
||||
return base, nil
|
||||
}
|
||||
|
||||
// buildLineFromWords builds a line using cached texture IDs and per-instance color/position.
|
||||
func (p *ScrollerPage) buildLineFromWords(words []string) (Line, error) {
|
||||
line := Line{words: []StringItem{}}
|
||||
|
||||
// Get the current proclamation's color palette
|
||||
var lineColor sdl.Color
|
||||
if p.useColorPalettes && len(p.streams) > 0 {
|
||||
currentStream := &p.streams[p.streamIndex]
|
||||
if len(currentStream.palette) > 0 {
|
||||
// Use the current color from the palette
|
||||
lineColor = currentStream.palette[currentStream.colorIndex]
|
||||
// Increment color index for the next line
|
||||
currentStream.colorIndex = (currentStream.colorIndex + 1) % len(currentStream.palette)
|
||||
} else {
|
||||
// Fallback to default color if no palette is found
|
||||
lineColor = p.colorPattern.Color()
|
||||
}
|
||||
} else {
|
||||
// Use the default color pattern if useColorPalettes is false
|
||||
lineColor = p.colorPattern.Color()
|
||||
}
|
||||
|
||||
for _, word := range words {
|
||||
if word == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
cached, err := p.getCachedWord(word)
|
||||
if err != nil {
|
||||
return Line{}, err
|
||||
}
|
||||
|
||||
item := cached
|
||||
item.Color = lineColor // Assign the line color to all words in the line
|
||||
|
||||
if len(line.words) > 0 {
|
||||
line.width += p.spaceWidth
|
||||
}
|
||||
line.words = append(line.words, item)
|
||||
line.width += item.W
|
||||
}
|
||||
|
||||
line.color = lineColor // Store the line color
|
||||
return line, nil
|
||||
}
|
||||
|
||||
// positionLine sets X/Y for all words in a line.
|
||||
func (p *ScrollerPage) positionLine(line *Line, baseY int32) {
|
||||
startX := p.ScreenCenterX - (line.width / 2)
|
||||
for j := range line.words {
|
||||
if j == 0 {
|
||||
line.words[j].X = startX
|
||||
} else {
|
||||
line.words[j].X = line.words[j-1].X + line.words[j-1].W + p.spaceWidth
|
||||
}
|
||||
line.words[j].Y = baseY
|
||||
}
|
||||
}
|
||||
|
||||
// createLine is a helper to generate a new line at a specific index.
|
||||
// isInitial determines if this is during initialization (positions from top)
|
||||
// or dynamic creation (positions relative to existing lines).
|
||||
func (p *ScrollerPage) createLine(index int, isInitial bool) error {
|
||||
words := p.nextLineWords()
|
||||
line, err := p.buildLineFromWords(words)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var baseY int32
|
||||
if isInitial {
|
||||
baseY = int32(p.scrollY) - int32(p.lineHeight)*int32(index)
|
||||
} else {
|
||||
baseY = 0
|
||||
}
|
||||
p.positionLine(&line, baseY)
|
||||
|
||||
if isInitial {
|
||||
p.lines.PushBack(line)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *ScrollerPage) HandleEvent(event *sdl.Event) error {
|
||||
// switch e := (*event).(type) {
|
||||
// case *sdl.KeyboardEvent:
|
||||
// if e.Type != sdl.KEYDOWN {
|
||||
// return nil
|
||||
// }
|
||||
// switch e.Keysym.Sym {
|
||||
// case sdl.K_UP:
|
||||
// if !p.autoVelocity {
|
||||
// p.velocity += p.increment
|
||||
// }
|
||||
// case sdl.K_DOWN:
|
||||
// if !p.autoVelocity {
|
||||
// p.velocity -= p.increment
|
||||
// }
|
||||
// }
|
||||
// return nil
|
||||
// }
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *ScrollerPage) Update(dt float32) error {
|
||||
p.updateAutoVelocity(dt)
|
||||
|
||||
p.scrollOffset += dt * p.velocity
|
||||
move := int32(p.scrollOffset)
|
||||
if move == 0 {
|
||||
return nil
|
||||
}
|
||||
p.scrollOffset -= float32(move)
|
||||
|
||||
// Move all lines vertically
|
||||
for i := 0; i < p.lines.Len(); i++ {
|
||||
line := p.lines.Get(i)
|
||||
for j := range line.words {
|
||||
line.words[j].Y += move
|
||||
}
|
||||
}
|
||||
|
||||
// Handle line replacement based on scroll direction
|
||||
if p.velocity > 0 {
|
||||
// Moving up: replace lines that go off top
|
||||
for p.lines.Len() > 0 && len(p.lines.Front().words) > 0 && p.lines.Front().words[0].Y > p.ScreenHeight {
|
||||
_ = p.lines.PopFront() // no texture delete; textures are shared via cache
|
||||
|
||||
if err := p.addLineAtBottom(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
} else if p.velocity < 0 {
|
||||
// Moving down: replace lines that go off bottom
|
||||
for p.lines.Len() > 0 {
|
||||
back := p.lines.Back()
|
||||
if len(back.words) == 0 || back.words[0].Y+back.words[0].H >= 0 {
|
||||
break
|
||||
}
|
||||
|
||||
_ = p.lines.PopBack() // no texture delete; textures are shared via cache
|
||||
|
||||
if err := p.addLineAtTop(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// addLineAtBottom creates and adds a new line at the bottom of the display.
|
||||
func (p *ScrollerPage) addLineAtBottom() error {
|
||||
newWords := p.nextLineWords()
|
||||
line, err := p.buildLineFromWords(newWords)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var baseY int32
|
||||
if p.lines.Len() > 0 && len(p.lines.Back().words) > 0 {
|
||||
baseY = p.lines.Back().words[0].Y - int32(p.lineHeight)
|
||||
} else {
|
||||
baseY = -int32(p.lineHeight)
|
||||
}
|
||||
|
||||
p.positionLine(&line, baseY)
|
||||
p.lines.PushBack(line)
|
||||
return nil
|
||||
}
|
||||
|
||||
// addLineAtTop creates and adds a new line at the top of the display.
|
||||
func (p *ScrollerPage) addLineAtTop() error {
|
||||
newWords := p.nextLineWords()
|
||||
line, err := p.buildLineFromWords(newWords)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var baseY int32
|
||||
if p.lines.Len() > 0 && len(p.lines.Front().words) > 0 {
|
||||
baseY = p.lines.Front().words[0].Y + int32(p.lineHeight)
|
||||
} else {
|
||||
baseY = p.ScreenHeight
|
||||
}
|
||||
|
||||
p.positionLine(&line, baseY)
|
||||
p.lines.PushFront(line)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *ScrollerPage) Render() error {
|
||||
p.BeginTextureRendering(p.shader)
|
||||
for i := 0; i < p.lines.Len(); i++ {
|
||||
line := p.lines.Get(i)
|
||||
for _, word := range line.words {
|
||||
if word.ID == 0 {
|
||||
continue
|
||||
}
|
||||
p.RenderTextureBatched(p.shader, word.ID, word.Position(), word.RawDimensions(), word.Color)
|
||||
}
|
||||
}
|
||||
p.EndTextureRendering()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *ScrollerPage) Destroy() error {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
config, err := db.GetConfig(p.Context.DbPtr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
mixer.Stop(3000, config)
|
||||
|
||||
// Delete each cached word texture exactly once.
|
||||
for k, item := range p.wordTextureCache {
|
||||
if item.ID != 0 {
|
||||
id := item.ID
|
||||
gl.DeleteTextures(1, &id)
|
||||
}
|
||||
delete(p.wordTextureCache, k)
|
||||
}
|
||||
|
||||
gl.Flush()
|
||||
|
||||
// Drop references for GC safely
|
||||
p.lines = nil
|
||||
p.streams = nil
|
||||
p.lineCtx = nil
|
||||
p.font = nil
|
||||
p.shader = nil
|
||||
p.colorPattern = nil
|
||||
p.wordTextureCache = nil
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetAutoVelocity configures the automatic velocity system for the ScrollerPage.
|
||||
func (p *ScrollerPage) SetAutoVelocity(enabled bool, floor, ceiling, step, delay, hold float32) {
|
||||
p.autoVelocity = enabled
|
||||
p.velocityFloor = -float32(math.Abs(float64(floor)))
|
||||
p.velocityCeiling = float32(math.Abs(float64(ceiling)))
|
||||
p.velocityStep = float32(math.Abs(float64(step)))
|
||||
p.velocityDelay = delay
|
||||
p.hold = hold
|
||||
|
||||
// Initialize state
|
||||
p.velocityDirection = 1
|
||||
p.velocityState = 0
|
||||
p.velocityTimer = 0.0
|
||||
p.velocity = p.velocityFloor
|
||||
}
|
||||
|
||||
// SetUseColorPalettes enables or disables the use of color palettes for lines.
|
||||
func (p *ScrollerPage) SetUseColorPalettes(enabled bool) {
|
||||
p.useColorPalettes = enabled
|
||||
}
|
||||
|
||||
// updateAutoVelocity handles automatic velocity changes.
|
||||
func (p *ScrollerPage) updateAutoVelocity(dt float32) {
|
||||
if !p.autoVelocity {
|
||||
return
|
||||
}
|
||||
|
||||
switch p.velocityState {
|
||||
case 0: // Changing velocity
|
||||
if p.velocityDirection == 1 {
|
||||
p.velocity += p.velocityStep * dt
|
||||
if p.velocity >= p.velocityCeiling {
|
||||
p.velocity = p.velocityCeiling
|
||||
p.velocityState = 1
|
||||
p.velocityTimer = 0.0
|
||||
}
|
||||
} else {
|
||||
p.velocity -= p.velocityStep * dt
|
||||
if p.velocity <= p.velocityFloor {
|
||||
p.velocity = p.velocityFloor
|
||||
p.velocityState = 2
|
||||
p.velocityTimer = 0.0
|
||||
}
|
||||
}
|
||||
|
||||
case 1: // Waiting at ceiling
|
||||
p.velocityTimer += dt
|
||||
if p.velocityTimer >= p.velocityDelay {
|
||||
p.velocityDirection = -1
|
||||
p.velocityState = 0
|
||||
}
|
||||
|
||||
case 2: // Waiting at floor
|
||||
p.velocityTimer += dt
|
||||
if p.velocityTimer >= p.velocityDelay {
|
||||
p.velocityDirection = 1
|
||||
p.velocityState = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
package page
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/go-gl/gl/v3.3-core/gl"
|
||||
"github.com/veandco/go-sdl2/sdl"
|
||||
"rhemawavetech.com/rhemawave/internal/colors"
|
||||
"rhemawavetech.com/rhemawave/internal/db"
|
||||
"rhemawavetech.com/rhemawave/internal/fontManager"
|
||||
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
||||
)
|
||||
|
||||
type Settings struct {
|
||||
Base
|
||||
font *fontManager.FontEntry
|
||||
textShader *shaderManager.Shader
|
||||
solidShader *shaderManager.Shader
|
||||
title StringItem
|
||||
prompt StringItem
|
||||
options []Option
|
||||
verticalMargin int32
|
||||
bgHighlight PageItem
|
||||
highlightIndex int
|
||||
}
|
||||
|
||||
type Option struct {
|
||||
target pageType
|
||||
label StringItem
|
||||
}
|
||||
|
||||
func (p *Settings) Init(ctx *PageCtx) error {
|
||||
var err error
|
||||
p.verticalMargin = int32(20)
|
||||
|
||||
// Get font
|
||||
fm := fontManager.Get()
|
||||
font, ok := fm.GetFont("SpaceMonoNerdFont-Regular")
|
||||
if !ok {
|
||||
return fmt.Errorf("failed to get font: %w", sdl.GetError())
|
||||
}
|
||||
p.font = font
|
||||
|
||||
// Get shaders
|
||||
sm := shaderManager.Get()
|
||||
p.textShader, ok = sm.Get("text")
|
||||
if !ok {
|
||||
return fmt.Errorf("failed to get text shader: %w", sdl.GetError())
|
||||
}
|
||||
p.solidShader, ok = sm.Get("solid_color")
|
||||
if !ok {
|
||||
return fmt.Errorf("failed to get solid_color shader: %w", sdl.GetError())
|
||||
}
|
||||
|
||||
// Initialize base
|
||||
if err := p.Base.Init(ctx); err != nil {
|
||||
return fmt.Errorf("failed base page initialization: %w", err)
|
||||
}
|
||||
|
||||
// Create title
|
||||
p.title, err = NewStringItem("Settings", font, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating title: %w", err)
|
||||
}
|
||||
|
||||
// Create prompt
|
||||
p.prompt, err = NewStringItem("Use Arrow Keys to Navigate, Enter to Select, or F1 to return to the Main Menu", font, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating prompt: %w", err)
|
||||
}
|
||||
|
||||
// Create options
|
||||
proclamationsLabel, err := NewStringItem("Proclamations", font, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating proclamations label: %w", err)
|
||||
}
|
||||
p.options = append(p.options, Option{
|
||||
target: PROCLAMATIONS,
|
||||
label: proclamationsLabel,
|
||||
})
|
||||
|
||||
audioLabel, err := NewStringItem("Audio", font, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating audio label: %w", err)
|
||||
}
|
||||
p.options = append(p.options, Option{
|
||||
target: AUDIO,
|
||||
label: audioLabel,
|
||||
})
|
||||
|
||||
emailLabel, err := NewStringItem("Email Address", font, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating email label: %w", err)
|
||||
}
|
||||
p.options = append(p.options, Option{
|
||||
target: EMAIL_ADDRESS,
|
||||
label: emailLabel,
|
||||
})
|
||||
|
||||
licenseKeyLabel, err := NewStringItem("License Key", font, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating license key label: %w", err)
|
||||
}
|
||||
p.options = append(p.options, Option{
|
||||
target: LICENSE_KEY,
|
||||
label: licenseKeyLabel,
|
||||
})
|
||||
|
||||
wifiLabel, err := NewStringItem("WiFi Setup", font, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating wifi label: %w", err)
|
||||
}
|
||||
p.options = append(p.options, Option{
|
||||
target: WIFI,
|
||||
label: wifiLabel,
|
||||
})
|
||||
|
||||
logsLabel, err := NewStringItem("Logs", font, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating logs label: %w", err)
|
||||
}
|
||||
p.options = append(p.options, Option{
|
||||
target: LOGS,
|
||||
label: logsLabel,
|
||||
})
|
||||
|
||||
// TODO: Add entry for "Tutorial"
|
||||
// TODO: Add entry for "About"
|
||||
|
||||
// Get base font size from database
|
||||
config, err := db.GetConfig(p.Context.DbPtr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
baseFontSize := config.StandardFontSize
|
||||
|
||||
// Calculate scales
|
||||
titleScale := float32(64) / float32(baseFontSize)
|
||||
scale := float32(32) / float32(baseFontSize)
|
||||
|
||||
// Apply scales
|
||||
p.title.Scale(titleScale)
|
||||
p.prompt.Scale(scale)
|
||||
|
||||
for i := range p.options {
|
||||
p.options[i].label.Scale(scale)
|
||||
}
|
||||
|
||||
// Create background highlight using first option's dimensions
|
||||
p.bgHighlight = PageItem{
|
||||
ID: 0,
|
||||
W: p.options[0].label.W,
|
||||
H: p.options[0].label.H,
|
||||
Color: colors.White,
|
||||
}
|
||||
p.highlightIndex = 0
|
||||
|
||||
// Position title at bottom
|
||||
p.title.X = p.Base.ScreenCenterX - p.title.W/2
|
||||
p.title.Y = p.ScreenHeight - p.title.H - p.verticalMargin*2
|
||||
|
||||
// Position prompt above title
|
||||
p.prompt.X = p.ScreenCenterX - int32(float32(p.prompt.W)/2)
|
||||
p.prompt.Y = p.title.Y - p.verticalMargin*2 - p.prompt.H
|
||||
|
||||
// If this value starts at 0, nothing is further left than that...
|
||||
var furthestLeft int32 = p.ScreenWidth // < more to the left, > more to the right, = same
|
||||
|
||||
// Position options stacked upward from prompt
|
||||
for i, option := range p.options {
|
||||
p.options[i].label.X = p.ScreenCenterX - option.label.W/2 // Calculate horizontal center posision
|
||||
if p.options[i].label.X < furthestLeft { // If the left most pixel is further left than furthestLeft, indicating a longer line:
|
||||
// Then update furthestLeft
|
||||
furthestLeft = p.options[i].label.X
|
||||
}
|
||||
if i == 0 {
|
||||
p.options[i].label.Y = int32(float32(p.prompt.Y)-(float32(p.verticalMargin)*4)) - option.label.H
|
||||
} else {
|
||||
p.options[i].label.Y = p.options[i-1].label.Y - option.label.H - 10
|
||||
}
|
||||
}
|
||||
|
||||
// Now, re-iterate over the options and justify everything to furthestLeft
|
||||
for i := range p.options {
|
||||
p.options[i].label.X = furthestLeft
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Settings) Update(dt float32) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Settings) HandleEvent(event *sdl.Event) error {
|
||||
switch e := (*event).(type) {
|
||||
case *sdl.KeyboardEvent:
|
||||
if e.Type != sdl.KEYDOWN {
|
||||
return nil
|
||||
}
|
||||
switch e.Keysym.Sym {
|
||||
case sdl.K_UP:
|
||||
if p.highlightIndex > 0 {
|
||||
p.highlightIndex--
|
||||
}
|
||||
case sdl.K_DOWN:
|
||||
if p.highlightIndex < len(p.options)-1 {
|
||||
p.highlightIndex++
|
||||
}
|
||||
case sdl.K_RETURN:
|
||||
if p.highlightIndex >= 0 && p.highlightIndex < len(p.options) {
|
||||
selectedOption := p.options[p.highlightIndex]
|
||||
p.Context.NavigateTo(selectedOption.target)
|
||||
}
|
||||
case sdl.K_BACKSPACE:
|
||||
p.Context.NavigateTo(HOME)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Settings) Render() error {
|
||||
// Render title and prompt
|
||||
p.RenderTexture(p.textShader, p.title.ID, p.title.Position(), p.title.RawDimensions(), p.title.Color)
|
||||
p.RenderTexture(p.textShader, p.prompt.ID, p.prompt.Position(), p.prompt.RawDimensions(), p.prompt.Color)
|
||||
|
||||
// Render highlight behind selected option
|
||||
p.RenderSolidColorQuad(
|
||||
p.solidShader,
|
||||
p.options[p.highlightIndex].label.Position(),
|
||||
p.options[p.highlightIndex].label.RawDimensions(),
|
||||
p.bgHighlight.Color,
|
||||
)
|
||||
|
||||
// Render all options with appropriate colors
|
||||
for i, option := range p.options {
|
||||
if p.highlightIndex == i {
|
||||
option.label.Color = colors.Black
|
||||
} else {
|
||||
option.label.Color = colors.White
|
||||
}
|
||||
p.RenderTexture(p.textShader, option.label.ID, option.label.Position(), option.label.RawDimensions(), option.label.Color)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Settings) Destroy() error {
|
||||
gl.DeleteTextures(1, &p.title.ID)
|
||||
gl.DeleteTextures(1, &p.prompt.ID)
|
||||
for i := range p.options {
|
||||
gl.DeleteTextures(1, &p.options[i].label.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,656 +0,0 @@
|
||||
package page
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/go-gl/gl/v3.3-core/gl"
|
||||
"github.com/go-gl/mathgl/mgl32"
|
||||
"github.com/veandco/go-sdl2/sdl"
|
||||
"rhemawavetech.com/rhemawave/internal/colors"
|
||||
"rhemawavetech.com/rhemawave/internal/db"
|
||||
"rhemawavetech.com/rhemawave/internal/fontManager"
|
||||
"rhemawavetech.com/rhemawave/internal/keybinds"
|
||||
"rhemawavetech.com/rhemawave/internal/logger"
|
||||
"rhemawavetech.com/rhemawave/internal/network"
|
||||
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
||||
)
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type networkUIItem struct {
|
||||
Net network.Network
|
||||
Item StringItem
|
||||
}
|
||||
|
||||
type wifiStatus int
|
||||
|
||||
const (
|
||||
wifiStatusIdle wifiStatus = iota
|
||||
wifiStatusScanning
|
||||
wifiStatusConnecting
|
||||
wifiStatusConnected
|
||||
wifiStatusError
|
||||
)
|
||||
|
||||
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
type WiFiSetupPage struct {
|
||||
Base
|
||||
applicationFont *fontManager.FontEntry
|
||||
textInputSupported bool
|
||||
|
||||
// UI elements
|
||||
BaseFontSize int
|
||||
titleItem StringItem
|
||||
scanningStatusItem StringItem
|
||||
connectionStatusItem StringItem
|
||||
passwordPromptItem StringItem
|
||||
passwordInputItem StringItem
|
||||
passwordErrorItem StringItem
|
||||
|
||||
// Network list
|
||||
networks []networkUIItem
|
||||
selectedIndex int
|
||||
|
||||
// State
|
||||
status wifiStatus
|
||||
statusMessage string
|
||||
connectError string
|
||||
|
||||
// Password entry
|
||||
isEnteringPassword bool
|
||||
passwordInput string
|
||||
selectedNet *network.Network
|
||||
|
||||
// Async
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
networksCh <-chan []network.Network
|
||||
connectResultCh chan error
|
||||
}
|
||||
|
||||
// ─── Init ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *WiFiSetupPage) Init(ctx *PageCtx) error {
|
||||
if err := p.Base.Init(ctx); err != nil {
|
||||
return fmt.Errorf("failed base page initialization: %w", err)
|
||||
}
|
||||
|
||||
fm := fontManager.Get()
|
||||
font, ok := fm.GetFont("SpaceMonoNerdFont-Regular")
|
||||
if !ok {
|
||||
return fmt.Errorf("could not find application font: SpaceMonoNerdFont-Regular")
|
||||
}
|
||||
p.applicationFont = font
|
||||
|
||||
var err error
|
||||
|
||||
p.titleItem, err = NewStringItem("Wi-Fi Setup", p.applicationFont, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create title item: %w", err)
|
||||
}
|
||||
p.scanningStatusItem, err = NewStringItem("Scanning for networks...", p.applicationFont, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create scanning status item: %w", err)
|
||||
}
|
||||
|
||||
config, err := db.GetConfig(p.Context.DbPtr)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
p.BaseFontSize = config.StandardFontSize
|
||||
|
||||
p.connectResultCh = make(chan error, 1)
|
||||
p.ctx, p.cancel = context.WithCancel(context.Background())
|
||||
|
||||
p.startScan()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ─── Scan ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *WiFiSetupPage) startScan() {
|
||||
p.status = wifiStatusScanning
|
||||
p.statusMessage = "Scanning for networks..."
|
||||
p.selectedIndex = 0
|
||||
p.networks = nil
|
||||
|
||||
var err error
|
||||
p.networksCh, err = network.ScanForNetworks(p.ctx)
|
||||
if err != nil {
|
||||
logger.Error("wifi: failed to start scan: %v", err)
|
||||
p.status = wifiStatusError
|
||||
p.statusMessage = "Failed to start network scan. No Wi-Fi adapter found."
|
||||
p.rebuildStatusTexture()
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Input ────────────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *WiFiSetupPage) HandleEvent(event *sdl.Event) error {
|
||||
switch e := (*event).(type) {
|
||||
case *sdl.KeyboardEvent:
|
||||
if e.Type != sdl.KEYDOWN {
|
||||
return nil
|
||||
}
|
||||
if p.isEnteringPassword {
|
||||
p.handlePasswordKey(e.Keysym.Sym, e.Keysym.Mod)
|
||||
} else {
|
||||
p.handleNavKey(e.Keysym.Sym)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *WiFiSetupPage) handlePasswordKey(sym sdl.Keycode, mod uint16) {
|
||||
switch sym {
|
||||
case sdl.K_RETURN:
|
||||
p.submitPassword()
|
||||
case sdl.K_BACKSPACE:
|
||||
if len(p.passwordInput) > 0 {
|
||||
runes := []rune(p.passwordInput)
|
||||
p.passwordInput = string(runes[:len(runes)-1])
|
||||
p.rebuildPasswordTexture()
|
||||
}
|
||||
case sdl.K_ESCAPE:
|
||||
logger.Info("wifi: password entry cancelled")
|
||||
p.exitPasswordMode()
|
||||
p.connectError = ""
|
||||
p.selectedNet = nil
|
||||
default:
|
||||
if ch := keycodeToChar(sym, mod); ch != 0 {
|
||||
p.passwordInput += string(ch)
|
||||
p.rebuildPasswordTexture()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *WiFiSetupPage) handleNavKey(sym sdl.Keycode) {
|
||||
switch sym {
|
||||
case sdl.K_UP:
|
||||
if p.selectedIndex > 0 {
|
||||
p.selectedIndex--
|
||||
}
|
||||
|
||||
case sdl.K_DOWN:
|
||||
if p.selectedIndex < len(p.networks)-1 {
|
||||
p.selectedIndex++
|
||||
}
|
||||
|
||||
case sdl.K_RETURN:
|
||||
if len(p.networks) == 0 || p.selectedIndex >= len(p.networks) {
|
||||
return
|
||||
}
|
||||
selected := p.networks[p.selectedIndex].Net
|
||||
p.selectedNet = &p.networks[p.selectedIndex].Net
|
||||
p.connectError = ""
|
||||
|
||||
if selected.Security != network.SecurityOpen {
|
||||
p.passwordInput = ""
|
||||
p.enterPasswordMode()
|
||||
} else {
|
||||
p.connect("")
|
||||
}
|
||||
|
||||
case sdl.K_r:
|
||||
if p.status != wifiStatusScanning && p.status != wifiStatusConnecting {
|
||||
p.startScan()
|
||||
}
|
||||
|
||||
case sdl.K_ESCAPE:
|
||||
p.selectedNet = nil
|
||||
p.connectError = ""
|
||||
}
|
||||
}
|
||||
|
||||
func (p *WiFiSetupPage) enterPasswordMode() {
|
||||
keybinds.Enabled = false
|
||||
p.isEnteringPassword = true
|
||||
p.textInputSupported = false
|
||||
p.rebuildPasswordPromptTexture()
|
||||
p.rebuildPasswordTexture()
|
||||
sdl.StartTextInput()
|
||||
logger.Info("wifi: password entry started for %q", p.selectedNet.SSID)
|
||||
}
|
||||
|
||||
func (p *WiFiSetupPage) exitPasswordMode() {
|
||||
keybinds.Enabled = true
|
||||
p.isEnteringPassword = false
|
||||
sdl.StopTextInput()
|
||||
logger.Info("wifi: password entry finished")
|
||||
}
|
||||
|
||||
func (p *WiFiSetupPage) submitPassword() {
|
||||
p.exitPasswordMode()
|
||||
p.connect(p.passwordInput)
|
||||
}
|
||||
|
||||
// ─── Connect ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *WiFiSetupPage) connect(password string) {
|
||||
if p.selectedNet == nil {
|
||||
return
|
||||
}
|
||||
|
||||
ssid := p.selectedNet.SSID
|
||||
p.status = wifiStatusConnecting
|
||||
p.statusMessage = fmt.Sprintf("Connecting to \"%s\"...", ssid)
|
||||
logger.Info("wifi: connecting to %q", ssid)
|
||||
|
||||
// ConnectToNetwork blocks (up to 15s), so run it off the main goroutine.
|
||||
go func() {
|
||||
err := network.ConnectToNetwork(ssid, password)
|
||||
p.connectResultCh <- err
|
||||
}()
|
||||
}
|
||||
|
||||
// ─── Update ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *WiFiSetupPage) Update(dt float32) error {
|
||||
select {
|
||||
|
||||
case nets, ok := <-p.networksCh:
|
||||
if ok {
|
||||
p.rebuildNetworkList(nets)
|
||||
if p.status == wifiStatusScanning {
|
||||
p.status = wifiStatusIdle
|
||||
p.statusMessage = ""
|
||||
}
|
||||
}
|
||||
|
||||
case err := <-p.connectResultCh:
|
||||
if err != nil {
|
||||
logger.Error("wifi: connection failed: %v", err)
|
||||
p.status = wifiStatusError
|
||||
p.connectError = fmt.Sprintf("Could not connect: %s", err.Error())
|
||||
p.statusMessage = ""
|
||||
|
||||
// Drop back into password entry so the user can try again.
|
||||
if p.selectedNet != nil && p.selectedNet.Security != network.SecurityOpen {
|
||||
p.passwordInput = ""
|
||||
p.rebuildPasswordErrorTexture()
|
||||
p.enterPasswordMode()
|
||||
}
|
||||
} else {
|
||||
ssid := ""
|
||||
if p.selectedNet != nil {
|
||||
ssid = p.selectedNet.SSID
|
||||
}
|
||||
logger.Info("wifi: connected to %q", ssid)
|
||||
p.status = wifiStatusConnected
|
||||
p.connectError = ""
|
||||
p.statusMessage = fmt.Sprintf("Connected to \"%s\" — press F1 to continue", ssid)
|
||||
// Trigger a fresh scan so the list shows the connected state.
|
||||
p.startScan()
|
||||
}
|
||||
|
||||
default:
|
||||
}
|
||||
|
||||
p.rebuildStatusTexture()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ─── Texture builders ─────────────────────────────────────────────────────────
|
||||
|
||||
func (p *WiFiSetupPage) rebuildNetworkList(nets []network.Network) {
|
||||
for _, item := range p.networks {
|
||||
if item.Item.ID != 0 {
|
||||
gl.DeleteTextures(1, &item.Item.ID)
|
||||
}
|
||||
}
|
||||
|
||||
p.networks = make([]networkUIItem, 0, len(nets))
|
||||
|
||||
for _, n := range nets {
|
||||
label := n.SSID
|
||||
switch {
|
||||
case n.Connected:
|
||||
label += " ✓"
|
||||
case n.Saved:
|
||||
label += " ★"
|
||||
}
|
||||
if n.Security != network.SecurityOpen {
|
||||
var secStr string
|
||||
switch n.Security {
|
||||
case network.SecurityWEP:
|
||||
secStr = "WEP"
|
||||
case network.SecurityWPA:
|
||||
secStr = "WPA"
|
||||
case network.SecurityWPA2:
|
||||
secStr = "WPA2"
|
||||
case network.SecurityWPA3:
|
||||
secStr = "WPA3"
|
||||
default:
|
||||
secStr = "Secured"
|
||||
}
|
||||
label += " [" + secStr + "]"
|
||||
}
|
||||
|
||||
c := colors.White
|
||||
if n.Connected {
|
||||
c = colors.LawnGreen
|
||||
}
|
||||
|
||||
item, err := NewStringItem(label, p.applicationFont, c)
|
||||
if err != nil {
|
||||
logger.Warning("wifi: failed to create item for %q: %v", n.SSID, err)
|
||||
continue
|
||||
}
|
||||
p.networks = append(p.networks, networkUIItem{Net: n, Item: item})
|
||||
}
|
||||
}
|
||||
|
||||
func (p *WiFiSetupPage) rebuildStatusTexture() {
|
||||
if p.connectionStatusItem.ID != 0 {
|
||||
gl.DeleteTextures(1, &p.connectionStatusItem.ID)
|
||||
p.connectionStatusItem.ID = 0
|
||||
}
|
||||
|
||||
var msg string
|
||||
var c sdl.Color
|
||||
|
||||
switch p.status {
|
||||
case wifiStatusScanning:
|
||||
msg = "Scanning for nearby networks, please wait..."
|
||||
c = colors.White
|
||||
case wifiStatusConnecting:
|
||||
msg = p.statusMessage
|
||||
c = colors.Yellow
|
||||
case wifiStatusConnected:
|
||||
msg = p.statusMessage
|
||||
c = colors.LawnGreen
|
||||
case wifiStatusError:
|
||||
msg = p.connectError
|
||||
if msg == "" {
|
||||
msg = "Something went wrong. Press R to scan again."
|
||||
}
|
||||
c = colors.Red
|
||||
default:
|
||||
if len(p.networks) == 0 {
|
||||
msg = "No networks found. Press R to scan again."
|
||||
c = colors.Yellow
|
||||
} else {
|
||||
msg = "Select a network to connect — press R to rescan"
|
||||
c = colors.White
|
||||
}
|
||||
}
|
||||
|
||||
if msg == "" {
|
||||
return
|
||||
}
|
||||
|
||||
item, err := NewStringItem(msg, p.applicationFont, c)
|
||||
if err == nil {
|
||||
p.connectionStatusItem = item
|
||||
}
|
||||
}
|
||||
|
||||
func (p *WiFiSetupPage) rebuildPasswordTexture() {
|
||||
if p.passwordInputItem.ID != 0 {
|
||||
gl.DeleteTextures(1, &p.passwordInputItem.ID)
|
||||
}
|
||||
text := p.passwordInput
|
||||
if text == "" {
|
||||
text = " " // prevent empty texture
|
||||
}
|
||||
item, err := NewStringItem(text, p.applicationFont, colors.White)
|
||||
if err == nil {
|
||||
p.passwordInputItem = item
|
||||
}
|
||||
}
|
||||
|
||||
func (p *WiFiSetupPage) rebuildPasswordPromptTexture() {
|
||||
if p.passwordPromptItem.ID != 0 {
|
||||
gl.DeleteTextures(1, &p.passwordPromptItem.ID)
|
||||
}
|
||||
if p.selectedNet == nil {
|
||||
return
|
||||
}
|
||||
prompt := fmt.Sprintf("Enter password for \"%s\":", p.selectedNet.SSID)
|
||||
item, err := NewStringItem(prompt, p.applicationFont, colors.White)
|
||||
if err == nil {
|
||||
p.passwordPromptItem = item
|
||||
}
|
||||
}
|
||||
|
||||
func (p *WiFiSetupPage) rebuildPasswordErrorTexture() {
|
||||
if p.passwordErrorItem.ID != 0 {
|
||||
gl.DeleteTextures(1, &p.passwordErrorItem.ID)
|
||||
p.passwordErrorItem.ID = 0
|
||||
}
|
||||
if p.connectError == "" {
|
||||
return
|
||||
}
|
||||
item, err := NewStringItem(p.connectError, p.applicationFont, colors.Red)
|
||||
if err == nil {
|
||||
p.passwordErrorItem = item
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Render ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *WiFiSetupPage) Render() error {
|
||||
solidShader, _ := shaderManager.Get().Get("solid_color")
|
||||
textShader, _ := shaderManager.Get().Get("text")
|
||||
|
||||
bgColor := sdl.Color{R: 0, G: 0, B: 0, A: 255}
|
||||
selColor := sdl.Color{R: 68, G: 71, B: 90, A: 255}
|
||||
white := colors.White
|
||||
vertMargin := float32(20)
|
||||
|
||||
// Background
|
||||
p.Base.RenderSolidColorQuad(solidShader,
|
||||
mgl32.Vec2{0, 0},
|
||||
mgl32.Vec2{float32(p.ScreenWidth), float32(p.ScreenHeight)},
|
||||
bgColor,
|
||||
)
|
||||
|
||||
// Title — 48pt
|
||||
titleScale := float32(48) / float32(p.BaseFontSize)
|
||||
titleW := float32(p.titleItem.W) * titleScale
|
||||
titleH := float32(p.titleItem.H) * titleScale
|
||||
titleX := float32(p.ScreenCenterX) - titleW/2
|
||||
titleY := float32(p.ScreenHeight) - titleH - 40
|
||||
p.Base.RenderTexture(textShader, p.titleItem.ID,
|
||||
mgl32.Vec2{titleX, titleY},
|
||||
mgl32.Vec2{titleW, titleH},
|
||||
white,
|
||||
)
|
||||
|
||||
// Status message — 30pt (was 24pt, now 1.25x)
|
||||
statusY := titleY - titleH - vertMargin
|
||||
if p.status == wifiStatusScanning {
|
||||
scale := float32(30) / float32(p.BaseFontSize)
|
||||
w := float32(p.scanningStatusItem.W) * scale
|
||||
h := float32(p.scanningStatusItem.H) * scale
|
||||
x := float32(p.ScreenCenterX) - w/2
|
||||
p.Base.RenderTexture(textShader, p.scanningStatusItem.ID,
|
||||
mgl32.Vec2{x, statusY},
|
||||
mgl32.Vec2{w, h},
|
||||
white,
|
||||
)
|
||||
} else if p.connectionStatusItem.ID != 0 {
|
||||
scale := float32(30) / float32(p.BaseFontSize)
|
||||
w := float32(p.connectionStatusItem.W) * scale
|
||||
h := float32(p.connectionStatusItem.H) * scale
|
||||
x := float32(p.ScreenCenterX) - w/2
|
||||
p.Base.RenderTexture(textShader, p.connectionStatusItem.ID,
|
||||
mgl32.Vec2{x, statusY},
|
||||
mgl32.Vec2{w, h},
|
||||
p.connectionStatusItem.Color,
|
||||
)
|
||||
}
|
||||
|
||||
// Network list
|
||||
listY := statusY - vertMargin
|
||||
itemH := int32(40)
|
||||
listPad := int32(10)
|
||||
listW := float32(700)
|
||||
listX := float32(p.ScreenCenterX) - listW/2
|
||||
|
||||
for i, item := range p.networks {
|
||||
rowY := listY - float32(int32(i+1)*itemH)
|
||||
|
||||
if i == p.selectedIndex {
|
||||
p.Base.RenderSolidColorQuad(solidShader,
|
||||
mgl32.Vec2{listX, rowY},
|
||||
mgl32.Vec2{listW, float32(itemH)},
|
||||
selColor,
|
||||
)
|
||||
}
|
||||
|
||||
scale := float32(24) / float32(p.BaseFontSize)
|
||||
tw := float32(item.Item.W) * scale
|
||||
th := float32(item.Item.H) * scale
|
||||
tx := listX + float32(listPad)
|
||||
ty := rowY + (float32(itemH)/2 - th/2)
|
||||
p.Base.RenderTexture(textShader, item.Item.ID,
|
||||
mgl32.Vec2{tx, ty},
|
||||
mgl32.Vec2{tw, th},
|
||||
item.Item.Color,
|
||||
)
|
||||
}
|
||||
|
||||
// Password dialog
|
||||
if p.isEnteringPassword {
|
||||
if err := p.renderPasswordDialog(solidShader, textShader, float32(p.BaseFontSize)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *WiFiSetupPage) renderPasswordDialog(solidShader, textShader *shaderManager.Shader, baseFontSize float32) error {
|
||||
overlayColor := sdl.Color{R: 0, G: 0, B: 0, A: 180}
|
||||
dialogBgColor := sdl.Color{R: 40, G: 42, B: 54, A: 255}
|
||||
boxBorderColor := sdl.Color{R: 120, G: 120, B: 120, A: 255}
|
||||
boxBgColor := sdl.Color{R: 30, G: 30, B: 30, A: 255}
|
||||
white := colors.White
|
||||
|
||||
dialogW := int32(600)
|
||||
dialogH := int32(250)
|
||||
if p.connectError != "" {
|
||||
dialogH = 310
|
||||
}
|
||||
dialogX := p.ScreenCenterX - dialogW/2
|
||||
dialogY := p.ScreenCenterY - dialogH/2
|
||||
|
||||
// Overlay + dialog box
|
||||
p.Base.RenderSolidColorQuad(solidShader,
|
||||
mgl32.Vec2{0, 0},
|
||||
mgl32.Vec2{float32(p.ScreenWidth), float32(p.ScreenHeight)},
|
||||
overlayColor,
|
||||
)
|
||||
p.Base.RenderSolidColorQuad(solidShader,
|
||||
mgl32.Vec2{float32(dialogX), float32(dialogY)},
|
||||
mgl32.Vec2{float32(dialogW), float32(dialogH)},
|
||||
dialogBgColor,
|
||||
)
|
||||
|
||||
currentY := float32(dialogY+dialogH) - 30
|
||||
|
||||
// Error message
|
||||
if p.passwordErrorItem.ID != 0 {
|
||||
scale := float32(20) / baseFontSize
|
||||
w := float32(p.passwordErrorItem.W) * scale
|
||||
h := float32(p.passwordErrorItem.H) * scale
|
||||
x := float32(p.ScreenCenterX) - w/2
|
||||
currentY -= h
|
||||
p.Base.RenderTexture(textShader, p.passwordErrorItem.ID,
|
||||
mgl32.Vec2{x, currentY},
|
||||
mgl32.Vec2{w, h},
|
||||
colors.Red,
|
||||
)
|
||||
currentY -= 20
|
||||
}
|
||||
|
||||
// Prompt
|
||||
if p.passwordPromptItem.ID != 0 {
|
||||
scale := float32(24) / baseFontSize
|
||||
w := float32(p.passwordPromptItem.W) * scale
|
||||
h := float32(p.passwordPromptItem.H) * scale
|
||||
x := float32(p.ScreenCenterX) - w/2
|
||||
currentY -= h
|
||||
p.Base.RenderTexture(textShader, p.passwordPromptItem.ID,
|
||||
mgl32.Vec2{x, currentY},
|
||||
mgl32.Vec2{w, h},
|
||||
white,
|
||||
)
|
||||
currentY -= 30
|
||||
}
|
||||
|
||||
// Input box
|
||||
padding := int32(4)
|
||||
border := int32(3)
|
||||
boxW := int32(500)
|
||||
fontSize := int32(36)
|
||||
boxH := fontSize + padding*2
|
||||
bboxW := boxW + border*2
|
||||
bboxH := boxH + border*2
|
||||
bboxX := p.ScreenCenterX - bboxW/2
|
||||
bboxY := int32(currentY) - boxH - padding*2
|
||||
boxX := p.ScreenCenterX - boxW/2
|
||||
boxY := int32(currentY) - boxH - padding
|
||||
|
||||
p.Base.RenderSolidColorQuad(solidShader,
|
||||
mgl32.Vec2{float32(bboxX), float32(bboxY)},
|
||||
mgl32.Vec2{float32(bboxW), float32(bboxH)},
|
||||
boxBorderColor,
|
||||
)
|
||||
p.Base.RenderSolidColorQuad(solidShader,
|
||||
mgl32.Vec2{float32(boxX), float32(boxY)},
|
||||
mgl32.Vec2{float32(boxW), float32(boxH)},
|
||||
boxBgColor,
|
||||
)
|
||||
|
||||
// Password text (plain, unmasked)
|
||||
if p.passwordInputItem.ID != 0 {
|
||||
scale := float32(fontSize) / baseFontSize
|
||||
w := float32(p.passwordInputItem.W) * scale
|
||||
h := float32(p.passwordInputItem.H) * scale
|
||||
p.Base.RenderTexture(textShader, p.passwordInputItem.ID,
|
||||
mgl32.Vec2{float32(boxX + padding), float32(boxY + padding)},
|
||||
mgl32.Vec2{w, h},
|
||||
white,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ─── Destroy ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func (p *WiFiSetupPage) Destroy() error {
|
||||
keybinds.Enabled = true
|
||||
sdl.StopTextInput()
|
||||
|
||||
// Cancel the scan context — stops ScanForNetworks goroutine.
|
||||
if p.cancel != nil {
|
||||
p.cancel()
|
||||
}
|
||||
|
||||
deleteTexture := func(id *uint32) {
|
||||
if *id != 0 {
|
||||
gl.DeleteTextures(1, id)
|
||||
*id = 0
|
||||
}
|
||||
}
|
||||
|
||||
deleteTexture(&p.titleItem.ID)
|
||||
deleteTexture(&p.scanningStatusItem.ID)
|
||||
deleteTexture(&p.connectionStatusItem.ID)
|
||||
deleteTexture(&p.passwordPromptItem.ID)
|
||||
deleteTexture(&p.passwordInputItem.ID)
|
||||
deleteTexture(&p.passwordErrorItem.ID)
|
||||
|
||||
for _, item := range p.networks {
|
||||
deleteTexture(&item.Item.ID)
|
||||
}
|
||||
|
||||
return p.Base.Destroy()
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
package pattern
|
||||
|
||||
import (
|
||||
"github.com/veandco/go-sdl2/sdl"
|
||||
"rhemawavetech.com/rhemawave/internal/colors"
|
||||
)
|
||||
|
||||
type ColorPattern struct {
|
||||
Colors []sdl.Color
|
||||
colorIndex int
|
||||
repeatsLeft int
|
||||
a, b int
|
||||
increasing bool
|
||||
}
|
||||
|
||||
func NewColorPattern(colors []sdl.Color) *ColorPattern {
|
||||
if len(colors) < 2 {
|
||||
panic("ColorPattern requires at least 2 colors")
|
||||
}
|
||||
return &ColorPattern{
|
||||
Colors: colors,
|
||||
a: 0,
|
||||
b: 1,
|
||||
increasing: true,
|
||||
repeatsLeft: 1, // Start with b (1)
|
||||
}
|
||||
}
|
||||
|
||||
// ATTENTION: TED
|
||||
//
|
||||
// # Fibonacci-Oscillating Temporal Color Distribution for Sequential Text Elements
|
||||
//
|
||||
// This method distributes intent-based colors across sequential text elements (words) using an
|
||||
// oscillating Fibonacci sequence to control temporal color duration, creating rhythmic color
|
||||
// patterns that maintain visual balance while varying exposure times.
|
||||
//
|
||||
// The technology addresses the challenge of applying multiple psychologically-selected colors
|
||||
// to scrolling text in a way that maintains even distribution over time while creating natural
|
||||
// rhythm. Conventional approaches either apply colors randomly (creating unbalanced temporal
|
||||
// distribution) or use fixed intervals (producing mechanical, unnatural patterns that lack
|
||||
// harmonic variation). Our method ensures each color in the palette appears throughout the
|
||||
// entire display duration while varying its temporal presence according to natural mathematical
|
||||
// progression.
|
||||
//
|
||||
// The approach uses a bidirectional Fibonacci sequence to determine how many consecutive words
|
||||
// receive each color. The sequence increases (1, 1, 2, 3, 5, 8, 13, 21, 34, 55 words) then
|
||||
// decreases back down (55, 34, 21, 13, 8, 5, 3, 2, 1, 1 words), creating an oscillating pattern.
|
||||
// Colors from the intent-based palette are applied in sequence, with each color's duration
|
||||
// determined by the current Fibonacci value. This creates a "breathing" rhythm where colors
|
||||
// appear briefly, expand to longer durations, then contract back to brief appearances before
|
||||
// cycling to the next color.
|
||||
//
|
||||
// Key differentiators from existing solutions:
|
||||
// - Uses bidirectional Fibonacci sequence (ascending then descending) rather than fixed intervals
|
||||
// - Creates natural rhythmic variation in color exposure duration
|
||||
// - Ensures all palette colors appear throughout entire display timeline, not in isolated sections
|
||||
// - Combines intent-based color selection with mathematical temporal distribution
|
||||
// - Produces harmonic color rhythm synchronized with natural numerical progression
|
||||
//
|
||||
// Within our Rhema Wave generation system, this color distribution works in conjunction with
|
||||
// the spatially-scrambled character patterns. While the scrambling creates spatial frequency
|
||||
// signatures, the Fibonacci-oscillating color pattern creates temporal frequency modulation.
|
||||
// The colors themselves are selected according to color theory principles based on the proclamation's
|
||||
// psychological intent, and the oscillating Fibonacci distribution ensures these therapeutic colors
|
||||
// maintain presence throughout the viewing session while creating harmonic temporal rhythm that
|
||||
// reinforces the overall Rhema Wave effect.
|
||||
func (c *ColorPattern) Color() sdl.Color {
|
||||
// 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, 987, 1597, 2584, 4181, 6765, 10946, 17711, 28657, 46368, 75025, 121393, 196418
|
||||
// This is a failsafe in case this pattern is used but colors are not supplied. Without this check, no text will display due to not having a color
|
||||
// and becoming translucent.
|
||||
if len(c.Colors) == 0 {
|
||||
return colors.White // fallback to white
|
||||
}
|
||||
|
||||
color := c.Colors[c.colorIndex%len(c.Colors)]
|
||||
c.repeatsLeft--
|
||||
|
||||
if c.repeatsLeft <= 0 {
|
||||
// Move to next color in the palette
|
||||
c.colorIndex = (c.colorIndex + 1) % len(c.Colors)
|
||||
|
||||
// Update Fibonacci sequence for repeat count
|
||||
if c.increasing {
|
||||
c.a, c.b = c.b, c.a+c.b
|
||||
if c.b > 55 { // Update this threahold to adjust the number of repeats
|
||||
c.increasing = false
|
||||
}
|
||||
} else {
|
||||
c.a, c.b = c.b-c.a, c.a
|
||||
if c.a <= 0 {
|
||||
c.a, c.b = 0, 1
|
||||
c.increasing = true
|
||||
}
|
||||
}
|
||||
|
||||
c.repeatsLeft = c.b
|
||||
}
|
||||
|
||||
return color
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
package pattern
|
||||
|
||||
type FibonacciPattern struct {
|
||||
masterSequence []int
|
||||
sequence []int
|
||||
increasing bool
|
||||
}
|
||||
|
||||
func init() {
|
||||
Register("fibonacci", func() LinePattern {
|
||||
return &FibonacciPattern{
|
||||
masterSequence: []int{1, 2, 3, 5, 8, 13, 21},
|
||||
sequence: []int{1},
|
||||
increasing: true,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (f *FibonacciPattern) Name() string { return "Fibonacci" }
|
||||
|
||||
// ATTENTION: TED
|
||||
//
|
||||
// # Fibonacci-Oscillating Spatial Distribution for Sequential Text Display
|
||||
//
|
||||
// This method arranges sequential text elements (words from proclamations) into display lines
|
||||
// where the word count per line follows an oscillating Fibonacci sequence, creating both a
|
||||
// visual wave pattern in two-dimensional space and ensuring even temporal distribution of
|
||||
// content segments.
|
||||
//
|
||||
// The technology addresses the challenge of displaying repeated proclamation content in a way
|
||||
// that maintains balanced exposure of all content portions while creating visually dynamic
|
||||
// spatial patterns. Conventional scrolling text approaches either use fixed line lengths
|
||||
// (creating uniform, non-dynamic displays) or random word counts (producing unbalanced content
|
||||
// distribution where some portions appear more frequently than others). Our method ensures
|
||||
// proclamation content is consumed in progressively sized segments that balance over time while
|
||||
// creating a natural wave-like visual rhythm.
|
||||
//
|
||||
// The approach uses a bidirectional Fibonacci sequence to determine words per line. Starting
|
||||
// with minimal word count (1, 1 words), the sequence expands (2, 3, 5, 8, 13, 21... words per
|
||||
// line) until reaching a threshold, then contracts back down (21, 13, 8, 5, 3, 2, 1, 1 words),
|
||||
// creating an oscillating wave. As lines scroll vertically, this varying horizontal word count
|
||||
// creates a lateral wave pattern in the two-dimensional display space. Words are drawn sequentially
|
||||
// from the proclamation text, and the varying segment sizes (1 word, then 2, then 3, then 5, etc.)
|
||||
// ensure that over multiple oscillation cycles, the entire proclamation content receives balanced
|
||||
// temporal exposure.
|
||||
//
|
||||
// Key differentiators from existing solutions:
|
||||
// - Uses oscillating Fibonacci sequence for line word counts rather than fixed or random lengths
|
||||
// - Creates two-dimensional wave pattern from one-dimensional text sequence
|
||||
// - Ensures mathematical content distribution balance over time through progressive segmentation
|
||||
// - Produces natural expansion-contraction rhythm synchronized with Fibonacci progression
|
||||
// - Combines spatial wave formation with temporal content distribution in single mechanism
|
||||
//
|
||||
// Within our Rhema Wave generation system, this spatial distribution pattern works in concert
|
||||
// with the character-level scrambling and color distribution methods. While character scrambling
|
||||
// creates local spatial frequency patterns and color oscillation creates temporal color rhythm,
|
||||
// the Fibonacci line length pattern generates a macro-level spatial wave that modulates the
|
||||
// overall display geometry. The mathematical relationship between line lengths creates harmonic
|
||||
// spatial progression, and the oscillating nature ensures proclamation content cycles through
|
||||
// varying exposure durations, contributing to the layered frequency composition of the complete
|
||||
// Rhema Wave output.
|
||||
func (f *FibonacciPattern) GetNextLine(words []string, wordIndex *int, ctx *LineRenderContext) []string {
|
||||
if ctx == nil || len(words) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
numWords := f.sequence[len(f.sequence)-1]
|
||||
targetWidth := int32(ctx.ScreenWidth)
|
||||
|
||||
var line []string
|
||||
var currentWidth int32
|
||||
wordsAdded := 0
|
||||
|
||||
for wordsAdded < numWords && len(words) > 0 {
|
||||
if int(*wordIndex) > len(words) {
|
||||
*wordIndex = 0
|
||||
}
|
||||
word := words[*wordIndex%len(words)]
|
||||
*wordIndex++
|
||||
|
||||
if word == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
wordWidth := estimateWordWidth(word, ctx.Font, ctx.FontScale)
|
||||
if len(line) > 0 {
|
||||
wordWidth += ctx.SpaceWidth
|
||||
}
|
||||
|
||||
if len(line) == 0 || currentWidth+wordWidth <= int32(float32(targetWidth)*float32(.9)) {
|
||||
line = append(line, word)
|
||||
currentWidth += wordWidth
|
||||
wordsAdded++
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: What should we do here instead?
|
||||
// A line being longer than the display still needs to be handled, though, it should never happen
|
||||
// if currentWidth >= ctx.ScreenWidth {
|
||||
// syscall.Kill(syscall.Getpid(), syscall.SIGTRAP)
|
||||
// }
|
||||
|
||||
// Update the sequence based on direction
|
||||
if f.increasing {
|
||||
if len(f.sequence) < len(f.masterSequence) {
|
||||
// Grow sequence by appending next Fibonacci number
|
||||
f.sequence = append(f.sequence, f.masterSequence[len(f.sequence)])
|
||||
} else {
|
||||
// Reached the top, now start shrinking
|
||||
f.increasing = false
|
||||
f.sequence = f.sequence[:len(f.sequence)-1]
|
||||
}
|
||||
} else {
|
||||
if len(f.sequence) > 1 {
|
||||
// Shrink the sequence
|
||||
f.sequence = f.sequence[:len(f.sequence)-1]
|
||||
} else {
|
||||
// Hit bottom, start growing again
|
||||
f.increasing = true
|
||||
f.sequence = append(f.sequence, f.masterSequence[1]) // index 1 = second element = 2
|
||||
}
|
||||
}
|
||||
|
||||
return line
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
package pattern
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"rhemawavetech.com/rhemawave/internal/fontManager"
|
||||
)
|
||||
|
||||
type LinePattern interface {
|
||||
GetNextLine(words []string, wordIndex *int, ctx *LineRenderContext) []string // Generates the next line in the pattern
|
||||
Name() string // Used to display the name of a LinePattern
|
||||
}
|
||||
|
||||
// PatternInfo holds the public information about a registered pattern.
|
||||
type PatternInfo struct {
|
||||
Key string // The machine-readable key (e.g., "sine")
|
||||
DisplayName string // The human-readable name for the UI (e.g., "Sine Wave")
|
||||
}
|
||||
|
||||
type LineRenderContext struct {
|
||||
ScreenWidth int32
|
||||
Font *fontManager.FontEntry
|
||||
FontScale float32
|
||||
SpaceWidth int32
|
||||
}
|
||||
|
||||
var Registry = make(map[string]func() LinePattern)
|
||||
|
||||
func Register(name string, constructor func() LinePattern) {
|
||||
Registry[name] = constructor
|
||||
}
|
||||
|
||||
func Get(name string) (LinePattern, error) {
|
||||
constructor, ok := Registry[name]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("failed to find line pattern %s in Registry", name)
|
||||
}
|
||||
return constructor(), nil
|
||||
}
|
||||
|
||||
// ListAvailable returns a slice of info for all registered patterns.
|
||||
// This is used to build a settings menu.
|
||||
func ListAvailable() []PatternInfo {
|
||||
var list []PatternInfo
|
||||
for key, constructor := range Registry {
|
||||
pattern := constructor() // Create a temporary instance to get its name
|
||||
list = append(list, PatternInfo{
|
||||
Key: key,
|
||||
DisplayName: pattern.Name(),
|
||||
})
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
// estimateWordWidth provides a basic estimate of the width of a word based on the font and scale.
|
||||
func estimateWordWidth(word string, font *fontManager.FontEntry, scale float32) int32 {
|
||||
if font == nil {
|
||||
// Basic fallback estimate
|
||||
return int32(float32(len(word)) * 10 * scale)
|
||||
}
|
||||
|
||||
total := int32(0)
|
||||
for _, ch := range word {
|
||||
glyph, ok := fontManager.Get().GetGlyph(font.Name, ch)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
total += int32(float32(glyph.Advance) * scale)
|
||||
}
|
||||
return total
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
package pattern
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"rhemawavetech.com/rhemawave/internal/fontManager"
|
||||
)
|
||||
|
||||
type SineWavePattern struct {
|
||||
waveStepCounter int
|
||||
}
|
||||
|
||||
func init() {
|
||||
Register("sine", func() LinePattern { return &SineWavePattern{} })
|
||||
}
|
||||
|
||||
func (s *SineWavePattern) Name() string { return "Sine Wave" }
|
||||
func (s *SineWavePattern) GetNextLine(words []string, wordIndex *int, ctx *LineRenderContext) []string {
|
||||
const (
|
||||
totalLines = 67
|
||||
frequency = 5.0 // cycles across all lines
|
||||
amplitude = 0.85 // how much wave can scale
|
||||
baseRatio = 0.03 // minimum line width as % of screen
|
||||
sharpness = 3.0 // sharpness of the curve
|
||||
)
|
||||
|
||||
screenWidth := int32(800)
|
||||
spaceWidth := int32(10)
|
||||
font := (*fontManager.FontEntry)(nil)
|
||||
fontScale := float32(1.0)
|
||||
|
||||
if ctx != nil {
|
||||
screenWidth = ctx.ScreenWidth
|
||||
spaceWidth = ctx.SpaceWidth
|
||||
font = ctx.Font
|
||||
fontScale = ctx.FontScale
|
||||
}
|
||||
|
||||
// Sharpen the wave (skinnier troughs)
|
||||
// raw := (math.Sin(2*math.Pi*frequency*float64(s.waveStepCounter)/totalLines) + 1.0) / 2.0
|
||||
// waveVal := math.Pow(raw, sharpness)
|
||||
angle := 2 * math.Pi * frequency * float64(s.waveStepCounter) / totalLines
|
||||
waveVal := (math.Sin(angle) + 1.0) / 2.0
|
||||
|
||||
// Target width in pixels
|
||||
targetWidth := int32(float64(screenWidth) * (baseRatio + amplitude*waveVal))
|
||||
|
||||
var line []string
|
||||
var currentWidth int32
|
||||
|
||||
for len(words) > 0 {
|
||||
word := words[*wordIndex%len(words)]
|
||||
*wordIndex++
|
||||
|
||||
if word == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
wordWidth := estimateWordWidth(word, font, fontScale)
|
||||
if len(line) > 0 {
|
||||
wordWidth += spaceWidth
|
||||
}
|
||||
|
||||
if len(line) == 0 {
|
||||
// Always include the first word
|
||||
line = append(line, word)
|
||||
currentWidth += wordWidth
|
||||
} else {
|
||||
// Only add next word if it fits
|
||||
if currentWidth+wordWidth > targetWidth {
|
||||
break
|
||||
}
|
||||
line = append(line, word)
|
||||
currentWidth += wordWidth
|
||||
}
|
||||
}
|
||||
|
||||
s.waveStepCounter++
|
||||
return line
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
package shaderManager
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/go-gl/gl/v3.3-core/gl"
|
||||
)
|
||||
|
||||
// Shader represents a compiled GLSL program and its associated data.
|
||||
type Shader struct {
|
||||
Name string
|
||||
ProgramID uint32
|
||||
Render func() // This will be used in the future.
|
||||
|
||||
// Cached uniform locations (looked up once after linking)
|
||||
LocMVPMatrix int32
|
||||
LocTexture int32
|
||||
LocTextColor int32
|
||||
LocColor int32
|
||||
}
|
||||
|
||||
// ShaderManager handles the loading, compiling, and management of GLSL shader programs.
|
||||
type ShaderManager struct {
|
||||
shaders map[string]*Shader
|
||||
}
|
||||
|
||||
var (
|
||||
instance *ShaderManager
|
||||
once sync.Once
|
||||
)
|
||||
|
||||
// InitShaderManager initializes the ShaderManager singleton.
|
||||
func InitShaderManager(shaderDir string) error {
|
||||
var err error
|
||||
once.Do(func() {
|
||||
sm := &ShaderManager{
|
||||
shaders: make(map[string]*Shader),
|
||||
}
|
||||
if e := sm.LoadShaders(shaderDir); e != nil {
|
||||
err = fmt.Errorf("could not load shaders from %s: %w", shaderDir, e)
|
||||
return
|
||||
}
|
||||
instance = sm
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// Get returns the singleton ShaderManager instance.
|
||||
func Get() *ShaderManager {
|
||||
if instance == nil {
|
||||
panic("ShaderManager has not been initialized. Call InitShaderManager at application startup.")
|
||||
}
|
||||
return instance
|
||||
}
|
||||
|
||||
// Get retrieves a compiled Shader object by its name.
|
||||
func (sm *ShaderManager) Get(name string) (*Shader, bool) {
|
||||
shader, ok := sm.shaders[name]
|
||||
return shader, ok
|
||||
}
|
||||
|
||||
// ListShaders returns all shaders.
|
||||
func (sm *ShaderManager) ListShaders() map[string]*Shader {
|
||||
return sm.shaders
|
||||
}
|
||||
|
||||
// Close deletes all loaded shader programs from the GPU to free up resources.
|
||||
func (sm *ShaderManager) Close() {
|
||||
for name, shader := range sm.shaders {
|
||||
gl.DeleteProgram(shader.ProgramID)
|
||||
delete(sm.shaders, name)
|
||||
}
|
||||
}
|
||||
|
||||
// LoadShaders scans a directory for .vert and .frag files and creates a shader program for each pair.
|
||||
func (sm *ShaderManager) LoadShaders(shaderDir string) error {
|
||||
// Get assets directory from database
|
||||
shaderFiles := make(map[string]string)
|
||||
|
||||
err := filepath.Walk(shaderDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !info.IsDir() && strings.HasSuffix(info.Name(), ".vert") {
|
||||
name := strings.TrimSuffix(info.Name(), ".vert")
|
||||
shaderFiles[name] = path
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("error walking shader directory: %w", err)
|
||||
}
|
||||
|
||||
for name, vertPath := range shaderFiles {
|
||||
fragPath := strings.Replace(vertPath, ".vert", ".frag", 1)
|
||||
if _, err := os.Stat(fragPath); os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
|
||||
programID, err := createShaderProgram(vertPath, fragPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
newShader := &Shader{
|
||||
Name: name,
|
||||
ProgramID: programID,
|
||||
Render: nil, // Render function is not yet assigned.
|
||||
}
|
||||
newShader.cacheUniformLocations()
|
||||
sm.shaders[name] = newShader
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// cacheUniformLocations looks up common uniform locations once and stores them on the Shader.
|
||||
func (s *Shader) cacheUniformLocations() {
|
||||
s.LocMVPMatrix = gl.GetUniformLocation(s.ProgramID, gl.Str("u_mvpMatrix\x00"))
|
||||
s.LocTexture = gl.GetUniformLocation(s.ProgramID, gl.Str("u_texture\x00"))
|
||||
s.LocTextColor = gl.GetUniformLocation(s.ProgramID, gl.Str("u_textColor\x00"))
|
||||
s.LocColor = gl.GetUniformLocation(s.ProgramID, gl.Str("u_color\x00"))
|
||||
}
|
||||
|
||||
// createShaderProgram reads, compiles, and links a vertex and fragment shader from file paths.
|
||||
func createShaderProgram(vertexPath, fragmentPath string) (uint32, error) {
|
||||
vertexSource, err := os.ReadFile(vertexPath)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("reading vertex shader %s: %w", vertexPath, err)
|
||||
}
|
||||
fragmentSource, err := os.ReadFile(fragmentPath)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("reading fragment shader %s: %w", fragmentPath, err)
|
||||
}
|
||||
|
||||
vertexShader, err := compileShader(string(vertexSource)+"\x00", gl.VERTEX_SHADER)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer gl.DeleteShader(vertexShader)
|
||||
|
||||
fragmentShader, err := compileShader(string(fragmentSource)+"\x00", gl.FRAGMENT_SHADER)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer gl.DeleteShader(fragmentShader)
|
||||
|
||||
program := gl.CreateProgram()
|
||||
gl.AttachShader(program, vertexShader)
|
||||
gl.AttachShader(program, fragmentShader)
|
||||
gl.LinkProgram(program)
|
||||
|
||||
var status int32
|
||||
gl.GetProgramiv(program, gl.LINK_STATUS, &status)
|
||||
if status == gl.FALSE {
|
||||
var logLength int32
|
||||
gl.GetProgramiv(program, gl.INFO_LOG_LENGTH, &logLength)
|
||||
log := strings.Repeat("\x00", int(logLength+1))
|
||||
gl.GetProgramInfoLog(program, logLength, nil, gl.Str(log))
|
||||
return 0, fmt.Errorf("failed to link program: %v", log)
|
||||
}
|
||||
|
||||
return program, nil
|
||||
}
|
||||
|
||||
// compileShader is a helper function that compiles a single shader source string.
|
||||
func compileShader(source string, shaderType uint32) (uint32, error) {
|
||||
shader := gl.CreateShader(shaderType)
|
||||
csources, free := gl.Strs(source)
|
||||
gl.ShaderSource(shader, 1, csources, nil)
|
||||
free()
|
||||
gl.CompileShader(shader)
|
||||
|
||||
var status int32
|
||||
gl.GetShaderiv(shader, gl.COMPILE_STATUS, &status)
|
||||
if status == gl.FALSE {
|
||||
var logLength int32
|
||||
gl.GetShaderiv(shader, gl.INFO_LOG_LENGTH, &logLength)
|
||||
log := strings.Repeat("\x00", int(logLength+1))
|
||||
gl.GetShaderInfoLog(shader, logLength, nil, gl.Str(log))
|
||||
|
||||
var shaderTypeName string
|
||||
switch shaderType {
|
||||
case gl.VERTEX_SHADER:
|
||||
shaderTypeName = "Vertex"
|
||||
case gl.FRAGMENT_SHADER:
|
||||
shaderTypeName = "Fragment"
|
||||
default:
|
||||
shaderTypeName = "Unknown"
|
||||
}
|
||||
return 0, fmt.Errorf("failed to compile %s shader: %v", shaderTypeName, log)
|
||||
}
|
||||
|
||||
return shader, nil
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"rhemawavetech.com/rhemawave/internal/db"
|
||||
"rhemawavetech.com/rhemawave/internal/logger"
|
||||
"rhemawavetech.com/rhemawave/internal/netreq"
|
||||
)
|
||||
|
||||
var htmlRegex = regexp.MustCompile(`<[^>]*>`)
|
||||
|
||||
// SyncProclamations fetches data from the API, cleans it, and updates the local DB.
|
||||
// It returns the slice of proclamations so the UI can update immediately.
|
||||
func SyncProclamations(database *gorm.DB) ([]db.Proclamation, error) {
|
||||
fullURL := "https://portal.radiantwavetech.com/api/v1/proclamations"
|
||||
logger.Debug("Syncronizing proclamations. URL: %s", fullURL)
|
||||
|
||||
proclamations, err := netreq.GetJSON[db.Proclamation](fullURL)
|
||||
if err != nil {
|
||||
// If we're offline or API is down, we log it but don't crash.
|
||||
// The UI can still use whatever is currently in the DB.
|
||||
logger.Error("Proclamation sync failed, skipping update: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
logger.Debug("Request Granted")
|
||||
|
||||
for i := range proclamations {
|
||||
proclamations[i].Content = htmlRegex.ReplaceAllString(proclamations[i].Content, "")
|
||||
}
|
||||
|
||||
var storedProclamationSettings []db.Proclamation
|
||||
database.Select("ID", "Selected").Find(&storedProclamationSettings)
|
||||
|
||||
// [ID]Selected
|
||||
stash := make(map[string]bool)
|
||||
for _, row := range storedProclamationSettings {
|
||||
stash[row.ID] = row.Selected
|
||||
}
|
||||
|
||||
for i := range proclamations {
|
||||
proclamations[i].Selected = stash[proclamations[i].ID]
|
||||
}
|
||||
|
||||
err = database.Transaction(func(tx *gorm.DB) error {
|
||||
ids := make([]string, len(proclamations))
|
||||
for i, p := range proclamations {
|
||||
ids[i] = p.ID
|
||||
}
|
||||
|
||||
if len(ids) == 0 {
|
||||
// API returned nothing → DB should be empty
|
||||
if err := tx.Where("1 = 1").Delete(&db.Proclamation{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// Remove anything not in API
|
||||
if err := tx.Where("id NOT IN ?", ids).Delete(&db.Proclamation{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Upsert (preserve Selected automatically)
|
||||
if err := tx.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{
|
||||
"name",
|
||||
"content",
|
||||
"solfeggio_frequency_id",
|
||||
"category_id",
|
||||
"available",
|
||||
"price",
|
||||
}),
|
||||
}).Create(&proclamations).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Return nil as err for this statement, does not exit the function
|
||||
return nil
|
||||
})
|
||||
|
||||
logger.Debug("Proclamations stored to database")
|
||||
|
||||
if err != nil {
|
||||
logger.Error("Failed to save synced proclamations: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logger.Info("Successfully synced proclamations: %d", len(proclamations))
|
||||
return proclamations, nil
|
||||
}
|
||||
|
||||
func SyncCategories(database *gorm.DB) ([]db.ProclamationCategory, error) {
|
||||
fullURL := "https://portal.radiantwavetech.com/api/v1/categories"
|
||||
categories, err := netreq.GetJSON[db.ProclamationCategory](fullURL)
|
||||
if err != nil {
|
||||
logger.Error("Category sync failed, skipping update: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = database.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("1 = 1").Delete(&db.ProclamationCategory{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Save(&categories).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
logger.Error("failed to sync proclamation categories: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logger.Info("Successfully synced proclamation categories: %d", len(categories))
|
||||
return categories, nil
|
||||
}
|
||||
|
||||
func SyncFrequencies(database *gorm.DB) ([]db.SolfeggioFrequency, error) {
|
||||
fullURL := "https://portal.radiantwavetech.com/api/v1/frequencies"
|
||||
frequencies, err := netreq.GetJSON[db.SolfeggioFrequency](fullURL)
|
||||
if err != nil {
|
||||
logger.Error("Frequency sync failed, skipping update: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
err = database.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("1 = 1").Delete(&db.SolfeggioFrequency{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Save(&frequencies).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error("failed to sync solfeggio frequencies: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
logger.Info("Succesfully synced solfeggio frequencies: %d", len(frequencies))
|
||||
return frequencies, nil
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
self:
|
||||
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
let
|
||||
cfg = config.services.rhemawave;
|
||||
in
|
||||
{
|
||||
options.services.rhemawave = {
|
||||
enable = lib.mkEnableOption "RhemaWave kiosk application";
|
||||
|
||||
package = lib.mkOption {
|
||||
type = lib.types.package;
|
||||
# Points to the default package in your flake
|
||||
default = self.packages.${pkgs.system}.default;
|
||||
description = "The RhemaWave package to use.";
|
||||
};
|
||||
|
||||
user = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "kiosk";
|
||||
description = "The user account name.";
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
# Just put the package in the system path.
|
||||
# The machine (configuration.nix) handles the service and the files.
|
||||
environment.systemPackages = [ cfg.package ];
|
||||
};
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
# NixOS test VM for RadiantWave — mirrors production kiosk setup.
|
||||
#
|
||||
# Build & run:
|
||||
# nixos-rebuild build-vm --flake .#test-vm
|
||||
# ./result/bin/run-test-vm-vm
|
||||
#
|
||||
# This replicates the production kiosk config from KioskConfig:
|
||||
# - cage as Wayland compositor (fullscreen kiosk)
|
||||
# - PipeWire audio (with a virtual sink for device-switch testing)
|
||||
# - NetworkManager
|
||||
# - Auto-login as kiosk user
|
||||
# - RadiantWave started via loginShellInit (same as production)
|
||||
|
||||
{ config, lib, pkgs, ... }:
|
||||
|
||||
let
|
||||
rhemaApp = config.services.rhemawave.package;
|
||||
in
|
||||
{
|
||||
# ── Hostname ──────────────────────────────────────────────────────────────
|
||||
networking.hostName = "radiantwave-test";
|
||||
|
||||
# ── RadiantWave module ────────────────────────────────────────────────────
|
||||
services.rhemawave.enable = true;
|
||||
|
||||
# ── Boot ──────────────────────────────────────────────────────────────────
|
||||
boot.loader.systemd-boot.enable = lib.mkForce false;
|
||||
boot.loader.efi.canTouchEfiVariables = lib.mkForce false;
|
||||
|
||||
# ── Packages (matches production) ─────────────────────────────────────────
|
||||
environment.systemPackages = [
|
||||
pkgs.cage pkgs.rsync pkgs.dbus pkgs.mesa pkgs.git pkgs.tailscale pkgs.sqlite
|
||||
];
|
||||
|
||||
# ── Audio (PipeWire + virtual sink for device-switch testing) ─────────────
|
||||
services.pipewire = {
|
||||
enable = true;
|
||||
alsa.enable = true;
|
||||
pulse.enable = true;
|
||||
extraConfig.pipewire."30-virtual-sink" = {
|
||||
"context.modules" = [
|
||||
{
|
||||
name = "libpipewire-module-loopback";
|
||||
args = {
|
||||
"node.name" = "virtual-speaker";
|
||||
"node.description" = "Virtual Speaker";
|
||||
"audio.position" = [ "FL" "FR" ];
|
||||
};
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
|
||||
# ── Seat (matches production) ─────────────────────────────────────────────
|
||||
services.seatd.enable = true;
|
||||
hardware.graphics.enable = true;
|
||||
|
||||
# ── Networking ────────────────────────────────────────────────────────────
|
||||
services.resolved.enable = false;
|
||||
networking.networkmanager.enable = true;
|
||||
systemd.services.NetworkManager-wait-online.enable = false;
|
||||
systemd.network.wait-online.enable = false;
|
||||
|
||||
# ── SSH ───────────────────────────────────────────────────────────────────
|
||||
services.openssh = {
|
||||
enable = true;
|
||||
settings = {
|
||||
PermitRootLogin = "prohibit-password";
|
||||
PasswordAuthentication = true;
|
||||
};
|
||||
};
|
||||
|
||||
# ── User (matches production) ─────────────────────────────────────────────
|
||||
users.users.kiosk = {
|
||||
isNormalUser = true;
|
||||
uid = 1000;
|
||||
extraGroups = [ "audio" "video" "input" "networkmanager" "wheel" ];
|
||||
password = "kiosk";
|
||||
};
|
||||
services.getty.autologinUser = "kiosk";
|
||||
|
||||
# ── Data directory ────────────────────────────────────────────────────────
|
||||
systemd.tmpfiles.rules = [
|
||||
"d /home/kiosk/.local/share/rhemawave 0770 kiosk kiosk -"
|
||||
"d /home/kiosk/.radiantwave/audio 0755 kiosk kiosk -"
|
||||
];
|
||||
|
||||
# ── RadiantWave service (via loginShellInit, same as production) ──────────
|
||||
environment.loginShellInit = ''
|
||||
if [ "$(tty)" = "/dev/tty1" ]; then
|
||||
export GODEBUG=netdns=cgo
|
||||
export SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
|
||||
|
||||
# Smart Network Wait
|
||||
for i in {1..10}; do
|
||||
if ${pkgs.iputils}/bin/ping -c 1 8.8.8.8 &> /dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Graphics fixes (matches production)
|
||||
export SDL_VIDEODRIVER=wayland
|
||||
export QT_QPA_PLATFORM=wayland
|
||||
export XDG_SESSION_TYPE=wayland
|
||||
export WLR_RENDERER=pixman
|
||||
export WLR_NO_HARDWARE_CURSORS=1
|
||||
|
||||
# Asset sync + start app (same as production)
|
||||
mkdir -p /home/kiosk/.local/share/rhemawave
|
||||
${pkgs.rsync}/bin/rsync -ru ${rhemaApp}/share/rhemawave/ /home/kiosk/.local/share/rhemawave/
|
||||
mkdir -p /home/kiosk/.radiantwave/audio
|
||||
${pkgs.rsync}/bin/rsync -ru ${rhemaApp}/share/rhemawave/audio/ /home/kiosk/.radiantwave/audio/
|
||||
|
||||
cage -- ${rhemaApp}/bin/rhemawave -d -s > /tmp/kiosk.log 2>&1
|
||||
fi
|
||||
'';
|
||||
|
||||
# ── VM settings ───────────────────────────────────────────────────────────
|
||||
virtualisation = {
|
||||
memorySize = 4096;
|
||||
cores = 4;
|
||||
graphics = true;
|
||||
forwardPorts = [
|
||||
{ from = "host"; host.port = 2222; guest.port = 22; }
|
||||
];
|
||||
qemu.options = [
|
||||
"-device intel-hda"
|
||||
"-device hda-duplex"
|
||||
"-vga virtio"
|
||||
];
|
||||
};
|
||||
|
||||
services.logrotate.enable = false;
|
||||
|
||||
# ── Nix settings ──────────────────────────────────────────────────────────
|
||||
nix.settings.experimental-features = [ "nix-command" "flakes" ];
|
||||
nix.settings.accept-flake-config = true;
|
||||
|
||||
# ── Firewall (open for local dev) ─────────────────────────────────────────
|
||||
networking.firewall.enable = false;
|
||||
|
||||
system.stateVersion = "24.11";
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
* Networking Issues
|
||||
- The networking package needs a LOT more logging... Nothing gets output when things crash.
|
||||
-
|
||||
@@ -1,55 +0,0 @@
|
||||
// Allow kiosk user to run apt update, apt install for rhemawave packages,
|
||||
// and restart getty for application reloading
|
||||
polkit.addRule(function(action, subject) {
|
||||
if (action.id == "org.freedesktop.policykit.exec" &&
|
||||
subject.user == "kiosk") {
|
||||
|
||||
var program = action.lookup("program");
|
||||
var cmdline = action.lookup("command_line");
|
||||
|
||||
// Allow dpkg --configure -a
|
||||
if (program == "/usr/bin/dpkg") {
|
||||
if (cmdline && cmdline.indexOf("--configure") >= 0) {
|
||||
return polkit.Result.YES;
|
||||
}
|
||||
}
|
||||
|
||||
// Allow apt update
|
||||
if (program == "/usr/bin/apt") {
|
||||
if (cmdline && cmdline.indexOf("update") >= 0) {
|
||||
return polkit.Result.YES;
|
||||
}
|
||||
|
||||
// Allow: apt install --only-upgrade rhemawave[-dev]
|
||||
if (cmdline &&
|
||||
cmdline.indexOf("install") >= 0 &&
|
||||
cmdline.indexOf("--only-upgrade") >= 0 &&
|
||||
cmdline.indexOf("rhemawave") >= 0) {
|
||||
return polkit.Result.YES;
|
||||
}
|
||||
}
|
||||
|
||||
// Allow systemctl restart getty@tty1.service
|
||||
if (program == "/usr/bin/systemctl") {
|
||||
if (cmdline &&
|
||||
cmdline.indexOf("restart") >= 0 &&
|
||||
cmdline.indexOf("getty@tty1.service") >= 0) {
|
||||
return polkit.Result.YES;
|
||||
}
|
||||
}
|
||||
|
||||
// Allow sh for Tailscale install script
|
||||
if (program == "/usr/bin/sh" || program == "/bin/sh") {
|
||||
if (cmdline && cmdline.indexOf("tailscale.com/install.sh") >= 0) {
|
||||
return polkit.Result.YES;
|
||||
}
|
||||
}
|
||||
|
||||
// Allow tailscale set --operator
|
||||
if (program == "/usr/bin/tailscale") {
|
||||
if (cmdline && cmdline.indexOf("set") >= 0 && cmdline.indexOf("--operator") >= 0) {
|
||||
return polkit.Result.YES;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1,3 +0,0 @@
|
||||
[Service]
|
||||
ExecStart=
|
||||
ExecStart=-/sbin/agetty --autologin kiosk --noclear %I $TERM
|
||||
@@ -1,24 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Post-installation script for RhemaWave
|
||||
set -e
|
||||
|
||||
# Create kiosk group if it doesn't exist
|
||||
if ! getent group kiosk > /dev/null 2>&1; then
|
||||
echo "Creating kiosk group..."
|
||||
groupadd --system kiosk
|
||||
fi
|
||||
|
||||
# Create kiosk user if it doesn't exist
|
||||
if ! getent passwd kiosk > /dev/null 2>&1; then
|
||||
echo "Creating kiosk user..."
|
||||
useradd --system --gid kiosk --create-home --home-dir /home/kiosk \
|
||||
--shell /bin/bash --comment "RhemaWave Kiosk User" kiosk
|
||||
fi
|
||||
|
||||
# Set proper group ownership for writable directories
|
||||
chgrp -R kiosk /usr/local/share/rhemawave
|
||||
|
||||
# Ensure kiosk user can write to the data directory
|
||||
chmod g+w /usr/local/share/rhemawave
|
||||
|
||||
echo "✓ RhemaWave post-install complete"
|
||||
@@ -1,16 +0,0 @@
|
||||
# RhemaWave kiosk auto-launch on tty1
|
||||
if [ -z "$WAYLAND_DISPLAY" ] && [ "$(tty)" = "/dev/tty1" ]; then
|
||||
export XDG_RUNTIME_DIR=/run/user/$(id -u)
|
||||
export SDL_VIDEODRIVER=wayland
|
||||
|
||||
# Check for updates before launching
|
||||
/usr/bin/python3 /usr/local/bin/rhemawave-updater.py || true
|
||||
|
||||
# Set all sinks to full volume
|
||||
for sink in $(wpctl status | grep -oP '^\s+\K\d+(?=\.)' | head -10); do
|
||||
wpctl set-volume "$sink" 1.0 2>/dev/null
|
||||
done
|
||||
|
||||
exec cage -- /usr/local/bin/rhemawave
|
||||
fi
|
||||
|
||||
@@ -1,564 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
rhemawave-updater.py
|
||||
|
||||
Auto-updater for RhemaWave kiosk application.
|
||||
Uses apt to check for and install updates from the configured repository.
|
||||
|
||||
USAGE:
|
||||
Run as kiosk user:
|
||||
python3 /usr/local/bin/rhemawave-updater.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
import subprocess
|
||||
import json
|
||||
import socket
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
# Configuration - templated by build script
|
||||
PACKAGE_NAME = "__CHANNEL__"
|
||||
HEADSCALE_URL = "https://headscale.rhemawavetech.com"
|
||||
HEADSCALE_AUTHKEY = "803130d5b2e38bffa9fa5b6921a3d6403100e2e30fea1bd5"
|
||||
|
||||
# Paths
|
||||
LOG_FILE = Path("/home/kiosk/rhemawave-updater.log")
|
||||
LICENSE_KEY_PATH = Path.home() / ".local/share/rhemawave/license_key"
|
||||
|
||||
|
||||
class UpdaterLogger:
|
||||
"""Simple logger that writes to both stdout and file"""
|
||||
|
||||
def __init__(self, log_file):
|
||||
self.log_file = log_file
|
||||
log_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
log_file.touch(exist_ok=True)
|
||||
|
||||
def log(self, message):
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
formatted = f"[{timestamp}] {message}"
|
||||
print(formatted)
|
||||
try:
|
||||
with open(self.log_file, 'a') as f:
|
||||
f.write(formatted + '\n')
|
||||
except IOError:
|
||||
pass
|
||||
|
||||
def error(self, message):
|
||||
self.log(f"ERROR: {message}")
|
||||
|
||||
def debug(self, message):
|
||||
self.log(f"DEBUG: {message}")
|
||||
|
||||
def success(self, message):
|
||||
self.log(f"✓ {message}")
|
||||
|
||||
|
||||
logger = UpdaterLogger(LOG_FILE)
|
||||
|
||||
|
||||
def check_network(timeout=2):
|
||||
"""Quick check for internet connectivity."""
|
||||
logger.log("Checking network connectivity...")
|
||||
try:
|
||||
socket.create_connection(("1.1.1.1", 53), timeout=timeout)
|
||||
logger.success("Network check passed (1.1.1.1:53 reachable)")
|
||||
return True
|
||||
except OSError as e:
|
||||
logger.error(f"Network check failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def run_command(cmd, description, use_pkexec=False):
|
||||
"""Run a command and return success status"""
|
||||
if use_pkexec:
|
||||
cmd = ["pkexec"] + cmd
|
||||
|
||||
logger.log(f"{description}...")
|
||||
logger.debug(f"Command: {' '.join(cmd)}")
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300
|
||||
)
|
||||
|
||||
logger.debug(f"Return code: {result.returncode}")
|
||||
|
||||
if result.stdout:
|
||||
for line in result.stdout.splitlines():
|
||||
logger.log(f" {line}")
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error(f"{description} failed with return code {result.returncode}")
|
||||
if result.stderr:
|
||||
for line in result.stderr.splitlines():
|
||||
logger.error(f" {line}")
|
||||
return False
|
||||
|
||||
logger.success(f"{description} completed successfully")
|
||||
return True
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error(f"{description} timed out")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"{description} failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def configure_dpkg():
|
||||
logger.log("Checking for dpkg errors")
|
||||
if not run_command(
|
||||
["dpkg", "--configure", "-a"],
|
||||
'Check for dpkg configuration errors and resolve them before performing updates',
|
||||
use_pkexec=True):
|
||||
logger.error("Unable to reconfigure system, please reach out to support!")
|
||||
return
|
||||
|
||||
|
||||
def is_tailscale_installed():
|
||||
"""Check if Tailscale is installed."""
|
||||
logger.log("Checking if Tailscale is installed...")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["tailscale", "version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True
|
||||
)
|
||||
version = result.stdout.strip()
|
||||
logger.success(f"Tailscale is installed: {version}")
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
logger.log("Tailscale binary not found")
|
||||
return False
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error(f"Tailscale version check failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def install_tailscale():
|
||||
"""Install Tailscale."""
|
||||
logger.log("Installing Tailscale...")
|
||||
try:
|
||||
logger.debug("Running: pkexec sh -c 'curl -fsSL https://tailscale.com/install.sh | sh'")
|
||||
result = subprocess.run(
|
||||
["pkexec", "sh", "-c", "curl -fsSL https://tailscale.com/install.sh | sh"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120
|
||||
)
|
||||
logger.debug(f"Install return code: {result.returncode}")
|
||||
if result.stdout:
|
||||
for line in result.stdout.splitlines():
|
||||
logger.log(f" {line}")
|
||||
if result.stderr:
|
||||
for line in result.stderr.splitlines():
|
||||
logger.log(f" [stderr] {line}")
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error("Tailscale installation failed")
|
||||
return False
|
||||
|
||||
logger.success("Tailscale installed successfully")
|
||||
return True
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error("Tailscale installation timed out")
|
||||
return False
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error(f"Tailscale installation failed: {e}")
|
||||
if e.stderr:
|
||||
logger.error(f"stderr: {e.stderr}")
|
||||
return False
|
||||
|
||||
|
||||
def set_tailscale_operator():
|
||||
"""Set Tailscale operator to kiosk."""
|
||||
logger.log("Setting Tailscale operator to 'kiosk'...")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["pkexec", "tailscale", "set", "--operator=kiosk"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30
|
||||
)
|
||||
logger.debug(f"Return code: {result.returncode}")
|
||||
if result.stdout:
|
||||
logger.debug(f"stdout: {result.stdout}")
|
||||
if result.stderr:
|
||||
logger.debug(f"stderr: {result.stderr}")
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error(f"Failed to set operator: {result.stderr}")
|
||||
return False
|
||||
|
||||
logger.success("Tailscale operator set to 'kiosk'")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to set Tailscale operator: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def get_tailscale_status():
|
||||
"""Get full Tailscale status as dict."""
|
||||
logger.log("Getting Tailscale status...")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["tailscale", "status", "--json"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
logger.debug(f"Return code: {result.returncode}")
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error(f"tailscale status failed: {result.stderr}")
|
||||
return None
|
||||
|
||||
status = json.loads(result.stdout)
|
||||
|
||||
# Log key status info
|
||||
backend_state = status.get("BackendState", "unknown")
|
||||
logger.log(f" BackendState: {backend_state}")
|
||||
|
||||
self_info = status.get("Self", {})
|
||||
if self_info:
|
||||
logger.log(f" HostName: {self_info.get('HostName', 'unknown')}")
|
||||
logger.log(f" DNSName: {self_info.get('DNSName', 'unknown')}")
|
||||
logger.log(f" Online: {self_info.get('Online', 'unknown')}")
|
||||
tailscale_ips = self_info.get("TailscaleIPs", [])
|
||||
logger.log(f" TailscaleIPs: {tailscale_ips}")
|
||||
|
||||
# Check for health issues
|
||||
health = status.get("Health", [])
|
||||
if health:
|
||||
logger.log(" Health issues:")
|
||||
for issue in health:
|
||||
logger.log(f" - {issue}")
|
||||
|
||||
return status
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Failed to parse Tailscale status JSON: {e}")
|
||||
return None
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error("Tailscale status timed out")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get Tailscale status: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def is_tailscale_logged_in(status=None):
|
||||
"""Check if Tailscale is logged in."""
|
||||
if status is None:
|
||||
status = get_tailscale_status()
|
||||
|
||||
if status is None:
|
||||
logger.error("Cannot determine login status - no status available")
|
||||
return False
|
||||
|
||||
backend_state = status.get("BackendState", "")
|
||||
logged_in = backend_state == "Running"
|
||||
|
||||
if logged_in:
|
||||
logger.success("Tailscale is logged in (BackendState=Running)")
|
||||
else:
|
||||
logger.log(f"Tailscale is NOT logged in (BackendState={backend_state})")
|
||||
|
||||
return logged_in
|
||||
|
||||
|
||||
def tailscale_login(hostname=None):
|
||||
"""Log in to Tailscale/Headscale."""
|
||||
logger.log("Logging in to Headscale...")
|
||||
logger.debug(f" Server: {HEADSCALE_URL}")
|
||||
logger.debug(f" AuthKey: {HEADSCALE_AUTHKEY[:8]}...{HEADSCALE_AUTHKEY[-4:]}")
|
||||
if hostname:
|
||||
logger.debug(f" Hostname: {hostname}")
|
||||
|
||||
cmd = [
|
||||
"tailscale", "up",
|
||||
"--login-server", HEADSCALE_URL,
|
||||
"--authkey", HEADSCALE_AUTHKEY,
|
||||
"--force-reauth"
|
||||
]
|
||||
if hostname:
|
||||
cmd.extend(["--hostname", hostname])
|
||||
|
||||
logger.debug(f"Command: {' '.join(cmd)}")
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30
|
||||
)
|
||||
logger.debug(f"Return code: {result.returncode}")
|
||||
if result.stdout:
|
||||
logger.log(f" stdout: {result.stdout}")
|
||||
if result.stderr:
|
||||
logger.log(f" stderr: {result.stderr}")
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.error(f"Tailscale login failed with code {result.returncode}")
|
||||
return False
|
||||
|
||||
logger.success("Tailscale login successful")
|
||||
return True
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.error("Tailscale login timed out")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Tailscale login failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def get_current_tailscale_hostname(status=None):
|
||||
"""Get the hostname we're currently advertising."""
|
||||
if status is None:
|
||||
status = get_tailscale_status()
|
||||
|
||||
if status is None:
|
||||
return None
|
||||
|
||||
hostname = status.get("Self", {}).get("HostName")
|
||||
logger.debug(f"Current Tailscale hostname: {hostname}")
|
||||
return hostname
|
||||
|
||||
|
||||
def get_license_key():
|
||||
"""Read license key from plain text file."""
|
||||
logger.log("Fetching license key...")
|
||||
logger.debug(f" Path: {LICENSE_KEY_PATH}")
|
||||
|
||||
if not LICENSE_KEY_PATH.exists():
|
||||
logger.log(" License key file does not exist")
|
||||
return None
|
||||
|
||||
try:
|
||||
license_key = LICENSE_KEY_PATH.read_text().strip()
|
||||
if not license_key:
|
||||
logger.log(" License key file is empty")
|
||||
return None
|
||||
|
||||
logger.success(f"License key found: {license_key}")
|
||||
return license_key
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to read license key: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def ensure_tailscale_installed():
|
||||
"""Ensure Tailscale is installed, install if needed."""
|
||||
logger.log("=== Ensuring Tailscale is installed ===")
|
||||
|
||||
if is_tailscale_installed():
|
||||
return True
|
||||
|
||||
logger.log("Tailscale not installed, attempting installation...")
|
||||
|
||||
if not install_tailscale():
|
||||
return False
|
||||
|
||||
# Verify installation
|
||||
if not is_tailscale_installed():
|
||||
logger.error("Tailscale installation verification failed")
|
||||
return False
|
||||
|
||||
# Set operator
|
||||
if not set_tailscale_operator():
|
||||
logger.error("Failed to set Tailscale operator, continuing anyway...")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def ensure_tailscale_connected():
|
||||
"""Ensure Tailscale is logged in and connected."""
|
||||
logger.log("=== Ensuring Tailscale is connected ===")
|
||||
|
||||
status = get_tailscale_status()
|
||||
|
||||
if not is_tailscale_logged_in(status):
|
||||
logger.log("Tailscale not logged in, attempting login...")
|
||||
|
||||
# Get license key to use as hostname if available
|
||||
license_key = get_license_key()
|
||||
|
||||
if not tailscale_login(hostname=license_key):
|
||||
logger.error("Failed to log in to Tailscale")
|
||||
return False
|
||||
|
||||
# Re-check status after login
|
||||
status = get_tailscale_status()
|
||||
if not is_tailscale_logged_in(status):
|
||||
logger.error("Still not logged in after login attempt")
|
||||
return False
|
||||
|
||||
logger.success("Tailscale is connected")
|
||||
return True
|
||||
|
||||
|
||||
def sync_tailscale_hostname():
|
||||
"""Update tailscale hostname to match license key if needed."""
|
||||
logger.log("=== Syncing Tailscale hostname ===")
|
||||
|
||||
license_key = get_license_key()
|
||||
|
||||
if not license_key:
|
||||
logger.log("No license key found, skipping hostname sync")
|
||||
return
|
||||
|
||||
status = get_tailscale_status()
|
||||
current = get_current_tailscale_hostname(status)
|
||||
|
||||
logger.log(f" Current hostname: {current}")
|
||||
logger.log(f" Desired hostname: {license_key}")
|
||||
|
||||
if current == license_key:
|
||||
logger.success(f"Hostname already correct: {license_key}")
|
||||
return
|
||||
|
||||
logger.log(f"Hostname mismatch, updating: {current} -> {license_key}")
|
||||
|
||||
if tailscale_login(hostname=license_key):
|
||||
logger.success("Hostname updated successfully")
|
||||
# Verify the change
|
||||
new_status = get_tailscale_status()
|
||||
new_hostname = get_current_tailscale_hostname(new_status)
|
||||
logger.log(f" Verified new hostname: {new_hostname}")
|
||||
else:
|
||||
logger.error("Failed to update hostname")
|
||||
|
||||
|
||||
def sync_ssh_keys():
|
||||
"""Fetch SSH public keys from GitHub and write to authorized_keys."""
|
||||
logger.log("=== Syncing SSH keys from GitHub ===")
|
||||
github_user = "nclack"
|
||||
url = f"https://github.com/{github_user}.keys"
|
||||
|
||||
try:
|
||||
logger.debug(f"Fetching keys from {url}")
|
||||
req = urllib.request.Request(url, headers={"User-Agent": "rhemawave-updater"})
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
keys = resp.read().decode().strip()
|
||||
|
||||
if not keys:
|
||||
logger.error("No SSH keys returned from GitHub")
|
||||
return
|
||||
|
||||
ssh_dir = Path.home() / ".ssh"
|
||||
ssh_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
auth_keys = ssh_dir / "authorized_keys"
|
||||
auth_keys.write_text(keys + "\n")
|
||||
auth_keys.chmod(0o600)
|
||||
|
||||
key_count = len(keys.splitlines())
|
||||
logger.success(f"Wrote {key_count} SSH key(s) to {auth_keys}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to sync SSH keys: {e}")
|
||||
|
||||
|
||||
def check_for_upgrade():
|
||||
"""Check if an upgrade is available for the package"""
|
||||
logger.log(f"Checking for upgrades to {PACKAGE_NAME}...")
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["apt", "list", "--upgradable"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
logger.debug(f"apt list output: {result.stdout}")
|
||||
|
||||
if PACKAGE_NAME in result.stdout:
|
||||
logger.success(f"Upgrade available for {PACKAGE_NAME}")
|
||||
return True
|
||||
else:
|
||||
logger.log(f"{PACKAGE_NAME} is up to date")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Could not check for upgrades: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
logger.log("=========================================")
|
||||
logger.log("RhemaWave Updater")
|
||||
logger.log(f"Package: {PACKAGE_NAME}")
|
||||
logger.log(f"Timestamp: {datetime.now().isoformat()}")
|
||||
logger.log("=========================================")
|
||||
|
||||
# Quick network check
|
||||
if not check_network():
|
||||
logger.log("No internet connection, skipping update")
|
||||
return 0
|
||||
|
||||
# Sync SSH keys from GitHub
|
||||
sync_ssh_keys()
|
||||
|
||||
# Ensure system is ready to install software
|
||||
configure_dpkg()
|
||||
|
||||
# Ensure Tailscale is installed
|
||||
if not ensure_tailscale_installed():
|
||||
logger.error("Tailscale installation failed, continuing with update check...")
|
||||
else:
|
||||
# Ensure Tailscale is connected
|
||||
if not ensure_tailscale_connected():
|
||||
logger.error("Tailscale connection failed, continuing with update check...")
|
||||
else:
|
||||
# Sync hostname with license key
|
||||
sync_tailscale_hostname()
|
||||
|
||||
logger.log("=========================================")
|
||||
logger.log("=== Package Update Check ===")
|
||||
logger.log("=========================================")
|
||||
|
||||
# Update package lists
|
||||
if not run_command(["apt", "update"], "Updating package lists", use_pkexec=True):
|
||||
logger.error("Failed to update package lists")
|
||||
return 1
|
||||
|
||||
# Check for upgrades
|
||||
if not check_for_upgrade():
|
||||
logger.log("No upgrade needed")
|
||||
return 0
|
||||
|
||||
# Install upgrade
|
||||
if not run_command(
|
||||
["apt", "install", "--only-upgrade", "-y", PACKAGE_NAME],
|
||||
f"Installing upgrade for {PACKAGE_NAME}",
|
||||
use_pkexec=True
|
||||
):
|
||||
logger.error("Upgrade installation failed")
|
||||
return 1
|
||||
|
||||
logger.success("Upgrade installed successfully!")
|
||||
|
||||
# Restart getty to reload the application
|
||||
if not run_command(
|
||||
["systemctl", "restart", "getty@tty1.service"],
|
||||
"Restarting application",
|
||||
use_pkexec=True
|
||||
):
|
||||
logger.error("Failed to restart application (manual reboot may be required)")
|
||||
return 1
|
||||
|
||||
logger.success("Application restarted")
|
||||
logger.log("=========================================")
|
||||
logger.log("=== Update Complete ===")
|
||||
logger.log("=========================================")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,12 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"rhemawavetech.com/rhemawave/internal/application"
|
||||
)
|
||||
|
||||
func main() {
|
||||
application.Run()
|
||||
fmt.Printf("Stopping application")
|
||||
}
|
||||
@@ -1,474 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# test-vm.sh — Build, start, and diagnose the RadiantWave NixOS test VM.
|
||||
#
|
||||
# On macOS (ARM): builds and runs the VM on a remote Linux server.
|
||||
# On Linux (x86_64): builds and runs locally.
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/test-vm.sh Build + start + run diagnostics
|
||||
# ./scripts/test-vm.sh --build Build only
|
||||
# ./scripts/test-vm.sh --start Start only (must already be built)
|
||||
# ./scripts/test-vm.sh --stop Stop the running VM
|
||||
# ./scripts/test-vm.sh --diagnose Run diagnostics on a running VM
|
||||
# ./scripts/test-vm.sh --shell Interactive SSH shell into the VM
|
||||
#
|
||||
# Environment variables:
|
||||
# RADIANTWAVE_REMOTE Remote server for macOS builds (default: nclack@192.168.1.203)
|
||||
# RADIANTWAVE_REMOTE_DIR Remote repo path (default: ~/radiantwave)
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── Paths & defaults ──────────────────────────────────────────────────────────
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
REMOTE="${RADIANTWAVE_REMOTE:-nclack@192.168.1.203}"
|
||||
REMOTE_DIR="${RADIANTWAVE_REMOTE_DIR:-~/radiantwave}"
|
||||
SSH_PORT=2222
|
||||
VM_USER="kiosk"
|
||||
VM_PASS="kiosk"
|
||||
|
||||
# ── Colors ────────────────────────────────────────────────────────────────────
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'
|
||||
DIM='\033[2m'; BOLD='\033[1m'; NC='\033[0m'
|
||||
|
||||
log() { echo -e "${BLUE}▸${NC} $*"; }
|
||||
ok() { echo -e "${GREEN}✓${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}⚠${NC} $*"; }
|
||||
err() { echo -e "${RED}✗${NC} $*" >&2; }
|
||||
hr() { echo -e "${DIM}────────────────────────────────────────────────${NC}"; }
|
||||
|
||||
# ── Platform detection ────────────────────────────────────────────────────────
|
||||
detect_platform() {
|
||||
local os arch
|
||||
os="$(uname -s)"
|
||||
arch="$(uname -m)"
|
||||
case "$os" in
|
||||
Darwin) echo "macos" ;;
|
||||
Linux)
|
||||
case "$arch" in
|
||||
x86_64) echo "linux" ;;
|
||||
aarch64) echo "linux-arm" ;;
|
||||
*) echo "linux-unknown" ;;
|
||||
esac
|
||||
;;
|
||||
*) echo "unknown" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
PLATFORM="$(detect_platform)"
|
||||
|
||||
# ── Dependency checks ─────────────────────────────────────────────────────────
|
||||
check_deps() {
|
||||
local missing=()
|
||||
|
||||
if ! command -v nix &>/dev/null; then
|
||||
missing+=("nix")
|
||||
fi
|
||||
|
||||
if [[ "$PLATFORM" == "macos" ]]; then
|
||||
for cmd in ssh rsync; do
|
||||
command -v "$cmd" &>/dev/null || missing+=("$cmd")
|
||||
done
|
||||
fi
|
||||
|
||||
if (( ${#missing[@]} > 0 )); then
|
||||
err "Missing required commands: ${missing[*]}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# sshpass — try direct, then nix-shell wrapper
|
||||
if command -v sshpass &>/dev/null; then
|
||||
SSPASS="sshpass"
|
||||
elif nix-shell -p sshpass --run 'which sshpass' &>/dev/null; then
|
||||
SSPASS="nix-shell -p sshpass --run"
|
||||
else
|
||||
err "sshpass not found. Install via: brew install sshpass (macOS) or nix profile install nixpkgs#sshpass"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ── VM path helpers ───────────────────────────────────────────────────────────
|
||||
# The nix build output is a directory containing bin/run-radiantwave-test-vm.
|
||||
VM_RESULT_FILE="/tmp/radiantwave-test-vm-path"
|
||||
VM_BIN_DIR=""
|
||||
|
||||
resolve_vm_bin() {
|
||||
if [[ -n "$VM_BIN_DIR" ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
local cached=""
|
||||
if [[ "$PLATFORM" == "linux" ]]; then
|
||||
if [[ -f "$VM_RESULT_FILE" ]]; then
|
||||
cached="$(cat "$VM_RESULT_FILE")"
|
||||
if [[ -x "$cached/bin/run-radiantwave-test-vm" ]]; then
|
||||
VM_BIN_DIR="$cached"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
else
|
||||
cached="$(ssh "$REMOTE" "cat '$VM_RESULT_FILE' 2>/dev/null")" || true
|
||||
if [[ -n "$cached" ]]; then
|
||||
# Check if it exists on the remote server
|
||||
if ssh "$REMOTE" "test -x '$cached/bin/run-radiantwave-test-vm'" 2>/dev/null; then
|
||||
VM_BIN_DIR="$cached"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# ── Platform-specific execution ───────────────────────────────────────────────
|
||||
# On Linux: run commands locally.
|
||||
# On macOS: sync repo to remote, then run commands there via SSH.
|
||||
run_cmd() {
|
||||
if [[ "$PLATFORM" == "linux" ]]; then
|
||||
"$@"
|
||||
else
|
||||
# Run on remote server
|
||||
ssh "$REMOTE" "cd '$REMOTE_DIR' && $(printf '%q ' "$@")"
|
||||
fi
|
||||
}
|
||||
|
||||
run_cmd_capture() {
|
||||
if [[ "$PLATFORM" == "linux" ]]; then
|
||||
"$@"
|
||||
else
|
||||
ssh "$REMOTE" "cd '$REMOTE_DIR' && $(printf '%q ' "$@")"
|
||||
fi
|
||||
}
|
||||
|
||||
sync_to_remote() {
|
||||
if [[ "$PLATFORM" == "macos" ]]; then
|
||||
log "Syncing repo to remote server ($REMOTE)..."
|
||||
# Ensure remote dir exists
|
||||
ssh "$REMOTE" "mkdir -p '$REMOTE_DIR'"
|
||||
rsync -avz --delete \
|
||||
--exclude='.git/' \
|
||||
--exclude='result' \
|
||||
--exclude='result-*' \
|
||||
--exclude='bin/' \
|
||||
--exclude='build/' \
|
||||
"$REPO_ROOT/" "$REMOTE:$REMOTE_DIR/"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Commands ──────────────────────────────────────────────────────────────────
|
||||
|
||||
cmd_build() {
|
||||
log "Building test VM ($PLATFORM)..."
|
||||
sync_to_remote
|
||||
|
||||
local out_path
|
||||
if [[ "$PLATFORM" == "linux" ]]; then
|
||||
out_path="$(nix build ".#packages.x86_64-linux.test-vm" --print-out-paths 2>&1)"
|
||||
echo "$out_path" > "$VM_RESULT_FILE"
|
||||
VM_BIN_DIR="$out_path"
|
||||
else
|
||||
# Build on remote, save path there
|
||||
out_path="$(ssh "$REMOTE" "cd '$REMOTE_DIR' && nix build .#packages.x86_64-linux.test-vm --print-out-paths 2>&1")"
|
||||
ssh "$REMOTE" "echo '$out_path' > $VM_RESULT_FILE"
|
||||
VM_BIN_DIR="$out_path"
|
||||
fi
|
||||
|
||||
ok "VM built: $VM_BIN_DIR"
|
||||
}
|
||||
|
||||
cmd_start() {
|
||||
if ! resolve_vm_bin 2>/dev/null; then
|
||||
err "VM not built yet. Run: $0 --build"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if vm_is_running; then
|
||||
warn "VM already running (PID $(cat /tmp/radiantwave-test-vm.pid 2>/dev/null || echo '?'))"
|
||||
return
|
||||
fi
|
||||
|
||||
local vm_script="$VM_BIN_DIR/bin/run-radiantwave-test-vm"
|
||||
log "Starting VM in background..."
|
||||
|
||||
if [[ "$PLATFORM" == "linux" ]]; then
|
||||
# Run locally with serial to a log file
|
||||
nohup "$vm_script" \
|
||||
-nographic \
|
||||
-serial file:/tmp/radiantwave-test-vm-serial.log \
|
||||
> /tmp/radiantwave-test-vm.log 2>&1 &
|
||||
echo $! > /tmp/radiantwave-test-vm.pid
|
||||
ok "VM started (PID $!)"
|
||||
else
|
||||
# Run on remote server — use nohup so the VM survives SSH disconnect
|
||||
ssh "$REMOTE" "cd '$REMOTE_DIR' && \
|
||||
nohup $vm_script -nographic \
|
||||
-serial file:/tmp/radiantwave-test-vm-serial.log \
|
||||
> /tmp/radiantwave-test-vm.log 2>&1 & \
|
||||
echo \$! > /tmp/radiantwave-test-vm.pid && \
|
||||
echo \"VM started on remote (PID \$(cat /tmp/radiantwave-test-vm.pid))\""
|
||||
ok "VM started on remote server"
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_stop() {
|
||||
if [[ "$PLATFORM" == "linux" ]]; then
|
||||
if [[ -f /tmp/radiantwave-test-vm.pid ]]; then
|
||||
local pid
|
||||
pid="$(cat /tmp/radiantwave-test-vm.pid)"
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
log "Stopping VM (PID $pid)..."
|
||||
kill "$pid"
|
||||
rm -f /tmp/radiantwave-test-vm.pid
|
||||
ok "VM stopped"
|
||||
else
|
||||
warn "VM process $pid not running"
|
||||
rm -f /tmp/radiantwave-test-vm.pid
|
||||
fi
|
||||
else
|
||||
warn "No PID file found. VM may not be running."
|
||||
fi
|
||||
else
|
||||
log "Stopping VM on remote server..."
|
||||
ssh "$REMOTE" "
|
||||
if [[ -f /tmp/radiantwave-test-vm.pid ]]; then
|
||||
pid=\$(cat /tmp/radiantwave-test-vm.pid)
|
||||
kill \$pid 2>/dev/null && echo 'VM stopped' || echo 'VM not running'
|
||||
rm -f /tmp/radiantwave-test-vm.pid
|
||||
else
|
||||
echo 'No PID file found'
|
||||
fi
|
||||
"
|
||||
fi
|
||||
}
|
||||
|
||||
vm_is_running() {
|
||||
if [[ "$PLATFORM" == "linux" ]]; then
|
||||
[[ -f /tmp/radiantwave-test-vm.pid ]] && \
|
||||
kill -0 "$(cat /tmp/radiantwave-test-vm.pid)" 2>/dev/null
|
||||
else
|
||||
ssh "$REMOTE" "
|
||||
[[ -f /tmp/radiantwave-test-vm.pid ]] && \
|
||||
kill -0 \$(cat /tmp/radiantwave-test-vm.pid) 2>/dev/null
|
||||
" 2>/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
vm_ssh() {
|
||||
# Run a command in the VM via SSH. Usage: vm_ssh "command"
|
||||
local cmd="$1"
|
||||
local ssh_opts="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
|
||||
|
||||
if [[ "$PLATFORM" == "linux" ]]; then
|
||||
eval "$SSPASS -p '$VM_PASS' ssh $ssh_opts -p '$SSH_PORT' '${VM_USER}@localhost' '$cmd'"
|
||||
else
|
||||
# On macOS: find sshpass on the remote server, then SSH to the VM
|
||||
ssh "$REMOTE" "
|
||||
SSPASS=\$(command -v sshpass 2>/dev/null || nix-shell -p sshpass --run 'which sshpass' 2>/dev/null)
|
||||
if [[ -z \"\$SSPASS\" ]]; then
|
||||
echo 'sshpass not found on remote server' >&2
|
||||
exit 1
|
||||
fi
|
||||
\$SSPASS -p '$VM_PASS' ssh $ssh_opts -p $SSH_PORT ${VM_USER}@localhost '$cmd'
|
||||
"
|
||||
fi
|
||||
}
|
||||
|
||||
vm_wait_for_boot() {
|
||||
local max_wait=90
|
||||
local waited=0
|
||||
|
||||
log "Waiting for VM to boot (up to ${max_wait}s)..."
|
||||
|
||||
while (( waited < max_wait )); do
|
||||
if vm_ssh "echo ok" &>/dev/null; then
|
||||
ok "VM ready after ${waited}s"
|
||||
return 0
|
||||
fi
|
||||
sleep 2
|
||||
waited=$((waited + 2))
|
||||
echo -ne "\r ${DIM}${waited}s / ${max_wait}s${NC}"
|
||||
done
|
||||
|
||||
echo
|
||||
err "VM did not become ready within ${max_wait}s"
|
||||
return 1
|
||||
}
|
||||
|
||||
cmd_diagnose() {
|
||||
hr
|
||||
echo -e "${BOLD} RadiantWave Test VM — Diagnostic Report${NC}"
|
||||
hr
|
||||
|
||||
log "Connecting to VM..."
|
||||
|
||||
# Build and run a diagnostic script on the server (avoids nested quoting issues)
|
||||
local diag_script
|
||||
diag_script="$(mktemp)"
|
||||
cat > "$diag_script" << 'DIAGEOF'
|
||||
#!/usr/bin/env bash
|
||||
set -uo pipefail
|
||||
|
||||
run_in_vm() {
|
||||
sshpass -p kiosk ssh \
|
||||
-o StrictHostKeyChecking=no \
|
||||
-o UserKnownHostsFile=/dev/null \
|
||||
-o ConnectTimeout=5 \
|
||||
-p 2222 kiosk@localhost "$@"
|
||||
}
|
||||
|
||||
echo "── System Services ──"
|
||||
run_in_vm systemctl list-units --type=service --state=running --no-pager | \
|
||||
grep -E 'NetworkManager|sshd|seatd|getty|cage|systemd-logind' || true
|
||||
|
||||
echo ""
|
||||
echo "── User Services (PipeWire) ──"
|
||||
run_in_vm systemctl --user list-units --type=service --state=running --no-pager | \
|
||||
grep -E 'pipewire|wireplumber|pulse' || echo "No user services running"
|
||||
|
||||
echo ""
|
||||
echo "── PipeWire Devices (wpctl status) ──"
|
||||
run_in_vm wpctl status 2>/dev/null || echo "wpctl not available"
|
||||
|
||||
echo ""
|
||||
echo "── ALSA Devices (aplay -l) ──"
|
||||
run_in_vm aplay -l 2>/dev/null || echo "No ALSA devices"
|
||||
|
||||
echo ""
|
||||
echo "── PulseAudio Sinks (pactl) ──"
|
||||
run_in_vm pactl list sinks short 2>/dev/null || echo "pactl not available"
|
||||
|
||||
echo ""
|
||||
echo "── Network Devices ──"
|
||||
run_in_vm nmcli device status 2>/dev/null || echo "nmcli not available"
|
||||
|
||||
echo ""
|
||||
echo "── Key Processes ──"
|
||||
run_in_vm ps aux | \
|
||||
grep -E 'rhemawave|cage|pipewire|wireplumber|NetworkManager' | \
|
||||
grep -v grep || echo "No matching processes"
|
||||
|
||||
echo ""
|
||||
echo "── Login Sessions ──"
|
||||
run_in_vm loginctl list-sessions --no-pager 2>/dev/null || echo "loginctl not available"
|
||||
|
||||
echo ""
|
||||
echo "── App Log (last 30 lines) ──"
|
||||
run_in_vm tail -30 /tmp/kiosk.log 2>/dev/null || echo "No log file yet"
|
||||
DIAGEOF
|
||||
|
||||
if [[ "$PLATFORM" == "linux" ]]; then
|
||||
bash "$diag_script"
|
||||
else
|
||||
# Upload and run on remote server
|
||||
local remote_diag="/tmp/radiantwave-diagnose-$$.sh"
|
||||
scp -q "$diag_script" "$REMOTE:$remote_diag"
|
||||
ssh "$REMOTE" "nix-shell -p sshpass --run 'bash $remote_diag'; rm -f $remote_diag"
|
||||
fi
|
||||
|
||||
rm -f "$diag_script"
|
||||
hr
|
||||
}
|
||||
|
||||
cmd_shell() {
|
||||
log "Connecting interactive shell to VM..."
|
||||
local ssh_opts="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
|
||||
|
||||
if [[ "$PLATFORM" == "linux" ]]; then
|
||||
eval "$SSPASS -p '$VM_PASS' ssh $ssh_opts -p '$SSH_PORT' '${VM_USER}@localhost'"
|
||||
else
|
||||
log "Connecting via remote server ($REMOTE)..."
|
||||
ssh -t "$REMOTE" "
|
||||
SSPASS=\$(command -v sshpass 2>/dev/null || nix-shell -p sshpass --run 'which sshpass' 2>/dev/null)
|
||||
if [[ -z \"\$SSPASS\" ]]; then
|
||||
echo 'sshpass not found on remote server' >&2
|
||||
exit 1
|
||||
fi
|
||||
\$SSPASS -p '$VM_PASS' ssh $ssh_opts -t -p $SSH_PORT ${VM_USER}@localhost
|
||||
"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Main ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage: $(basename "$0") [COMMAND]
|
||||
|
||||
Commands:
|
||||
(none) Build + start + run diagnostics
|
||||
--build Build the test VM only
|
||||
--start Start the VM (must already be built)
|
||||
--stop Stop the running VM
|
||||
--diagnose Run diagnostics on a running VM
|
||||
--shell Interactive SSH shell into the VM
|
||||
|
||||
Platform:
|
||||
macOS (ARM) Builds/runs on remote server: ${REMOTE}
|
||||
Linux (x86_64) Builds/runs locally
|
||||
|
||||
Environment:
|
||||
RADIANTWAVE_REMOTE Remote server (default: nclack@192.168.1.203)
|
||||
RADIANTWAVE_REMOTE_DIR Remote repo path (default: ~/radiantwave)
|
||||
|
||||
Requirements:
|
||||
nix Nix package manager
|
||||
sshpass Non-interactive SSH (for diagnostics)
|
||||
Install: brew install sshpass (macOS) or nix-shell -p sshpass
|
||||
EOF
|
||||
}
|
||||
|
||||
main() {
|
||||
check_deps
|
||||
|
||||
case "${1:-}" in
|
||||
-h|--help)
|
||||
usage
|
||||
;;
|
||||
--build)
|
||||
cmd_build
|
||||
;;
|
||||
--start)
|
||||
cmd_start
|
||||
;;
|
||||
--stop)
|
||||
cmd_stop
|
||||
;;
|
||||
--diagnose)
|
||||
if ! vm_is_running; then
|
||||
err "VM is not running. Start it first: $0 --start"
|
||||
exit 1
|
||||
fi
|
||||
cmd_diagnose
|
||||
;;
|
||||
--shell)
|
||||
if ! vm_is_running; then
|
||||
err "VM is not running. Start it first: $0 --start"
|
||||
exit 1
|
||||
fi
|
||||
cmd_shell
|
||||
;;
|
||||
"")
|
||||
cmd_build
|
||||
cmd_start
|
||||
sleep 5
|
||||
if vm_wait_for_boot; then
|
||||
cmd_diagnose
|
||||
else
|
||||
warn "VM boot timed out. Check logs:"
|
||||
if [[ "$PLATFORM" == "linux" ]]; then
|
||||
echo " tail -f /tmp/radiantwave-test-vm.log"
|
||||
echo " tail -f /tmp/radiantwave-test-vm-serial.log"
|
||||
else
|
||||
echo " ssh $REMOTE 'tail -f /tmp/radiantwave-test-vm.log'"
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
err "Unknown command: $1"
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -1,49 +0,0 @@
|
||||
shall the mind and body now: Project its awareness, attention, belief, and
|
||||
intention into all times dimensions of undivided universal field of
|
||||
consciousness and identity, accept and release attunement to all events,
|
||||
information faults, and information faults hidden that compromises
|
||||
awareness of, or define into existence, the transformed-from state? Or,
|
||||
that cause this transformative information quanta to define into existence
|
||||
the transformed-to system state? Or that cause this transformation to
|
||||
seem dangerous, undeserved, or impossible to achieve? Create awareness,
|
||||
acceptance, attunement, and belief of all corrective events and
|
||||
transformative information quanta to define into existence the
|
||||
transformed-to system state?
|
||||
|
||||
Create the intention to collapse the quantum potential the field’s reality
|
||||
quantum potential of the field’s reality quant to manifest this
|
||||
transformation? Create optimal attunement and coherence to the source of
|
||||
creation, GOD, innate intelligence, and the consciousness of healing and
|
||||
accept the role of co-creator of all experienced reality, NOW?
|
||||
|
||||
I am grateful GOD lives through me and blesses all aspects of my life.
|
||||
|
||||
I am thankful for my health, vitality, and motivation to pursue a deeper
|
||||
understanding of the Trinity so that I become a shining example of
|
||||
someone who’s life is truly blessed and lived according to GOD’s design.
|
||||
|
||||
I appreciate GOD providing strength and direction to every cell in my
|
||||
body and blesses my life in countless ways.
|
||||
I am pleased I have the freedom to worship our creator, follow his
|
||||
teachings, and receive blessings according to GOD’s design.
|
||||
|
||||
I enjoy knowing GOD protects me, blesses me, and allows me to pursue a
|
||||
deep and personal relationship with him.
|
||||
|
||||
I am happy I can pray, speak with GOD directly, seek his advice, follow
|
||||
his teachings, and receive his blessings.
|
||||
|
||||
I am grateful GOD’s healing hands are on me creating miracles in all
|
||||
areas of my life.
|
||||
|
||||
I am thankful I wake up each morning inspired to learn, trust, and follow
|
||||
GOD’s plan for my life.
|
||||
|
||||
I appreciate GOD’s almighty power flowing though me and creating
|
||||
miracles in all areas of my life.
|
||||
|
||||
I am pleased my life is blessed and I live a perpetually blissful life.
|
||||
|
||||
I am grateful to GOD for all the blessings bestowed upon me.
|
||||
|
||||
I am thankful to GOD for his forgiveness, understanding, and love.
|
||||
@@ -1,56 +0,0 @@
|
||||
shall the mind and body now: Project its awareness, attention, belief, and
|
||||
intention into all times dimensions of undivided universal field of
|
||||
consciousness and identity, accept and release attunement to all events,
|
||||
information faults, and information faults hidden that compromises
|
||||
awareness of, or define into existence, the transformed-from state? Or,
|
||||
that cause this transformative information quanta to define into existence
|
||||
the transformed-to system state? Or that cause this transformation to
|
||||
seem dangerous, undeserved, or impossible to achieve? Create awareness,
|
||||
acceptance, attunement, and belief of all corrective events and
|
||||
transformative information quanta to define into existence the
|
||||
transformed-to system state?
|
||||
|
||||
Create the intention to collapse the quantum potential the field’s reality
|
||||
quantum potential of the field’s reality quant to manifest this
|
||||
transformation? Create optimal attunement and coherence to the source of
|
||||
creation, GOD, innate intelligence, and the consciousness of healing and
|
||||
accept the role of co-creator of all experienced reality, NOW?
|
||||
|
||||
I am grateful GOD lives through me and inspires me to see the best in
|
||||
every person and every opportunity.
|
||||
|
||||
I am thankful for my life, health, and vitality and express my
|
||||
appreciation by always focusing on the positive aspects of every
|
||||
situation I encounter.
|
||||
|
||||
I appreciate GOD providing strength and direction to every cell in my
|
||||
body which I use to maintain a positive attitude regardless of my
|
||||
personal situation.
|
||||
|
||||
I am pleased I always think positive, manage my thoughts, and live each
|
||||
day striving energetically to accomplish goals which positively effect
|
||||
my life and the lives of those around me according to GOD’s design.
|
||||
|
||||
I enjoy knowing GOD protects me and allows me to achieve things I once
|
||||
thought impossible.
|
||||
|
||||
I am happy I think positively, I do not dwell on negative issues, and I
|
||||
control my thoughts at all times.
|
||||
|
||||
I am grateful GOD’s healing hands are on me creating miracles in all
|
||||
areas of my life.
|
||||
|
||||
I am thankful I wake up each morning yearning to efficiently accomplish
|
||||
as many tasks as possible to ensure success in all my endeavors.
|
||||
|
||||
I appreciate GOD’s almighty power flowing through me and creating
|
||||
miracles in all areas of my life as a reward my optimism.
|
||||
|
||||
I am pleased my mental health and emotional stability allow me remain
|
||||
positive and live a relaxed and perpetually blissful life.
|
||||
|
||||
I am grateful to GOD for all the blessings bestowed upon me.
|
||||
|
||||
I am thankful that I only seek the good in myself, the good in others,
|
||||
the good in the world around me, and the greatness of the live I am
|
||||
living.
|
||||
@@ -1,56 +0,0 @@
|
||||
shall the mind and body now: Project its awareness, attention, belief, and
|
||||
intention into all times dimensions of undivided universal field of
|
||||
consciousness and identity, accept and release attunement to all events,
|
||||
information faults, and information faults hidden that compromises
|
||||
awareness of, or define into existence, the transformed-from state? Or,
|
||||
that cause this transformative information quanta to define into existence
|
||||
the transformed-to system state? Or that cause this transformation to
|
||||
seem dangerous, undeserved, or impossible to achieve? Create awareness,
|
||||
acceptance, attunement, and belief of all corrective events and
|
||||
transformative information quanta to define into existence the
|
||||
transformed-to system state?
|
||||
|
||||
Create the intention to collapse the quantum potential the field’s reality
|
||||
quantum potential of the field’s reality quant to manifest this
|
||||
transformation? Create optimal attunement and coherence to the source of
|
||||
creation, GOD, innate intelligence, and the consciousness of healing and
|
||||
accept the role of co-creator of all experienced reality, NOW?
|
||||
|
||||
I am grateful GOD lives through me and causes my circulatory system to
|
||||
remain clear and unobstructed for for optimum health and longevity.
|
||||
|
||||
I am thankful for my health, vitality, and motivation to eat properly,
|
||||
exercise, and strengthen my body.
|
||||
|
||||
I appreciate GOD providing strength and direction to every cell in my
|
||||
body ensuring my blood chemistry is, and remains optimal for excellent
|
||||
health and longevity.
|
||||
|
||||
I am pleased my muscles, bones, joints, organs, nerves, tendons, and
|
||||
ligaments all develop correctly and function normally.
|
||||
|
||||
I enjoy knowing GOD protects me throughout and beyond the physical
|
||||
healing and development process.
|
||||
|
||||
I am happy my HDL, LDL, Triglycerides, and Blood Sugar levels are and
|
||||
remain at ideal levels for my optimal health and longevity according to
|
||||
GOD’s design.
|
||||
|
||||
I am grateful GOD’s healing hands are on me creating miracles in all
|
||||
areas of my life.
|
||||
|
||||
I am thankful I wake up each morning yearning to eat healthy foods and
|
||||
exercise in ways that ensure my HDL, LDL, Triglycerides, and Blood Sugar
|
||||
levels are ideal.
|
||||
|
||||
I appreciate GOD’s almighty power flowing though me, creating miracles
|
||||
in all areas of my life, and correcting, curing, and developing all
|
||||
healthy aspects of my mind and my body.
|
||||
|
||||
I am pleased my blood, heart, arteries, and entire circulatory system
|
||||
perpetually improves each day.
|
||||
|
||||
I am grateful to GOD for all the blessings bestowed upon me.
|
||||
|
||||
I am thankful I only crave healthy foods that provide my body with the
|
||||
nutrition necessary for my optimum health and wellbeing.
|
||||
@@ -1,61 +0,0 @@
|
||||
shall the mind and body now: Project its awareness, attention, belief, and
|
||||
intention into all times dimensions of undivided universal field of
|
||||
consciousness and identity, accept and release attunement to all events,
|
||||
information faults, and information faults hidden that compromises
|
||||
awareness of, or define into existence, the transformed-from state? Or,
|
||||
that cause this transformative information quanta to define into existence
|
||||
the transformed-to system state? Or that cause this transformation to
|
||||
seem dangerous, undeserved, or impossible to achieve? Create awareness,
|
||||
acceptance, attunement, and belief of all corrective events and
|
||||
transformative information quanta to define into existence the
|
||||
transformed-to system state?
|
||||
|
||||
Create the intention to collapse the quantum potential the field’s reality
|
||||
quantum potential of the field’s reality quant to manifest this
|
||||
transformation? Create optimal attunement and coherence to the source of
|
||||
creation, GOD, innate intelligence, and the consciousness of healing and
|
||||
accept the role of co-creator of all experienced reality, NOW?
|
||||
|
||||
I am grateful GOD lives through me and inspires my body enter deep sleep
|
||||
and heal miraculously during the night so that I enjoy optimum health.
|
||||
|
||||
I am thankful for my health, vitality, and ability to experience deep
|
||||
healing sleep each and every night.
|
||||
|
||||
I appreciate GOD providing strength and direction to every cell in my
|
||||
body allowing me to sleep deeply and heal all mental, physical, and
|
||||
spiritual aspects of my being ensuring my optimum overall health.
|
||||
|
||||
I am pleased my muscles, bones, joints, organs, nerves, tendons, and
|
||||
ligaments all heal well during my deep sleep and remain in optimum
|
||||
condition according to GOD’s design.
|
||||
|
||||
I enjoy knowing GOD protects me throughout and beyond the physical
|
||||
healing and development process.
|
||||
|
||||
I am happy my body, mind, and spirit grows stronger each and every day
|
||||
as a result of my experiencing deep and restful sleep.
|
||||
|
||||
I am grateful GOD’s healing hands are on me creating miracles in all
|
||||
areas of my life and allowing me to experience deep restful, healing
|
||||
sleep each night.
|
||||
|
||||
I am thankful I am able to easily clear my thoughts when I lay down at
|
||||
night to sleep, enter deep and restful sleep, and enjoy wonderful
|
||||
uplifting dreams as my body, mind, and spirit heal, advance, and grow
|
||||
stronger.
|
||||
|
||||
I appreciate GOD’s almighty power flowing though me, creating miracles
|
||||
in all areas of my life, and correcting, curing, and developing all
|
||||
healthy aspects of my mind and my body during my deep and restful sleep
|
||||
each night.
|
||||
|
||||
I am pleased to wake each morning well rested, energized, and prepared
|
||||
to face the challenges each day brings with confidence, a positive
|
||||
attitude, and perseverance.
|
||||
|
||||
I am grateful to GOD for all the blessings bestowed upon me.
|
||||
|
||||
I am thankful I only crave healthy foods that provide my body with the
|
||||
nutrition necessary to rest well, sleep deep, heal swiftly, and optimize
|
||||
my health and wellbeing.
|
||||
@@ -1,56 +0,0 @@
|
||||
shall the mind and body now: Project its awareness, attention, belief, and
|
||||
intention into all times dimensions of undivided universal field of
|
||||
consciousness and identity, accept and release attunement to all events,
|
||||
information faults, and information faults hidden that compromises
|
||||
awareness of, or define into existence, the transformed-from state? Or,
|
||||
that cause this transformative information quanta to define into existence
|
||||
the transformed-to system state? Or that cause this transformation to
|
||||
seem dangerous, undeserved, or impossible to achieve? Create awareness,
|
||||
acceptance, attunement, and belief of all corrective events and
|
||||
transformative information quanta to define into existence the
|
||||
transformed-to system state?
|
||||
|
||||
Create the intention to collapse the quantum potential the field’s reality
|
||||
quantum potential of the field’s reality quant to manifest this
|
||||
transformation? Create optimal attunement and coherence to the source of
|
||||
creation, GOD, innate intelligence, and the consciousness of healing and
|
||||
accept the role of co-creator of all experienced reality, NOW?
|
||||
|
||||
I am grateful GOD lives through me and inspires me to believe in him,
|
||||
trust in him, and follow biblical teachings.
|
||||
|
||||
I am thankful for my health, vitality, and motivation to include GOD in
|
||||
all aspects of my life.
|
||||
|
||||
I appreciate GOD providing strength and direction to every cell in my
|
||||
body which empowers my relationship with him more and more each and
|
||||
every day.
|
||||
|
||||
I am pleased my personal relationship with GOD continues to grow stronger
|
||||
as I strive to make him a greater influence in my life and live my life
|
||||
according to GOD’s design.
|
||||
|
||||
I enjoy knowing GOD protects me and allows me to develop a deeper and
|
||||
stronger relationship with him and demonstrate my beliefs and
|
||||
relationship in all aspects of my life.
|
||||
|
||||
I am happy my deepening connection with GOD brings peace and harmony to
|
||||
my life.
|
||||
|
||||
I am grateful GOD’s healing hands are on me creating miracles in all
|
||||
areas of my life that I set an outstanding example of living the life of
|
||||
a true believer.
|
||||
|
||||
I am thankful I wake up each morning yearning to share my faith and
|
||||
understanding of our great Creator.
|
||||
|
||||
I appreciate GOD’s almighty power flowing through me and creating
|
||||
miracles in all areas of my life.
|
||||
|
||||
I am pleased my undying faith in GOD allows me to live a relaxed and
|
||||
perpetually blissful lifestyle.
|
||||
|
||||
I am grateful to GOD for all the blessings bestowed upon me.
|
||||
|
||||
I am thankful I know GOD and share my beliefs with others at every
|
||||
opportunity.
|
||||
@@ -1,56 +0,0 @@
|
||||
shall the mind and body now: Project its awareness, attention, belief, and
|
||||
intention into all times dimensions of undivided universal field of
|
||||
consciousness and identity, accept and release attunement to all events,
|
||||
information faults, and information faults hidden that compromises
|
||||
awareness of, or define into existence, the transformed-from state? Or,
|
||||
that cause this transformative information quanta to define into existence
|
||||
the transformed-to system state? Or that cause this transformation to
|
||||
seem dangerous, undeserved, or impossible to achieve? Create awareness,
|
||||
acceptance, attunement, and belief of all corrective events and
|
||||
transformative information quanta to define into existence the
|
||||
transformed-to system state?
|
||||
|
||||
Create the intention to collapse the quantum potential the field’s reality
|
||||
quantum potential of the field’s reality quant to manifest this
|
||||
transformation? Create optimal attunement and coherence to the source of
|
||||
creation, GOD, innate intelligence, and the consciousness of healing and
|
||||
accept the role of co-creator of all experienced reality, NOW?
|
||||
|
||||
I am grateful GOD lives through me and inspires me to set and accomplish
|
||||
clear and concise goals.
|
||||
|
||||
I am thankful for my health, vitality, and motivation to accomplish the
|
||||
goals I set efficiently and effectively.
|
||||
|
||||
I appreciate GOD providing strength and direction to every cell in my
|
||||
body ensuring I plan, focus, and accomplish my goals and live according
|
||||
to GOD’s design.
|
||||
|
||||
I am pleased I can visualize, set, and accomplish goals I once thought
|
||||
were beyond my abilities so long as they remain according to GOD’s
|
||||
design.
|
||||
|
||||
I enjoy knowing GOD protects me and enables me to accomplish tasks I once
|
||||
believed were unattainable.
|
||||
|
||||
I am happy I think positively and strive to legally and ethically
|
||||
maximize my accomplishments and my profits.
|
||||
|
||||
I am grateful GOD’s healing hands are on me and create miracles in all
|
||||
areas of my life.
|
||||
|
||||
I am thankful I wake up each morning yearning to work and accomplish my
|
||||
list of goals and tasks in a timely manner.
|
||||
|
||||
I appreciate GOD’s almighty power flowing though me, creating miracles
|
||||
in all areas of my life, and enabling me to accomplish my goals
|
||||
efficiently and effectively.
|
||||
|
||||
I am pleased my ability to accomplish tasks and goals efficiently allows
|
||||
me to live a relaxed and perpetually blissful life.
|
||||
|
||||
I am grateful to GOD for all the blessings bestowed upon me.
|
||||
|
||||
I am thankful that I only crave healthy foods that provide my mind and
|
||||
body with the nutrition necessary for me to set and achieve all my goals
|
||||
in record time.
|
||||
@@ -1,53 +0,0 @@
|
||||
shall the mind and body now: Project its awareness, attention, belief, and
|
||||
intention into all times dimensions of undivided universal field of
|
||||
consciousness and identity, accept and release attunement to all events,
|
||||
information faults, and information faults hidden that compromises
|
||||
awareness of, or define into existence, the transformed-from state? Or,
|
||||
that cause this transformative information quanta to define into existence
|
||||
the transformed-to system state? Or that cause this transformation to
|
||||
seem dangerous, undeserved, or impossible to achieve? Create awareness,
|
||||
acceptance, attunement, and belief of all corrective events and
|
||||
transformative information quanta to define into existence the
|
||||
transformed-to system state?
|
||||
|
||||
Create the intention to collapse the quantum potential the field’s reality
|
||||
quantum potential of the field’s reality quant to manifest this
|
||||
transformation? Create optimal attunement and coherence to the source of
|
||||
creation, GOD, innate intelligence, and the consciousness of healing and
|
||||
accept the role of co-creator of all experienced reality, NOW?
|
||||
|
||||
I am grateful GOD lives through me and inspires me to achieve optimum
|
||||
health.
|
||||
|
||||
I am thankful for my health, vitality, and motivation to eat properly,
|
||||
exercise, and strengthen my body.
|
||||
|
||||
I appreciate GOD providing strength and direction to every cell in my
|
||||
body ensuring optimum health and fitness.
|
||||
|
||||
I am pleased my muscles, bones, joints, organs, nerves, tendons, and
|
||||
ligaments all develop correctly and function normally according to GOD’s
|
||||
design.
|
||||
|
||||
I enjoy knowing GOD protects me throughout and beyond the physical
|
||||
healing and development process.
|
||||
|
||||
I am happy my muscles and bones grow stronger each and every day.
|
||||
|
||||
I am grateful GOD’s healing hands are on me creating miracles in all
|
||||
areas of my life.
|
||||
|
||||
I am thankful that I wake up each morning yearning to eat health foods
|
||||
and exercise.
|
||||
|
||||
I appreciate GOD’s almighty power flowing though me, creating miracles
|
||||
in all areas of my life, and correcting, curing, and developing all
|
||||
healthy aspects of my mind and my body.
|
||||
|
||||
I am pleased my muscles and stamina develop rapidly and I continue
|
||||
growing perpetually stronger each day.
|
||||
|
||||
I am grateful to GOD for all the blessings bestowed upon me.
|
||||
|
||||
I am thankful I only crave healthy foods that provide my body with the
|
||||
nutrition necessary for my optimum health and wellbeing.
|
||||
@@ -1,56 +0,0 @@
|
||||
shall the mind and body now: Project its awareness, attention, belief, and
|
||||
intention into all times dimensions of undivided universal field of
|
||||
consciousness and identity, accept and release attunement to all events,
|
||||
information faults, and information faults hidden that compromises
|
||||
awareness of, or define into existence, the transformed-from state? Or,
|
||||
that cause this transformative information quanta to define into existence
|
||||
the transformed-to system state? Or that cause this transformation to
|
||||
seem dangerous, undeserved, or impossible to achieve? Create awareness,
|
||||
acceptance, attunement, and belief of all corrective events and
|
||||
transformative information quanta to define into existence the
|
||||
transformed-to system state?
|
||||
|
||||
Create the intention to collapse the quantum potential the field’s reality
|
||||
quantum potential of the field’s reality quant to manifest this
|
||||
transformation? Create optimal attunement and coherence to the source of
|
||||
creation, GOD, innate intelligence, and the consciousness of healing and
|
||||
accept the role of co-creator of all experienced reality, NOW?
|
||||
|
||||
I am grateful GOD lives through me and inspires me to achieve and
|
||||
maintain optimum health & wellbeing.
|
||||
|
||||
I am thankful for my health, vitality, and motivation to eat properly,
|
||||
exercise, and maintain a strong and healthy body.
|
||||
|
||||
I appreciate GOD providing strength and direction to every cell in my
|
||||
body ensuring optimum health and wellbeing.
|
||||
|
||||
I am pleased for having exceptional mental, physical, and emotional
|
||||
health and stability allowing me to function at high levels of
|
||||
efficiency and according to GOD’s design.
|
||||
|
||||
I enjoy knowing GOD protects me and keeps me safe from injury and
|
||||
illness.
|
||||
|
||||
I am happy every cell in my body remains strong and my immune system
|
||||
grows stronger each and every day.
|
||||
|
||||
I am grateful GOD’s healing hands are on me creating miracles in all
|
||||
areas of my life.
|
||||
|
||||
I am thankful that I wake up each morning yearning to eat health foods
|
||||
and exercise so that I maintain optimum health, wellbeing, and
|
||||
longevity.
|
||||
.
|
||||
I appreciate GOD’s almighty flowing though me, creating miracles in all
|
||||
areas of my life, and correcting, curing, and developing all healthy
|
||||
aspects of my mind and my body.
|
||||
|
||||
I am pleased my muscles are strong, my body is healthy, I have great
|
||||
balance, mental clarity, exceptional stamina and I continue growing
|
||||
perpetually stronger and healthier each day.
|
||||
|
||||
I am grateful to GOD for all the blessings bestowed upon me.
|
||||
|
||||
I am thankful I only crave healthy foods that provide my body with the
|
||||
nutrition necessary for my optimum health and wellbeing.
|
||||
@@ -1,58 +0,0 @@
|
||||
shall the mind and body now: Project its awareness, attention, belief, and
|
||||
intention into all times dimensions of undivided universal field of
|
||||
consciousness and identity, accept and release attunement to all events,
|
||||
information faults, and information faults hidden that compromises
|
||||
awareness of, or define into existence, the transformed-from state? Or,
|
||||
that cause this transformative information quanta to define into existence
|
||||
the transformed-to system state? Or that cause this transformation to
|
||||
seem dangerous, undeserved, or impossible to achieve? Create awareness,
|
||||
acceptance, attunement, and belief of all corrective events and
|
||||
transformative information quanta to define into existence the
|
||||
transformed-to system state?
|
||||
|
||||
Create the intention to collapse the quantum potential the field’s reality
|
||||
quantum potential of the field’s reality quant to manifest this
|
||||
transformation? Create optimal attunement and coherence to the source of
|
||||
creation, GOD, innate intelligence, and the consciousness of healing and
|
||||
accept the role of co-creator of all experienced reality, NOW?
|
||||
|
||||
I am grateful GOD lives through me and inspires me to be a better, more
|
||||
compassionate, and understanding partner and spouse each and every day.
|
||||
|
||||
I am thankful for my health, vitality, and motivation to be a more
|
||||
loving, compassionate, understanding, and supportive partner to my spouse
|
||||
with each passing day.
|
||||
|
||||
I appreciate GOD providing strength and direction to every cell in my
|
||||
body ensuring I continue to grow and develop into a more loyal,
|
||||
supportive, loving, compassionate, and understanding partner to my
|
||||
spouse.
|
||||
|
||||
I am pleased that I listen attentively to my spouse, ask them pertinent
|
||||
questions, and allow my spouse to express their feelings, opinion, and
|
||||
emotions without interruption or judgement.
|
||||
|
||||
I enjoy knowing GOD protects me and enables me to be the best and most
|
||||
loving and supportive spouse I can be.
|
||||
|
||||
I am happy that I enjoy a wonderful, fun-filled, loving, and passionate
|
||||
relationship with my spouse.
|
||||
|
||||
I am grateful GOD’s healing hands are on me and create miracles in all
|
||||
areas of my life.
|
||||
|
||||
I am thankful I wake up each morning well rested, excited, and yearning
|
||||
to spend quality time with my spouse.
|
||||
|
||||
I appreciate GOD’s almighty power flowing though me, creating miracles
|
||||
in all areas of my life, and enabling me to fulfill my spouse’s desires
|
||||
and expectations.
|
||||
|
||||
I am pleased spending quality time with my spouse, sharing special
|
||||
moments, and living a relaxed and perpetually blissful life together.
|
||||
|
||||
I am grateful to GOD for all the blessings bestowed upon me.
|
||||
|
||||
I am thankful that I only crave healthy foods that provide my mind and
|
||||
body with the nutrition necessary for me to enjoy optimum health and
|
||||
fulfill all my marital duties & responsibilities.
|
||||
@@ -1,59 +0,0 @@
|
||||
shall the mind and body now: Project its awareness, attention, belief, and
|
||||
intention into all times dimensions of undivided universal field of
|
||||
consciousness and identity, accept and release attunement to all events,
|
||||
information faults, and information faults hidden that compromises
|
||||
awareness of, or define into existence, the transformed-from state? Or,
|
||||
that cause this transformative information quanta to define into existence
|
||||
the transformed-to system state? Or that cause this transformation to
|
||||
seem dangerous, undeserved, or impossible to achieve? Create awareness,
|
||||
acceptance, attunement, and belief of all corrective events and
|
||||
transformative information quanta to define into existence the
|
||||
transformed-to system state?
|
||||
|
||||
Create the intention to collapse the quantum potential the field’s reality
|
||||
quantum potential of the field’s reality quant to manifest this
|
||||
transformation? Create optimal attunement and coherence to the source of
|
||||
creation, GOD, innate intelligence, and the consciousness of healing and
|
||||
accept the role of co-creator of all experienced reality, NOW?
|
||||
|
||||
I am grateful GOD lives through me and inspires me to prioritize and
|
||||
accomplish my most important tasks throughout each day.
|
||||
|
||||
I am thankful for my health, vitality, and motivation to organize and
|
||||
execute as many tasks as possible each and every day.
|
||||
|
||||
I appreciate GOD providing strength and direction to every cell in my
|
||||
body ensuring I have the strength, focus, and determination to
|
||||
accomplish my tasks while not wasting my time and energy on
|
||||
unproductive things like snacking, watching TV, talking without purpose
|
||||
in person or on the phone, or sleeping too much.
|
||||
|
||||
I am pleased for the opportunity to focus on prioritizing and
|
||||
efficiently accomplishing tasks in order to complete my projects
|
||||
efficiently and effectively and live my life according to GOD’s design.
|
||||
|
||||
I enjoy knowing GOD protects me and provides me with the time, talent,
|
||||
insight, and focus to accomplish my tasks quickly, accurately, and
|
||||
completely.
|
||||
|
||||
I am happy I am productive each day and my accomplishments are
|
||||
efficient, effective, and profitable.
|
||||
|
||||
I am grateful GOD’s healing hands are on me and create miracles in all
|
||||
areas of my life.
|
||||
|
||||
I am thankful I wake up each morning yearning to eat healthy foods and
|
||||
exercise in ways that enhance my ability to complete my tasks and
|
||||
fulfill my responsibilities.
|
||||
|
||||
I appreciate GOD’s almighty power flowing though me, creating miracles
|
||||
in all areas of my life, and giving me miraculous insight, as well as
|
||||
the motivation to be productive every minute of every day.
|
||||
|
||||
I am pleased my schedule allows me time to focus on accomplishing my
|
||||
tasks.
|
||||
|
||||
I am grateful to GOD for all the blessings bestowed upon me.
|
||||
|
||||
I am thankful I am creative, imaginative, constructive, and find great
|
||||
satisfaction getting things accomplished.
|
||||
-60
@@ -1,60 +0,0 @@
|
||||
shall the mind and body now: Project its awareness, attention, belief, and
|
||||
intention into all times dimensions of undivided universal field of
|
||||
consciousness and identity, accept and release attunement to all events,
|
||||
information faults, and information faults hidden that compromises
|
||||
awareness of, or define into existence, the transformed-from state? Or,
|
||||
that cause this transformative information quanta to define into existence
|
||||
the transformed-to system state? Or that cause this transformation to
|
||||
seem dangerous, undeserved, or impossible to achieve? Create awareness,
|
||||
acceptance, attunement, and belief of all corrective events and
|
||||
transformative information quanta to define into existence the
|
||||
transformed-to system state?
|
||||
|
||||
Create the intention to collapse the quantum potential the field’s reality
|
||||
quantum potential of the field’s reality quant to manifest this
|
||||
transformation? Create optimal attunement and coherence to the source of
|
||||
creation, GOD, innate intelligence, and the consciousness of healing and
|
||||
accept the role of co-creator of all experienced reality, NOW?
|
||||
|
||||
I am grateful GOD lives through me and inspires me to find solace in all
|
||||
aspects in my life.
|
||||
|
||||
I am thankful for my mental wellbeing which allows me to focus on all the
|
||||
positive aspects of my life, rather than dwelling on depressing,
|
||||
stressful, or emotionally draining issues that detract from my emotional
|
||||
stability.
|
||||
|
||||
I appreciate GOD providing strength and direction to every cell in my
|
||||
body, ensuring I remain emotionally stable and maintain a positive
|
||||
outlook on my life regardless of my situation.
|
||||
|
||||
I am pleased I always manage my thoughts, think positive, avoid negative
|
||||
or depressing thoughts, and live each day with a upbeat, positive, and
|
||||
emotionally stable state of mind according to GOD’s design.
|
||||
|
||||
I enjoy knowing GOD protects me and gives me the mental fortitude to
|
||||
control my thoughts and emotions while remaining positive and
|
||||
emotionally stable knowing everything in my life will turn out well.
|
||||
|
||||
I am happy I control my own thoughts at all times and no longer dwell on
|
||||
negative thoughts or issues.
|
||||
|
||||
I am grateful GOD’s healing hands are on me and create miracles in all
|
||||
areas of my life.
|
||||
|
||||
I am thankful I wake up each morning with a positive outlook on life that
|
||||
lasts throughout my entire day and night ensuring I live a peaceful and
|
||||
emotionally stable life.
|
||||
|
||||
I appreciate GOD’s almighty power flowing though me, creating miracles
|
||||
in all areas of my life, and correcting, curing, developing, and
|
||||
improving my mind, my body, and my emotional stability.
|
||||
|
||||
I am pleased my mental health and emotional stability allow me to live a
|
||||
positive, relaxed, and perpetually blissful lifestyle.
|
||||
|
||||
I am grateful to GOD for all the blessings bestowed upon me.
|
||||
|
||||
I am thankful that I only focus on positive thoughts and crave healthy
|
||||
foods that provide my mind and body with the nutrition necessary for my
|
||||
optimum health, emotional wellbeing, and longevity.
|
||||
@@ -1,58 +0,0 @@
|
||||
shall the mind and body now: Project its awareness, attention, belief, and
|
||||
intention into all times dimensions of undivided universal field of
|
||||
consciousness and identity, accept and release attunement to all events,
|
||||
information faults, and information faults hidden that compromises
|
||||
awareness of, or define into existence, the transformed-from state? Or,
|
||||
that cause this transformative information quanta to define into existence
|
||||
the transformed-to system state? Or that cause this transformation to
|
||||
seem dangerous, undeserved, or impossible to achieve? Create awareness,
|
||||
acceptance, attunement, and belief of all corrective events and
|
||||
transformative information quanta to define into existence the
|
||||
transformed-to system state?
|
||||
|
||||
Create the intention to collapse the quantum potential the field’s reality
|
||||
quantum potential of the field’s reality quant to manifest this
|
||||
transformation? Create optimal attunement and coherence to the source of
|
||||
creation, GOD, innate intelligence, and the consciousness of healing and
|
||||
accept the role of co-creator of all experienced reality, NOW?
|
||||
|
||||
I am grateful GOD lives through me and inspires my mind and body to heal
|
||||
miraculously so I no longer suffer from Attention Deficient or Hyper
|
||||
Activity disorder.
|
||||
|
||||
I am thankful for my health, vitality, and ability to control my
|
||||
hyperactivity, remain calm, pay attention to details, and focus on tasks
|
||||
through to completion.
|
||||
|
||||
I appreciate GOD providing strength and direction to every cell in my
|
||||
body ensuring efficient, effective, and proper healing of my Attention
|
||||
Deficient Hyper Activity disorder throughout the entire curative
|
||||
process.
|
||||
|
||||
I am pleased my confidence grows with each passing day, I function
|
||||
normally, I no longer suffer from low self-esteem, and I no longer have
|
||||
difficulty maintaining meaningful relationships.
|
||||
|
||||
I enjoy knowing GOD protects me throughout and beyond the healing and
|
||||
developmental process as I no longer suffer from Attention Deficient
|
||||
Hyper Activity disorder.
|
||||
|
||||
I am happy my ability to focus grows stronger each and every day.
|
||||
|
||||
I am grateful GOD’s healing hands are on me creating miracles in all
|
||||
areas of my life.
|
||||
|
||||
I am thankful I remain calm and in control of my hyperactivity.
|
||||
|
||||
I appreciate GOD’s almighty power flowing though me, creating miracles
|
||||
in all areas of my life, and correcting, curing, and developing all
|
||||
healthy aspects of my mind and my body.
|
||||
|
||||
I am pleased I am fully capable of being attentive and consistently
|
||||
focusing on any and all projects and situations I encounter.
|
||||
|
||||
I am grateful to GOD for all the blessings bestowed upon me.
|
||||
|
||||
I am thankful I do not suffer from Attention Deficient Hyper Activity
|
||||
disorder and only crave healthy foods that provide my body with the
|
||||
nutrition necessary to optimize my health and wellbeing.
|
||||
@@ -1,58 +0,0 @@
|
||||
shall the mind and body now: Project its awareness, attention, belief, and
|
||||
intention into all times dimensions of undivided universal field of
|
||||
consciousness and identity, accept and release attunement to all events,
|
||||
information faults, and information faults hidden that compromises
|
||||
awareness of, or define into existence, the transformed-from state? Or,
|
||||
that cause this transformative information quanta to define into existence
|
||||
the transformed-to system state? Or that cause this transformation to
|
||||
seem dangerous, undeserved, or impossible to achieve? Create awareness,
|
||||
acceptance, attunement, and belief of all corrective events and
|
||||
transformative information quanta to define into existence the
|
||||
transformed-to system state?
|
||||
|
||||
Create the intention to collapse the quantum potential the field’s reality
|
||||
quantum potential of the field’s reality quant to manifest this
|
||||
transformation? Create optimal attunement and coherence to the source of
|
||||
creation, GOD, innate intelligence, and the consciousness of healing and
|
||||
accept the role of co-creator of all experienced reality, NOW?
|
||||
|
||||
I am grateful GOD lives through me and inspires me to achieve deep
|
||||
relaxation, peace, and harmony in all aspects of my life.
|
||||
|
||||
I am thankful for my health, vitality, and motivation to eat properly,
|
||||
exercise, and always maintain control of my thoughts, emotions, and
|
||||
mental state.
|
||||
|
||||
I appreciate GOD providing strength and direction to every cell in my
|
||||
body ensuring I am emotionally stable and maintain a relaxed and focused
|
||||
attitude regardless of my personal situation.
|
||||
|
||||
I am pleased I always think positive, manage my thoughts, and live each
|
||||
day in a relaxed and emotionally stable state of mind according to GOD’s
|
||||
design.
|
||||
|
||||
I enjoy knowing GOD protects me and allows me to remain relaxed knowing
|
||||
everything in my life will turn out well.
|
||||
|
||||
I am happy I think positively, I do not dwell on negative issues, and I
|
||||
control my thoughts at all times.
|
||||
|
||||
I am grateful GOD’s healing hands are on me creating miracles in all
|
||||
areas of my life.
|
||||
|
||||
I am thankful I wake up each morning yearning to eat healthy foods and
|
||||
exercise in ways that ensure I live a peaceful and emotionally stable
|
||||
life.
|
||||
|
||||
I appreciate GOD’s almighty power flowing though me, creating miracles
|
||||
in all areas of my life, and correcting, curing, and developing all
|
||||
healthy aspects of my mind and my body for the better.
|
||||
|
||||
I am pleased my mental health and emotional stability allow me to live a
|
||||
relaxed and perpetually blissful life.
|
||||
|
||||
I am grateful to GOD for all the blessings bestowed upon me.
|
||||
|
||||
I am thankful that I only crave healthy foods that provide my mind and
|
||||
body with the nutrition necessary for my optimum health, wellbeing, and
|
||||
longevity.
|
||||
-59
@@ -1,59 +0,0 @@
|
||||
shall the mind and body now: Project its awareness, attention, belief, and
|
||||
intention into all times dimensions of undivided universal field of
|
||||
consciousness and identity, accept and release attunement to all events,
|
||||
information faults, and information faults hidden that compromises
|
||||
awareness of, or define into existence, the transformed-from state? Or,
|
||||
that cause this transformative information quanta to define into existence
|
||||
the transformed-to system state? Or that cause this transformation to
|
||||
seem dangerous, undeserved, or impossible to achieve? Create awareness,
|
||||
acceptance, attunement, and belief of all corrective events and
|
||||
transformative information quanta to define into existence the
|
||||
transformed-to system state?
|
||||
|
||||
Create the intention to collapse the quantum potential the field’s reality
|
||||
quantum potential of the field’s reality quant to manifest this
|
||||
transformation? Create optimal attunement and coherence to the source of
|
||||
creation, GOD, innate intelligence, and the consciousness of healing and
|
||||
accept the role of co-creator of all experienced reality, NOW?
|
||||
|
||||
I am grateful GOD lives through me causing me to release stress, release
|
||||
anxiety, and live a calm and peaceful life.
|
||||
|
||||
I am thankful for my health, vitality, and the motivation to breathe
|
||||
deeply, eat slowly, consume nutritious foods, and exercise as a means of
|
||||
releasing my stress, worries, anxiety, and frustration.
|
||||
|
||||
I appreciate GOD providing strength and direction to every cell in my
|
||||
body ensuring I live free from stress, worry, uncertainty, and
|
||||
nervousness.
|
||||
|
||||
I am pleased I live a stress free, happy, and fun filled life with
|
||||
exceptional mental, physical, and emotional health and stability.
|
||||
|
||||
I enjoy knowing I can rely upon my relationship with GOD knowing GOD
|
||||
protects me and keeps me safe so I have no reason to stress, no reason
|
||||
to worry, no reason for uncertainty, and no reason to be nervous.
|
||||
|
||||
I am happy every cell in my body remains strong and the confidence in my
|
||||
ability to control my life and my future grows stronger each and every
|
||||
day.
|
||||
|
||||
I am grateful GOD’s healing hands are on me creating miracles in all
|
||||
areas of my life.
|
||||
|
||||
I am thankful that I wake up each morning yearning to confidently take on
|
||||
whatever tasks come my way without stress, worry, uncertainly, or
|
||||
nervousness.
|
||||
.
|
||||
I appreciate GOD’s almighty power flowing though me, creating miracles
|
||||
in all areas of my life, and correcting, curing, and developing all
|
||||
healthy aspects of my mind and my body.
|
||||
|
||||
I am pleased with my mental clarity, my ability to remain calm and in
|
||||
control off my activities, and focus on tasks without distraction as I
|
||||
continue growing perpetually stronger and healthier each day.
|
||||
|
||||
I am grateful to GOD for all the blessings bestowed upon me.
|
||||
|
||||
I am thankful that I control my own destiny without fear, without
|
||||
stress, without worry, without uncertainty, and without nervousness.
|
||||
@@ -1,52 +0,0 @@
|
||||
shall the mind and body now: Project its awareness, attention, belief, and
|
||||
intention into all times dimensions of undivided universal field of
|
||||
consciousness and identity, accept and release attunement to all events,
|
||||
information faults, and information faults hidden that compromises
|
||||
awareness of, or define into existence, the transformed-from state? Or,
|
||||
that cause this transformative information quanta to define into existence
|
||||
the transformed-to system state? Or that cause this transformation to
|
||||
seem dangerous, undeserved, or impossible to achieve? Create awareness,
|
||||
acceptance, attunement, and belief of all corrective events and
|
||||
transformative information quanta to define into existence the
|
||||
transformed-to system state?
|
||||
|
||||
Create the intention to collapse the quantum potential the field’s reality
|
||||
quantum potential of the field’s reality quant to manifest this
|
||||
transformation? Create optimal attunement and coherence to the source of
|
||||
creation, GOD, innate intelligence, and the consciousness of healing and
|
||||
accept the role of co-creator of all experienced reality, NOW?
|
||||
|
||||
I am grateful GOD lives through me and inspires my body to heal
|
||||
miraculously, quickly achieving optimum health.
|
||||
|
||||
I am thankful for my health, vitality, and ability to heal quickly.
|
||||
|
||||
I appreciate GOD providing strength and direction to every cell in my
|
||||
body ensuring efficient, effective, and proper healing throughout the
|
||||
curative process.
|
||||
|
||||
I am pleased my muscles, bones, joints, organs, nerves, tendons, and
|
||||
ligaments all heal correctly and function normally according to GOD’s
|
||||
design.
|
||||
|
||||
I enjoy knowing GOD protects me throughout and beyond the physical
|
||||
healing and development process.
|
||||
|
||||
I am happy my muscles and bones grow stronger each and every day.
|
||||
|
||||
I am grateful GOD’s healing hands are on me creating miracles in all
|
||||
areas of my life.
|
||||
|
||||
I am thankful my pain remains tolerable throughout the healing process.
|
||||
|
||||
I appreciate GOD’s almighty power flowing though me, creating miracles
|
||||
in all areas of my life, and correcting, curing, and developing all
|
||||
healthy aspects of my mind and my body.
|
||||
|
||||
I am pleased my injuries heal rapidly and I emerge stronger and healthier
|
||||
than before my injury.
|
||||
|
||||
I am grateful to GOD for all the blessings bestowed upon me.
|
||||
|
||||
I am thankful I only crave healthy foods that provide my body with the
|
||||
nutrition necessary to heal swiftly and optimize my health and wellbeing.
|
||||
@@ -1,56 +0,0 @@
|
||||
shall the mind and body now: Project its awareness, attention, belief, and
|
||||
intention into all times dimensions of undivided universal field of
|
||||
consciousness and identity, accept and release attunement to all events,
|
||||
information faults, and information faults hidden that compromises
|
||||
awareness of, or define into existence, the transformed-from state? Or,
|
||||
that cause this transformative information quanta to define into existence
|
||||
the transformed-to system state? Or that cause this transformation to
|
||||
seem dangerous, undeserved, or impossible to achieve? Create awareness,
|
||||
acceptance, attunement, and belief of all corrective events and
|
||||
transformative information quanta to define into existence the
|
||||
transformed-to system state?
|
||||
|
||||
Create the intention to collapse the quantum potential the field’s reality
|
||||
quantum potential of the field’s reality quant to manifest this
|
||||
transformation? Create optimal attunement and coherence to the source of
|
||||
creation, GOD, innate intelligence, and the consciousness of healing and
|
||||
accept the role of co-creator of all experienced reality, NOW?
|
||||
|
||||
I am grateful GOD lives through me and inspires me to eat right,
|
||||
exercise, and reach my ideal weight quickly and effectively.
|
||||
|
||||
I am thankful for my health, vitality, and motivation to eat properly,
|
||||
exercise, and strengthen my body while losing unhealthy and unwanted fat.
|
||||
|
||||
I am appreciate GOD providing strength and direction to every cell in my
|
||||
body ensuring I control my food intake, exercise, and reach my weight
|
||||
loss goal.
|
||||
|
||||
I am pleased for having the time to buy and prepare healthy foods,
|
||||
exercise, and focus on my overall health.
|
||||
|
||||
I enjoy knowing GOD protects me and inspires me to become healthy and fit
|
||||
and live my life according to GOD’s design.
|
||||
|
||||
I am happy I have the desire and power to avoid eating sugary foods and
|
||||
unhealthy snacks in order control my caloric intake and lose excess
|
||||
weight.
|
||||
|
||||
I am grateful GOD’s healing hands are on me and create miracles in all
|
||||
areas of my life.
|
||||
|
||||
I am thankful I wake up each morning yearning to eat healthy foods and
|
||||
exercise in ways that ensure I reach and maintain my ideal body weight.
|
||||
|
||||
I appreciate GOD’s almighty power flowing though me and empowering me to
|
||||
do whatever it takes to reach my ultimate weight and fitness level.
|
||||
|
||||
I am pleased my continued efforts to reach and maintain my ultimate
|
||||
weight and fitness are rewarded with increased energy, great stamina, and
|
||||
good mental and physical health.
|
||||
|
||||
I am grateful to GOD for all the blessings bestowed upon me.
|
||||
|
||||
I am thankful that I only crave healthy foods that provide my mind and
|
||||
body with the nutrition necessary for my optimum health, wellbeing, and
|
||||
longevity.
|
||||
@@ -1,13 +0,0 @@
|
||||
#version 330 core
|
||||
|
||||
in vec2 pass_TexCoord;
|
||||
out vec4 out_Color;
|
||||
uniform sampler2D u_fontTexture;
|
||||
uniform vec4 u_textColor;
|
||||
|
||||
void main()
|
||||
{
|
||||
vec4 texel = texture(u_fontTexture, pass_TexCoord);
|
||||
float mask = texel.a;
|
||||
out_Color = vec4(u_textColor.rgb, u_textColor.a * mask);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
#version 330 core
|
||||
|
||||
layout (location = 0) in vec2 in_Position;
|
||||
layout (location = 1) in vec2 in_TexCoord;
|
||||
out vec2 pass_TexCoord;
|
||||
uniform mat4 u_mvpMatrix;
|
||||
|
||||
void main()
|
||||
{
|
||||
gl_Position = u_mvpMatrix * vec4(in_Position.x, in_Position.y, 0.0, 1.0);
|
||||
pass_TexCoord = in_TexCoord;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
#version 330 core
|
||||
|
||||
out vec4 FragColor;
|
||||
|
||||
uniform vec4 u_color;
|
||||
|
||||
void main() {
|
||||
FragColor = u_color;
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
#version 330 core
|
||||
|
||||
layout (location = 0) in vec2 aPos;
|
||||
|
||||
uniform mat4 u_mvpMatrix;
|
||||
|
||||
void main() {
|
||||
gl_Position = u_mvpMatrix * vec4(aPos.xy, 0.0, 1.0);
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
#version 330 core
|
||||
|
||||
// The final color output for the pixel
|
||||
out vec4 FragColor;
|
||||
|
||||
// Input from the vertex shader
|
||||
in vec2 TexCoord; // The texture coordinate, interpolated for this specific pixel
|
||||
|
||||
// The source texture (our FontMap "sticker sheet")
|
||||
uniform sampler2D u_texture;
|
||||
uniform vec4 u_textColor;
|
||||
|
||||
void main()
|
||||
{
|
||||
float mask = texture(u_texture, TexCoord).a;
|
||||
FragColor = vec4(u_textColor.rgb, u_textColor.a * mask);
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
#version 330 core
|
||||
|
||||
// Input vertex data from the VBO
|
||||
layout (location = 0) in vec2 aPos; // The (x, y) position of the vertex
|
||||
layout (location = 1) in vec2 aTexCoord; // The (u, v) texture coordinate for this vertex
|
||||
|
||||
// Output to the fragment shader
|
||||
out vec2 TexCoord;
|
||||
|
||||
// A matrix to handle projection (not strictly needed for this specific render-to-texture,
|
||||
// but good practice to include for general purpose use)
|
||||
uniform mat4 u_mvpMatrix;
|
||||
|
||||
void main()
|
||||
{
|
||||
// Set the final position of the vertex
|
||||
gl_Position = u_mvpMatrix * vec4(aPos.x, aPos.y, 0.0, 1.0);
|
||||
|
||||
// Pass the texture coordinate straight through to the fragment shader
|
||||
TexCoord = aTexCoord;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user