commit 9251b3a196a2bb9f24bb007c63eec699d8475347 Author: NeilClack Date: Fri Aug 7 14:51:41 2026 -0500 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0ec03f0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Build directories +build/ +cmake-build-*/ + +# Nix +.direnv/ +result + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Build artifacts +*.o +*.obj +*.dylib +*.so +*.dll +*.a +*.lib +*.exe + +# Compiled binary +bin/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ea5dbea --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,206 @@ +# RadiantWave — Agent Instructions + +## What This Is + +A kiosk app for RadiantWave wellness technology. Displays scrolling proclamation +text (Fibonacci line-length pattern, per-frequency color palettes), plays +solfeggio-frequency audio, and runs a first-run provisioning walkthrough on a +headless Linux kiosk. Talks to `https://portal.radiantwavetech.com/api/v2`. + +**Language:** C++17 (no extensions). **Build:** CMake + Ninja. +**Graphics:** SDL3 + SDL3_ttf/SDL3_image. **Audio:** SDL3_mixer. +**Also links:** SDL3_net, SQLite3, libcurl, OpenSSL, GTest. +**Linux-only:** libsystemd (sd-bus) via pkg-config, compiled only when +`UNIX AND NOT APPLE` — guarded by `#ifdef __linux__` in the network manager. + +--- + +## Build & Run + +```sh +cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug +cmake --build build # builds the app AND every test binary +./build/radiantwave +``` + +The Home footer's top "Version" row is baked in at build time. Default it to +the project version, or pass your own at configure time: + +```sh +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. + +- 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. + +--- + +## Tests + +There is **no ctest integration** and no single "run all" target. Each test +file is its own `add_executable` in `CMakeLists.txt`; build them all with +`cmake --build build`, then run each `./build/test_*` binary. + +Two frameworks coexist: +- `tests`, `test_assets`, `test_provisioning` — the minimal built-in harness + (`tests/test_harness.h`, `TEST(name)` + `RUN_ALL_TESTS()`). +- Everything else — GoogleTest (`GTest::gtest_main`). GTest is a hard + `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`. + +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. + +--- + +## Runtime data & keys + +- `AppState` (in `app_state.h`) holds renderer, `PageManager`, `KeybindManager`, + `config`, `db`, `network`, `audio`, `audioDownloader`, `licenseTracker`, and + 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). +- `ConfigManager` hot-reloads `config.yaml` from a watcher thread and fires + `onChanged` callbacks; `AudioManager` volume/device/`dynamic_audio` are read + from it. + +--- + +## First-run provisioning walkthrough + +Fixed prerequisite order (`src/provisioning.h`), evaluated on every boot; the +first missing step's page is pushed: + +1. Internet reachable? → `WiFiSetupPage` +2. Email address set? → `EmailAddressPage` +3. License key set? → `LicenseKeyPage` +4. Everything satisfied → `Home` + +`advance()` pops the current step and re-evaluates; internet is only re-probed +when it was still missing (cached in `state.walkthroughInternetUp`). A +brand-new device (nothing configured) only sets the `firstRunWalkthrough` +placeholder flag — the welcome walkthrough is not implemented yet. + +--- + +## Architecture + +### Entry point (`src/main.cpp`) + +SDL3 callback API (`SDL_MAIN_USE_CALLBACKS`): `SDL_AppInit` inits subsystems +and calls `provisioning::begin`, `SDL_AppEvent` routes through keybinds then +pages, `SDL_AppIterate` updates/renders, `SDL_AppQuit` tears down. + +### Pages & navigation + +`Page` lifecycle (`src/page.h`): `onPush`/`onResume` create textures, +`onPause` destroys them, `onPop` final-cleans. `PageManager` is a stack +(`push`/`pop`/`popToRoot`/`replace`). Home and the provisioning steps use +`replace()`-style flows; only settings sub-pages stack deeply. TAB pops the +stack and pushes a fresh Home. + +Existing pages: Home, Settings, WiFiSetupPage, EmailAddressPage, LicenseKeyPage, +LicenseBlockedPage, AudioSettingsPage, ProclamationOptionsPage, ScrollerPage, +LogViewerPage, TimezonePage, DateFormatPage. + +### Keybinds (`src/keybind_manager.cpp`) + +Binds are checked **before** the active page sees events. Left/right modifier +pairs are normalized to combined masks, so a bind's modifiers must be a subset +of the held modifiers (plain binds fire regardless of modifiers). + +Global binds in `main.cpp`: **TAB** returns Home, **UP/DOWN** move the Home +menu selection. There is no quit keybind — the app is a kiosk. App quit (SDL +window/system close) fades audio out over ~5s before teardown. Note: Home's +UP/DOWN handlers both increment `currentMenuItem` `(x+1)%2` (see +`src/home.cpp`) — there is no separate "down" path. + +### ScrollerPage + +Full-screen vertical scroller. Proclamation streams come from the DB +(`scroller_streams.cpp`), colors from per-frequency palettes with a 528 Hz +fallback, lines built from a Fibonacci length pattern +(`fibonacci_line_pattern.cpp`). Audio is crossfaded per stream via +`AudioManager` fade APIs. Word textures are cached and page text rendering is +cached. + +--- + +## Portal API & color palettes + +- Request/response changes against the portal are tracked in `ApiUpdates.md` + (sections #4 and #5). +- **Color palettes are meant to come from the portal, not code releases.** The + client-side pieces are implemented but deliberately **not wired into any sync + or startup flow** until the portal routes exist: + - `api::fetchColorPalettes()` / `api::parseColorPalettesJson()` — the + `GET /color-palettes` route. + - `sync::syncColorPalettesFromApi()` / `sync::syncProclamationPalettesFromApi()` + / `sync::parseProclamationPalettesJson()` — replace the local + `color_palettes` table via `db::replaceColorPalettes()`. + - `db::getPaletteForFrequencyId()` falls back to the 528 Hz palette for + frequency ids with no rows; the startup seed only backfills missing ids, so + portal values win after a sync. +- Do **not** call the above sync functions from the app until the portal routes + ship. Tests live in `tests/test_color_palettes.cpp` (`test_color_palettes` + target). +- **Intended wiring points** (marked with `TODO(portal #4/#5)` comments): + `syncProclamationsFromApi()` in `src/network/proclamation_sync.cpp`, plus the + call sites `Home::syncProclamationsIfEmpty()` (`src/home.cpp`) and + `ProclamationOptionsPage::onPush()` (`src/proclamation_options_page.cpp`). + +--- + +## Style & UI Layout Rules + +- `PascalCase` for types, `camelCase` for functions/vars, `snake_case` for + files, `UPPER_SNAKE` for constants/macros. +- Use `std::unique_ptr`; no raw `new`/`delete` outside the SDL callbacks. +- Use `override` on all virtual implementations. `#pragma once` headers. +- One class per header/source pair. SDL3 headers first, then stdlib, then + project headers. + +### UI Menu & List Layout Conventions +- **Left Alignment & Centered Bounding Box**: In vertical menus/lists, all text + options are left-aligned with each other (`sharedX`). The bounding box + enclosing all options (width = widest item, height = sum of item heights + + margins) MUST BE centered on the screen. +- **Vertical Spacing**: All margins between consecutive list items are + **12 pixels**. +- **Selection Underline**: The 6-pixel underline occupies half the 12px margin + (2-3px gap below the item text, 3-4px gap above the next item). + +--- + +## Common Pitfalls + +- Do not commit audio assets (`.wav` files) — downloaded at runtime. +- Do not modify files in `archive/` — reference only. +- Build needs SDL3 + SDL3_ttf/image/mixer/net + GTest installed; CI uses + self-hosted Forgejo runners. +- `test_assets` must pass before committing — it catches missing fonts/images + that break the app at runtime. +- `cmake --build build` builds everything; pass `--target ` to build one + test binary. diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..dd53c2b --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,279 @@ +cmake_minimum_required(VERSION 3.20) +project(RadiantWave VERSION 0.1.0 LANGUAGES CXX) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +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 ` +# 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)") + +find_package(SDL3 REQUIRED) +find_package(SDL3_ttf REQUIRED) +find_package(SDL3_image REQUIRED) +find_package(SDL3_mixer REQUIRED) +find_package(SDL3_net REQUIRED) +find_package(SQLite3 REQUIRED) +find_package(CURL REQUIRED) +find_package(OpenSSL REQUIRED) +find_package(GTest REQUIRED) +include(GoogleTest) + +# Network manager dependencies (Linux only) +if(UNIX AND NOT APPLE) + find_package(PkgConfig REQUIRED) + pkg_check_modules(SDBUS REQUIRED libsystemd) +endif() + +add_executable(radiantwave + src/main.cpp + src/page_manager.cpp + src/home.cpp + src/settings.cpp + src/wifi_setup_page.cpp + src/audio_settings_page.cpp + src/audio/audio_manager.cpp + src/audio/audio_downloader.cpp + src/timezone_page.cpp + src/timezone_helper.cpp + src/log_viewer_page.cpp + src/logger/logger.cpp + src/keybind_manager.cpp + src/provisioning.cpp + src/network/nm_connection.cpp + src/network/nm_device.cpp + src/network/network_manager.cpp + src/network/json.cpp + src/network/api_client.cpp + src/network/license_service.cpp + src/network/license_status.cpp + src/config/yaml_parser.cpp + src/config/config_manager.cpp + src/db/database_manager.cpp + src/network/proclamation_sync.cpp + src/proclamation_options_page.cpp + src/email_address_page.cpp + src/license_key_page.cpp + src/license_blocked_page.cpp + src/validation.cpp + src/date_format.cpp + src/date_format_page.cpp + src/fibonacci_line_pattern.cpp + src/scroller_streams.cpp + src/scroller_page.cpp + src/scramble/fibonacci_scrambler.cpp +) + +target_compile_definitions(radiantwave PRIVATE + APP_VERSION="${APP_VERSION}" + API_KEY="${API_KEY}" +) + +target_include_directories(radiantwave PRIVATE src) +target_link_libraries(radiantwave PRIVATE + SDL3::SDL3 + SDL3_ttf::SDL3_ttf + SDL3_image::SDL3_image + SDL3_mixer::SDL3_mixer + SDL3_net::SDL3_net + SQLite3::SQLite3 + CURL::libcurl + OpenSSL::Crypto +) + +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) +endif() + +install(TARGETS radiantwave RUNTIME DESTINATION bin) + +add_executable(tests + tests/test_page_manager.cpp + tests/test_keybind_manager.cpp + src/page_manager.cpp + src/keybind_manager.cpp +) +target_include_directories(tests PRIVATE src) +target_link_libraries(tests PRIVATE SDL3::SDL3) + +# Provisioning walkthrough logic tests (pure functions, no app linkage) +add_executable(test_provisioning + tests/test_provisioning.cpp +) +target_include_directories(test_provisioning PRIVATE src) +target_link_libraries(test_provisioning PRIVATE SDL3::SDL3) + +add_executable(test_assets + tests/test_assets.cpp +) +target_include_directories(test_assets PRIVATE src) + +# Network manager unit tests +add_executable(test_network_unit + tests/network/test_nm_connection.cpp + src/network/nm_connection.cpp +) +target_include_directories(test_network_unit PRIVATE src) +target_link_libraries(test_network_unit PRIVATE GTest::gtest_main) + +# Config unit tests +add_executable(test_config + tests/test_config.cpp + src/config/yaml_parser.cpp + src/config/config_manager.cpp +) +target_include_directories(test_config PRIVATE src) +target_link_libraries(test_config PRIVATE GTest::gtest_main) + +# Database unit tests +add_executable(test_db + tests/test_db.cpp + src/db/database_manager.cpp +) +target_include_directories(test_db PRIVATE src) +target_link_libraries(test_db PRIVATE GTest::gtest_main SQLite3::SQLite3) + +# Proclamation sync unit tests +add_executable(test_proclamation_sync + tests/test_proclamation_sync.cpp + src/db/database_manager.cpp + src/network/proclamation_sync.cpp + src/network/json.cpp + src/network/api_client.cpp +) +target_include_directories(test_proclamation_sync PRIVATE src) +target_link_libraries(test_proclamation_sync PRIVATE GTest::gtest_main SQLite3::SQLite3 CURL::libcurl) + +# Portal API client unit tests (offline parse tests + live-API tests) +add_executable(test_api_client + tests/test_api_client.cpp + src/network/api_client.cpp + src/network/json.cpp + src/network/proclamation_sync.cpp + src/db/database_manager.cpp +) +target_include_directories(test_api_client PRIVATE src) +target_link_libraries(test_api_client PRIVATE GTest::gtest_main CURL::libcurl SQLite3::SQLite3) + +# License validation service unit tests (offline + live-API tests) +add_executable(test_license_service + tests/test_license_service.cpp + src/network/license_service.cpp + src/network/api_client.cpp + src/network/json.cpp + src/db/database_manager.cpp +) +target_include_directories(test_license_service PRIVATE src) +target_link_libraries(test_license_service PRIVATE GTest::gtest_main CURL::libcurl SQLite3::SQLite3) + +# License status tracker unit tests (offline mapping + worker lifecycle) +add_executable(test_license_status + tests/test_license_status.cpp + src/network/license_status.cpp + src/network/license_service.cpp + src/network/api_client.cpp + src/network/json.cpp + src/db/database_manager.cpp +) +target_include_directories(test_license_status PRIVATE src) +target_link_libraries(test_license_status PRIVATE GTest::gtest_main CURL::libcurl SQLite3::SQLite3) + +# Validation/format unit tests +add_executable(test_validation + tests/test_validation.cpp + src/validation.cpp +) +target_include_directories(test_validation PRIVATE src) +target_link_libraries(test_validation PRIVATE GTest::gtest_main) + +# Date/time format unit tests +add_executable(test_date_format + tests/test_date_format.cpp + src/date_format.cpp +) +target_include_directories(test_date_format PRIVATE src) +target_link_libraries(test_date_format PRIVATE GTest::gtest_main) + +# TimeZone unit tests +add_executable(test_timezone + tests/test_timezone.cpp + src/timezone_helper.cpp +) +target_include_directories(test_timezone PRIVATE src) +target_link_libraries(test_timezone PRIVATE GTest::gtest_main) + +# Logger unit tests +add_executable(test_logger + tests/test_logger.cpp + src/logger/logger.cpp +) +target_include_directories(test_logger PRIVATE src) +target_link_libraries(test_logger PRIVATE GTest::gtest_main) + +# Audio unit tests +add_executable(test_audio + tests/test_audio.cpp + src/audio/audio_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) + +# Audio downloader unit tests +add_executable(test_audio_downloader + tests/test_audio_downloader.cpp + src/audio/audio_downloader.cpp + src/db/database_manager.cpp + src/logger/logger.cpp +) +target_include_directories(test_audio_downloader PRIVATE src) +target_link_libraries(test_audio_downloader PRIVATE GTest::gtest_main CURL::libcurl OpenSSL::Crypto SQLite3::SQLite3) + +# Scroller Fibonacci pattern unit tests +add_executable(test_fibonacci_pattern + tests/test_fibonacci_pattern.cpp + src/fibonacci_line_pattern.cpp +) +target_include_directories(test_fibonacci_pattern PRIVATE src) +target_link_libraries(test_fibonacci_pattern PRIVATE GTest::gtest_main) + +# Scroller streams unit tests +add_executable(test_scroller_streams + tests/test_scroller_streams.cpp + src/scroller_streams.cpp +) +target_include_directories(test_scroller_streams PRIVATE src) +target_link_libraries(test_scroller_streams PRIVATE GTest::gtest_main SQLite3::SQLite3) + +# Fibonacci affine image scrambler unit tests +add_executable(test_fibonacci_scrambler + tests/test_fibonacci_scrambler.cpp + src/scramble/fibonacci_scrambler.cpp +) +target_include_directories(test_fibonacci_scrambler PRIVATE src) +target_link_libraries(test_fibonacci_scrambler PRIVATE GTest::gtest_main) + +# Color palette API + storage unit tests (readiness: not wired into the app yet) +add_executable(test_color_palettes + tests/test_color_palettes.cpp + src/network/api_client.cpp + src/network/json.cpp + src/network/proclamation_sync.cpp + src/db/database_manager.cpp +) +target_include_directories(test_color_palettes PRIVATE src) +target_link_libraries(test_color_palettes PRIVATE GTest::gtest_main CURL::libcurl SQLite3::SQLite3) diff --git a/Manual.md b/Manual.md new file mode 100644 index 0000000..e07bf49 --- /dev/null +++ b/Manual.md @@ -0,0 +1,575 @@ +# 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: + \/None**, **Internet: Reachable/Unreachable** (green/red). +- Status message banner (blue-bordered): "Scanning for available + networks...", "Connecting to \...", "Connected successfully!", + "Connection failed: \", "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: \" (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: \**; status banner; 5-item +menu: + +| # | Item | Interaction | +| :--- | :--- | :--- | +| 0 | Volume: `[▓▓▓▓▓▓▓▓░░] 80%` | LEFT/RIGHT step −/+ 5% (0–100, clamps). | +| 1 | Output Device: `[\]` | ENTER opens the Device selector modal. | +| 2 | Audio Track: `[\]` | 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: +\** (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 \"). | +| **`ESC`** / **`TAB`** / **`BACKSPACE`** | Back to Settings. | + +**Timezone step:** + +| Key | Action | +| :--- | :--- | +| **`UP`** / **`DOWN`** | Select a timezone. | +| **`RETURN`** / **`ENTER`** | Save the timezone (status "Timezone set to \") 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 \ 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: \" (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: \" (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 (\)". +- Missing email → "Saved - add your email address to validate the license". +- Invalid → "License check failed: \". +- 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: \" +(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: \ | Sort: Newest/Oldest First | Entries: N | TZ: \**; +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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..ccc5d0f --- /dev/null +++ b/README.md @@ -0,0 +1,599 @@ +# RadiantWave + +Kiosk application for RadiantWave wellness technology. Displays scrolling +proclamation text with custom shaders, plays audio at solfeggio frequencies, +and manages WiFi/provisioning on a headless Linux kiosk. + +**Language:** C++17 +**Build:** CMake + Ninja (local) or Nix (flake) +**Graphics:** SDL3 +**Audio:** SDL3_mixer +**Target:** Linux kiosk (Wayland/X11); developed on macOS + +--- + +## Project Layout + +``` +radiantwave/ +├── CMakeLists.txt Build config +├── .vscode/ VS Code launch + task configs +├── assets/ +│ ├── fonts/ Font files (Roboto-Regular.ttf) +│ └── images/ Logo and background images +├── src/ +│ ├── main.cpp Entry point (SDL3 callbacks, global keybinds) +│ ├── app_state.h AppState struct (window, renderer, pages, keybinds) +│ ├── page.h Page base class (lifecycle interface) +│ ├── page_manager.h PageManager declaration +│ ├── page_manager.cpp PageManager implementation +│ ├── keybind_manager.h KeybindManager declaration +│ ├── keybind_manager.cpp KeybindManager implementation +│ ├── home.h Home page declaration +│ ├── home.cpp Home page implementation +│ ├── settings.h Settings page declaration +│ └── settings.cpp Settings page implementation +├── tests/ +│ ├── test_harness.h Minimal test framework +│ ├── test_page.h TestPage mock +│ ├── test_page_manager.cpp PageManager unit tests +│ └── test_assets.cpp Asset existence checks +└── archive/ Previous Go implementation (reference only) +``` + +--- + +## Building + +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`). + +### Prerequisites (CMake + Ninja) + +- **CMake** >= 3.20 +- **Ninja** build tool +- **C++17** compiler (Clang or GCC) +- **SDL3**, **SDL3_ttf**, **SDL3_image**, **SDL3_mixer**, **SDL3_net** +- **SQLite3**, **libcurl**, **OpenSSL** +- **GoogleTest** (hard requirement: the build fails without it) +- Linux only: `pkg-config` + systemd (`libsystemd`) for the NetworkManager + sd-bus code, plus glibc's `resolv` + +Install the dependencies for your platform: + +#### macOS (Homebrew) + +```sh +brew install cmake ninja pkg-config \ + sdl3 sdl3_ttf sdl3_image sdl3_mixer sdl3_net \ + sqlite googletest +``` + +libcurl and OpenSSL headers ship with the macOS SDK. If your Xcode SDK's +OpenSSL is too old, `brew install openssl@3` and point CMake at it. + +#### Debian / Ubuntu (trixie and newer ship SDL3) + +```sh +sudo apt install cmake ninja-build g++ pkg-config \ + libsdl3-dev libsdl3-ttf-dev libsdl3-image-dev libsdl3-mixer-dev libsdl3-net-dev \ + libsqlite3-dev libcurl4-openssl-dev libssl-dev libgtest-dev libsystemd-dev +``` + +Older releases do not package SDL3 yet — use the Nix path below instead. + +#### NixOS / any Nix system + +The dev shell provides the compiler and every dependency: + +```sh +nix develop +``` + +### Configure, build, run (CMake + Ninja) + +```sh +# Configure (first time, or after CMakeLists.txt changes) +cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug + +# Build +cmake --build build + +# Run +./build/radiantwave +``` + +Release build: + +```sh +cmake -S . -B build-release -G Ninja -DCMAKE_BUILD_TYPE=Release +cmake --build build-release +./build-release/radiantwave +``` + +Clean: + +```sh +rm -rf build/ +``` + +#### App version + +The Home screen footer shows a **Version** row at the top of its status list. +The value is baked in at build time via the `APP_VERSION` CMake cache variable +and defaults to the project version (`0.1.0`). Pass your own value when +configuring: + +```sh +cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug -DAPP_VERSION="1.2.3" +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. + +#### Portal API key + +Every portal request sends `Authorization: Bearer `. 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. + +### Building with Nix + +The flake supports `x86_64-linux` and `aarch64-linux` (the deployment +targets) plus `aarch64-darwin` (development): + +```sh +nix build .#default # current system +nix build .#packages.x86_64-linux.default # needs a Linux machine/builders +nix build .#packages.aarch64-linux.default +``` + +`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 +tests. The app is marked `unfree`; the flake sets `allowUnfreePredicate` for +`radiantwave`, so no global `allowUnfree` setting is needed. + +Note: building the Linux packages from a macOS machine requires remote +builders or emulation — the flake does not cross-compile. + +### NixOS deployment + +Import `radiantwave.nixosModules.default` into your `nixosSystem` `modules` +and enable the service: + +```nix +# configuration.nix +{ ... }: { + services.radiantwave.enable = true; + # services.radiantwave.package = ...; # default: the flake's package + # services.radiantwave.user = "kiosk"; # default +} +``` + +The module only adds the package to the system path — the machine +configuration owns the display/audio session (`nixos/vm.nix` is a complete +working example). + +Try the kiosk in a VM first (cage + PipeWire + NetworkManager + auto-login): + +```sh +nix build .#packages.x86_64-linux.test-vm +./result/bin/run-radiantwave-test-vm +# or +nixos-rebuild build-vm --flake .#test-vm +./result/bin/run-radiantwave-test-vm +``` + +--- + +## VS Code + +### Required extensions + +- **C/C++** (`ms-vscode.cpptools`) — IntelliSense + launch configs +- **CodeLLDB** (`vadimcn.vscode-lldb`) — LLDB debugger frontend + +Install from the Extensions sidebar, or: + +```sh +code --install-extension ms-vscode.cpptools +code --install-extension vadimcn.vscode-lldb +``` + +### Launch configurations + +`F5` with `Debug RadiantWave` selected builds and launches the app under LLDB. +`Debug Tests` builds and launches the test runner. + +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 | +| `run-tests` | — | Build + run tests | + +--- + +## Testing + +Two frameworks coexist. `tests`, `test_assets`, and `test_provisioning` use +the minimal built-in harness (`tests/test_harness.h`); every other `test_*` +binary is GoogleTest. Each test file is its own CMake target — build them all +with `cmake --build build`, then run each binary from the repo root (the tests +resolve `assets/...` relative to the working directory): + +```sh +cmake --build build +./build/test_assets +./build/tests +./build/test_network_unit +./build/test_config +# ... one ./build/test_ per target +``` + +Live-portal tests only run when their `RW_LIVE_*` env vars are set, except +`test_api_client` and `test_license_service`, which hit the portal +unconditionally — keep those off a hermetic/offline build. + +Expected output: + +``` +# tests (PageManager) +push_single_page... ok +push_two_pages... ok +pop_single_page... ok +pop_resumes_previous... ok +replace_on_empty... ok +replace_on_existing... ok +pop_empty_stack... ok +depth_tracks_correctly... ok +push_pop_push_sequence... ok +9 passed, 0 failed + +# test_assets (asset existence) +fonts_exist... ok +rd_title_image_exists... ok +rwt_circular_image_exists... ok +3 passed, 0 failed +``` + +### Writing new tests + +Tests live in `tests/`. To add a test file: + +1. Create `tests/test_yourthing.cpp` +2. Include the harness and the header under test: + +```cpp +#include "test_harness.h" +#include "../src/your_header.h" + +TEST(my_new_test) +{ + // use ASSERT_TRUE, ASSERT_FALSE, ASSERT_EQ +} + +RUN_ALL_TESTS() +``` + +3. Add the file to the `tests` target in `CMakeLists.txt`: + +```cmake +add_executable(tests + tests/test_page_manager.cpp + tests/test_yourthing.cpp # <-- add here + src/page_manager.cpp + src/your_source.cpp +) +``` + +4. Rebuild and run: + +```sh +cmake --build build --target tests +./build/tests +``` + +### Test conventions + +- One `TEST()` per behavior. +- Name tests as `what_you_are_testing` (snake_case). +- Use `TestPage` mock from `test_page.h` to verify lifecycle calls. +- Call `clear_log()` before each test that checks lifecycle events. +- `AppState` is defined minimally in test files — pages must not depend on + fields beyond what `AppState` actually has. + +--- + +## Architecture + +### SDL3 Callback Entry Point + +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 | +| `SDL_AppQuit` | Once at exit | Destroy window, renderer, clean up | + +Application state (`AppState`) is passed between callbacks via `void*`. + +### AppState + +```cpp +struct AppState { + SDL_Window* window; + SDL_Renderer* renderer; + PageManager pages; + KeybindManager keybinds; + bool quitRequested; + int currentMenuItem; +}; +``` + +All shared state lives here. Pages receive `AppState&` during lifecycle calls +so they can access the renderer, window, keybinds, or any future shared state. + +### Page Lifecycle + +Every page implements the `Page` interface: + +```cpp +class Page { + virtual void onPush(AppState& state) = 0; // create textures, load state + virtual void onResume(AppState& state) = 0; // re-create textures after being paused + virtual void onPause(AppState& state) = 0; // destroy textures, save state + virtual void onPop(AppState& state) = 0; // final cleanup + virtual void handleEvent(const SDL_Event& event) = 0; + virtual void update(float dt) = 0; + virtual void render(SDL_Renderer* renderer) = 0; +}; +``` + +Lifecycle rules: + +- **Push:** current page gets `onPause()`, new page gets `onPush()`. +- **Pop:** top page gets `onPop()`, previous page gets `onResume()`. +- **Replace:** top page gets `onPop()`, new page gets `onPush()`. + +At most one page has textures alive at a time. Inactive pages destroy their +textures in `onPause()` and re-create them in `onResume()`. + +### PageManager + +Stack-based navigation. Owned by `AppState`. + +```cpp +PageManager pages; + +// Navigate +pages.push(state, std::make_unique()); +pages.pop(state); +pages.popToRoot(state); // pop all pages, resume root +pages.replace(state, std::make_unique()); + +// Query +Page* current = pages.current(); +std::size_t depth = pages.depth(); + +// Delegates to current page +pages.handleEvent(event); +pages.update(dt); +pages.render(renderer); +``` + +### Navigation Model + +Pages replace each other rather than stacking deeply. Home uses `replace()` +to swap itself for Settings (so Settings is the only page on the stack). +TAB pops all pages and pushes a fresh Home. The page stack is primarily for +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 | — | +| AudioOptions | Audio device selection, volume | — | +| ProclamationOptions | Browse/search proclamations | — | +| WiFiSetupPage | WiFi scan, WPA connect, password entry | — | +| LicenseKeyPage | License key input | — | +| EmailAddressPage | Email input with validation | — | +| LogViewerPage | Scrollable log viewer | — | +| DownloadingAssets | Stub during download phase | — | +| Message | Generic message page | — | + +--- + +## Keybinds + +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) | +| ENTER | Select current menu item | + +Keybinds are managed by `KeybindManager` and checked before events reach +the active page. Pages can also register their own keybinds via +`state.keybinds.bind()`. + +--- + +## Rendering + +### Virtual Framebuffer + +Renders to a 1920x1080 FBO, then blits to native window resolution. +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) | + +--- + +## Audio + +- SDL3_mixer for playback. +- PipeWire sink enumeration via `pactl list sinks short`. +- `.wav` files downloaded at runtime from `files.radiantwavetech.com`. +- 9 solfeggio frequency presets (174Hz - 963Hz). + +--- + +## Database + +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 | +| proclamation_categories | Category metadata | +| solfeggio_frequencies | 9 frequency presets | +| audio_files | Downloaded audio files | + +--- + +## Network + +D-Bus integration via sdbus-c++: +- Async WiFi scan +- WPA/WPA2 connection +- Captive portal detection + +### Remote APIs + +| Endpoint | Purpose | +|----------|---------| +| `portal.radiantwavetech.com/api/v2/proclamations` | Sync proclamations | +| `files.radiantwavetech.com/files/` | Audio file downloads | +| HTTP API | License validation | + +Request/response changes against the portal are tracked in `ApiUpdates.md`. +Changes #4 and #5 cover color palettes: the portal will expose them per +proclamation and via a standalone `/api/v2/color-palettes` route. The kiosk +side (`api::fetchColorPalettes`, `sync::syncColorPalettesFromApi`, +`db::replaceColorPalettes`) is implemented but **not wired into the app** until +those routes exist — palettes are meant to be updated by the portal, not code +releases. + +--- + +## Style Guide + +### C++ + +- **Standard:** C++17. No extensions, no GNU-isms. +- **Formatting:** 4 spaces, no tabs. Column limit: 100. +- **Naming:** + - `PascalCase` for types, classes, structs, enums. + - `camelCase` for functions, methods, variables. + - `snake_case` for files (`page_manager.cpp`). + - `UPPER_SNAKE_CASE` for constants and macros. +- **Includes:** SDL3 first, then stdlib, then project headers. +- **Ownership:** Use `std::unique_ptr` for heap allocation. No raw `new`/`delete` + outside of the SDL callback entry/exit points. +- **Callbacks:** Use `override` on all virtual method implementations. +- **Error handling:** Return `SDL_APP_FAILURE` on unrecoverable errors. Log with + `SDL_Log()`. No exceptions for control flow. +- **No globals.** Pass state through `AppState&` or function parameters. + +### Files + +- One class per header/source pair. +- Headers use `#pragma once`. +- Keep `src/` for production code, `tests/` for test code. +- `archive/` is read-only reference. Never modify files in it. + +### Testing + +- One `TEST()` per behavior. +- Use `TestPage` mock to verify lifecycle calls. +- Call `clear_log()` before tests that inspect lifecycle events. +- Prefer `ASSERT_EQ` over `ASSERT_TRUE(a == b)` for better error output. + +--- + +## Provisioning State Machine + +``` +FIRST_RUN -> NEEDS_INTERNET -> NEEDS_LICENSE_KEY -> NEEDS_EMAIL_ADDRESS -> DOWNLOADING_ASSETS -> READY +``` + +Each state maps to a page. The state machine evaluates DB values and network +status to decide which page to show. + +--- + +## Rebuild Progress + +| Component | Status | +|-----------|--------| +| Entry / Event Loop | Done | +| Window + Renderer | Done | +| Page System | Done | +| PageManager (stack) | Done | +| KeybindManager | Done | +| Home Page | Done | +| Settings Page | Done | +| Provisioning FSM | — | +| Pages (11 types) | — | +| OpenGL Context | — | +| Virtual Framebuffer | — | +| Shader Manager | — | +| Font Manager | — | +| Line Patterns | — | +| Color Patterns | — | +| Mixer | — | +| Network / WiFi | — | +| Database (SQLite) | — | +| HTTP Client | — | +| Logging | — | +| Config / App State | — | + +--- + +## Common Pitfalls + +- Do not commit audio assets (`.wav` files) -- downloaded at runtime. +- Do not modify files in `archive/` -- it is reference only. +- `build/` is gitignored. Do not commit build artifacts. +- Use the Nix dev shell or ensure SDL3 is installed for building. +- CI uses self-hosted Forgejo runners. +- Asset tests (`test_assets`) must pass before committing -- they catch missing + fonts and images that would break the app at runtime. diff --git a/algorithm.md b/algorithm.md new file mode 100644 index 0000000..4a94449 --- /dev/null +++ b/algorithm.md @@ -0,0 +1,502 @@ +**Ben Williams** February 9, 2007 MATH491 Summer Research Project 2006–2007 Department of Mathematics and Statistics, University of Canterbury + +## Abstract + +An analysis of some recently proposed methods of digital image scrambling that use Fibonacci numbers. This report describes the mathematics required to understand why these methods work. + +## Contents + +1. Introduction +2. Properties of the Fibonacci numbers +3. Affine transformations +4. Applications to digital image scrambling +5. Conclusion Appendix: Methods to calculate $f_n$ in $\mathbb{Z}_m$ + +--- + +## 1. Introduction + +Digital image scrambling is a method to hide the content of an image. One application is in satellite television broadcasting systems to prevent unauthorized viewing. The scrambling algorithm needs to be fast so that each frame can be de-scrambled in real-time. A method used in the past has been to permute the rows of the image. A key is then required to describe the inverse permutation. + +Other methods of scrambling include scrambling pixel positions or scrambling the color values of each pixel. Without knowledge of the key it should be difficult to recover the scrambled image given time constraints. + +Digital image scrambling methods have specific requirements that general data encryption algorithms may not provide. The algorithms need to be simple and fast so that they can be used by low cost equipment. The scrambled image should not adversely affect data compression algorithms, and information about the image such as dimension needs to be available without decrypting the image. + +In order to understand the new scrambling methods proposed in [2], [3], [4], some properties of the Fibonacci numbers are described. Affine transformations are then presented because these will be used to scramble pixels to new positions within an image. Some affine transformations that use Fibonacci numbers for scrambling digital images are described and analysed. + +--- + +## 2. Properties of the Fibonacci numbers + +**Definition 2.1.** The _Fibonacci numbers_ are the numbers in the sequence $0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, \dots$, where the $n$th Fibonacci number $f_n$ is defined by the linear recurrence relation + +$$f_n = f_{n-1} + f_{n-2} \quad \text{for } n \ge 2 \text{ and } f_0 = 0,\ f_1 = 1.$$ + +**Theorem 2.1.** $f_n$ is even $\iff$ 3 divides $n$. + +_Proof._ Consider the Fibonacci sequence where each number is reduced modulo 2. + +$$s_n = f_n \bmod 2$$ $$s_n = (f_{n-1} + f_{n-2}) \bmod 2$$ $$s_n = (s_{n-1} + s_{n-2}) \bmod 2$$ + +The sequence generated is + +$$0, 1, 1, 0, 1, 1, 0, 1, 1, 0, \dots$$ + +The period of the sequence is 3 because $s_i = s_{i+3}$ for all $i \in \mathbb{N}$. It follows that + +$$s_{3k} = 0, \qquad s_{3k+1} = 1, \qquad s_{3k+2} = 1 \quad \text{for all } k \in \mathbb{N}.$$ + +Therefore $f_n \equiv 0 \pmod 2 \iff n = 3k$. $\blacksquare$ + +**Definition 2.2.** The _Fibonacci matrix_ is the $2 \times 2$ matrix + +$$F = \begin{bmatrix} 0 & 1 \ 1 & 1 \end{bmatrix}$$ + +It is used to represent the Fibonacci sequence as a matrix equation. Let $\mathbf{u}_n = \begin{bmatrix} f_{n-1} \ f_n \end{bmatrix}$. Then for $n \ge 1$, $\mathbf{u}_{n+1} = F\mathbf{u}_n$. + +**Theorem 2.2.** + +$$f_n = \frac{(1+\sqrt5)^n - (1-\sqrt5)^n}{2^n \sqrt5}$$ + +**Table 1: The Fibonacci numbers** + +|$n$|$f_n$|$f_{n+1}$|$f_n^2 \bmod f_{n+1}$| +|---|---|---|---| +|0|0|1|0| +|1|1|1|0| +|2|1|2|-1| +|3|2|3|1| +|4|3|5|-1| +|5|5|8|1| +|6|8|13|-1| +|7|13|21|1| +|8|21|34|-1| +|9|34|55|1| +|10|55|89|-1| +|11|89|144|1| +|12|144|233|-1| +|13|233|377|1| +|14|377|610|-1| +|15|610|987|1| +|16|987|1597|-1| + +_Proof of Theorem 2.2._ [5] p. 355. + +$$\mathbf{u}_{n+1} = F\mathbf{u}_n = F(F\mathbf{u}_{n-1}) = F^2 \mathbf{u}_{n-1} = \cdots = F^n \mathbf{u}_{n-(n-1)} = F^n \mathbf{u}_1$$ + +$\lambda$ is an eigenvalue of $F$ if $\det(F - \lambda I) = 0$. Solving the characteristic equation for $F$ gives eigenvalues + +$$\lambda_1 = \frac{1+\sqrt5}{2}, \qquad \lambda_2 = \frac{1-\sqrt5}{2}$$ + +Let $\mathbf{v}_1$ and $\mathbf{v}_2$ be corresponding eigenvectors. These eigenvectors are linearly independent because $\lambda_1 \ne \lambda_2$, therefore they span $\mathbb{R}^2$. + +Let $\mathbf{u}_1 = c_1 \mathbf{v}_1 + c_2 \mathbf{v}_2$. + +$$\mathbf{u}_{n+1} = F^n \mathbf{u}_1 = F^n(c_1 \mathbf{v}_1 + c_2 \mathbf{v}_2) = c_1 F^n \mathbf{v}_1 + c_2 F^n \mathbf{v}_2$$ + +$$\begin{bmatrix} f_n \ f_{n+1} \end{bmatrix} = c_1 \lambda_1^n \mathbf{v}_1 + c_2 \lambda_2^n \mathbf{v}_2$$ + +Therefore $f_n = d_1 \lambda_1^n + d_2 \lambda_2^n$ for coefficients yet to be determined. Solving the system of equations + +$$f_0 = d_1 \lambda_1^0 + d_2 \lambda_2^0 = d_1 + d_2 = 0$$ + +$$f_1 = d_1 \lambda_1^1 + d_2 \lambda_2^1 = d_1\left(\frac{1+\sqrt5}{2}\right) + d_2\left(\frac{1-\sqrt5}{2}\right) = 1$$ + +gives $d_1 = \dfrac{1}{\sqrt5}$, $d_2 = \dfrac{-1}{\sqrt5}$. + +$$f_n = \frac{1}{\sqrt5}\left(\left(\frac{1+\sqrt5}{2}\right)^n - \left(\frac{1-\sqrt5}{2}\right)^n\right) = \frac{(1+\sqrt5)^n - (1-\sqrt5)^n}{2^n\sqrt5} \qquad \blacksquare$$ + +**Lemma 2.3.** For $n \ge 1$ + +$$F^n = \begin{bmatrix} f_{n-1} & f_n \ f_n & f_{n+1} \end{bmatrix}$$ + +_Proof._ By induction. Let $P(n)$ be the proposition that $F^n = \begin{bmatrix} f_{n-1} & f_n \ f_n & f_{n+1} \end{bmatrix}$. + +$P(1)$ is the proposition that $F = \begin{bmatrix} 0 & 1 \ 1 & 1 \end{bmatrix}$, which is true. + +If $P(k)$ is true for some positive integer $k$ then + +$$F^k F = \begin{bmatrix} f_{k-1} & f_k \ f_k & f_{k+1} \end{bmatrix}\begin{bmatrix} 0 & 1 \ 1 & 1 \end{bmatrix} = \begin{bmatrix} f_k & f_{k-1}+f_k \ f_{k+1} & f_k+f_{k+1} \end{bmatrix} = \begin{bmatrix} f_k & f_{k+1} \ f_{k+1} & f_{k+2} \end{bmatrix} = \begin{bmatrix} f_{(k+1)-1} & f_{k+1} \ f_{k+1} & f_{(k+1)+1} \end{bmatrix} = F^{k+1}$$ + +Therefore $P(k) \Rightarrow P(k+1)$ and by induction, $P(n)$ is true for all $n \ge 1$. $\blacksquare$ + +**Theorem 2.4.** Let $f_n$ denote the $n$th Fibonacci number. Then for $n \ge 1$ + +$$f_{n-1} \cdot f_{n+1} - f_n^2 = (-1)^n$$ + +_Proof._ [7] Taking determinants on both sides of the equation in Lemma 2.3 gives + +$$\begin{vmatrix} f_{n-1} & f_n \ f_n & f_{n+1} \end{vmatrix} = \det(F^n) = \det(F)^n = (-1)^n \qquad \blacksquare$$ + +**Theorem 2.5.** Any two consecutive Fibonacci numbers $f_n$ and $f_{n+1}$ are relatively prime. + +_Proof._ By induction. Let $P(n)$ be the proposition that $\gcd(f_n, f_{n+1}) = 1$. + +$P(0)$ is the proposition that $\gcd(0,1)=1$, which is true. + +If $P(k)$ is true for some positive integer $k$ then + +$$\gcd(f_k, f_{k+1}) = 1 = \gcd(f_k+f_{k+1}, f_{k+1}) = \gcd(f_{k+1}, f_{k+2})$$ + +because $f_k + f_{k+1} = f_{k+2}$ by Definition 2.1. + +Therefore $P(k) \Rightarrow P(k+1)$ and by induction, $P(n)$ is true for all $n \ge 0$. $\blacksquare$ + +--- + +## 3. Affine transformations + +Let $\mathbb{Z}_m = {0,1,2,\dots,m-1}$. Denote $\mathbb{Z}_m^n$ as the set of vectors of dimension $n$ with each component an element in $\mathbb{Z}_m$. Denote $M_n(\mathbb{Z}_m)$ as the set of all $n \times n$ matrices with each entry an element in $\mathbb{Z}_m$. Matrix operations addition and multiplication over $\mathbb{Z}_m$ are defined the same as addition and multiplication over $\mathbb{Z}$ with each matrix entry reduced modulo $m$. + +For example: Let $A = (a_{i,j})$, $B = (b_{i,j}) \in M_n(\mathbb{Z}_m)$ and $m \in \mathbb{N}$. We write + +$$A \equiv B \pmod m$$ + +if $a_{i,j} \equiv b_{i,j} \pmod m$ for all $i,j \in {0,1,\dots,n}$. + +**Definition 3.1.** If there exists $A' \in M_n(\mathbb{Z}_m)$ such that $AA' \equiv I_n \pmod m$ then $A$ is _invertible_mod $m$. + +**Theorem 3.1.** Let $A \in M_n(\mathbb{Z}_m)$ and $m \in \mathbb{N}$. $A$ is invertible $\iff \det(A)$ and $m$ are relatively prime. + +_Proof._ [4] p. 94. Suppose $A$ is invertible. Then $AB \equiv I \pmod m$ for some $B \in M_n(\mathbb{Z}_m)$. + +$$\det(AB) \equiv 1 \pmod m$$ $$\det(A) \cdot \det(B) \equiv 1 \pmod m$$ + +Let $a = \det(A)$ and $b = \det(B)$. + +$$ab + km = 1 \text{ for some } k \in \mathbb{Z}$$ + +Suppose $c \mid a$ and $c \mid m$. Then $c \mid (ab+km)$, i.e. $c \mid 1$. Therefore $\gcd(a,m) = 1$. + +Conversely suppose $\gcd(a,m) = 1$. Then + +$$ab + km = 1 \text{ for some } b,k \in \mathbb{Z}$$ $$ab \equiv 1 \pmod m$$ + +A well known equation for square matrices is + +$$A , \mathrm{adj}(A) = \det(A), I$$ + +where $\mathrm{adj}(A)$ is the adjoint matrix of $A$. This equation gives a formula to calculate $A^{-1}$. + +$$A,\mathrm{adj}(A) \equiv \det(A), I \pmod m$$ $$bA,\mathrm{adj}(A) \equiv ba, I \pmod m$$ $$A, b,\mathrm{adj}(A) \equiv I \pmod m$$ $$A^{-1} \equiv b,\mathrm{adj}(A) \pmod m \qquad \blacksquare$$ + +**Remark 1.** If $\det(A) = 0$ then $A$ is not invertible, since $\gcd(0,m) = m$. An example of a non-invertible matrix with a non-zero determinant is $\begin{bmatrix} 2 & 0 \ 0 & 1 \end{bmatrix}$ over $M_2(\mathbb{Z}_4)$. + +**Definition 3.2.** A transformation $T: \mathbb{Z}_m^n \to \mathbb{Z}_m^n$ defined by $T(\mathbf{x}) = A\mathbf{x} + \mathbf{b} \pmod m$ for some $\mathbf{b} \in \mathbb{Z}_m^n$, $A \in M_n(\mathbb{Z}_m)$ is called _affine_. If $\mathbf{b} = 0$, then $T$ is a _linear_ transformation. + +**Corollary 3.2.** The affine linear map from Definition 3.2 is bijective if and only if $\det(A)$ and $m$ are relatively prime. + +**Definition 3.3.** Let $A \in M_n(\mathbb{Z}_m)$ and $m \in \mathbb{N}$. $A$ is _periodic_ if there exists $t \in \mathbb{N}$ such that $t > 0$ and $A^t = I_n$. The _period_ of $A$ is the smallest positive integer $t$ such that $A^t = I_n$. + +An affine transformation $T: \mathbb{Z}_m^n \to \mathbb{Z}_m^n$ is _periodic_ if $T^t(\mathbf{x}) = \mathbf{x}$ for some positive integer $t$ and all $\mathbf{x} \in \mathbb{Z}_m^n$. + +**Theorem 3.3.** Let $A \in M_n(\mathbb{Z}_m)$ and $m \in \mathbb{N}$. $A$ is periodic $\iff$ $A$ is invertible. + +_Proof._ Suppose $A$ is periodic. Then for some positive integer $t$ + +$$A^t \equiv I \pmod m$$ $$A, A^{t-1} \equiv I \pmod m$$ + +Therefore $A$ is invertible. + +Conversely, suppose $A$ is invertible. There are exactly $m^{n^2}$ distinct matrices in $M_n(\mathbb{Z}_m)$. Define the multiset $S = {A^i \bmod m : 0 \le i \le m^{n^2}}$. There exists $A^k, A^l \in S$ where $k \le l$ and $A^k = A^l$. + +$$A^k \equiv A^l \pmod m$$ $$A^k(A^{-1})^k \equiv A^l(A^{-1})^k \pmod m$$ $$I \equiv A^{l-k} \pmod m$$ + +Therefore $A$ is periodic. $\blacksquare$ + +**Theorem 3.4.** The affine transformation $T: \mathbf{x} \to A\mathbf{x} + c\mathbf{b}$ defined in 3.2 is periodic if and only if $A$ is invertible. + +_Proof._ Define affine transformation $S: \mathbb{Z}_m^{n+1} \to \mathbb{Z}_m^{n+1}$ by + +$$S(\mathbf{v}) = \begin{bmatrix} A & \mathbf{b} \ \mathbf{0}^T & 1 \end{bmatrix} = A'\mathbf{v}$$ + +Let $\mathbf{y} = \begin{bmatrix} \mathbf{x} \ c \end{bmatrix}$ for some $c \in \mathbb{Z}_m$, $\mathbf{x} \in \mathbb{Z}_m^n$. Then + +$$S(\mathbf{y}) = \begin{bmatrix} A & \mathbf{b} \ \mathbf{0}^T & 1 \end{bmatrix}\begin{bmatrix} \mathbf{x} \ c \end{bmatrix} = \begin{bmatrix} A\mathbf{x} + c\mathbf{b} \ c \end{bmatrix} = \begin{bmatrix} T(\mathbf{x}) \ c \end{bmatrix}$$ + +Let $P(n)$ be the proposition that + +$$S^n\left(\begin{bmatrix} \mathbf{x} \ c \end{bmatrix}\right) = \begin{bmatrix} T^n(\mathbf{x}) \ c \end{bmatrix}$$ + +$P(1)$ has been proved. If $P(k)$ is true, then + +$$S^{k+1}(\mathbf{y}) = S(S^k(\mathbf{y})) = \begin{bmatrix} A & \mathbf{b} \ \mathbf{0}^T & 1 \end{bmatrix}\begin{bmatrix} T^k(\mathbf{x}) \ c \end{bmatrix} = \begin{bmatrix} A,T^k(\mathbf{x}) + c\mathbf{b} \ c \end{bmatrix} = \begin{bmatrix} T^{k+1}(\mathbf{x}) \ c \end{bmatrix}$$ + +Therefore $P(k) \Rightarrow P(k+1)$ and by induction, $P(n)$ is true for all $n \ge 1$. + +If there exists positive integer $t$ such that + +$$S^t\left(\begin{bmatrix} \mathbf{x} \ c \end{bmatrix}\right) = \begin{bmatrix} T^t(\mathbf{x}) \ c \end{bmatrix} = \begin{bmatrix} \mathbf{x} \ c \end{bmatrix}$$ + +then $S$ and $T$ are both periodic, otherwise $S$ and $T$ are both non-periodic. + +$$T \text{ is periodic} \iff S \text{ is periodic}$$ $$T \text{ is periodic} \iff A' \text{ is invertible (Theorem 3.3)}$$ $$T \text{ is periodic} \iff \text{there exists } A'^{-1} = \begin{bmatrix} A^{-1} & A^{-1}(-\mathbf{b}) \ \mathbf{0}^T & 1 \end{bmatrix}$$ $$T \text{ is periodic} \iff A \text{ is invertible} \qquad \blacksquare$$ + +**Remark 2.** Any scalar multiple of $\mathbf{b}$ can be used in $T$ and the period will remain the same. + +--- + +## 4. Applications to digital image scrambling + +**Definition 4.1.** The _Fibonacci affine transformation_ $T: \mathbb{Z}_m^2 \to \mathbb{Z}_m^2$ is defined by + +$$T(\mathbf{x}) = F\mathbf{x} + \mathbf{b} \pmod m$$ + +for some $\mathbf{b} \in \mathbb{Z}_m^2$, $m \in \mathbb{N}$ and $F$ from Definition 2.2. + +**Proposition 4.1.** The Fibonacci affine transformation is periodic. + +_Proof._ $\gcd(\det(F), m) = 1$ for all $m$, so by Theorems 3.1 and 3.4, the transformation is periodic. $\blacksquare$ + +The Fibonacci matrix transformation is described in [3] where the authors use it to scramble images of dimension $m \times m$ pixels. Each pixel has a coordinate in $\mathbb{Z}_m^2$ that can be scrambled to a new coordinate by $T$. The period of the transformation varies for different $m$. The suggested scrambling method is to apply $T$ on every pixel of the image. Then to decrypt, repeatedly apply $T$ to the scrambled image until the original image is recovered. It is unclear why the inverse transformation of $T$ is not used to make the decryption quicker. The vector $\mathbf{b}$ can be used as a key. Different values of $\mathbf{b}$ will affect the period of $T$. + +**Definition 4.2.** The _Arnold transformation_ $T: \mathbb{Z}_m^2 \to \mathbb{Z}_m^2$ is defined by $T(\mathbf{x}) = A\mathbf{x} \pmod m$ for $m \in \mathbb{N}$ and + +$$A = \begin{bmatrix} 1 & 1 \ 1 & 2 \end{bmatrix}$$ + +The Arnold transformation works the same way as the Fibonacci matrix transformation. The Arnold transformation is also periodic for all $m$. + +**Remark 3.** $F^2 = A$. + +Another transformation described in [4] works as follows. + +**Definition 4.3.** $T: \mathbb{Z}_m^2 \to \mathbb{Z}_m^2$ defined by $T(\mathbf{x}) = B\mathbf{x} + \mathbf{b} \pmod{f_{n+1}}$ for some $\mathbf{b} \in \mathbb{Z}_m^2$, $m \in \mathbb{N}$ with + +$$B = \begin{bmatrix} f_n & 0 \ 0 & f_n \end{bmatrix}$$ + +**Proposition 4.2.** $B$ is invertible, hence the transformation is periodic. + +_Proof._ $\det(B) = f_n^2 = f_{n-1}f_{n+1} + (-1)^{n+1}$ by Theorem 2.4. + +If $c \mid \det(B)$ and $c \mid f_{n+1}$ then $c \mid 1$, therefore $\det(B)$ and $f_{n+1}$ are coprime. $\blacksquare$ + +**Proposition 4.3.** When $n$ is odd the period of $B$ is 2. When $n$ is even the period of $B$ is 4. + +_Proof._ From Theorem 2.4 we have + +$$f_n^2 \equiv (-1)^{n+1} \pmod{f_{n+1}}$$ + +so $f_n^4 \equiv 1 \pmod{f_{n+1}}$ and $B^4 \equiv I \pmod{f_{n+1}}$. + +If $B$ has period $t$ then $t \mid 4$. + +When $n$ is even, $B \ne I$ and $B^2 \equiv -I \pmod{f_{n+1}}$. Therefore when $n$ is even the period of $B$ is 4. + +When $n$ is odd, $f_n^2 \equiv 1 \pmod{f_{n+1}}$, so $B^2 \equiv I \pmod{f_{n+1}}$. Since $B \ne I \pmod{f_{n+1}}$, when $n$ is odd the period of $B$ is 2. $\blacksquare$ + +**Remark 4.** Although $B$ has period 2 or 4, the period of the transformation may not be 2 or 4 when $\mathbf{b}$ is non-zero. + +The transformation permutes the columns and rows of the image by the permutation $R: k \to kf_n + b \pmod{f_{n+1}}$ where $k$ is a column or row number in ${1,2,\dots,f_{n+1}-1}$. The authors in [4] also include zero in this set but zero is a fixed point when $b = 0$. The authors state that having a period of 2 or 4 is efficient because encryption and decryption are the same functions and decryption can be achieved by reapplying the transformation. + +The case where $b = 0$ will be examined. Let $S: k \to kf_n \pmod{f_{n+1}}$ for $k$ in ${1,2,\dots,f_{n+1}-1}$. It follows from Proposition 4.3 that $S$ has period 4 if $n$ is even and period 2 if $n$ is odd. + +**Definition 4.4.** A _uniform permutation_ is a permutation in which all disjoint cycles are of the same length. This means there are no fixed points unless the permutation is the identity. + +**Remark 5.** If a permutation has period $t$, then the length of each cycle divides $t$, since $t$ is the lowest common multiple of all cycle lengths. + +Suppose the columns or rows of an image are scrambled using a uniform permutation. Then every row or column is moved, so the image content may be hidden more effectively than a permutation that leaves some rows or columns unchanged. + +In general the permutation $S$ is not uniform. Fixed points can be found by solving $x$ in the equation + +$$xf_n \equiv x \pmod{f_{n+1}} \tag{1}$$ + +**Proposition 4.4.** For $n > 3$, if $n$ is odd then $S$ is not uniform. + +_Proof._ $x = k(f_n+1)$, $k \in \mathbb{N}$, gives solutions to equation (1). + +$$k(f_n+1) \cdot f_n = k(f_n^2+f_n) \equiv k(1+f_n) \pmod{f_{n+1}}$$ + +**Remark 6.** When $n=3$, $S$ is uniform. $\blacksquare$ + +**Proposition 4.5.** If $n$ is even, then $S$ has no transpositions. + +_Proof._ A transposition exists if + +$$x \equiv f_n^2 x \pmod{f_{n+1}} \tag{2}$$ + +and + +$$x \not\equiv f_n x \pmod{f_{n+1}} \tag{3}$$ + +for some $x \in {1,2,\dots,f_{n+1}-1}$. This proof will show that if equation (2) has a solution, then $x \equiv f_n x \pmod{f_{n+1}}$ has the same solution. + +Solutions are found by solving for $x$: + +$$x \equiv f_n^2 x \pmod{f_{n+1}} \tag{4}$$ $$x \equiv -x \pmod{f_{n+1}} \tag{5}$$ $$2x \equiv 0 \pmod{f_{n+1}} \tag{6}$$ + +A solution exists $\iff$ 2 divides $f_{n+1}$, then $x = \dfrac{f_{n+1}}{2}$. + +$f_{n+1}$ is even, so by Theorem 2.1, $f_n$ is odd. + +$$f_n x = (2m+1)x \equiv (2m)\frac{f_{n+1}}{2} + \frac{f_{n+1}}{2} \equiv m f_{n+1} + x \pmod{f_{n+1}}$$ $$f_n x \equiv x \pmod{f_{n+1}}$$ + +This is a contradiction to equation (3). Therefore, no transpositions exist. $\blacksquare$ + +**Proposition 4.6.** For even $n$, $S$ is uniform $\iff f_{n+1}$ is odd. + +_Proof._ Suppose $S$ is uniform, then no fixed points exist. If $x \equiv f_n x \pmod{f_{n+1}}$ has no solutions then by Proposition 4.5 + +$$x \equiv f_n^2 x \pmod{f_{n+1}} \tag{7}$$ $$2x \equiv 0 \pmod{f_{n+1}} \tag{8}$$ + +has no solutions so $f_{n+1}$ is odd. + +Conversely, suppose $f_{n+1}$ is odd and a fixed point $x$ exists. Then equation (8) has no solution, thus + +$$x \equiv f_n^2 x \pmod{f_{n+1}}$$ + +has no solution. But a fixed point exists so + +$$x \equiv f_n x \equiv f_n^2 x \pmod{f_{n+1}}$$ + +This is a contradiction. $\blacksquare$ + +**Proposition 4.7.** For even $n$, $f_{n+1}$ is odd $\iff n \equiv 0$ or $4 \pmod 6$. + +_Proof._ Let $f_{n+1}$ be odd, then by Theorem 2.1 either + +$$n+1 = 3k+1 \implies n = 3k$$ + +or + +$$n+1 = 3l+2 \implies n = 3l+1$$ + +Equivalently, $f_{n+1}$ is odd $\iff n = 3k$ or $n = 3l+1$. Now restrict $n$ to even values, so let $k = 2m$ and $l = 2m+1$. Then + +$$n = 6m$$ + +or + +$$n = 3(2m+1)+1 = 6m+4$$ + +Therefore, for even $n$, $f_{n+1}$ is odd $\iff n = 6m$ or $n = 6m+4$. $\blacksquare$ + +--- + +## 5. Conclusion + +The Fibonacci transformation and the Arnold transformation can be used on square images of any size. The period of the transformation will vary for different size images. The period of $F \bmod m$ can be as large as $6m$ so the decryption process described could be very inefficient. + +The transformation in Definition 4.3 transforms pixels of an $f_{n+1} \times f_{n+1}$ image. In most practical cases, images are unlikely to be square and of this dimension. In this case the image can be cropped to $f_{n+1} \times f_{n+1}$ so that the transformation can be applied. Alternatively, a large Fibonacci number can be chosen that exceeds the dimensions of the image. But this would generate an encrypted image larger than the unencrypted image. Due to these limitations, the transformations do not appear to be very practical. + +--- + +## Appendix: Methods to calculate $f_n$ in $\mathbb{Z}_m$ + +To calculate the $n$th Fibonacci number mod $m$, the equation in Theorem 2.2 can be used and then $f_n$ can be reduced modulo $m$. For very large $n$, this is infeasible. If $F$ can be diagonalized over $\mathbb{Z}_m$ then calculating $F^n$ is fast. + +Eigenvalues of $F$ exist if for non-zero vector $\mathbf{x}$, + +$$F\mathbf{x} \equiv \lambda \mathbf{x} \pmod m$$ $$F\mathbf{x} - \lambda \mathbf{x} \equiv \mathbf{0} \pmod m$$ $$(F - \lambda I)\mathbf{x} \equiv \mathbf{0} \pmod m$$ + +Therefore $\lambda$ is an eigenvalue of $F$ if + +$$\gcd(\det(F-\lambda I), m) > 1$$ + +The characteristic polynomial of $F$ is $p(\lambda) = \det(F - \lambda I) = \lambda^2 - \lambda - 1$. + +For example: $\lambda_1 = 3$ and $\lambda_2 = 8$ are eigenvalues of $F$ in $\mathbb{Z}_{10}$ because $p(3) = 5$ and $p(8) = 5$ and $\gcd(5,10) \ne 1$. + +$$E_3 = \mathrm{null}\begin{bmatrix} -3 & 1 \ 1 & -2 \end{bmatrix} = \mathrm{null}\begin{bmatrix} 7 & 1 \ 1 & 8 \end{bmatrix} = \mathrm{null}\begin{bmatrix} 1 & 8 \ 7 & 1 \end{bmatrix} = \mathrm{null}\begin{bmatrix} 1 & 8 \ 0 & 5 \end{bmatrix} = \mathrm{span}\begin{bmatrix} 6 \ -2 \end{bmatrix}$$ + +$$E_8 = \mathrm{null}\begin{bmatrix} -8 & 1 \ 1 & -7 \end{bmatrix} = \mathrm{null}\begin{bmatrix} 2 & 1 \ 1 & 3 \end{bmatrix} = \mathrm{null}\begin{bmatrix} 1 & 3 \ 2 & 1 \end{bmatrix} = \mathrm{null}\begin{bmatrix} 1 & 3 \ 0 & 5 \end{bmatrix} = \mathrm{span}\begin{bmatrix} 6 \ -2 \end{bmatrix}$$ + +Although $\lambda_1$ and $\lambda_2$ are distinct, they have the same eigenspace so we do not have two linearly independent eigenvectors to diagonalize $F$. + +**Proposition .1.** If $m \mid p(\lambda_1)$ and $m \mid p(\lambda_2)$ for distinct eigenvalues $\lambda_1$ and $\lambda_2$, and $\gcd(\lambda_1 - \lambda_2, m) = 1$, then + +$$f_n \equiv (\lambda_1 - \lambda_2)^{-1}(\lambda_1^n - \lambda_2^n) \pmod m$$ + +_Proof._ If $m \mid p(\lambda)$ then $\lambda(\lambda - 1) \equiv 1 \pmod m$ and $\lambda(1-\lambda) \equiv -1 \pmod m$. + +$$E_\lambda = \mathrm{null}\begin{bmatrix} -\lambda & 1 \ 1 & 1-\lambda \end{bmatrix} = \mathrm{null}\begin{bmatrix} 1 & 1-\lambda \ -\lambda & 1 \end{bmatrix} = \mathrm{null}\begin{bmatrix} 1 & 1-\lambda \ 0 & \lambda(1-\lambda)+1 \end{bmatrix} = \mathrm{null}\begin{bmatrix} 1 & 1-\lambda \ 0 & 0 \end{bmatrix} = \mathrm{span}\begin{bmatrix} \lambda-1 \ 1 \end{bmatrix}$$ + +Let $\mathbf{v}_1$ and $\mathbf{v}_2$ be eigenvectors corresponding to $\lambda_1$ and $\lambda_2$ and let $P = [\mathbf{v}_1\ \mathbf{v}_2]$. + +$$F = PDP^{-1}$$ $$F^n = PD^nP^{-1} = \begin{bmatrix} \lambda_1-1 & \lambda_2-1 \ 1 & 1 \end{bmatrix}\begin{bmatrix} \lambda_1^n & 0 \ 0 & \lambda_2^n \end{bmatrix}\frac{1}{\det(P)}\begin{bmatrix} 1 & 1-\lambda_2 \ -1 & \lambda_1-1 \end{bmatrix}$$ + +$$= \frac{1}{\det(P)}\begin{bmatrix} \lambda_1-1 & \lambda_2-1 \ 1 & 1 \end{bmatrix}\begin{bmatrix} \lambda_1^n & (1-\lambda_2)\lambda_1^n \ -\lambda_2^n & (\lambda_1-1)\lambda_2^n \end{bmatrix}$$ + +$$= \frac{1}{\det(P)}\begin{bmatrix} (\lambda_1-1)\lambda_1^n+(1-\lambda_2)\lambda_2^n & (\lambda_1-1)(1-\lambda_2)\lambda_1^n+(\lambda_1-1)(\lambda_2-1)\lambda_2^n \ \lambda_1^n-\lambda_2^n & (1-\lambda_2)\lambda_1^n+(\lambda_1-1)\lambda_2^n \end{bmatrix}$$ + +$$F^n = \begin{bmatrix} f_{n-1} & f_n \ f_n & f_{n+1} \end{bmatrix}$$ + +$$f_n = \frac{1}{\det(P)}\lambda_1^n - \lambda_2^n \pmod m$$ + +$$f_n = (\lambda_1-\lambda_2)^{-1}(\lambda_1^n - \lambda_2^n) \pmod m \qquad \blacksquare$$ + +**Remark 7.** If $m \mid p(\lambda_1)$ and $m \mid p(\lambda_2)$, this does not imply $\gcd(\lambda_1-\lambda_2, m) = 1$. E.g. in $\mathbb{Z}_{55}$, $p(48) \equiv 0 \pmod{55}$ and $p(8) \equiv 0 \pmod{55}$ but $\gcd(48-8, 55) = 5$. + +The eigenvalues in these examples have been found by trying each element in $\mathbb{Z}_m$. Eigenvalues can be found directly if the equation from Theorem 2.2 is used. There are some advantages to using prime $m$. When $m$ is prime, 2 is invertible and a method to calculate $\sqrt5$ is possible for some primes. Also $\gcd(\lambda_1-\lambda_2, m) = 1$ is always true, so the equation from Proposition .1 can be used once the eigenvalues have been found. + +**Definition .1.** Given integers $a$ and $p$, if $x^2 \equiv a \pmod p$ for some $x \in {1,2,\dots,p-1}$ then $a$ is a _quadratic residue_ mod $p$. + +**Definition .2.** Given odd prime $p$ and integer $a$, the _Legendre symbol_ is + +$$(a/p) = a^{\frac{p-1}{2}} \pmod p$$ + +**Theorem .2.** For $p$ an odd prime and $a \in \mathbb{Z}_p^*$ + +$$a \text{ is a quadratic residue} \pmod p \iff (a/p) \equiv 1 \pmod p$$ + +_Proof._ [9] Suppose $x^2 \equiv a \pmod p$ for some $x \in \mathbb{Z}_p^*$. Then by using Fermat's Theorem + +$$a^{\frac{p-1}{2}} = (x^2)^{\frac{p-1}{2}} = x^{p-1} \equiv 1 \pmod p$$ + +Suppose $(a/p) = 1$ and let $a = g^k$ for primitive $g \in \mathbb{Z}_p^*$ and $k \in {1,2,\dots,p-1}$. Note: this is always possible for prime $p$. + +$$a^{\frac{p-1}{2}} = (g^k)^{\frac{p-1}{2}} = g^{k(\frac{p-1}{2})} \equiv 1 \pmod p \quad \text{(by assumption)}$$ $$g^{k(\frac{p-1}{2})} = g^{p-1} \pmod p \quad \text{(because primitive)}$$ + +Therefore $(p-1) \mid k(\tfrac{p-1}{2})$, so $k$ is even. Then $a = g^k = g^{2m} = (g^m)^2$ for some $m \in \mathbb{N}$. Thus $a$ is a quadratic residue. $\blacksquare$ + +**Theorem .3 (Law of Quadratic Reciprocity).** For distinct odd primes $p$ and $q$, + +1. If both $p$ and $q \equiv 3 \pmod 4$, then either $(q/p) = 1$ or $(p/q) = 1$ but not both. +2. If $p$ or $q \equiv 1 \pmod 4$, then $(q/p) = 1 \iff (p/q) = 1$. + +**Proposition .4.** For odd prime $p$, 5 is a quadratic residue $\iff p \equiv 1$ or $-1 \pmod{10}$. + +_Proof._ By Quadratic reciprocity, + +$$(5/p) = 1 \iff (p/5) = 1$$ $$(5/p) = 1 \iff p^{\frac{5-1}{2}} \equiv 1 \pmod 5$$ $$(5/p) = 1 \iff p^2 \equiv 1 \pmod 5$$ $$(5/p) = 1 \iff p \equiv 1 \text{ or } p \equiv -1 \pmod 5$$ $$(5/p) = 1 \iff p = 5k \pm 1$$ $$(5/p) = 1 \iff p = 5(2m) \pm 1 \quad \text{(odd } k \text{ gives even } p\text{)}$$ $$(5/p) = 1 \iff p \equiv 1 \text{ or } p \equiv -1 \pmod{10} \qquad \blacksquare$$ + +Now that we can identify which primes have 5 as a quadratic residue, there still remains the problem of calculating $\sqrt5$. In general, calculating square roots of quadratic residues has the same level of complexity as integer factorization [12]. + +**Proposition .5.** For given integer $a$, if $p \equiv 3 \pmod 4$ then $\sqrt{a} \equiv a^{\frac{p+1}{4}} \pmod p$. + +_Proof._ Assume $a$ is a quadratic residue. Then + +$$a^{\frac{p-1}{2}} \equiv 1 \pmod p$$ $$a \cdot a^{\frac{p-1}{2}} \equiv a \pmod p$$ $$a^{\frac{p-1}{2}+1} \equiv a \pmod p$$ $$a^{\frac{p+1}{2}} \equiv a \pmod p$$ $$\sqrt{a^{\frac{p+1}{2}}} \equiv \sqrt{a} \pmod p$$ $$a^{\frac{p+1}{4}} \equiv \sqrt{a} \pmod p$$ + +There is always a solution because $4 \mid (p+1)$. $\blacksquare$ + +**Remark 8.** In the first 1000 primes the proportion that are of the form $10k \pm 1$ is 0.491. The proportion that are of the form $4k+3$ is 0.504. The proportion that satisfy both forms is 0.251. + +--- + +## References + +1. Buchmann, J.A. _Introduction to Cryptography_, Springer-Verlag New York Inc, 2001. +2. J. Zou, R. K. Ward, D. Qi. _A new image scrambling method based on Fibonacci numbers_, Proceedings of the 2004 International Symposium on Circuits and Systems ISCAS 2004, May 23–26, 2004, Vancouver, Canada, Vol 3. pg 965. +3. J. Zou, X. Tie, R. K. Ward, D. Qi. _Some Novel Image Scrambling Methods Based on Affine Modular Matrix Transformation_, Journal of Information & Computational Science 2:1 (2005) 223–227. +4. J. Zou, R. K. Ward, D. Qi. _The Generalized Fibonacci Transformations and Application to Image Scrambling_, Acoustics, Speech, and Signal Processing, 2004. Proceedings. (ICASSP '04). IEEE International Conference on, Vol 3. pg 385. +5. Poole, D. _Linear Algebra: A Modern Introduction_, 2nd Ed, Australia: Belmont, CA: Thomson Brooks/Cole, 2006. +6. W. Zeng, H. Yu, and C. Lin. _Multimedia Security Technologies for Digital Rights Management_, Amsterdam; Boston: Academic Press, 2006. +7. http://en.wikipedia.org/wiki/Fibonacci_number +8. http://mcraeclan.com/MathHelp/BasicNumberSquareQuadraticResidues.htm (Read 30/11/2006) +9. http://rooster.stanford.edu/~ben/maths/numbertheory/qr.html (Read 30/11/2006) +10. http://mathworld.wolfram.com/QuadraticResidue.html (Read 30/11/2006) +11. http://en.wikipedia.org/wiki/Quadratic_reciprocity (Read 5/12/2006) +12. http://en.wikipedia.org/wiki/Quadratic_residue (Read 5/12/2006) +--- +I asked if this algorithm could be modified to handle images of any size instead of pinned Fibonacci numbered sizes. This was the response I received, but I did not receive any examples, code or math: + +Good news: the paper's own math already gives us what we need — the restriction to specific sizes (like fn+1×fn+1fn+1​×fn+1​) only applied to Zou's _scaled_ transformation (Definition 4.3). The plain **Fibonacci affine transform**(Definition 4.1, T(x)=Fx+b  mT(x)=Fx+bmodm) is periodic for _every_ modulus mm, because det⁡(F)=−1det(F)=−1 is coprime to every integer (Proposition 4.1). The only real constraint is that FF acts on a **square** Zm2Zm2​grid. + +So to handle _any_ image size, we just need two small extensions beyond the paper: + +1. **Pad to a square.** Any W×HW×H image can be embedded in a square canvas of side N=max⁡(W,H)N=max(W,H) (no need for NN to be a Fibonacci number, or a power of two, or anything special — any NNworks because of the fact above). +2. **Use the exact inverse instead of relying on the period.** The paper decrypts by re-applying TT until the period comes back around, which the authors themselves call inefficient (up to 6N6N applications). Since F−1≡[−1110](modN)F−1≡[−11​10​](modN) always exists, we can decrypt in exactly as many steps as we encrypted — no searching for the period. + +That turns the "encryption key" into: a **round count** tt (how many times FF is applied — more rounds = more scrambled) and a **sequence of offset vectors** b1,…,btb1​,…,bt​ (derived from a passphrase), applied in order to encrypt and in reverse with F−1F−1 to decrypt. + +I built this as a working tool below: type text, it's rendered to an image, padded to a square, and scrambled with this generalized Fibonacci map — fully reversible with the same key. \ No newline at end of file diff --git a/archive/.envrc b/archive/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/archive/.envrc @@ -0,0 +1 @@ +use flake diff --git a/archive/.forgejo/workflows/build-dev.yml b/archive/.forgejo/workflows/build-dev.yml new file mode 100644 index 0000000..40bd7cf --- /dev/null +++ b/archive/.forgejo/workflows/build-dev.yml @@ -0,0 +1,66 @@ +# 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"}' diff --git a/archive/.forgejo/workflows/build-prod.yml b/archive/.forgejo/workflows/build-prod.yml new file mode 100644 index 0000000..a0746e0 --- /dev/null +++ b/archive/.forgejo/workflows/build-prod.yml @@ -0,0 +1,63 @@ +# 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"}' diff --git a/archive/AGENTS.md b/archive/AGENTS.md new file mode 100644 index 0000000..7482a01 --- /dev/null +++ b/archive/AGENTS.md @@ -0,0 +1,261 @@ +# 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. diff --git a/archive/Dockerfile b/archive/Dockerfile new file mode 100644 index 0000000..cd62b81 --- /dev/null +++ b/archive/Dockerfile @@ -0,0 +1,24 @@ +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 \ No newline at end of file diff --git a/archive/README.org b/archive/README.org new file mode 100644 index 0000000..c2a383d --- /dev/null +++ b/archive/README.org @@ -0,0 +1,90 @@ +#+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 \ No newline at end of file diff --git a/archive/TestList.org b/archive/TestList.org new file mode 100644 index 0000000..028c389 --- /dev/null +++ b/archive/TestList.org @@ -0,0 +1,19 @@ +* 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 +- diff --git a/archive/VMTestList.org b/archive/VMTestList.org new file mode 100644 index 0000000..224817a --- /dev/null +++ b/archive/VMTestList.org @@ -0,0 +1,44 @@ +- [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 diff --git a/archive/changelog.txt b/archive/changelog.txt new file mode 100644 index 0000000..be91e9f --- /dev/null +++ b/archive/changelog.txt @@ -0,0 +1,42 @@ +# 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 diff --git a/archive/docker-compose.yaml b/archive/docker-compose.yaml new file mode 100644 index 0000000..91b6d69 --- /dev/null +++ b/archive/docker-compose.yaml @@ -0,0 +1,22 @@ +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: \ No newline at end of file diff --git a/archive/flake.lock b/archive/flake.lock new file mode 100644 index 0000000..20db77f --- /dev/null +++ b/archive/flake.lock @@ -0,0 +1,61 @@ +{ + "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 +} diff --git a/archive/flake.nix b/archive/flake.nix new file mode 100644 index 0000000..3259b18 --- /dev/null +++ b/archive/flake.nix @@ -0,0 +1,116 @@ +{ + 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; + }; +} diff --git a/archive/go.mod b/archive/go.mod new file mode 100644 index 0000000..b6f03a6 --- /dev/null +++ b/archive/go.mod @@ -0,0 +1,27 @@ +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 +) diff --git a/archive/go.sum b/archive/go.sum new file mode 100644 index 0000000..ca95aae --- /dev/null +++ b/archive/go.sum @@ -0,0 +1,24 @@ +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= diff --git a/archive/internal/application/application.go b/archive/internal/application/application.go new file mode 100644 index 0000000..3610781 --- /dev/null +++ b/archive/internal/application/application.go @@ -0,0 +1,548 @@ +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 +} diff --git a/archive/internal/application/application_test.go b/archive/internal/application/application_test.go new file mode 100644 index 0000000..a467f61 --- /dev/null +++ b/archive/internal/application/application_test.go @@ -0,0 +1,475 @@ +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 +} diff --git a/archive/internal/colors/colors.go b/archive/internal/colors/colors.go new file mode 100644 index 0000000..64ba63c --- /dev/null +++ b/archive/internal/colors/colors.go @@ -0,0 +1,214 @@ +// 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} +) diff --git a/archive/internal/db/DB.go b/archive/internal/db/DB.go new file mode 100644 index 0000000..0e852a5 --- /dev/null +++ b/archive/internal/db/DB.go @@ -0,0 +1,112 @@ +// 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{}, + ) +} diff --git a/archive/internal/db/Schema.go b/archive/internal/db/Schema.go new file mode 100644 index 0000000..0d7f6bb --- /dev/null +++ b/archive/internal/db/Schema.go @@ -0,0 +1,58 @@ +// 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 +} diff --git a/archive/internal/db/queries.go b/archive/internal/db/queries.go new file mode 100644 index 0000000..de2ed52 --- /dev/null +++ b/archive/internal/db/queries.go @@ -0,0 +1,14 @@ +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 +} diff --git a/archive/internal/download/Download.go b/archive/internal/download/Download.go new file mode 100644 index 0000000..60c2bd0 --- /dev/null +++ b/archive/internal/download/Download.go @@ -0,0 +1,239 @@ +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: " " (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 +} diff --git a/archive/internal/fontManager/fontManager.go b/archive/internal/fontManager/fontManager.go new file mode 100644 index 0000000..20e0096 --- /dev/null +++ b/archive/internal/fontManager/fontManager.go @@ -0,0 +1,890 @@ +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 +} diff --git a/archive/internal/graphics/graphics.go b/archive/internal/graphics/graphics.go new file mode 100644 index 0000000..89d158c --- /dev/null +++ b/archive/internal/graphics/graphics.go @@ -0,0 +1,128 @@ +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) + } +} diff --git a/archive/internal/graphics/virtual_framebuffer.go b/archive/internal/graphics/virtual_framebuffer.go new file mode 100644 index 0000000..27d3663 --- /dev/null +++ b/archive/internal/graphics/virtual_framebuffer.go @@ -0,0 +1,193 @@ +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 +} diff --git a/archive/internal/keybinds/keybinds.go b/archive/internal/keybinds/keybinds.go new file mode 100644 index 0000000..96b3636 --- /dev/null +++ b/archive/internal/keybinds/keybinds.go @@ -0,0 +1,79 @@ +// 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 +} diff --git a/archive/internal/license/license.go b/archive/internal/license/license.go new file mode 100644 index 0000000..dbee2cf --- /dev/null +++ b/archive/internal/license/license.go @@ -0,0 +1,73 @@ +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 +} diff --git a/archive/internal/logger/logger.go b/archive/internal/logger/logger.go new file mode 100644 index 0000000..8214b06 --- /dev/null +++ b/archive/internal/logger/logger.go @@ -0,0 +1,231 @@ +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...) } diff --git a/archive/internal/mixer/mixer.go b/archive/internal/mixer/mixer.go new file mode 100644 index 0000000..5d4f086 --- /dev/null +++ b/archive/internal/mixer/mixer.go @@ -0,0 +1,496 @@ +// 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 ". ..." + 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 + } +} diff --git a/archive/internal/mixer/mixer_internal_test.go b/archive/internal/mixer/mixer_internal_test.go new file mode 100644 index 0000000..0e97ef7 --- /dev/null +++ b/archive/internal/mixer/mixer_internal_test.go @@ -0,0 +1,312 @@ +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) + } + } + }) + } +} diff --git a/archive/internal/mixer/mixer_test.go b/archive/internal/mixer/mixer_test.go new file mode 100644 index 0000000..796f032 --- /dev/null +++ b/archive/internal/mixer/mixer_test.go @@ -0,0 +1,38 @@ +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) + } + }) + } +} diff --git a/archive/internal/netreq/netReq.go b/archive/internal/netreq/netReq.go new file mode 100644 index 0000000..f9c4b4c --- /dev/null +++ b/archive/internal/netreq/netReq.go @@ -0,0 +1,83 @@ +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 +} diff --git a/archive/internal/network/network.go b/archive/internal/network/network.go new file mode 100644 index 0000000..1510fc3 --- /dev/null +++ b/archive/internal/network/network.go @@ -0,0 +1,577 @@ +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 diff --git a/archive/internal/page/AudioOptions.go b/archive/internal/page/AudioOptions.go new file mode 100644 index 0000000..c38a093 --- /dev/null +++ b/archive/internal/page/AudioOptions.go @@ -0,0 +1,626 @@ +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 +} diff --git a/archive/internal/page/DownloadingAssets.go b/archive/internal/page/DownloadingAssets.go new file mode 100644 index 0000000..912a6a8 --- /dev/null +++ b/archive/internal/page/DownloadingAssets.go @@ -0,0 +1,28 @@ +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 +} diff --git a/archive/internal/page/Message.go b/archive/internal/page/Message.go new file mode 100644 index 0000000..c010f22 --- /dev/null +++ b/archive/internal/page/Message.go @@ -0,0 +1,29 @@ +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 +} diff --git a/archive/internal/page/emailAddressPage.go b/archive/internal/page/emailAddressPage.go new file mode 100644 index 0000000..14b274c --- /dev/null +++ b/archive/internal/page/emailAddressPage.go @@ -0,0 +1,407 @@ +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() +} diff --git a/archive/internal/page/home.go b/archive/internal/page/home.go new file mode 100644 index 0000000..087828b --- /dev/null +++ b/archive/internal/page/home.go @@ -0,0 +1,419 @@ +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() +} diff --git a/archive/internal/page/home_test.go b/archive/internal/page/home_test.go new file mode 100644 index 0000000..02d59e4 --- /dev/null +++ b/archive/internal/page/home_test.go @@ -0,0 +1,107 @@ +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) + } +} diff --git a/archive/internal/page/licenseKeyPage.go b/archive/internal/page/licenseKeyPage.go new file mode 100644 index 0000000..675354e --- /dev/null +++ b/archive/internal/page/licenseKeyPage.go @@ -0,0 +1,459 @@ +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() +} diff --git a/archive/internal/page/logViewerPage.go b/archive/internal/page/logViewerPage.go new file mode 100644 index 0000000..6f82313 --- /dev/null +++ b/archive/internal/page/logViewerPage.go @@ -0,0 +1,273 @@ +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() +} diff --git a/archive/internal/page/page.go b/archive/internal/page/page.go new file mode 100644 index 0000000..8528d53 --- /dev/null +++ b/archive/internal/page/page.go @@ -0,0 +1,236 @@ +// 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 +} diff --git a/archive/internal/page/pageItem.go b/archive/internal/page/pageItem.go new file mode 100644 index 0000000..e34cf2e --- /dev/null +++ b/archive/internal/page/pageItem.go @@ -0,0 +1,83 @@ +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 + +} diff --git a/archive/internal/page/proclamationOptions.go b/archive/internal/page/proclamationOptions.go new file mode 100644 index 0000000..c0ac305 --- /dev/null +++ b/archive/internal/page/proclamationOptions.go @@ -0,0 +1,793 @@ +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 +} diff --git a/archive/internal/page/router.go b/archive/internal/page/router.go new file mode 100644 index 0000000..340b667 --- /dev/null +++ b/archive/internal/page/router.go @@ -0,0 +1,79 @@ +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) +} diff --git a/archive/internal/page/scrollerPage.go b/archive/internal/page/scrollerPage.go new file mode 100644 index 0000000..406f79b --- /dev/null +++ b/archive/internal/page/scrollerPage.go @@ -0,0 +1,829 @@ +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 + } + } +} diff --git a/archive/internal/page/settingsPage.go b/archive/internal/page/settingsPage.go new file mode 100644 index 0000000..93eb689 --- /dev/null +++ b/archive/internal/page/settingsPage.go @@ -0,0 +1,255 @@ +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 +} diff --git a/archive/internal/page/wifiSetupPage.go b/archive/internal/page/wifiSetupPage.go new file mode 100644 index 0000000..e6755d4 --- /dev/null +++ b/archive/internal/page/wifiSetupPage.go @@ -0,0 +1,656 @@ +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() +} diff --git a/archive/internal/pattern/color/colorPatterns.go b/archive/internal/pattern/color/colorPatterns.go new file mode 100644 index 0000000..196c750 --- /dev/null +++ b/archive/internal/pattern/color/colorPatterns.go @@ -0,0 +1,100 @@ +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 +} diff --git a/archive/internal/pattern/line/fibonacci.go b/archive/internal/pattern/line/fibonacci.go new file mode 100644 index 0000000..f63af7a --- /dev/null +++ b/archive/internal/pattern/line/fibonacci.go @@ -0,0 +1,127 @@ +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 +} diff --git a/archive/internal/pattern/line/linePatterns.go b/archive/internal/pattern/line/linePatterns.go new file mode 100644 index 0000000..4a04bc8 --- /dev/null +++ b/archive/internal/pattern/line/linePatterns.go @@ -0,0 +1,71 @@ +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 +} diff --git a/archive/internal/pattern/line/sineWavePattern.go b/archive/internal/pattern/line/sineWavePattern.go new file mode 100644 index 0000000..fd73e3d --- /dev/null +++ b/archive/internal/pattern/line/sineWavePattern.go @@ -0,0 +1,80 @@ +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 +} diff --git a/archive/internal/shaderManager/shaderManager.go b/archive/internal/shaderManager/shaderManager.go new file mode 100644 index 0000000..f4b6396 --- /dev/null +++ b/archive/internal/shaderManager/shaderManager.go @@ -0,0 +1,199 @@ +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 +} diff --git a/archive/internal/sync/sync.go b/archive/internal/sync/sync.go new file mode 100644 index 0000000..3aa2d4c --- /dev/null +++ b/archive/internal/sync/sync.go @@ -0,0 +1,144 @@ +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 +} diff --git a/archive/nixos/module.nix b/archive/nixos/module.nix new file mode 100644 index 0000000..1ace79a --- /dev/null +++ b/archive/nixos/module.nix @@ -0,0 +1,31 @@ +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 ]; + }; +} \ No newline at end of file diff --git a/archive/nixos/vm.nix b/archive/nixos/vm.nix new file mode 100644 index 0000000..968760e --- /dev/null +++ b/archive/nixos/vm.nix @@ -0,0 +1,144 @@ +# 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"; +} diff --git a/archive/notes.org b/archive/notes.org new file mode 100644 index 0000000..7df70a0 --- /dev/null +++ b/archive/notes.org @@ -0,0 +1,3 @@ +* Networking Issues +- The networking package needs a LOT more logging... Nothing gets output when things crash. +- \ No newline at end of file diff --git a/archive/packaging/kiosk/99-rhemawave-updater.rules b/archive/packaging/kiosk/99-rhemawave-updater.rules new file mode 100644 index 0000000..9cdbc4f --- /dev/null +++ b/archive/packaging/kiosk/99-rhemawave-updater.rules @@ -0,0 +1,55 @@ +// 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; + } + } + } +}); \ No newline at end of file diff --git a/archive/packaging/kiosk/override.conf b/archive/packaging/kiosk/override.conf new file mode 100644 index 0000000..5470b4e --- /dev/null +++ b/archive/packaging/kiosk/override.conf @@ -0,0 +1,3 @@ +[Service] +ExecStart= +ExecStart=-/sbin/agetty --autologin kiosk --noclear %I $TERM \ No newline at end of file diff --git a/archive/packaging/kiosk/post-install.sh b/archive/packaging/kiosk/post-install.sh new file mode 100755 index 0000000..b1cb9aa --- /dev/null +++ b/archive/packaging/kiosk/post-install.sh @@ -0,0 +1,24 @@ +#!/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" \ No newline at end of file diff --git a/archive/packaging/kiosk/rhemawave-kiosk.sh b/archive/packaging/kiosk/rhemawave-kiosk.sh new file mode 100755 index 0000000..911f67d --- /dev/null +++ b/archive/packaging/kiosk/rhemawave-kiosk.sh @@ -0,0 +1,16 @@ +# 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 + diff --git a/archive/packaging/rhemawave-updater.py b/archive/packaging/rhemawave-updater.py new file mode 100755 index 0000000..42ed895 --- /dev/null +++ b/archive/packaging/rhemawave-updater.py @@ -0,0 +1,564 @@ +#!/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()) diff --git a/archive/rhemawave.go b/archive/rhemawave.go new file mode 100644 index 0000000..767b2af --- /dev/null +++ b/archive/rhemawave.go @@ -0,0 +1,12 @@ +package main + +import ( + "fmt" + + "rhemawavetech.com/rhemawave/internal/application" +) + +func main() { + application.Run() + fmt.Printf("Stopping application") +} diff --git a/archive/scripts/test-vm.sh b/archive/scripts/test-vm.sh new file mode 100755 index 0000000..d4f3d7e --- /dev/null +++ b/archive/scripts/test-vm.sh @@ -0,0 +1,474 @@ +#!/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 <