diff --git a/archive/.envrc b/archive/.envrc deleted file mode 100644 index 3550a30..0000000 --- a/archive/.envrc +++ /dev/null @@ -1 +0,0 @@ -use flake diff --git a/archive/.forgejo/workflows/build-dev.yml b/archive/.forgejo/workflows/build-dev.yml deleted file mode 100644 index 40bd7cf..0000000 --- a/archive/.forgejo/workflows/build-dev.yml +++ /dev/null @@ -1,66 +0,0 @@ -# This action builds the `dev` branch any time a Pull Request is submitted to dev. -# The purpose of this is two fold: -# - Prevent breaking changes from being merged to dev -# - Automates the building of RadiantWave-Dev which can then be uploaded -# to the Attic server, and the development machines can pull prebuilt -# binaries instead of building everything from source, resulting in faster -# updates, once updates work again that is... -on: - push: - branches: - - dev - pull_request: - branches: - - dev - -jobs: - build: - runs-on: neils-server-runner - steps: - - name: Checkout code - uses: actions/checkout@v6 - - - name: Test - run: | - . /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh - nix develop --command go test ./internal/mixer/ -v -timeout 30s - - - name: Build - run: | - . /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh - nix build -L - echo "Working dir: $(pwd)" - echo "Result symlink: $(readlink -f result)" - find result/ -type f - - - name: Notify - if: always() - uses: https://github.com/dawidd6/action-send-mail@v3 - with: - server_address: smtp.gmail.com - server_port: 465 - username: ${{ secrets.MAIL_USERNAME }} - password: ${{ secrets.MAIL_PASSWORD }} - subject: "RadiantWave build ${{ job.status }}" - to: cnclack@pm.me - from: ci@radiantwavetech.com - body: "Build ${{ job.status }} for commit ${{ github.sha }} on branch dev" - - # - name: Trigger KioskConfig dev build - # if: success() - # run: | - # curl -X POST \ - # -H "Authorization: token ${{ secrets.TOKEN }}" \ - # -H "Content-Type: application/json" \ - # -d '{"ref": "main"}' \ - # https://codeberg.org/api/v1/repos/RhemaWave-Technologies/KioskConfig/actions/workflows/update-dev.yaml/dispatches - - # - name: Trigger KioskConfig dev build - # if: success() - # run: | - # curl -X POST \ - # -H "Authorization: token ${{ secrets.FORGEJO_TOKEN }}" \ - # -H "Accept: application/json" \ - # -H "Content-Type: application/json" \ - # https://codeberg.org/api/v1/repos/RhemaWave-Technologies/KioskConfig/dispatches \ - # -d '{"type": "radiantwave-dev-built"}' diff --git a/archive/.forgejo/workflows/build-prod.yml b/archive/.forgejo/workflows/build-prod.yml deleted file mode 100644 index a0746e0..0000000 --- a/archive/.forgejo/workflows/build-prod.yml +++ /dev/null @@ -1,63 +0,0 @@ -# This action builds the `dev` branch any time a Pull Request is submitted to dev. -# The purpose of this is two fold: -# - Prevent breaking changes from being merged to dev -# - Automates the building of RadiantWave-Dev which can then be uploaded -# to the Attic server, and the development machines can pull prebuilt -# binaries instead of building everything from source, resulting in faster -# updates, once updates work again that is... -on: - pull_request: - branches: - - main - -jobs: - build: - runs-on: neils-server-runner - steps: - - name: Checkout code - uses: actions/checkout@v6 - - - name: Test - run: | - . /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh - nix develop --command go test ./internal/mixer/ -v -timeout 30s - - - name: Build - run: | - . /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh - nix build -L - echo "Working dir: $(pwd)" - echo "Result symlink: $(readlink -f result)" - find result/ -type f - - - name: Notify - if: always() - uses: https://github.com/dawidd6/action-send-mail@v3 - with: - server_address: smtp.gmail.com - server_port: 465 - username: ${{ secrets.MAIL_USERNAME }} - password: ${{ secrets.MAIL_PASSWORD }} - subject: "RadiantWave build ${{ job.status }}" - to: cnclack@pm.me - from: ci@radiantwavetech.com - body: "Build ${{ job.status }} for commit ${{ github.sha }} on branch main" - - # - name: Trigger KioskConfig dev build - # if: success() - # run: | - # curl -X POST \ - # -H "Authorization: token ${{ secrets.TOKEN }}" \ - # -H "Content-Type: application/json" \ - # -d '{"ref": "main"}' \ - # https://codeberg.org/api/v1/repos/RhemaWave-Technologies/KioskConfig/actions/workflows/update-dev.yaml/dispatches - - # - name: Trigger KioskConfig main build - # if: success() - # run: | - # curl -X POST \ - # -H "Authorization: token ${{ secrets.FORGEJO_TOKEN }}" \ - # -H "Accept: application/json" \ - # -H "Content-Type: application/json" \ - # https://codeberg.org/api/v1/repos/RhemaWave-Technologies/KioskConfig/dispatches \ - # -d '{"type": "radiantwave-dev-built"}' diff --git a/archive/AGENTS.md b/archive/AGENTS.md deleted file mode 100644 index 7482a01..0000000 --- a/archive/AGENTS.md +++ /dev/null @@ -1,261 +0,0 @@ -# RadiantWave — Codebase Architecture - -## What This Is - -A kiosk application for "RadiantWave" wellness technology. It displays scrolling -proclamation text rendered with custom shaders, plays audio at solfeggio -frequencies, and manages WiFi/provisioning on a headless Linux kiosk. - -**Core libraries:** SDL2, OpenGL 3.3, SQLite (GORM), D-Bus (NetworkManager). -**Build system:** Nix flake. **CI:** Forgejo on a self-hosted runner. - ---- - -## Application Lifecycle - -``` -rhemawave.go → internal/application.Run() -``` - -`Run()` performs: -1. SDL2 init (video, audio, events) -2. Create window with `SDL_WINDOW_OPENGL | SDL_WINDOW_SHOWN` -3. OpenGL 3.3 context -4. ShaderManager singleton init -5. FontManager singleton init -6. Mixer singleton init -7. SQLite connect + GORM auto-migrate -8. Sync proclamations from remote API -9. Start audio download goroutine -10. Enter SDL event loop - -The event loop polls SDL events, calls `Router.Update()` + `Router.Render()`, -and runs `checkState()` to drive the provisioning state machine. - ---- - -## Provisioning State Machine - -Defined in `internal/application/application.go`: - -``` -FIRST_RUN → NEEDS_INTERNET → NEEDS_LICENSE_KEY → NEEDS_EMAIL_ADDRESS → DOWNLOADING_ASSETS → READY -``` - -Each state maps to a page. `checkState()` evaluates DB values and network -status to decide which page to show. - ---- - -## Page System (`internal/page/`) - -All pages implement the `Page` interface: - -```go -type Page interface { - Init(ctx *PageCtx) - HandleEvent(event sdl.Event) error - Update(dt float64) - Render() - Destroy() -} -``` - -`PageCtx` provides: `*gorm.DB`, screen dimensions, `DownloadDone` channel, -`NavigateTo(pageType)` callback. - -`Router` (`router.go`) maps `pageType` enum → concrete page struct, manages -lifecycle (Destroy old → Init new). - -| Page | Purpose | -|------|---------| -| `Home` | Version info, download progress, license status, F2/F3 keybinds | -| `ScrollerPage` | **Main screen:** scrolling proclamation text with shaders, line patterns, color patterns, audio playback, velocity control | -| `SettingsPage` | Menu: audio, WiFi, proclamations, license, email, logs, volume | -| `AudioOptions` | Audio device selection (via PipeWire pactl), on/off, volume | -| `ProclamationOptions` | Browse/search/select proclamations from DB | -| `WiFiSetupPage` | WiFi scan, WPA connect, password entry | -| `LicenseKeyPage` | License key text input, saves to DB | -| `EmailAddressPage` | Email text input with regex validation | -| `LogViewerPage` | Scrollable log viewer from DB | -| `DownloadingAssets` | Stub during download phase | -| `Message` | Generic message page (stub) | - ---- - -## OpenGL Rendering - -### Virtual Framebuffer (`internal/graphics/virtual_framebuffer.go`) -Renders to a 1920x1080 FBO, then `glBlitFramebuffer()` to the native window -resolution. This decouples layout from display size. - -### Shaders (`share/rhemawave/shaders/`) -| Shader | Purpose | -|--------|---------| -| `scroller.vert/frag` | Text with font texture; uniform `u_textColor` for per-word coloring | -| `text.vert/frag` | Non-scrolling text elements | -| `solid_color.vert/frag` | Flat color (backgrounds, UI) | - ---- - -## Font System (`internal/fontManager/`) - -**FontManager** (singleton) wraps SDL_ttf + OpenGL textures: -- Loads TTF/OTF fonts, creates GL texture atlas per font. -- `GetGlyph(fontName, rune)` → glyph metrics (advance, bearing, texture coords). -- `RenderString(fontName, text, x, y, color, scale)` → renders to GL. -- **Scrambled font mode:** Characters are spatially rearranged in the atlas, creating visual "frequency signatures" when rendered. - -### Fonts (`share/rhemawave/fonts/`) -| File | Use | -|------|-----| -| `Phibonacci_regular.otf` | Decorative display font | -| `Roboto-Regular.ttf` | Clean sans-serif (UI) | -| `SpaceMonoNerdFont-Regular.ttf` | Monospace with icons | - ---- - -## Pattern System (`internal/pattern/`) - -### Line Patterns (`internal/pattern/line/`) -`LinePattern` interface: `GetNextLine(words, wordIndex, ctx) []string`. -Registered via `init()` (plugin-style Registry map). - -| Pattern | Behavior | -|---------|----------| -| Fibonacci | Oscillating sequence (1,2,3,5,8,13,21) for words-per-line; creates wave in 2D | -| Sine Wave | Sine function → line width as % of screen; 5 cycles over 67 lines | - -### Color Patterns (`internal/pattern/color/`) -Distributes colors across words using Fibonacci-oscillating durations -(1,1,2,3,5,8,13,21,34,55 words per color, then reverses). - ---- - -## Audio System - -### Mixer (`internal/mixer/`) -Singleton wrapping SDL_mixer: -- PipeWire sink enumeration via `pactl list sinks short` -- Volume persistence to DB -- Device switching with volume restore -- `Play(wavPath)`, `Stop()`, `SwitchDevice(deviceName)` - -### Audio Files -`.wav` files are gitignored. Downloaded at runtime from -`files.radiantwavetech.com`, verified against `checksums.md5`, stored in -`~/.radiantwave/audio/`. A goroutine handles async download; a channel -signals completion. - -### Solfeggio Frequencies -9 presets (174Hz–963Hz), each mapped to a "healing intention". Stored in DB, -selectable in AudioOptions page. - ---- - -## Database (`internal/db/`) - -SQLite at `~/.local/share/rhemawave/database.sqlite` (WAL mode). - -| Model | Purpose | -|-------|---------| -| `AppConfig` | Key-value store: license key, email, audio device, volume, download state | -| `Log` | Application logs (level, message, timestamp) | -| `Proclamation` | Synced proclamation text (title, content, category, intent colors) | -| `ProclamationCategory` | Category metadata | -| `SolfeggioFrequency` | 9 frequency presets | -| `AudioFile` | Downloaded audio files (path, checksum, status) | - ---- - -## Network (`internal/network/`) - -D-Bus integration via `gonetworkmanager`: -- Async WiFi scan (channel-based results) -- WPA/WPA2 connection support -- Captive portal detection - -### Remote APIs -| Endpoint | Purpose | -|----------|---------| -| `portal.radiantwavetech.com/api/v1/proclamations` | Sync proclamations | -| `files.radiantwavetech.com/files/` | Audio file downloads | -| HTTP API | License validation | - -Generic HTTP client in `internal/netreq/netReq.go`: typed `GetJSON[T]()` and -`Download()` with progress callback. - ---- - -## Assets (`share/rhemawave/`) - -Copied into nix store at build time: -- `fonts/` — 3 font files (see above) -- `shaders/` — 3 GLSL shader pairs -- `proclamations/home/` — 16 text files (healing, wellness, productivity) -- `proclamations/commercial/` — 1 text file -- `tos/` — 3 HTML files (Welcome, Notice, Getting Started) - ---- - -## C++ Rebuild Map - -These are the components that need to be reimplemented in C++ with CMake/Ninja: - -| Component | Go Package | C++ Equivalent | External Deps | -|-----------|-----------|----------------|---------------| -| Entry / Event Loop | `rhemawave.go`, `application.go` | `main.cpp` | SDL2 | -| Provisioning FSM | `application.go` | State machine class | — | -| Page Router | `page/router.go` | Router class | — | -| Page (11 types) | `page/*.go` | 11 page classes implementing Page interface | — | -| OpenGL Context | `application.go` | GL context init | SDL2, OpenGL 3.3 | -| Virtual Framebuffer | `graphics/virtual_framebuffer.go` | FBO + blit | OpenGL | -| Shader Manager | `graphics/shaderManager.go` | Shader class | OpenGL | -| Font Manager | `fontManager/fontManager.go` | Font atlas class | SDL_ttf, OpenGL | -| Line Patterns | `pattern/line/*.go` | Pattern classes + registry | — | -| Color Patterns | `pattern/color/*.go` | Color pattern class | — | -| Mixer | `mixer/mixer.go` | Mixer class | SDL_mixer | -| Network / WiFi | `network/network.go` | NetworkManager wrapper | D-Bus (sdbus-c++) | -| Database | `db/*.go` | SQLite wrapper | SQLite3 (direct) or ORM | -| HTTP Client | `netreq/netReq.go` | HTTP client | libcurl or cpp-httplib | -| Logging | `logger/logger.go` | Logger class | — | -| Config / App State | `db/models.go` | Config struct + DB | SQLite3 | - ---- - -## Build & Run - -Dev shell via direnv (`use flake` in `.envrc`). Inside nix dev shell: - -```sh -go build -o bin/rhemawave rhemawave.go # Build -./bin/rhemawave # Run -nix build -L # Nix build (CI) -``` - -CGO required (`CGO_ENABLED=1`). The nix shell sets `CGO_CFLAGS`/`CGO_LDFLAGS` -for SDL2. Do not `go build` outside the nix shell. - -## Tests - -```sh -go test ./internal/mixer/ -``` - -CI runs `go test` then `nix build -L`. - -## Branch Model - -`dev` → `main` via PRs. CI in `.forgejo/workflows/`: -- `build-dev.yml`: PRs to `dev` -- `build-prod.yml`: PRs to `main` - -Both run `nix build -L` on self-hosted `neils-server-runner`. - -## Common Pitfalls - -- `dev_scripts/reset.sh` referenced in nix devShell hook does not exist. -- `share/rhemawave/audio/*.wav` is gitignored — never commit audio assets. -- `vendor/` is committed — run `go mod vendor` after `go mod tidy`. -- CI uses a self-hosted Forgejo runner, not GitHub-hosted runners. -- `packaging/` contains a kiosk-specific updater, not part of the main build. diff --git a/archive/Dockerfile b/archive/Dockerfile deleted file mode 100644 index cd62b81..0000000 --- a/archive/Dockerfile +++ /dev/null @@ -1,24 +0,0 @@ -FROM ubuntu:24.04 - -ENV DEBIAN_FRONTEND=noninteractive -ENV USER=root -ENV NIX_CONFIG="experimental-features = nix-command flakes" -ENV PATH="/nix/var/nix/profiles/default/bin:${PATH}" - -RUN apt-get update && apt-get install -y \ - curl \ - xz-utils \ - ca-certificates \ - git \ - build-essential \ - pkg-config \ - rsync \ - && rm -rf /var/lib/apt/lists/* - -# Install Nix -RUN curl -L https://nixos.org/nix/install | sh -s -- --daemon - -# Install Go -RUN nix profile install nixpkgs#go - -WORKDIR /workspace \ No newline at end of file diff --git a/archive/README.org b/archive/README.org deleted file mode 100644 index c2a383d..0000000 --- a/archive/README.org +++ /dev/null @@ -1,90 +0,0 @@ -#+TITLE: Radiantwave & KioskConfig Development & Release Pipeline -#+AUTHOR: Neil C. -#+DATE: 2026-06-23 -#+OPTIONS: toc:nil num:nil -#+TAGS: development nixos radiantwave kiosk automation - -* Radiant Wave - -* Full Pipeline Workflow (Detailed) - -** Development Phase -1. Create a new branch for the issue -2. Implement the changes for the issue - -** Integration to =dev= -3. Open a pull request to =dev= -4. CI: Run =nix build= to verify no errors -5. CI: Run linting and unit tests -6. CI: Auto-update =rhemawave-dev= flake input -7. CI: Build all =dev= VMs in parallel (Kiosk, Commercial, RPi) -8. Merge the pull request to =dev= after CI passes - -** Testing Phase -9. Run all VMs (Kiosk, Commercial, RPi) and test all functionality of the application on each VM -10. Install =dev-iso= to a test machine and verify installation and application functionality - -** Release to =main= -11. Open a pull request from =dev= to =main= -12. CI: Build all =main= VMs in parallel (Kiosk, Commercial, RPi) -13. CI: Build all ISO images in parallel -14. CI: Auto-rename ISO images to format =radiantwave-{system}-{YYYY-MM-DD}-{git-hash}.iso= -15. CI: Upload ISO images to file server -16. CI: Move old ISO images to =archives/= on file server -17. Merge the pull request to =main= after CI passes -18. Tag the release (optional, for traceability) - -* Automation Opportunities - -This section describes which parts of the workflow can be automated via CI/CD, and how. - -** Group 1: Development Validation (Steps 3-8) -- =What to automate=: PR triggering, =nix build=, linting, unit tests, flake updates, dev VM builds -- =How=: GitHub Actions workflow triggered on PR to =dev= -- =Why=: Catch integration errors before manual testing; ensure =dev= is always buildable - -** Group 2: Testing (Steps 9-10) -- =What to automate=: VM functionality testing, ISO installation verification -- =How=: Scripted test suites, automated ISO installation in VMs -- =Why=: Reduce manual toil; increase test coverage and consistency - -** Group 3: Release Pipeline (Steps 11-16) -- =What to automate=: Main VM/ISO builds, ISO renaming, file server uploads, archiving -- =How=: GitHub Actions workflow triggered on PR to =main= -- =Why=: Ensure consistent releases; eliminate human error in naming/uploading - -* Preflight Checklist - -- [ ] Create a new branch for the issue -- [ ] Implement the changes for the issue -- [ ] Open a pull request to =dev= -- [ ] Merge the pull request to =dev= after CI passes -- [ ] Run all VMs (Kiosk, Commercial, RPi) and test all functionality of the application on each VM -- [ ] Install =dev-iso= to a test machine and verify installation and application functionality -- [ ] Open a pull request from =dev= to =main= -- [ ] Merge the pull request to =main= after CI passes -- [ ] Tag the release (optional, for traceability) - -* Automation Task List - -** Group 1: Development Validation -- [ ] Set up GitHub Actions workflow for PRs to =dev= - - [ ] Configure =nix build= step - - [ ] Add linting and unit test steps - - [ ] Add flake auto-update step - - [ ] Configure parallel VM builds (Kiosk, Commercial, RPi) -- [ ] Verify workflow blocks PR merge on failure - -** Group 2: Testing -- [ ] Create automated test scripts for VM functionality -- [ ] Set up automated ISO installation testing -- [ ] Integrate with CI for optional automated testing - -** Group 3: Release Pipeline -- [ ] Set up GitHub Actions workflow for PRs to =main= - - [ ] Configure parallel VM builds - - [ ] Configure parallel ISO builds - - [ ] Add ISO auto-renaming step (using =git rev-parse HEAD= and =date +%Y-%m-%d=) - - [ ] Add file server upload step (=rsync= or similar) - - [ ] Add archive old ISOs step -- [ ] Verify workflow blocks PR merge on failure \ No newline at end of file diff --git a/archive/TestList.org b/archive/TestList.org deleted file mode 100644 index 028c389..0000000 --- a/archive/TestList.org +++ /dev/null @@ -1,19 +0,0 @@ -* Virtual Machine Testing -To be completed after updating rhemawave-dev, deleting nixos.qcow2, and building rhemawavekiosk-dev - -- [ ] Closure builds -- [ ] VM Starts -- [ ] RadiantWave starts -- [ ] WiFi screen does NOT appear (no wifi nic in vm) -- [ ] Email Address scren appears -- [ ] I can type an email address -- [ ] I cannot submit an incomplete email -- [ ] I can submit a complete email -- [ ] License Key screen appears -- [ ] I can type a license key -- [ ] I cannot submit an incomplete license -- [ ] I can submit a complete license -- [ ] Home screen appears -- [ ] Version string is correct -- [ ] Status: Downloading files -- diff --git a/archive/VMTestList.org b/archive/VMTestList.org deleted file mode 100644 index 224817a..0000000 --- a/archive/VMTestList.org +++ /dev/null @@ -1,44 +0,0 @@ -- [X] Version string is correct -- [X] Status shows READY or Downloading Assets -- [X] Email address is present and correct -- [X] License key is present and correct -- [X] Settings menu opens -- [X] Logs open -- [X] No proprietary data is exposed in logs -- [X] Logs are colored correctly -- [X] WiFi Setup opens -- [X] WiFi page shows "Something went wrong" because there are no WiFi nics available -- [X] License key opens -- [X] License key displays current license key -- [X] Backspace deletes characters -- [X] I can type in a new license key -- [X] I can submit a license key -- [X] License Key shows new license key -- [X] I cannot submit an incomplete license key -- [X] Email address opens -- [X] Email addrss displays current email address -- [X] Backspace deletes characters -- [X] I can type a new email address -- [X] I can submit an email address -- [X] Email address shows new email address -- [X] I cannot submit an invalid email address -- [X] Audio opens -- [X] I can toggle audio -- [X] At least one audio device is listed -- [X] Volume is at 100% -- [X] I can change the volume -- [X] Returning to the Audio page shows toggle and volume updated values -- [X] Proclamation Browser opens -- [X] Proclamation Browser is populated -- [X] Space bar opens and closes categories -- [X] Enter opens and closes categories -- [X] Space toggles a proclamation -- [X] Enter toggles a proclamation -- [X] Typing characters starts a search -- [X] I can search for Confidence and ADHD -- [X] Enter during search toggles selection -- [X] Space during search toggles selection -- [X] Returning to the Proclamation browser shows my saved proclamations -- [X] Scroller opens and starts playing -- [X] Audio is playing -- [X] Colors appear correct diff --git a/archive/changelog.txt b/archive/changelog.txt deleted file mode 100644 index be91e9f..0000000 --- a/archive/changelog.txt +++ /dev/null @@ -1,42 +0,0 @@ -# Changelog - -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/). - -## [Unreleased] - -### Fixed - -### Changed (Major Refactors) -- Mixer package: extract wpctl calls behind an interface - -## [07112026-1710] - -### Fixed -- Play() deadlocks when called before device is open (recursive SwitchDevice -> Play) -- Audio volume silently zeroed after switching output devices (Init used hardcoded 0.20; SwitchDevice did not restore saved volume) -- SwitchDevice not persisted to database across restarts (caller now saves to AppConfig) -- Device restore path passes invalid device name "system default" to SDL (now passes "" for default) -- Stop() no-op when audio toggled off mid-playback (removed AudioEnabled guard from Stop) -- App shows "READY" before audio files finish downloading (state machine now gates READY on download completion via DOWNLOADING_ASSETS state) -- Download failure no longer silently allows READY (downloadFailed flag prevents premature READY) -- App restarts repeatedly after downloads complete (checkState re-navigated to HOME, closing DownloadDone channel twice -> panic) -- F2/F3 keybinds now work during DOWNLOADING_ASSETS (blocked only during early prerequisites: no wifi, no email, no license key) -- Active device highlight incorrect when using default device (default device now maps to first SDL device) - -### Added -- Human-readable audio device names (Built-in Speakers, HDMI/TV/Monitor) -- Unit tests for mixer package (parseSinks, parseSinkLine, ConvertVolume) -- Regression tests for state machine (download gating, failure handling, prerequisite priority, non-blocking channel read) -- Regression tests for mixer (Stop with AudioEnabled=false, SwitchDevice edge cases) -- Regression tests for Home page (double-close guard, download failure path) - -### Changed (Major Refactors) -- Mixer package: inject audio backend dependencies for testability -- SwitchDevice now takes *db.AppConfig to access saved volume -- Audio files moved from ~/.local/share/rhemawave/audio to ~/.radiantwave/audio -- State machine uses DOWNLOADING_ASSETS state before READY -- checkState() skips re-navigation when transitioning between HOME-mapped states -- network.init() logs warning instead of crashing when D-Bus is unavailable -- CI now runs go test before nix build diff --git a/archive/docker-compose.yaml b/archive/docker-compose.yaml deleted file mode 100644 index 91b6d69..0000000 --- a/archive/docker-compose.yaml +++ /dev/null @@ -1,22 +0,0 @@ -services: - builder: - build: . - working_dir: /workspace - init: true - - volumes: - - .:/workspace - - nix-store:/nix - - command: > - bash -lc ' - set -euo pipefail - rm -rf build - mkdir -p build - OUT=$$(nix build . --print-out-paths) - echo "Build output: $$OUT" - rsync -a --chmod=ugo=rwX --no-owner --no-group "$$OUT"/ build/ - ' - -volumes: - nix-store: \ No newline at end of file diff --git a/archive/flake.lock b/archive/flake.lock deleted file mode 100644 index 20db77f..0000000 --- a/archive/flake.lock +++ /dev/null @@ -1,61 +0,0 @@ -{ - "nodes": { - "flake-utils": { - "inputs": { - "systems": "systems" - }, - "locked": { - "lastModified": 1731533236, - "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, - "nixpkgs": { - "locked": { - "lastModified": 1776877367, - "narHash": "sha256-EHq1/OX139R1RvBzOJ0aMRT3xnWyqtHBRUBuO1gFzjI=", - "owner": "NixOS", - "repo": "nixpkgs", - "rev": "0726a0ecb6d4e08f6adced58726b95db924cef57", - "type": "github" - }, - "original": { - "owner": "NixOS", - "ref": "nixos-unstable", - "repo": "nixpkgs", - "type": "github" - } - }, - "root": { - "inputs": { - "flake-utils": "flake-utils", - "nixpkgs": "nixpkgs" - } - }, - "systems": { - "locked": { - "lastModified": 1681028828, - "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", - "owner": "nix-systems", - "repo": "default", - "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", - "type": "github" - }, - "original": { - "owner": "nix-systems", - "repo": "default", - "type": "github" - } - } - }, - "root": "root", - "version": 7 -} diff --git a/archive/flake.nix b/archive/flake.nix deleted file mode 100644 index 3259b18..0000000 --- a/archive/flake.nix +++ /dev/null @@ -1,116 +0,0 @@ -{ - description = "RhemaWave"; - - inputs = { - nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; - flake-utils.url = "github:numtide/flake-utils"; - }; - - outputs = - { - self, - nixpkgs, - flake-utils, - }: - nixpkgs.lib.recursiveUpdate - (flake-utils.lib.eachDefaultSystem ( - system: - let - pkgs = import nixpkgs { - inherit system; - config.allowUnfreePredicate = pkg: (nixpkgs.lib.getName pkg) == "rhemawave"; - }; - - buildInputs = with pkgs; [ - SDL2 - SDL2_ttf - SDL2_image - SDL2_mixer - libGL - sqlite - dbus - ] ++ pkgs.lib.optionals pkgs.stdenv.isLinux [ - wayland - wayland-protocols - libxkbcommon - libdecor - libx11 - libxext - ]; - - nativeBuildInputs = with pkgs; [ - go - pkg-config - ]; - - in - { - packages.default = pkgs.buildGoModule { - pname = "rhemawave"; - version = "v1.1.1"; - src = ./.; - - vendorHash = null; - - inherit buildInputs nativeBuildInputs; - - env.CGO_ENABLED = "1"; - - preBuild = '' - export CGO_CFLAGS="$(pkg-config --cflags SDL2_mixer SDL2_ttf SDL2_image) $CGO_CFLAGS" - export CGO_LDFLAGS="$(pkg-config --libs SDL2_mixer SDL2_ttf SDL2_image) $CGO_LDFLAGS" - ''; - - ldflags = [ - "-s" - "-w" - ]; - - postInstall = '' - mkdir -p $out/share/rhemawave - cp -r share/rhemawave/* $out/share/rhemawave/ - ''; - - meta = with pkgs.lib; { - description = "RhemaWave kiosk application"; - license = licenses.unfree; - platforms = platforms.linux; - }; - }; - - devShells.default = pkgs.mkShell { - inherit buildInputs nativeBuildInputs; - - shellHook = '' - export OLD_PS1="$PS1" - export PS1="[nix-env] $OLD_PS1" - - export CGO_CFLAGS="$(pkg-config --cflags SDL2_mixer SDL2_ttf SDL2_image) $CGO_CFLAGS" - export CGO_LDFLAGS="$(pkg-config --libs SDL2_mixer SDL2_ttf SDL2_image) $CGO_LDFLAGS" - - echo "RhemaWave dev shell" - echo " go version: $(go version)" - echo " CGO_ENABLED=1" - echo "" - echo "Build: go build -o bin/rhemawave rhemawave.go" - echo "Run: ./bin/rhemawave" - ''; - }; - } - )) - { - nixosModules.default = import ./nixos/module.nix self; - - nixosConfigurations.test-vm = nixpkgs.lib.nixosSystem { - system = "x86_64-linux"; - modules = [ - "${nixpkgs}/nixos/modules/virtualisation/qemu-vm.nix" - self.nixosModules.default - ./nixos/vm.nix - ]; - }; - - packages.x86_64-linux.test-vm = - self.nixosConfigurations.test-vm.config.system.build.vm; - }; -} diff --git a/archive/go.mod b/archive/go.mod deleted file mode 100644 index b6f03a6..0000000 --- a/archive/go.mod +++ /dev/null @@ -1,27 +0,0 @@ -module rhemawavetech.com/rhemawave - -go 1.24.0 - -toolchain go1.24.2 - -require github.com/veandco/go-sdl2 v0.4.40 - -require github.com/go-gl/mathgl v1.2.0 - -require github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 - -require ( - github.com/Wifx/gonetworkmanager/v2 v2.2.0 - github.com/google/uuid v1.3.0 -) - -require github.com/godbus/dbus/v5 v5.1.0 // indirect - -require ( - github.com/jinzhu/inflection v1.0.0 // indirect - github.com/jinzhu/now v1.1.5 // indirect - github.com/mattn/go-sqlite3 v1.14.32 // indirect - golang.org/x/text v0.31.0 // indirect - gorm.io/driver/sqlite v1.6.0 - gorm.io/gorm v1.31.1 -) diff --git a/archive/go.sum b/archive/go.sum deleted file mode 100644 index ca95aae..0000000 --- a/archive/go.sum +++ /dev/null @@ -1,24 +0,0 @@ -github.com/Wifx/gonetworkmanager/v2 v2.2.0 h1:kPstgsQtY8CmDOOFZd81ytM9Gi3f6ImzPCKF7nNhQ2U= -github.com/Wifx/gonetworkmanager/v2 v2.2.0/go.mod h1:fMDb//SHsKWxyDUAwXvCqurV3npbIyyaQWenGpZ/uXg= -github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA= -github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw= -github.com/go-gl/mathgl v1.2.0 h1:v2eOj/y1B2afDxF6URV1qCYmo1KW08lAMtTbOn3KXCY= -github.com/go-gl/mathgl v1.2.0/go.mod h1:pf9+b5J3LFP7iZ4XXaVzZrCle0Q/vNpB/vDe5+3ulRE= -github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= -github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= -github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= -github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= -github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= -github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= -github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs= -github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= -github.com/veandco/go-sdl2 v0.4.40 h1:fZv6wC3zz1Xt167P09gazawnpa0KY5LM7JAvKpX9d/U= -github.com/veandco/go-sdl2 v0.4.40/go.mod h1:OROqMhHD43nT4/i9crJukyVecjPNYYuCofep6SNiAjY= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= -gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= -gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= -gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= -gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/archive/internal/application/application.go b/archive/internal/application/application.go deleted file mode 100644 index 3610781..0000000 --- a/archive/internal/application/application.go +++ /dev/null @@ -1,548 +0,0 @@ -package application - -import ( - "errors" - "os" - "path/filepath" - "time" - - "github.com/go-gl/gl/v3.3-core/gl" - "github.com/veandco/go-sdl2/sdl" - "github.com/veandco/go-sdl2/ttf" - "gorm.io/gorm" - "rhemawavetech.com/rhemawave/internal/db" - "rhemawavetech.com/rhemawave/internal/download" - "rhemawavetech.com/rhemawave/internal/fontManager" - "rhemawavetech.com/rhemawave/internal/graphics" - "rhemawavetech.com/rhemawave/internal/keybinds" - "rhemawavetech.com/rhemawave/internal/license" - "rhemawavetech.com/rhemawave/internal/logger" - "rhemawavetech.com/rhemawave/internal/mixer" - "rhemawavetech.com/rhemawave/internal/network" - "rhemawavetech.com/rhemawave/internal/page" - "rhemawavetech.com/rhemawave/internal/shaderManager" - "rhemawavetech.com/rhemawave/internal/sync" -) - -const ( - targetFPS = 60.0 - openGLMajorVersion = 3 - openGLMinorVersion = 3 - // volumeStepSize = 16 - // apiURL = "https://portal.rhemawavetech.com/api/v1" -) - -type State int - -const ( - FIRST_RUN State = iota - NEEDS_INTERNET - NEEDS_LICENSE_KEY - NEEDS_EMAIL_ADDRESS - DOWNLOADING_ASSETS - READY -) - -type Application struct { - Window *sdl.Window - License *license.Response - pageStack []page.Page - pendingAction func() - running bool - DB *gorm.DB - PageRouter *page.Router - state State - downloadsComplete bool - downloadFailed bool -} - -var lastNetworkCheckTime time.Time -var networkIsUp bool - -// ---------------------------------------------------------------------------- -// Main Entry Point -// ---------------------------------------------------------------------------- - -func Run() { - var err error - var homeDir string - var dataDir string - var DB *gorm.DB - - networkIsUp = false - - logger.Info("Starting Application") - - homeDir, err = os.UserHomeDir() - if err != nil { - logger.Error("unable to fetch user $HOME directory: %v", err) - return - } - - dataDir = filepath.Join(homeDir, ".local", "share", "rhemawave") - if err := os.MkdirAll(dataDir, 0755); err != nil { - logger.Error("unable to create data directory %s %v", dataDir, err) - return - } - - testFile := filepath.Join(dataDir, ".access_test") - f, err := os.Create(testFile) - if err != nil { - logger.Error("data %s is not writeable: %v", dataDir, err) - return - } - f.Close() - os.Remove(testFile) - - logger.AddFile(filepath.Join(dataDir, "log.txt")) - logger.SetLevel = logger.LevelInfo - - DB, err = db.Connect(filepath.Join(dataDir, "database.sqlite")) - if err != nil { - logger.Error("application failed to connect to the database: %v", err) - return - } - - if err = db.Migrate(DB); err != nil { - logger.Error("application failed to perform database migrations: %v", err) - return - } - - firstRun := false - - // Populate the initial Config cache OR create defaults - cfg, err := db.GetConfig(DB) - if err != nil { - if errors.Is(err, gorm.ErrRecordNotFound) { - firstRun = true - cfg = &db.AppConfig{ - Model: gorm.Model{ID: 1}, - DataDir: dataDir, - AssetsDir: dataDir, - SystemLevel: "BASIC", - SelectedAudioDevice: "default", - InitialFontSize: 128, - StandardFontSize: 32, - LinePattern: "fibonacci", - AudioEnabled: true, - SavedVolume: 1.0, - } - - if err := DB.Create(cfg).Error; err != nil { - logger.Error("failed to create config: %v", err) - return - } - } - } - - logger.AddDatabase(DB) - - app := &Application{ - DB: DB, - } - - logger.Debug("==== Debug Statement ====") - - if firstRun { - app.state = FIRST_RUN - } - - w, h := app.initializeSDLAndOpenGL() - defer app.cleanup() - - app.initializeManagers() - app.initializeAudio() - // Create a buffered channel so log writes don't slow down downloads. - downloadProgressChan := make(chan download.DownloadStatus, 1000) - downloadDone := make(chan struct{}) - - app.PageRouter = page.NewRouter(page.PageCtx{ - DbPtr: app.DB, - W: w, - H: h, - MessageChannel: downloadProgressChan, - DownloadDone: downloadDone, - }) - - // Sync the proclamations from the API to the Database - sync.SyncProclamations(DB) - sync.SyncCategories(DB) - sync.SyncFrequencies(DB) - - // audioFileNames := []string{ - // "OriginalAudio.wav", - // "174Hz.wav", - // "396Hz.wav", - // "417Hz.wav", - // "432Hz.wav", - // "639Hz.wav", - // "741Hz.wav", - // "852Hz.wav", - // "963Hz.wav", - // "1111Hz.wav", - // "14Hz_Binaural.wav", - // "40Hz_Binaural.wav", - // "AlphaWaves.wav", - // "PinealGland.wav", - // } - - audioDir := filepath.Join(homeDir, ".radiantwave", "audio") - - _, err = os.Stat(audioDir) - if os.IsNotExist(err) { - if err = os.MkdirAll(audioDir, 0755); err != nil { - logger.Error("unable to create audio directory: %v", err) - } - } - - go download.DownloadAudioFiles(app.PageRouter.Context.MessageChannel, audioDir, "https://files.radiantwavetech.com/audio/") - - app.runEventLoop() -} - -// computeState determines what state the application should be in based on -// current conditions, without performing any navigation. It returns the first -// unmet prerequisite in priority order: network > email > license > downloads > ready. -func (app *Application) computeState() State { - if app.state == READY { - return READY - } - - // Check if downloads have finished (non-blocking) - if !app.downloadsComplete { - select { - case <-app.PageRouter.Context.DownloadDone: - app.downloadsComplete = true - default: - } - } - - // Check if there is an internet connection - if !networkIsUp { - if lastNetworkCheckTime.IsZero() || time.Since(lastNetworkCheckTime) >= time.Second { - lastNetworkCheckTime = time.Now() - if !network.HasInternet() { - return NEEDS_INTERNET - } - networkIsUp = true // internet confirmed, don't check again - } else { - return app.state // throttled, can't determine yet - } - } - - // Check for email address - config, err := db.GetConfig(app.DB) - if err != nil { - panic(err) - } - - // A rather annoying, insecure, but sometimes necessary "print the config" statement - // data, err := json.MarshalIndent(config, "", " ") - // if err != nil { - // logger.Error("failed to marshal config: %v", err) - // } else { - // logger.Debug("AppConfig: %s", data) - // } - - if config.EmailAddress == "" { - return NEEDS_EMAIL_ADDRESS - } - - // Check for license key - config, err = db.GetConfig(app.DB) - if err != nil { - panic(err) - } - if config.LicenseKey == "" { - return NEEDS_LICENSE_KEY - } - - // All config prerequisites met -- now wait for downloads to finish - if !app.downloadsComplete { - return DOWNLOADING_ASSETS - } - - return READY -} - -// checkState keeps watch of the application state and sets pages -// accordingly. This function runs once per frame. -func (app *Application) checkState() { - newState := app.computeState() - - if newState == app.state { - return - } - - oldState := app.state - app.state = newState - - // Both DOWNLOADING_ASSETS and READY map to the HOME page. - // Skip re-navigation when transitioning between them to avoid - // destroying and recreating the Home page (which would close - // the already-closed DownloadDone channel). - oldIsHome := oldState == DOWNLOADING_ASSETS || oldState == READY - newIsHome := newState == DOWNLOADING_ASSETS || newState == READY - if oldIsHome && newIsHome { - return - } - - switch newState { - case NEEDS_INTERNET: - app.PageRouter.NavigateTo(page.WIFI) - case NEEDS_EMAIL_ADDRESS: - logger.Warning("No email address detected") - app.PageRouter.NavigateTo(page.EMAIL_ADDRESS) - case NEEDS_LICENSE_KEY: - app.PageRouter.NavigateTo(page.LICENSE_KEY) - case DOWNLOADING_ASSETS, READY: - app.PageRouter.NavigateTo(page.HOME) - } -} - -func (app *Application) initializeSDLAndOpenGL() (W, H int32) { - logger.Info("Initializing SDL") - if err := sdl.Init(sdl.INIT_VIDEO | sdl.INIT_AUDIO); err != nil { - logger.Fatal("SDL init failed: %v", err) - } - - logger.Info("Initializing TTF") - if err := ttf.Init(); err != nil { - logger.Fatal("TTF init failed: %v", err) - } - - sdl.GLSetAttribute(sdl.GL_CONTEXT_MAJOR_VERSION, openGLMajorVersion) - sdl.GLSetAttribute(sdl.GL_CONTEXT_MINOR_VERSION, openGLMinorVersion) - sdl.GLSetAttribute(sdl.GL_CONTEXT_PROFILE_MASK, sdl.GL_CONTEXT_PROFILE_CORE) - sdl.ShowCursor(sdl.DISABLE) - sdl.SetRelativeMouseMode(true) - - logger.Info("Querying display mode") - displayMode, err := sdl.GetDisplayMode(0, 0) - if err != nil { - logger.Fatal("Display mode query failed: %v", err) - } - if displayMode.W == 0 || displayMode.H == 0 { - logger.Fatal("Invalid display dimensions: %dx%d", displayMode.W, displayMode.H) - } - logger.Info("Display: %dx%d @ %dHz", displayMode.W, displayMode.H, displayMode.RefreshRate) - - logger.Info("Creating window") - window, err := sdl.CreateWindow( - "Rhema Wave", - sdl.WINDOWPOS_UNDEFINED, - sdl.WINDOWPOS_UNDEFINED, - displayMode.W, - displayMode.H, - sdl.WINDOW_OPENGL|sdl.WINDOW_FULLSCREEN_DESKTOP|sdl.WINDOW_SHOWN, - ) - if err != nil { - logger.Fatal("Window creation failed: %v", err) - } - app.Window = window - - logger.Info("Creating OpenGL context") - if _, err := app.Window.GLCreateContext(); err != nil { - logger.Fatal("OpenGL context creation failed: %v", err) - } - - logger.Info("Initializing go-gl") - if err := gl.InitWithProcAddrFunc(sdl.GLGetProcAddress); err != nil { - logger.Fatal("go-gl initialization failed: %v", err) - } - - logger.Info("OpenGL version: %s", gl.GoStr(gl.GetString(gl.VERSION))) - logger.Info("OpenGL renderer: %s", gl.GoStr(gl.GetString(gl.RENDERER))) - - if err := sdl.GLSetSwapInterval(1); err != nil { - logger.Warning("Failed to enable vsync: %v", err) - } - - w, h := app.Window.GLGetDrawableSize() - logger.Info("Drawable size: %dx%d", w, h) - - logger.Info("Initializing virtual framebuffer (%dx%d -> %dx%d)", - graphics.VirtualWidth, graphics.VirtualHeight, w, h) - if err := graphics.InitVirtualFramebuffer(w, h); err != nil { - logger.Fatal("Virtual framebuffer init failed: %v", err) - } - - app.initKeybinds() - logger.Info("SDL and OpenGL initialization complete") - return graphics.VirtualWidth, graphics.VirtualHeight -} - -func (app *Application) initializeManagers() { - logger.Info("Initializing ShaderManager") - config, err := db.GetConfig(app.DB) - if err != nil { - panic(err) - } - shaderDir := filepath.Join(config.AssetsDir, "shaders") - logger.Info("Loading shaders from %s", shaderDir) - if err := shaderManager.InitShaderManager(shaderDir); err != nil { - logger.Fatal("ShaderManager initialization failed: %v", err) - } - - logger.Info("Initializing FontManager") - if err := fontManager.InitFontManager(config); err != nil { - logger.Fatal("FontManager initialization failed: %v", err) - } -} - -func (app *Application) initializeAudio() { - logger.Info("Initializing audio") - config, err := db.GetConfig(app.DB) - if err != nil { - panic(err) - } - if err := mixer.Init(config, app.refreshCache); err != nil { - logger.Error("Unable to initialize audio device '%s': %v", config.SelectedAudioDevice, err) - logger.Error("Audio will be unavailable until a device is configured") - } else { - logger.Info("Audio initialized with device: '%s'", config.SelectedAudioDevice) - } -} - -func (app *Application) refreshCache() {} - -func (app *Application) cleanup() { - logger.Info("Cleaning up") - - if app.PageRouter != nil && app.PageRouter.ActivePage != nil { - app.PageRouter.ActivePage.Destroy() - } - - graphics.DestroyVirtualFramebuffer() - - if app.Window != nil { - app.Window.Destroy() - } - - ttf.Quit() - sdl.Quit() - logger.Info("Cleanup complete") - logger.Close() -} - -// ---------------------------------------------------------------------------- -// Event Loop -// ---------------------------------------------------------------------------- - -func (app *Application) runEventLoop() { - if app.PageRouter == nil || app.PageRouter.ActivePage == nil { - logger.Fatal("No initial page loaded, Router is empty") - } - const frameDelay = time.Second / targetFPS - app.running = true - lastTime := time.Now() - logger.Info("Entering event loop") - - for app.running { - frameStart := time.Now() - - app.checkState() - app.handleEvents() - if !app.running { - break - } - - deltaTime := float32(time.Since(lastTime).Seconds()) - lastTime = time.Now() - - app.updateCurrentPage(deltaTime) - app.renderFrame() - - frameTime := time.Since(frameStart) - if frameTime < frameDelay { - time.Sleep(frameDelay - frameTime) - } - } - - logger.Info("Event loop exited") -} - -func (app *Application) handleEvents() { - for event := sdl.PollEvent(); event != nil; event = sdl.PollEvent() { - switch e := event.(type) { - case *sdl.QuitEvent: - app.running = false - return - - case *sdl.KeyboardEvent: - if e.Type == sdl.KEYDOWN && e.Repeat == 0 { - isCtrlPressed := (e.Keysym.Mod&sdl.KMOD_CTRL) != 0 || (e.Keysym.Mod&sdl.KMOD_GUI) != 0 - if e.Keysym.Sym == sdl.K_c && isCtrlPressed { - continue - } - keybinds.PerformAction(e) - } - } - - if currentPage := app.PageRouter.ActivePage; currentPage != nil { - if err := currentPage.HandleEvent(&event); err != nil { - logger.Error("Page event handler error: %v", err) - } - } - } -} - -func (app *Application) updateCurrentPage(deltaTime float32) { - if currentPage := app.PageRouter.ActivePage; currentPage != nil { - currentPage.Update(deltaTime) - } -} - -func (app *Application) renderFrame() { - graphics.BeginVirtualRender() - gl.ClearColor(0.0, 0.0, 0.0, 1.0) - gl.Clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT) - - if currentPage := app.PageRouter.ActivePage; currentPage != nil { - currentPage.Render() - } - - graphics.BlitToScreen() - app.Window.GLSwap() -} - -// ---------------------------------------------------------------------------- -// Keybind Registration -// ---------------------------------------------------------------------------- - -func (app *Application) initKeybinds() { - keybinds.Register(sdl.K_F3, sdl.KMOD_NONE, func() { - if app.state != READY && app.state != DOWNLOADING_ASSETS { - return - } - app.PageRouter.NavigateTo(page.SETTINGS) - }) - - keybinds.Register(sdl.K_F2, sdl.KMOD_NONE, func() { - if app.state != READY && app.state != DOWNLOADING_ASSETS { - return - } - app.PageRouter.NavigateTo(page.SCROLLER) - }) - - keybinds.Register(sdl.K_F1, sdl.KMOD_NONE, func() { - if app.state != READY && app.state != DOWNLOADING_ASSETS { - return - } - app.PageRouter.NavigateTo(page.HOME) - - }) - - keybinds.Register(sdl.K_q, sdl.KMOD_RCTRL, func() { - app.Stop() - }) -} - -func (app *Application) Stop() { - app.running = false -} - -func (app *Application) Quit() { - logger.Info("User has quit the application") - app.running = false -} diff --git a/archive/internal/application/application_test.go b/archive/internal/application/application_test.go deleted file mode 100644 index a467f61..0000000 --- a/archive/internal/application/application_test.go +++ /dev/null @@ -1,475 +0,0 @@ -package application - -import ( - "testing" - "time" - - "gorm.io/gorm" - "rhemawavetech.com/rhemawave/internal/db" - "rhemawavetech.com/rhemawave/internal/page" -) - -// Regression: The app used to reach READY before audio downloads completed, -// showing "Status: READY" while "0 / 14 Files Downloaded" was still in progress. -// The fix gates READY on downloadsComplete being true. - -// TestStateTransitionsToDownloadingAssets verifies that when all config -// prerequisites are met but downloads are not complete, the state transitions -// to DOWNLOADING_ASSETS instead of READY. -func TestStateTransitionsToDownloadingAssets(t *testing.T) { - app := &Application{ - state: FIRST_RUN, - DB: setupTestDB(t), - } - - networkIsUp = true - config, err := db.GetConfig(app.DB) - if err != nil { - t.Fatal(err) - } - config.EmailAddress = "test@example.com" - config.LicenseKey = "test-key-123" - app.DB.Save(config) - - app.downloadsComplete = false - - downloadDone := make(chan struct{}) - app.PageRouter = &page.Router{ - Context: page.PageCtx{ - DbPtr: app.DB, - DownloadDone: downloadDone, - }, - } - - result := app.computeState() - - if result != DOWNLOADING_ASSETS { - t.Errorf("state = %v, want DOWNLOADING_ASSETS (downloads not complete)", result) - } -} - -// TestStateTransitionsToReadyWhenDownloadsComplete verifies that the state -// transitions to READY only after downloads are complete. -func TestStateTransitionsToReadyWhenDownloadsComplete(t *testing.T) { - app := &Application{ - state: FIRST_RUN, - DB: setupTestDB(t), - } - - networkIsUp = true - config, err := db.GetConfig(app.DB) - if err != nil { - t.Fatal(err) - } - config.EmailAddress = "test@example.com" - config.LicenseKey = "test-key-123" - app.DB.Save(config) - - app.downloadsComplete = true - - downloadDone := make(chan struct{}) - app.PageRouter = &page.Router{ - Context: page.PageCtx{ - DbPtr: app.DB, - DownloadDone: downloadDone, - }, - } - - result := app.computeState() - - if result != READY { - t.Errorf("state = %v, want READY", result) - } -} - -// Regression: Download failure should prevent the app from reaching READY. -// If downloads fail, the user sees an error and must restart to retry. -func TestDownloadFailurePreventsReady(t *testing.T) { - app := &Application{ - state: FIRST_RUN, - DB: setupTestDB(t), - } - - networkIsUp = true - config, err := db.GetConfig(app.DB) - if err != nil { - t.Fatal(err) - } - config.EmailAddress = "test@example.com" - config.LicenseKey = "test-key-123" - app.DB.Save(config) - - app.downloadsComplete = false - app.downloadFailed = true - - downloadDone := make(chan struct{}) - app.PageRouter = &page.Router{ - Context: page.PageCtx{ - DbPtr: app.DB, - DownloadDone: downloadDone, - }, - } - - result := app.computeState() - - if result == READY { - t.Error("state should not be READY when download has failed") - } -} - -// TestStateStaysReadyOnceSet verifies that once the state reaches READY, -// computeState() returns READY without checking other prerequisites. -func TestStateStaysReadyOnceSet(t *testing.T) { - app := &Application{ - state: READY, - DB: setupTestDB(t), - } - - downloadDone := make(chan struct{}) - app.PageRouter = &page.Router{ - Context: page.PageCtx{ - DbPtr: app.DB, - DownloadDone: downloadDone, - }, - } - - result := app.computeState() - - if result != READY { - t.Errorf("state = %v, want READY (should not change once set)", result) - } -} - -// TestEmailMissingPreventsReady verifies that missing email blocks progress -// even when network is up and downloads are complete. -func TestEmailMissingPreventsReady(t *testing.T) { - app := &Application{ - state: FIRST_RUN, - DB: setupTestDB(t), - } - - networkIsUp = true - config, err := db.GetConfig(app.DB) - if err != nil { - t.Fatal(err) - } - config.EmailAddress = "" - config.LicenseKey = "test-key-123" - app.DB.Save(config) - - app.downloadsComplete = true - - downloadDone := make(chan struct{}) - app.PageRouter = &page.Router{ - Context: page.PageCtx{ - DbPtr: app.DB, - DownloadDone: downloadDone, - }, - } - - result := app.computeState() - - if result != NEEDS_EMAIL_ADDRESS { - t.Errorf("state = %v, want NEEDS_EMAIL_ADDRESS", result) - } -} - -// TestLicenseMissingPreventsReady verifies that missing license key blocks -// progress even when email and network are present. -func TestLicenseMissingPreventsReady(t *testing.T) { - app := &Application{ - state: FIRST_RUN, - DB: setupTestDB(t), - } - - networkIsUp = true - config, err := db.GetConfig(app.DB) - if err != nil { - t.Fatal(err) - } - config.EmailAddress = "test@example.com" - config.LicenseKey = "" - app.DB.Save(config) - - app.downloadsComplete = true - - downloadDone := make(chan struct{}) - app.PageRouter = &page.Router{ - Context: page.PageCtx{ - DbPtr: app.DB, - DownloadDone: downloadDone, - }, - } - - result := app.computeState() - - if result != NEEDS_LICENSE_KEY { - t.Errorf("state = %v, want NEEDS_LICENSE_KEY", result) - } -} - -// TestDownloadDoneChannelSetsDownloadsComplete verifies that receiving on -// the DownloadDone channel sets downloadsComplete to true. -func TestDownloadDoneChannelSetsDownloadsComplete(t *testing.T) { - app := &Application{ - state: FIRST_RUN, - DB: setupTestDB(t), - } - - networkIsUp = true - config, err := db.GetConfig(app.DB) - if err != nil { - t.Fatal(err) - } - config.EmailAddress = "test@example.com" - config.LicenseKey = "test-key-123" - app.DB.Save(config) - - app.downloadsComplete = false - - downloadDone := make(chan struct{}) - close(downloadDone) - app.PageRouter = &page.Router{ - Context: page.PageCtx{ - DbPtr: app.DB, - DownloadDone: downloadDone, - }, - } - - result := app.computeState() - - if !app.downloadsComplete { - t.Error("downloadsComplete should be true after DownloadDone channel is closed") - } - if result != READY { - t.Errorf("state = %v, want READY after downloads complete", result) - } -} - -// TestPrerequisitePriorityEmailBeforeLicense verifies that missing email -// is reported before missing license key, even when both are absent. -func TestPrerequisitePriorityEmailBeforeLicense(t *testing.T) { - app := &Application{ - state: FIRST_RUN, - DB: setupTestDB(t), - } - - networkIsUp = true - config, err := db.GetConfig(app.DB) - if err != nil { - t.Fatal(err) - } - config.EmailAddress = "" - config.LicenseKey = "" - app.DB.Save(config) - - app.downloadsComplete = true - - downloadDone := make(chan struct{}) - app.PageRouter = &page.Router{ - Context: page.PageCtx{ - DbPtr: app.DB, - DownloadDone: downloadDone, - }, - } - - result := app.computeState() - - if result != NEEDS_EMAIL_ADDRESS { - t.Errorf("state = %v, want NEEDS_EMAIL_ADDRESS (email should be checked before license)", result) - } -} - -// TestPrerequisitePriorityLicenseAfterEmail verifies that when email is -// present but license is missing, the state is NEEDS_LICENSE_KEY. -func TestPrerequisitePriorityLicenseAfterEmail(t *testing.T) { - app := &Application{ - state: FIRST_RUN, - DB: setupTestDB(t), - } - - networkIsUp = true - config, err := db.GetConfig(app.DB) - if err != nil { - t.Fatal(err) - } - config.EmailAddress = "test@example.com" - config.LicenseKey = "" - app.DB.Save(config) - - app.downloadsComplete = true - - downloadDone := make(chan struct{}) - app.PageRouter = &page.Router{ - Context: page.PageCtx{ - DbPtr: app.DB, - DownloadDone: downloadDone, - }, - } - - result := app.computeState() - - if result != NEEDS_LICENSE_KEY { - t.Errorf("state = %v, want NEEDS_LICENSE_KEY", result) - } -} - -// TestStateBlocksOnDownloadUntilChannelClosed verifies that computeState -// returns DOWNLOADING_ASSETS repeatedly until DownloadDone is closed. -func TestStateBlocksOnDownloadUntilChannelClosed(t *testing.T) { - app := &Application{ - state: FIRST_RUN, - DB: setupTestDB(t), - } - - networkIsUp = true - config, err := db.GetConfig(app.DB) - if err != nil { - t.Fatal(err) - } - config.EmailAddress = "test@example.com" - config.LicenseKey = "test-key-123" - app.DB.Save(config) - - app.downloadsComplete = false - - downloadDone := make(chan struct{}) - app.PageRouter = &page.Router{ - Context: page.PageCtx{ - DbPtr: app.DB, - DownloadDone: downloadDone, - }, - } - - // Should return DOWNLOADING_ASSETS multiple times before channel closes. - for i := 0; i < 5; i++ { - result := app.computeState() - if result != DOWNLOADING_ASSETS { - t.Fatalf("iteration %d: state = %v, want DOWNLOADING_ASSETS", i, result) - } - } - - // Now close the channel. - close(downloadDone) - - result := app.computeState() - if result != READY { - t.Errorf("after close: state = %v, want READY", result) - } -} - -// TestDownloadDoneNonBlocking verifies that computeState does not block -// when DownloadDone channel is open (non-blocking select). -func TestDownloadDoneNonBlocking(t *testing.T) { - app := &Application{ - state: FIRST_RUN, - DB: setupTestDB(t), - } - - networkIsUp = true - config, err := db.GetConfig(app.DB) - if err != nil { - t.Fatal(err) - } - config.EmailAddress = "test@example.com" - config.LicenseKey = "test-key-123" - app.DB.Save(config) - - app.downloadsComplete = false - - downloadDone := make(chan struct{}) - app.PageRouter = &page.Router{ - Context: page.PageCtx{ - DbPtr: app.DB, - DownloadDone: downloadDone, - }, - } - - // computeState must return promptly even though DownloadDone is not closed. - done := make(chan struct{}) - go func() { - result := app.computeState() - if result != DOWNLOADING_ASSETS { - t.Errorf("state = %v, want DOWNLOADING_ASSETS", result) - } - close(done) - }() - - select { - case <-done: - // Good: returned promptly. - case <-time.After(2 * time.Second): - t.Fatal("computeState blocked on DownloadDone channel (should be non-blocking)") - } -} - -// TestMultipleComputeStateCallsAfterDownloadComplete verifies that calling -// computeState repeatedly after downloads finish does not panic or change state. -func TestMultipleComputeStateCallsAfterDownloadComplete(t *testing.T) { - app := &Application{ - state: FIRST_RUN, - DB: setupTestDB(t), - } - - networkIsUp = true - config, err := db.GetConfig(app.DB) - if err != nil { - t.Fatal(err) - } - config.EmailAddress = "test@example.com" - config.LicenseKey = "test-key-123" - app.DB.Save(config) - - downloadDone := make(chan struct{}) - close(downloadDone) - app.PageRouter = &page.Router{ - Context: page.PageCtx{ - DbPtr: app.DB, - DownloadDone: downloadDone, - }, - } - - // First call transitions to READY. - result := app.computeState() - if result != READY { - t.Fatalf("first call: state = %v, want READY", result) - } - app.state = result - - // Subsequent calls should return READY without panic. - for i := 0; i < 10; i++ { - result = app.computeState() - if result != READY { - t.Fatalf("iteration %d: state = %v, want READY", i, result) - } - } -} - -func setupTestDB(t *testing.T) *gorm.DB { - t.Helper() - d, err := db.Connect(":memory:") - if err != nil { - t.Fatal(err) - } - if err := db.Migrate(d); err != nil { - t.Fatal(err) - } - cfg := &db.AppConfig{ - Model: gorm.Model{ID: 1}, - DataDir: t.TempDir(), - AssetsDir: t.TempDir(), - SystemLevel: "BASIC", - SelectedAudioDevice: "default", - InitialFontSize: 128, - StandardFontSize: 32, - LinePattern: "fibonacci", - AudioEnabled: true, - SavedVolume: 1.0, - EmailAddress: "", - LicenseKey: "", - } - if err := d.Create(cfg).Error; err != nil { - t.Fatal(err) - } - return d -} diff --git a/archive/internal/colors/colors.go b/archive/internal/colors/colors.go deleted file mode 100644 index 64ba63c..0000000 --- a/archive/internal/colors/colors.go +++ /dev/null @@ -1,214 +0,0 @@ -// Package sdlcolors provides a comprehensive palette of 256 standard colors -// for use with the Go SDL2 bindings. Each color is a public variable of -// type sdl.Color, making them easily accessible for rendering tasks. -package colors - -import "github.com/veandco/go-sdl2/sdl" - -// Basic Colors -var ( - White = sdl.Color{R: 255, G: 255, B: 255, A: 255} - Silver = sdl.Color{R: 192, G: 192, B: 192, A: 255} - Gray = sdl.Color{R: 128, G: 128, B: 128, A: 255} - Black = sdl.Color{R: 0, G: 0, B: 0, A: 255} - Red = sdl.Color{R: 255, G: 0, B: 0, A: 255} - Maroon = sdl.Color{R: 128, G: 0, B: 0, A: 255} - Yellow = sdl.Color{R: 255, G: 255, B: 0, A: 255} - Olive = sdl.Color{R: 128, G: 128, B: 0, A: 255} - Lime = sdl.Color{R: 0, G: 255, B: 0, A: 255} - Green = sdl.Color{R: 0, G: 128, B: 0, A: 255} - Aqua = sdl.Color{R: 0, G: 255, B: 255, A: 255} - Teal = sdl.Color{R: 0, G: 128, B: 128, A: 255} - Blue = sdl.Color{R: 0, G: 0, B: 255, A: 255} - Navy = sdl.Color{R: 0, G: 0, B: 128, A: 255} - Fuchsia = sdl.Color{R: 255, G: 0, B: 255, A: 255} - Purple = sdl.Color{R: 128, G: 0, B: 128, A: 255} -) - -// Grayscale -var ( - Gainsboro = sdl.Color{R: 220, G: 220, B: 220, A: 255} - LightGray = sdl.Color{R: 211, G: 211, B: 211, A: 255} - DarkGray = sdl.Color{R: 169, G: 169, B: 169, A: 255} - DimGray = sdl.Color{R: 105, G: 105, B: 105, A: 255} - LightSlateGray = sdl.Color{R: 119, G: 136, B: 153, A: 255} - SlateGray = sdl.Color{R: 112, G: 128, B: 144, A: 255} - DarkSlateGray = sdl.Color{R: 47, G: 79, B: 79, A: 255} -) - -// Error Colors -var ( - ErrorRed = sdl.Color{R: 255, G: 80, B: 80, A: 255} -) - -// Pinks & Reds -var ( - Pink = sdl.Color{R: 255, G: 192, B: 203, A: 255} - LightPink = sdl.Color{R: 255, G: 182, B: 193, A: 255} - HotPink = sdl.Color{R: 255, G: 105, B: 180, A: 255} - DeepPink = sdl.Color{R: 255, G: 20, B: 147, A: 255} - PaleVioletRed = sdl.Color{R: 219, G: 112, B: 147, A: 255} - MediumVioletRed = sdl.Color{R: 199, G: 21, B: 133, A: 255} - LightSalmon = sdl.Color{R: 255, G: 160, B: 122, A: 255} - Salmon = sdl.Color{R: 250, G: 128, B: 114, A: 255} - DarkSalmon = sdl.Color{R: 233, G: 150, B: 122, A: 255} - LightCoral = sdl.Color{R: 240, G: 128, B: 128, A: 255} - IndianRed = sdl.Color{R: 205, G: 92, B: 92, A: 255} - Crimson = sdl.Color{R: 220, G: 20, B: 60, A: 255} - Firebrick = sdl.Color{R: 178, G: 34, B: 34, A: 255} - DarkRed = sdl.Color{R: 139, G: 0, B: 0, A: 255} - DeepBloodRed = sdl.Color{R: 139, G: 0, B: 0, A: 255} -) - -// Oranges -var ( - LightOrange = sdl.Color{R: 255, G: 200, B: 127, A: 255} - Orange = sdl.Color{R: 255, G: 165, B: 0, A: 255} - OrangeRed = sdl.Color{R: 255, G: 69, B: 0, A: 255} - DarkOrange = sdl.Color{R: 255, G: 140, B: 0, A: 255} - DarkOrangeRed = sdl.Color{R: 255, G: 140, B: 105, A: 255} - Tomato = sdl.Color{R: 255, G: 99, B: 71, A: 255} - Coral = sdl.Color{R: 255, G: 127, B: 80, A: 255} - PaleOrange = sdl.Color{R: 255, G: 223, B: 186, A: 255} - RedOrange = sdl.Color{R: 255, G: 69, B: 0, A: 255} -) - -// Yellows -var ( - Gold = sdl.Color{R: 255, G: 215, B: 0, A: 255} - LightYellow = sdl.Color{R: 255, G: 255, B: 224, A: 255} - LemonChiffon = sdl.Color{R: 255, G: 250, B: 205, A: 255} - LightGoldenrodYellow = sdl.Color{R: 250, G: 250, B: 210, A: 255} - PapayaWhip = sdl.Color{R: 255, G: 239, B: 213, A: 255} - Moccasin = sdl.Color{R: 255, G: 228, B: 181, A: 255} - PeachPuff = sdl.Color{R: 255, G: 218, B: 185, A: 255} - PaleGoldenrod = sdl.Color{R: 238, G: 232, B: 170, A: 255} - Khaki = sdl.Color{R: 240, G: 230, B: 140, A: 255} - DarkKhaki = sdl.Color{R: 189, G: 183, B: 107, A: 255} -) - -// Purples & Violets -var ( - Lavender = sdl.Color{R: 230, G: 230, B: 250, A: 255} - Thistle = sdl.Color{R: 216, G: 191, B: 216, A: 255} - Plum = sdl.Color{R: 221, G: 160, B: 221, A: 255} - Violet = sdl.Color{R: 238, G: 130, B: 238, A: 255} - Orchid = sdl.Color{R: 218, G: 112, B: 214, A: 255} - Magenta = sdl.Color{R: 255, G: 0, B: 255, A: 255} - MediumOrchid = sdl.Color{R: 186, G: 85, B: 211, A: 255} - MediumPurple = sdl.Color{R: 147, G: 112, B: 219, A: 255} - RebeccaPurple = sdl.Color{R: 102, G: 51, B: 153, A: 255} - BlueViolet = sdl.Color{R: 138, G: 43, B: 226, A: 255} - DarkViolet = sdl.Color{R: 148, G: 0, B: 211, A: 255} - DarkOrchid = sdl.Color{R: 153, G: 50, B: 204, A: 255} - DarkMagenta = sdl.Color{R: 139, G: 0, B: 139, A: 255} - Indigo = sdl.Color{R: 75, G: 0, B: 130, A: 255} - SlateBlue = sdl.Color{R: 106, G: 90, B: 205, A: 255} - DarkSlateBlue = sdl.Color{R: 72, G: 61, B: 139, A: 255} - MediumSlateBlue = sdl.Color{R: 123, G: 104, B: 238, A: 255} - LavenderBlush = sdl.Color{R: 255, G: 240, B: 245, A: 255} - LavenderGray = sdl.Color{R: 250, G: 240, B: 250, A: 255} -) - -// Greens -var ( - GreenYellow = sdl.Color{R: 173, G: 255, B: 47, A: 255} - Chartreuse = sdl.Color{R: 127, G: 255, B: 0, A: 255} - LawnGreen = sdl.Color{R: 124, G: 252, B: 0, A: 255} - LimeGreen = sdl.Color{R: 50, G: 205, B: 50, A: 255} - PaleGreen = sdl.Color{R: 152, G: 251, B: 152, A: 255} - LightGreen = sdl.Color{R: 144, G: 238, B: 144, A: 255} - MediumSpringGreen = sdl.Color{R: 0, G: 250, B: 154, A: 255} - SpringGreen = sdl.Color{R: 0, G: 255, B: 127, A: 255} - MediumSeaGreen = sdl.Color{R: 60, G: 179, B: 113, A: 255} - SeaGreen = sdl.Color{R: 46, G: 139, B: 87, A: 255} - ForestGreen = sdl.Color{R: 34, G: 139, B: 34, A: 255} - DarkGreen = sdl.Color{R: 0, G: 100, B: 0, A: 255} - YellowGreen = sdl.Color{R: 154, G: 205, B: 50, A: 255} - OliveDrab = sdl.Color{R: 107, G: 142, B: 35, A: 255} - DarkOliveGreen = sdl.Color{R: 85, G: 107, B: 47, A: 255} - MediumAquamarine = sdl.Color{R: 102, G: 205, B: 170, A: 255} - DarkSeaGreen = sdl.Color{R: 143, G: 188, B: 143, A: 255} - LightSeaGreen = sdl.Color{R: 32, G: 178, B: 170, A: 255} - DarkCyan = sdl.Color{R: 0, G: 139, B: 139, A: 255} - MintGreen = sdl.Color{R: 173, G: 255, B: 169, A: 255} - VeryPaleGreen = sdl.Color{R: 200, G: 255, B: 210, A: 255} - Honeydew = sdl.Color{R: 240, G: 255, B: 240, A: 255} -) - -// Blues & Cyans -var ( - LightCyan = sdl.Color{R: 224, G: 255, B: 255, A: 255} - Cyan = sdl.Color{R: 0, G: 255, B: 255, A: 255} - AquaMarine = sdl.Color{R: 127, G: 255, B: 212, A: 255} - PaleTurquoise = sdl.Color{R: 175, G: 238, B: 238, A: 255} - Turquoise = sdl.Color{R: 64, G: 224, B: 208, A: 255} - MediumTurquoise = sdl.Color{R: 72, G: 209, B: 204, A: 255} - DarkTurquoise = sdl.Color{R: 0, G: 206, B: 209, A: 255} - CadetBlue = sdl.Color{R: 95, G: 158, B: 160, A: 255} - SteelBlue = sdl.Color{R: 70, G: 130, B: 180, A: 255} - LightSteelBlue = sdl.Color{R: 176, G: 196, B: 222, A: 255} - PowderBlue = sdl.Color{R: 176, G: 224, B: 230, A: 255} - LightBlue = sdl.Color{R: 173, G: 216, B: 230, A: 255} - SkyBlue = sdl.Color{R: 135, G: 206, B: 235, A: 255} - LightSkyBlue = sdl.Color{R: 135, G: 206, B: 250, A: 255} - DeepSkyBlue = sdl.Color{R: 0, G: 191, B: 255, A: 255} - DodgerBlue = sdl.Color{R: 30, G: 144, B: 255, A: 255} - CornflowerBlue = sdl.Color{R: 100, G: 149, B: 237, A: 255} - MediumBlue = sdl.Color{R: 0, G: 0, B: 205, A: 255} - DarkBlue = sdl.Color{R: 0, G: 0, B: 139, A: 255} - MidnightBlue = sdl.Color{R: 25, G: 25, B: 112, A: 255} - RoyalBlue = sdl.Color{R: 65, G: 105, B: 225, A: 255} -) - -// Browns -var ( - Cornsilk = sdl.Color{R: 255, G: 248, B: 220, A: 255} - BlanchedAlmond = sdl.Color{R: 255, G: 235, B: 205, A: 255} - Bisque = sdl.Color{R: 255, G: 228, B: 196, A: 255} - NavajoWhite = sdl.Color{R: 255, G: 222, B: 173, A: 255} - Wheat = sdl.Color{R: 245, G: 222, B: 179, A: 255} - BurlyWood = sdl.Color{R: 222, G: 184, B: 135, A: 255} - Tan = sdl.Color{R: 210, G: 180, B: 140, A: 255} - RosyBrown = sdl.Color{R: 188, G: 143, B: 143, A: 255} - SandyBrown = sdl.Color{R: 244, G: 164, B: 96, A: 255} - Goldenrod = sdl.Color{R: 218, G: 165, B: 32, A: 255} - DarkGoldenrod = sdl.Color{R: 184, G: 134, B: 11, A: 255} - Peru = sdl.Color{R: 205, G: 133, B: 63, A: 255} - Chocolate = sdl.Color{R: 210, G: 105, B: 30, A: 255} - SaddleBrown = sdl.Color{R: 139, G: 69, B: 19, A: 255} - Sienna = sdl.Color{R: 160, G: 82, B: 45, A: 255} - Brown = sdl.Color{R: 165, G: 42, B: 42, A: 255} -) - -// Whites -var ( - Snow = sdl.Color{R: 255, G: 250, B: 250, A: 255} - MintCream = sdl.Color{R: 245, G: 255, B: 250, A: 255} - Azure = sdl.Color{R: 240, G: 255, B: 255, A: 255} - AliceBlue = sdl.Color{R: 240, G: 248, B: 255, A: 255} - GhostWhite = sdl.Color{R: 248, G: 248, B: 255, A: 255} - WhiteSmoke = sdl.Color{R: 245, G: 245, B: 245, A: 255} - Seashell = sdl.Color{R: 255, G: 245, B: 238, A: 255} - Beige = sdl.Color{R: 245, G: 245, B: 220, A: 255} - OldLace = sdl.Color{R: 253, G: 245, B: 230, A: 255} - FloralWhite = sdl.Color{R: 255, G: 250, B: 240, A: 255} - Ivory = sdl.Color{R: 255, G: 255, B: 240, A: 255} - AntiqueWhite = sdl.Color{R: 250, G: 235, B: 215, A: 255} - Linen = sdl.Color{R: 250, G: 240, B: 230, A: 255} - MistyRose = sdl.Color{R: 255, G: 228, B: 225, A: 255} -) - -// Groups - -// Based on Solfeggio Frequencies -var ( - Freq_396_Colors = [7]sdl.Color{DeepBloodRed, Firebrick, Crimson, Tomato, DarkOrangeRed, Orange, PeachPuff} - Freq_417_Colors = [7]sdl.Color{RedOrange, Coral, DarkOrange, Orange, LightOrange, PaleOrange, BlanchedAlmond} - // Default proclamation needs to be using this - Freq_528_Colors = [7]sdl.Color{LimeGreen, MediumSeaGreen, LightGreen, PaleGreen, MintGreen, VeryPaleGreen, Honeydew} - Freq_639_Colors = [7]sdl.Color{SpringGreen, DarkTurquoise, Turquoise, CadetBlue, AquaMarine, PaleTurquoise, LightCyan} - Freq_741_Colors = [7]sdl.Color{DeepSkyBlue, DodgerBlue, RoyalBlue, CornflowerBlue, SkyBlue, LightBlue, AliceBlue} - Freq_852_Colors = [7]sdl.Color{Indigo, MediumPurple, BlueViolet, MediumSlateBlue, Orchid, Thistle, Lavender} - Freq_963_Colors = [7]sdl.Color{DarkViolet, MediumOrchid, MediumVioletRed, Plum, Violet, LavenderBlush, LavenderGray} -) diff --git a/archive/internal/db/DB.go b/archive/internal/db/DB.go deleted file mode 100644 index 0e852a5..0000000 --- a/archive/internal/db/DB.go +++ /dev/null @@ -1,112 +0,0 @@ -// Package db provides a database connection and functions for interacting with the database. -// The DB object should be held and maintained by the application, not by this package. -// The application should be responsible for connecting to, migrating, and initial population -// of the database. This package should only provide the schema definitions and helper functions. -// Schema definitions should clone API response structures as closely as possible. - -package db - -import ( - "fmt" - "time" - - "gorm.io/driver/sqlite" - "gorm.io/gorm" -) - -const ( - connectMaxAttempts = 5 - connectRetryDelay = 500 * time.Millisecond -) - -// Connect opens the SQLite database at dbpath with WAL mode and a hardened -// lock-recovery strategy suitable for kiosk use where the process may have -// crashed and left stale lock artefacts behind. -func Connect(dbpath string) (*gorm.DB, error) { - - // ── Stale lock recovery ─────────────────────────────────────────────── - // SQLite WAL mode leaves a .db-wal and .db-shm alongside the main file. - // If the previous process crashed, the OS releases file locks but the - // artefacts remain. SQLite recovers them automatically on the next open, - // UNLESS an OS-level advisory lock is still held (rare but possible on - // Linux with certain crash patterns). Removing them before opening is - // safe: SQLite treats absent WAL/SHM files as a clean slate and will - // recreate them. We only do this if the main DB file already exists - // (i.e. this is not a first-run) to avoid masking legitimate errors. - // if _, err := os.Stat(dbpath); err == nil { - // for _, ext := range []string{"-wal", "-shm"} { - // artefact := dbpath + ext - // if _, err := os.Stat(artefact); err == nil { - // if removeErr := os.Remove(artefact); removeErr != nil { - // // Non-fatal: log and continue — SQLite may still recover. - // fmt.Printf("[DB] warning: could not remove stale %s: %v\n", ext, removeErr) - // } else { - // fmt.Printf("[DB] removed stale lock artefact: %s\n", artefact) - // } - // } - // } - // } - - // ── Connection with retry ───────────────────────────────────────────── - // Even after removing WAL/SHM, the main DB file itself may still be - // briefly locked if another process is shutting down. Retry with - // backoff before giving up. - var ( - db *gorm.DB - err error - ) - for attempt := 1; attempt <= connectMaxAttempts; attempt++ { - db, err = gorm.Open(sqlite.Open(dbpath), &gorm.Config{}) - if err == nil { - break - } - if attempt == connectMaxAttempts { - return nil, fmt.Errorf("database still locked after %d attempts: %w", connectMaxAttempts, err) - } - fmt.Printf("[DB] open attempt %d/%d failed (%v), retrying in %s\n", - attempt, connectMaxAttempts, err, connectRetryDelay) - time.Sleep(connectRetryDelay) - } - - // ── Connection pool ─────────────────────────────────────────────────── - // SQLite does not support concurrent writers. Setting MaxOpenConns(1) - // forces gorm to serialise all queries through a single connection, - // preventing "database is locked" errors from competing goroutines - // within this process. This is the single most important setting for - // SQLite in a multi-goroutine application. - sqlDB, err := db.DB() - if err != nil { - return nil, fmt.Errorf("could not get underlying sql.DB: %w", err) - } - sqlDB.SetMaxOpenConns(1) - sqlDB.SetMaxIdleConns(1) - - // ── Pragmas ─────────────────────────────────────────────────────────── - pragmas := []string{ - "PRAGMA journal_mode=WAL", - "PRAGMA synchronous=FULL", - "PRAGMA foreign_keys=ON", - // 5 s gives the serialised connection time to finish a long write - // before returning SQLITE_BUSY to the application layer. - "PRAGMA busy_timeout=5000", - } - for _, pragma := range pragmas { - if _, err := sqlDB.Exec(pragma); err != nil { - return nil, fmt.Errorf("failed to set %q: %w", pragma, err) - } - } - - return db, nil -} - -// Migrate the database schema. -func Migrate(db *gorm.DB) error { - return db.AutoMigrate( - &AppConfig{}, - &Log{}, - &Proclamation{}, - &ProclamationCategory{}, - &SolfeggioFrequency{}, - &AudioFile{}, - ) -} diff --git a/archive/internal/db/Schema.go b/archive/internal/db/Schema.go deleted file mode 100644 index 0d7f6bb..0000000 --- a/archive/internal/db/Schema.go +++ /dev/null @@ -1,58 +0,0 @@ -// schema.go defines the database schema for the GORM models. -package db - -import "gorm.io/gorm" - -type AppConfig struct { - gorm.Model - DataDir string - AssetsDir string - EmailAddress string - LicenseKey string - SystemLevel string // BASIC, ADVANCED, ELITE, COMMERCIAL - SelectedAudioDevice string // The pipewire device name - InitialFontSize int // How large to create the font atlas - StandardFontSize int // The font size used for most text rendering - LinePattern string // "fibonacci" - AudioEnabled bool - SavedVolume float64 // Last saved volume as a percentage - FirstRun bool // Is this the first time the application has been run? -} - -type Log struct { - gorm.Model - Timestamp int64 - Level string - Message string -} - -type Proclamation struct { - ID string `gorm:"primaryKey"` - Name string - Content string - SolfeggioFrequencyID int - CategoryID int - Selected bool - Available bool - Price float64 -} - -type ProclamationCategory struct { - ID int `gorm:"primaryKey"` - Name string - ParentId int -} - -type SolfeggioFrequency struct { - ID int `gorm:"primaryKey"` - Name string - Value int -} - -type AudioFile struct { - ID int `gorm:"primaryKey"` - Name string - Path string - Selected bool - Order int -} diff --git a/archive/internal/db/queries.go b/archive/internal/db/queries.go deleted file mode 100644 index de2ed52..0000000 --- a/archive/internal/db/queries.go +++ /dev/null @@ -1,14 +0,0 @@ -package db - -import "gorm.io/gorm" - -// GetConfig fetches the application config from the database -func GetConfig(db *gorm.DB) (*AppConfig, error) { - var cfg AppConfig - err := db.First(&cfg, 1).Error - if err != nil { - return nil, err - } - - return &cfg, nil -} diff --git a/archive/internal/download/Download.go b/archive/internal/download/Download.go deleted file mode 100644 index 60c2bd0..0000000 --- a/archive/internal/download/Download.go +++ /dev/null @@ -1,239 +0,0 @@ -package download - -import ( - "bufio" - "crypto/md5" - "fmt" - "io" - "net/http" - "os" - "path/filepath" - "strings" - - "rhemawavetech.com/rhemawave/internal/logger" -) - -// DownloadStatus holds information related to downloads and is -// the container for information returned via channels within this package. -type DownloadStatus struct { - Max int64 - Cur int64 - Msg string - Err error -} - -// manifest maps filename → expected MD5 hex string -type manifest map[string]string - -// ─── Server script to generate checksums.md5 ───────────────────────────────── -// -// Run this on your file server whenever you add or replace audio files: -// -// cd /path/to/audio && md5sum *.mp3 > checksums.md5 -// -// That's it. The app will pick up changes automatically on next run. -// ───────────────────────────────────────────────────────────────────────────── - -const manifestFilename = "checksums.md5" - -// DownloadAudioFiles downloads audio files, skipping any that already exist -// locally with a matching MD5. Files with a wrong checksum are deleted and -// re-downloaded. Progress is sent to progressChan, which is closed on return. -func DownloadAudioFiles(progressChan chan<- DownloadStatus, destDir string, urlBase string) { - defer close(progressChan) - - send := func(cur, max int64, msg string, err error) { - progressChan <- DownloadStatus{Cur: cur, Max: max, Msg: msg, Err: err} - } - - send(0, 0, "Fetching file manifest...", nil) - m, err := fetchManifest(urlBase + manifestFilename) - if err != nil { - send(0, 0, "Failed to fetch manifest", err) - return - } - logger.Info("download: manifest loaded, %d files listed", len(m)) - - send(0, 0, "Comparing checksums...", nil) - - type work struct { - filename string - expectedMD5 string - } - var queue []work - - for filename, expectedMD5 := range m { - localPath := filepath.Join(destDir, filename) - localMD5, err := fileMD5(localPath) - if err != nil { - logger.Debug("download: %s not found locally, queuing", filename) - queue = append(queue, work{filename, expectedMD5}) - continue - } - if localMD5 != expectedMD5 { - logger.Debug("download: %s checksum mismatch (got %s, want %s), re-downloading", - filename, localMD5, expectedMD5) - if err := os.Remove(localPath); err != nil { - logger.Warning("download: failed to remove corrupt file %s: %v", localPath, err) - } - queue = append(queue, work{filename, expectedMD5}) - } else { - logger.Debug("download: %s OK", filename) - } - } - - if len(queue) == 0 { - send(0, 0, "All files are up to date.", nil) - logger.Info("download: nothing to do") - return - } - - total := len(queue) - send(0, 0, fmt.Sprintf("0 / %d Files Downloaded", total), nil) - logger.Info("download: %d file(s) to download", total) - - totalDownloaded := int64(0) - filesDownloaded := 0 - - for _, w := range queue { - url := urlBase + w.filename - destPath := filepath.Join(destDir, w.filename) - - logger.Debug("download: starting %s", w.filename) - - downloaded, err := downloadFile(url, destPath, func(n int64) { - totalDownloaded += n - send(totalDownloaded, 0, fmt.Sprintf("%d / %d Files Downloaded", filesDownloaded, total), nil) - }) - - if err != nil { - logger.Error("download: failed to download %s: %v", w.filename, err) - send(totalDownloaded, 0, fmt.Sprintf("%d / %d Files Downloaded", filesDownloaded, total), err) - return - } - - gotMD5, err := fileMD5(destPath) - if err != nil { - logger.Error("download: could not hash %s after download: %v", w.filename, err) - os.Remove(destPath) - send(totalDownloaded, 0, "Checksum verification failed", err) - return - } - if gotMD5 != w.expectedMD5 { - err := fmt.Errorf("checksum mismatch after download: got %s, want %s", gotMD5, w.expectedMD5) - logger.Error("download: %s %v", w.filename, err) - os.Remove(destPath) - send(totalDownloaded, 0, "Checksum verification failed — file removed", err) - return - } - - filesDownloaded++ - statusMsg := fmt.Sprintf("%d / %d Files Downloaded", filesDownloaded, total) - logger.Info("download: %s (%d bytes)", statusMsg, downloaded) - send(totalDownloaded, 0, statusMsg, nil) - } - - send(totalDownloaded, totalDownloaded, "All downloads complete!", nil) - logger.Info("download: all files downloaded and verified") -} - -// ─── Helpers ────────────────────────────────────────────────────────────────── - -// fetchManifest downloads and parses a checksums.md5 file. -// Format per line: " " (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 deleted file mode 100644 index 20e0096..0000000 --- a/archive/internal/fontManager/fontManager.go +++ /dev/null @@ -1,890 +0,0 @@ -package fontManager - -import ( - "fmt" - "os" - "path/filepath" - "strings" - "sync" - - "github.com/go-gl/gl/v3.3-core/gl" - "github.com/go-gl/mathgl/mgl32" - "github.com/veandco/go-sdl2/sdl" - "github.com/veandco/go-sdl2/ttf" - "rhemawavetech.com/rhemawave/internal/db" - "rhemawavetech.com/rhemawave/internal/graphics" - "rhemawavetech.com/rhemawave/internal/shaderManager" -) - -// FontManager handles the loading and management of fonts. -type FontManager struct { - Fonts map[string]*FontEntry - - // Reusable GPU objects for string texture rendering - stringFBO uint32 - stringVAO uint32 - stringVBO uint32 -} - -var ( - fontManagerInstance *FontManager - fontManagerOnce sync.Once - initErr error // Package-level variable to capture error from within sync.Once -) - -// InitFontManager initializes the FontManager singleton. -// It must be called once at startup after ShaderManager has been initialized. -func InitFontManager(cfg *db.AppConfig) error { - fontManagerOnce.Do(func() { - - // Dependency check: Ensure ShaderManager is ready - sm := shaderManager.Get() - if sm == nil { - initErr = fmt.Errorf("cannot initialize FontManager because ShaderManager is not initialized") - return // Exit the anonymous function - } - - fm := &FontManager{ - Fonts: make(map[string]*FontEntry), - } - - fontPath := filepath.Join(cfg.AssetsDir, "fonts") - if err := fm.loadFonts(fontPath, cfg); err != nil { - initErr = fmt.Errorf("could not load fonts from '%s': %w", fontPath, err) - return - } - - // --- NEW: Create the scrambled font after loading normal fonts --- - // We will use "DejaVuSans" as the base. If you use a different default font, - // change this string. - baseFontForScramble := "SpaceMonoNerdFont-Regular" - if _, ok := fm.Fonts[baseFontForScramble]; ok { - if err := fm.CreateScrambledFont(baseFontForScramble, "Scrambled"); err != nil { - // This is not a fatal error for the whole application, so we just log it. - } - } else { - } - // --- END NEW --- - - // Create reusable GPU objects for string texture rendering - gl.GenFramebuffers(1, &fm.stringFBO) - gl.GenVertexArrays(1, &fm.stringVAO) - gl.GenBuffers(1, &fm.stringVBO) - - // Set up VAO attribute layout once - gl.BindVertexArray(fm.stringVAO) - gl.BindBuffer(gl.ARRAY_BUFFER, fm.stringVBO) - gl.BufferData(gl.ARRAY_BUFFER, 6*4*4, nil, gl.DYNAMIC_DRAW) // 6 vertices * 4 floats * 4 bytes - gl.EnableVertexAttribArray(0) - gl.VertexAttribPointerWithOffset(0, 2, gl.FLOAT, false, 4*4, 0) - gl.EnableVertexAttribArray(1) - gl.VertexAttribPointerWithOffset(1, 2, gl.FLOAT, false, 4*4, 2*4) - gl.BindVertexArray(0) - gl.BindBuffer(gl.ARRAY_BUFFER, 0) - - fontManagerInstance = fm - }) - - // Return the error that might have been captured inside the sync.Once block. - return initErr -} - -// Get returns the singleton instance of the FontManager. -// It will panic if InitFontManager has not been called successfully first. -func Get() *FontManager { - if fontManagerInstance == nil { - panic("FontManager has not been initialized or initialization failed. Call InitFontManager at application startup.") - } - return fontManagerInstance -} - -// FontEntry combines related Font information together -type FontEntry struct { - Name string - Font *ttf.Font - Map *FontMap -} - -// FontMap contains the character map for a specific font -type FontMap struct { - FontMapTextureID uint32 // Memory address of the FontMapTexture within the GPU - Width int32 // The width of the font map texture - Height int32 // The height of the font map FontMap Texture - Glyphs map[rune]Glyph // A map of runes and their locations within the Fontmap - padding int32 -} - -// TempGlyph is a temporary struct used only during font map creation. -type TempGlyph struct { - Rune rune - Advance int - surface *sdl.Surface -} - -// Glyph contains the rendering information for a single character -type Glyph struct { - Rune rune // The character this glyph represents. - SrcRect sdl.Rect // The source rectangle in the texture atlas. - Advance int // How far to advance the cursor after drawing this glyph. - TextureID uint32 // The ID of the texture atlas this glyph belongs to. -} - -// loadFonts scans the fonts directory, loads all .ttf/.otf files, and creates a font map for each. -func (fm *FontManager) loadFonts(fontDir string, cfg *db.AppConfig) error { - files, err := os.ReadDir(fontDir) - if err != nil { - return fmt.Errorf("could not read font directory '%s': %w", fontDir, err) - } - - for _, file := range files { - if file.IsDir() { - continue - } - fileName := file.Name() - if strings.HasSuffix(strings.ToLower(fileName), ".ttf") || strings.HasSuffix(strings.ToLower(fileName), ".otf") { - fontPath := filepath.Join(fontDir, fileName) - baseFontSize := cfg.StandardFontSize - if err != nil { - return fmt.Errorf("could not convert init_font_size to int: %w", err) - } - font, err := ttf.OpenFont(fontPath, baseFontSize) // Using a higher resolution for better quality - if err != nil { - continue - } - - fontName := strings.TrimSuffix(fileName, filepath.Ext(fileName)) - fm.Fonts[fontName] = &FontEntry{ - Name: fontName, - Font: font, - Map: nil, - } - } - } - - for _, fontEntry := range fm.Fonts { - if err := fm.createFontMap(fontEntry, false, nil); err != nil { - return fmt.Errorf("unable to create Font Map for font: %s : %v", fontEntry.Name, err) - } - } - - return nil -} - -// CreateScrambledFont generates a new font entry by scrambling the glyphs of a base font. -func (fm *FontManager) CreateScrambledFont(baseFontName string, newFontName string) error { - baseFont, ok := fm.Fonts[baseFontName] - if !ok { - return fmt.Errorf("base font '%s' not found", baseFontName) - } - - scrambledFontEntry := &FontEntry{ - Name: newFontName, - Font: baseFont.Font, // It uses the same underlying TTF font object for rendering - Map: nil, - } - - if err := fm.createFontMap(scrambledFontEntry, true, baseFont); err != nil { - return fmt.Errorf("unable to create scrambled font map: %w", err) - } - - fm.Fonts[newFontName] = scrambledFontEntry - return nil -} - -// ATTENTION: TED -// -// # Deterministic Fibonacci-Based Visual Pattern Generation for Character Data -// -// This method transforms rendered text characters into structured visual patterns that produce -// character-specific spatial frequency signatures while maintaining perfect reversibility. -// -// The technology addresses a fundamental limitation in subliminal text display: conventional -// approaches either use unmodified text (consciously readable, defeating the subliminal purpose) -// or simple blurring/masking (destroys information content and character-specific structure). -// Our method preserves complete character information in a transformed visual state that remains -// illegible to conscious observation while generating structured spatial patterns. -// -// The approach treats each rendered character as a two-dimensional grid and applies deterministic -// geometric transformations parameterized by Fibonacci sequence values. The character's Unicode -// value seeds all transformation parameters, ensuring identical characters produce identical -// patterns while different characters generate distinct spatial signatures. Multiple transformation -// stages compound to create high-entropy visual output with underlying mathematical structure. -// -// Key differentiators from existing solutions: -// - Operates on rendered visual data rather than underlying text data or encrypted values -// - Generates character-specific spatial frequency patterns rather than uniform noise -// - Uses Fibonacci-sequence-derived parameters to create mathematically structured transformations -// - Maintains pixel-perfect reversibility despite high visual entropy -// - Produces deterministic, reproducible patterns for each character -// -// Within our Rhema Wave generation system, these transformed characters create spatial interference -// patterns designed for subconscious processing. Each character contributes a unique frequency -// signature to the overall visual field, with Fibonacci-based geometry intended to create harmonic -// relationships between character patterns. The result is a consciously illegible display that -// maintains structured information content in the spatial domain, forming a component of the -// broader Rhema Wave output. -func (fm *FontManager) scrambleSurface(surface *sdl.Surface, r rune) error { - if surface.W < 8 || surface.H < 8 { - return nil - } - if err := surface.Lock(); err != nil { - return fmt.Errorf("could not lock surface for scrambling: %w", err) - } - defer surface.Unlock() - - W := int(surface.W) - H := int(surface.H) - - // Use much finer grid for complete illegibility - // Fibonacci number determines grid size: 5, 8, or 13 - fib := []int{5, 8, 13} - fibIndex := int(r) % len(fib) - gridSize := fib[fibIndex] - - // Ensure we have at least 8x8 grid for strong scrambling - if gridSize < 8 { - gridSize = 8 - } - - blockW := W / gridSize - blockH := H / gridSize - - if blockW == 0 || blockH == 0 { - // Surface too small, fall back to 4x4 - gridSize = 4 - blockW = W / gridSize - blockH = H / gridSize - if blockW == 0 || blockH == 0 { - return nil - } - } - - numBlocks := gridSize * gridSize - - // Create complete permutation using Fibonacci linear congruential generator - blockIndices := make([]int, numBlocks) - - // Use Fibonacci numbers for the LCG parameters - // F(7)=13, F(8)=21, F(9)=34, F(10)=55, F(11)=89 - fibSeq := []int{13, 21, 34, 55, 89, 144} - multiplier := fibSeq[int(r)%len(fibSeq)] - increment := fibSeq[(int(r)+1)%len(fibSeq)] - - // Generate pseudo-random permutation - for i := 0; i < numBlocks; i++ { - blockIndices[i] = (multiplier*i + increment*int(r)) % numBlocks - } - - // Additional scrambling: apply multiple Fibonacci-based transformations - numTransforms := 3 + (int(r) % 5) // 3-7 transformations - - for t := 0; t < numTransforms; t++ { - transformType := (int(r) + t*fibSeq[t%len(fibSeq)]) % 5 - - switch transformType { - case 0: // Reverse sections - sectionSize := gridSize / 2 - for i := 0; i < numBlocks; i += sectionSize { - end := i + sectionSize - if end > numBlocks { - end = numBlocks - } - reverseSlice(blockIndices[i:end]) - } - - case 1: // Spiral rotation - // Rotate blocks in a spiral pattern - for layer := 0; layer < gridSize/2; layer++ { - rotateLayer(blockIndices, gridSize, layer) - } - - case 2: // Fibonacci walk swap - step := fib[t%len(fib)] - for i := 0; i < numBlocks-step; i += step { - blockIndices[i], blockIndices[i+step] = blockIndices[i+step], blockIndices[i] - } - - case 3: // Checkerboard transpose - for i := 0; i < gridSize; i++ { - for j := 0; j < gridSize; j++ { - if (i+j)%2 == 0 { - idx1 := i*gridSize + j - idx2 := j*gridSize + i - if idx2 < numBlocks { - blockIndices[idx1], blockIndices[idx2] = blockIndices[idx2], blockIndices[idx1] - } - } - } - } - - case 4: // Random-walk permutation - walkLen := fib[(t+1)%len(fib)] - pos := int(r+rune(t)) % numBlocks - for w := 0; w < walkLen && pos < numBlocks-1; w++ { - nextPos := (pos + fib[w%len(fib)]) % numBlocks - blockIndices[pos], blockIndices[nextPos] = blockIndices[nextPos], blockIndices[pos] - pos = nextPos - } - } - } - - // Copy pixels to perform the scrambling - pixels := surface.Pixels() - bytesPerPixel := int(surface.Format.BytesPerPixel) - pitch := int(surface.Pitch) - originalPixels := make([]byte, len(pixels)) - copy(originalPixels, pixels) - - // Rearrange all blocks - for dstIdx := 0; dstIdx < numBlocks; dstIdx++ { - srcIdx := blockIndices[dstIdx] - - srcCol := srcIdx % gridSize - srcRow := srcIdx / gridSize - dstCol := dstIdx % gridSize - dstRow := dstIdx / gridSize - - srcX := srcCol * blockW - srcY := srcRow * blockH - dstX := dstCol * blockW - dstY := dstRow * blockH - - // Copy block pixel by pixel - for y := 0; y < blockH; y++ { - if srcY+y >= H || dstY+y >= H { - continue - } - - srcOffset := (srcY+y)*pitch + srcX*bytesPerPixel - dstOffset := (dstY+y)*pitch + dstX*bytesPerPixel - - rowLen := blockW * bytesPerPixel - if srcX+blockW > W { - rowLen = (W - srcX) * bytesPerPixel - } - if dstX+blockW > W { - maxLen := (W - dstX) * bytesPerPixel - if maxLen < rowLen { - rowLen = maxLen - } - } - - if srcOffset+rowLen <= len(originalPixels) && dstOffset+rowLen <= len(pixels) { - copy(pixels[dstOffset:dstOffset+rowLen], originalPixels[srcOffset:srcOffset+rowLen]) - } - } - } - - return nil -} - -// Helper function to reverse a slice -func reverseSlice(arr []int) { - for i, j := 0, len(arr)-1; i < j; i, j = i+1, j-1 { - arr[i], arr[j] = arr[j], arr[i] - } -} - -// Helper function to rotate a layer in spiral pattern -func rotateLayer(arr []int, gridSize, layer int) { - if layer >= gridSize/2 { - return - } - - // Extract the layer elements - var temp []int - - // Top edge - for i := layer; i < gridSize-layer; i++ { - temp = append(temp, arr[layer*gridSize+i]) - } - // Right edge - for i := layer + 1; i < gridSize-layer; i++ { - temp = append(temp, arr[i*gridSize+(gridSize-layer-1)]) - } - // Bottom edge - if gridSize-layer-1 > layer { - for i := gridSize - layer - 2; i >= layer; i-- { - temp = append(temp, arr[(gridSize-layer-1)*gridSize+i]) - } - } - // Left edge - if gridSize-layer-1 > layer { - for i := gridSize - layer - 2; i > layer; i-- { - temp = append(temp, arr[i*gridSize+layer]) - } - } - - // Rotate the temp array by 1 position - if len(temp) > 0 { - last := temp[len(temp)-1] - copy(temp[1:], temp[0:len(temp)-1]) - temp[0] = last - } - - // Put back the rotated elements - idx := 0 - // Top edge - for i := layer; i < gridSize-layer && idx < len(temp); i++ { - arr[layer*gridSize+i] = temp[idx] - idx++ - } - // Right edge - for i := layer + 1; i < gridSize-layer && idx < len(temp); i++ { - arr[i*gridSize+(gridSize-layer-1)] = temp[idx] - idx++ - } - // Bottom edge - if gridSize-layer-1 > layer { - for i := gridSize - layer - 2; i >= layer && idx < len(temp); i-- { - arr[(gridSize-layer-1)*gridSize+i] = temp[idx] - idx++ - } - } - // Left edge - if gridSize-layer-1 > layer { - for i := gridSize - layer - 2; i > layer && idx < len(temp); i-- { - arr[i*gridSize+layer] = temp[idx] - idx++ - } - } -} - -// createFontMap generates a texture atlas for a given font entry. -func (fm *FontManager) createFontMap(font *FontEntry, scramble bool, baseFont *FontEntry) error { - // var chars []rune - // for i := rune(32); i <= 126; i++ { // Common ASCII characters - // chars = append(chars, i) - // } - - // Standard ASCII (A-Z, a-z, 0-9) + punctuation - chars := []rune{ - // Numbers 0-9 - 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, // 0-9 - - // Uppercase A-Z - 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, // A-O - 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, // P-Z - - // Lowercase a-z - 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, // a-m - 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, // n-z - - // Basic punctuation (ASCII 32-47, 58-64, 91-96, 123-126) - 32, // Space - 33, // ! - 34, // " - 35, // # - 36, // $ - 37, // % - 38, // & - 39, // ' - 40, // ( - 41, // ) - 42, // * - 43, // + - 44, // , - 45, // - - 46, // . - 47, // / - 58, // : - 59, // ; - 60, // < - 61, // = - 62, // > - 63, // ? - 64, // @ - 91, // [ - 92, // \ - 93, // ] - 94, // ^ - 95, // _ - 96, // ` - 123, // { - 124, // | - 125, // } - 126, // ~ - - // Common Unicode punctuation - 161, // ¡ - 191, // ¿ - 8211, // – - 8212, // — - 8216, // ‘ - 8217, // ’ - 8220, // “ - 8221, // ” - 8226, // • - 8230, // … - 8240, // ‰ - 8364, // € - 162, // ¢ - 163, // £ - 165, // ¥ - 169, // © - 174, // ® - 8482, // ™ - 232, // è - 233, // é - 10003, // checkmark - } - tempGlyphs := make([]TempGlyph, 0, len(chars)) - var totalWidth, maxHeight int32 - padding := int32(2) // 2 pixels of padding between glyphs - - // If scrambling, use the base font's metrics but our new entry's name - fontToRenderFrom := font - if scramble && baseFont != nil { - fontToRenderFrom = baseFont - } - - for _, char := range chars { - tempGlyph, err := fm.createTempGlyph(fontToRenderFrom, char) - if err != nil { - continue - } - - // --- NEW: Scramble the surface if requested --- - if scramble { - if err := fm.scrambleSurface(tempGlyph.surface, char); err != nil { - // We can continue, it will just use the unscrambled glyph - } - } - // --- END NEW --- - - tempGlyphs = append(tempGlyphs, tempGlyph) - totalWidth += tempGlyph.surface.W + padding - if tempGlyph.surface.H > maxHeight { - maxHeight = tempGlyph.surface.H - } - } - - atlasSurface, err := sdl.CreateRGBSurface(0, totalWidth, maxHeight, 32, 0x00ff0000, 0x0000ff00, 0x000000ff, 0xff000000) - if err != nil { - return fmt.Errorf("could not create atlas surface: %w", err) - } - defer atlasSurface.Free() - - fontMap := &FontMap{ - Glyphs: make(map[rune]Glyph), - padding: padding, - } - - var currentX int32 = 0 - for _, tempGlyph := range tempGlyphs { - dstRect := sdl.Rect{X: currentX, Y: 0, W: tempGlyph.surface.W, H: tempGlyph.surface.H} - if err := tempGlyph.surface.Blit(nil, atlasSurface, &dstRect); err != nil { - tempGlyph.surface.Free() - continue - } - - fontMap.Glyphs[tempGlyph.Rune] = Glyph{ - Rune: tempGlyph.Rune, - Advance: tempGlyph.Advance, - SrcRect: dstRect, - } - - currentX += tempGlyph.surface.W + padding - tempGlyph.surface.Free() - } - - textureID, err := graphics.CreateTextureFromSurface(atlasSurface) - if err != nil { - return fmt.Errorf("could not create texture from atlas surface: %w", err) - } - - fontMap.FontMapTextureID = textureID - fontMap.Width = atlasSurface.W - fontMap.Height = atlasSurface.H - - for r, g := range fontMap.Glyphs { - g.TextureID = textureID - fontMap.Glyphs[r] = g - } - - font.Map = fontMap - return nil -} - -// createTempGlyph renders a single character to a temporary SDL surface. -func (fm *FontManager) createTempGlyph(font *FontEntry, char rune) (TempGlyph, error) { - runeSurface, err := font.Font.RenderGlyphBlended(char, sdl.Color{R: 255, G: 255, B: 255, A: 255}) - if err != nil { - return TempGlyph{}, fmt.Errorf("unable to create glyph for char '%c': %w", char, err) - } - - metrics, err := font.Font.GlyphMetrics(char) - if err != nil { - runeSurface.Free() - return TempGlyph{}, fmt.Errorf("unable to fetch glyph metrics for char '%c': %w", char, err) - } - - return TempGlyph{ - Rune: char, - Advance: metrics.Advance, - surface: runeSurface, - }, nil -} - -// GetFont retrieves a loaded font by its name. -func (fm *FontManager) GetFont(name string) (*FontEntry, bool) { - fontEntry, ok := fm.Fonts[name] - return fontEntry, ok -} - -// GetGlyph retrieves the rendering data for a specific character from a font's map. -func (fm *FontManager) GetGlyph(fontName string, r rune) (Glyph, bool) { - fontEntry, ok := fm.Fonts[fontName] - if !ok || fontEntry.Map == nil { - return Glyph{}, false - } - glyph, ok := fontEntry.Map.Glyphs[r] - return glyph, ok -} - -// GetFontNames returns a slice of all loaded font names. -func (fm *FontManager) GetFontNames() []string { - names := make([]string, 0, len(fm.Fonts)) - for name := range fm.Fonts { - names = append(names, name) - } - return names -} - -// Close frees all loaded font resources, including the generated textures. -func (fm *FontManager) Close() { - for _, fontEntry := range fm.Fonts { - if fontEntry.Map != nil && fontEntry.Map.FontMapTextureID != 0 { - gl.DeleteTextures(1, &fontEntry.Map.FontMapTextureID) - } - if fontEntry.Font != nil { - fontEntry.Font.Close() - } - } - - if fm.stringFBO != 0 { - gl.DeleteFramebuffers(1, &fm.stringFBO) - } - if fm.stringVAO != 0 { - gl.DeleteVertexArrays(1, &fm.stringVAO) - } - if fm.stringVBO != 0 { - gl.DeleteBuffers(1, &fm.stringVBO) - } -} - -func (fm *FontManager) CreateStringTexture(fontName string, text string, shader *shaderManager.Shader) (textureID uint32, width int32, height int32, err error) { - shaderID := shader.ProgramID - if fontName == "" || text == "" { - return 0, 0, 0, fmt.Errorf("font name and text cannot be blank") - } - - selectedFont, ok := fm.Fonts[fontName] - if !ok || selectedFont.Map == nil { - return 0, 0, 0, fmt.Errorf("font '%s' not found or its font map is not created", fontName) - } - - var totalWidth, maxHeight int32 - glyphsToRender := make([]Glyph, 0, len(text)) - - for _, r := range text { - g, ok := selectedFont.Map.Glyphs[r] - if !ok { - g = selectedFont.Map.Glyphs['?'] // Fallback - } - totalWidth += g.SrcRect.W + selectedFont.Map.padding - if g.SrcRect.H > maxHeight { - maxHeight = g.SrcRect.H - } - glyphsToRender = append(glyphsToRender, g) - } - - if totalWidth <= 0 || maxHeight <= 0 { - return 0, 0, 0, fmt.Errorf("calculated texture dimensions are invalid: %dx%d", totalWidth, maxHeight) - } - - var lastFBO int32 - gl.GetIntegerv(gl.FRAMEBUFFER_BINDING, &lastFBO) - var lastViewport [4]int32 - gl.GetIntegerv(gl.VIEWPORT, &lastViewport[0]) - - gl.BindFramebuffer(gl.FRAMEBUFFER, fm.stringFBO) - - var newTextureID uint32 - gl.GenTextures(1, &newTextureID) - gl.BindTexture(gl.TEXTURE_2D, newTextureID) - gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR) - gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR) - gl.PixelStorei(gl.UNPACK_ALIGNMENT, 1) - - gl.TexImage2D(gl.TEXTURE_2D, 0, gl.RGBA, totalWidth, maxHeight, 0, gl.RGBA, gl.UNSIGNED_BYTE, nil) - gl.FramebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, newTextureID, 0) - - if gl.CheckFramebufferStatus(gl.FRAMEBUFFER) != gl.FRAMEBUFFER_COMPLETE { - gl.BindFramebuffer(gl.FRAMEBUFFER, uint32(lastFBO)) - gl.DeleteTextures(1, &newTextureID) - return 0, 0, 0, fmt.Errorf("framebuffer is not complete") - } - - gl.Viewport(0, 0, totalWidth, maxHeight) - gl.ClearColor(0.0, 0.0, 0.0, 0.0) - gl.Clear(gl.COLOR_BUFFER_BIT) - gl.UseProgram(shaderID) - - projection := mgl32.Ortho(0, float32(totalWidth), 0, float32(maxHeight), -1, 1) - gl.UniformMatrix4fv(shader.LocMVPMatrix, 1, false, &projection[0]) - - if shader.LocTextColor != -1 { - gl.Uniform4f(shader.LocTextColor, 1.0, 1.0, 1.0, 1.0) - } - - gl.ActiveTexture(gl.TEXTURE0) - gl.Uniform1i(shader.LocTexture, 0) - gl.Enable(gl.BLEND) - gl.BlendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA) - gl.BindTexture(gl.TEXTURE_2D, selectedFont.Map.FontMapTextureID) - - gl.BindVertexArray(fm.stringVAO) - gl.BindBuffer(gl.ARRAY_BUFFER, fm.stringVBO) - - var currentX float32 - atlasW, atlasH := float32(selectedFont.Map.Width), float32(selectedFont.Map.Height) - - for _, glyph := range glyphsToRender { - xPos := currentX - yPos := float32(0) - w, h := float32(glyph.SrcRect.W), float32(glyph.SrcRect.H) - u0 := float32(glyph.SrcRect.X) / atlasW - v0 := float32(glyph.SrcRect.Y) / atlasH - u1 := u0 + w/atlasW - v1 := v0 + h/atlasH - vertices := []float32{ - xPos, yPos + h, u0, v0, - xPos, yPos, u0, v1, - xPos + w, yPos, u1, v1, - xPos, yPos + h, u0, v0, - xPos + w, yPos, u1, v1, - xPos + w, yPos + h, u1, v0, - } - gl.BufferSubData(gl.ARRAY_BUFFER, 0, len(vertices)*4, gl.Ptr(vertices)) - gl.DrawArrays(gl.TRIANGLES, 0, 6) - currentX += float32(glyph.Advance) - } - - gl.BindVertexArray(0) - gl.BindFramebuffer(gl.FRAMEBUFFER, uint32(lastFBO)) - gl.Viewport(lastViewport[0], lastViewport[1], lastViewport[2], lastViewport[3]) - gl.Disable(gl.BLEND) - - return newTextureID, totalWidth, maxHeight, nil -} - -// NewCreateStringTexture does the same thing as CreateStringTexture, except that it returns the width in pixels used by the glyphs -// in the texture, rather than the width of the texture itself. This can be used for trimming. -func (fm *FontManager) NewCreateStringTexture(fontName string, text string, shader *shaderManager.Shader) (textureID uint32, width float32, height int32, err error) { - shaderID := shader.ProgramID - if fontName == "" || text == "" { - return 0, 0, 0, fmt.Errorf("font name and text cannot be blank") - } - - selectedFont, ok := fm.Fonts[fontName] - if !ok || selectedFont.Map == nil { - return 0, 0, 0, fmt.Errorf("font '%s' not found or its font map is not created", fontName) - } - - var totalWidth, maxHeight int32 - glyphsToRender := make([]Glyph, 0, len(text)) - - for _, r := range text { - g, ok := selectedFont.Map.Glyphs[r] - if !ok { - g = selectedFont.Map.Glyphs['?'] // Fallback - } - totalWidth += g.SrcRect.W + selectedFont.Map.padding - if g.SrcRect.H > maxHeight { - maxHeight = g.SrcRect.H - } - glyphsToRender = append(glyphsToRender, g) - } - - if totalWidth <= 0 || maxHeight <= 0 { - return 0, 0, 0, fmt.Errorf("calculated texture dimensions are invalid: %dx%d", totalWidth, maxHeight) - } - - var lastFBO int32 - gl.GetIntegerv(gl.FRAMEBUFFER_BINDING, &lastFBO) - var lastViewport [4]int32 - gl.GetIntegerv(gl.VIEWPORT, &lastViewport[0]) - - gl.BindFramebuffer(gl.FRAMEBUFFER, fm.stringFBO) - - var newTextureID uint32 - gl.GenTextures(1, &newTextureID) - gl.BindTexture(gl.TEXTURE_2D, newTextureID) - gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR) - gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR) - gl.PixelStorei(gl.UNPACK_ALIGNMENT, 1) - - gl.TexImage2D(gl.TEXTURE_2D, 0, gl.RGBA, totalWidth, maxHeight, 0, gl.RGBA, gl.UNSIGNED_BYTE, nil) - gl.FramebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, newTextureID, 0) - - if gl.CheckFramebufferStatus(gl.FRAMEBUFFER) != gl.FRAMEBUFFER_COMPLETE { - gl.BindFramebuffer(gl.FRAMEBUFFER, uint32(lastFBO)) - gl.DeleteTextures(1, &newTextureID) - return 0, 0, 0, fmt.Errorf("framebuffer is not complete") - } - - gl.Viewport(0, 0, totalWidth, maxHeight) - gl.ClearColor(0.0, 0.0, 0.0, 0.0) - gl.Clear(gl.COLOR_BUFFER_BIT) - gl.UseProgram(shaderID) - - projection := mgl32.Ortho(0, float32(totalWidth), 0, float32(maxHeight), -1, 1) - gl.UniformMatrix4fv(shader.LocMVPMatrix, 1, false, &projection[0]) - - if shader.LocTextColor != -1 { - gl.Uniform4f(shader.LocTextColor, 1.0, 1.0, 1.0, 1.0) - } - - gl.ActiveTexture(gl.TEXTURE0) - gl.Uniform1i(shader.LocTexture, 0) - gl.Enable(gl.BLEND) - gl.BlendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA) - gl.BindTexture(gl.TEXTURE_2D, selectedFont.Map.FontMapTextureID) - - gl.BindVertexArray(fm.stringVAO) - gl.BindBuffer(gl.ARRAY_BUFFER, fm.stringVBO) - - var currentX float32 - atlasW, atlasH := float32(selectedFont.Map.Width), float32(selectedFont.Map.Height) - - for _, glyph := range glyphsToRender { - xPos := currentX - yPos := float32(0) - w, h := float32(glyph.SrcRect.W), float32(glyph.SrcRect.H) - u0 := float32(glyph.SrcRect.X) / atlasW - v0 := float32(glyph.SrcRect.Y) / atlasH - u1 := u0 + w/atlasW - v1 := v0 + h/atlasH - vertices := []float32{ - xPos, yPos + h, u0, v0, - xPos, yPos, u0, v1, - xPos + w, yPos, u1, v1, - xPos, yPos + h, u0, v0, - xPos + w, yPos, u1, v1, - xPos + w, yPos + h, u1, v0, - } - gl.BufferSubData(gl.ARRAY_BUFFER, 0, len(vertices)*4, gl.Ptr(vertices)) - gl.DrawArrays(gl.TRIANGLES, 0, 6) - currentX += float32(glyph.Advance) - } - - usedWidth := float32(currentX) - - gl.BindVertexArray(0) - gl.BindFramebuffer(gl.FRAMEBUFFER, uint32(lastFBO)) - gl.Viewport(lastViewport[0], lastViewport[1], lastViewport[2], lastViewport[3]) - gl.Disable(gl.BLEND) - - return newTextureID, usedWidth, maxHeight, nil -} diff --git a/archive/internal/graphics/graphics.go b/archive/internal/graphics/graphics.go deleted file mode 100644 index 89d158c..0000000 --- a/archive/internal/graphics/graphics.go +++ /dev/null @@ -1,128 +0,0 @@ -package graphics - -import ( - "fmt" - "log" - - "github.com/go-gl/gl/v3.3-core/gl" - "github.com/veandco/go-sdl2/sdl" -) - -// CreateTextureFromSurface uploads an SDL surface to an OpenGL texture and returns the texture ID. -func CreateTextureFromSurface(surface *sdl.Surface) (uint32, error) { - if surface == nil { - return 0, fmt.Errorf("CreateTextureFromSurface: input surface is nil") - } - - var textureID uint32 - var err error - - surfaceToUpload := surface - var convertedSurface *sdl.Surface = nil - - sdlFormatEnum := surface.Format.Format - var dataFormat uint32 - var internalFormat int32 = gl.RGBA8 - - if sdlFormatEnum == sdl.PIXELFORMAT_INDEX8 { - convertedSurface, err = surface.ConvertFormat(sdl.PIXELFORMAT_ARGB8888, 0) - if err != nil { - return 0, fmt.Errorf("failed to convert INDEX8 surface to ARGB8888: %w", err) - } - defer convertedSurface.Free() - - surfaceToUpload = convertedSurface - sdlFormatEnum = surfaceToUpload.Format.Format - } - - switch sdlFormatEnum { - case sdl.PIXELFORMAT_ARGB8888: - dataFormat = gl.BGRA - case sdl.PIXELFORMAT_ABGR8888: - dataFormat = gl.RGBA - case sdl.PIXELFORMAT_RGBA8888: - dataFormat = gl.RGBA - case sdl.PIXELFORMAT_BGRA8888: - dataFormat = gl.BGRA - default: - return 0, fmt.Errorf("unhandled SDL pixel format for OpenGL upload: %s", sdl.GetPixelFormatName(uint(sdlFormatEnum))) - } - - for errCode := gl.GetError(); errCode != gl.NO_ERROR; errCode = gl.GetError() { - log.Printf("OpenGL error pending before GenTextures: 0x%X", errCode) - } - - gl.GenTextures(1, &textureID) - if errCode := gl.GetError(); errCode != gl.NO_ERROR { - return 0, fmt.Errorf("OpenGL error 0x%X after GenTextures", errCode) - } - if textureID == 0 { - return 0, fmt.Errorf("gl.GenTextures returned textureID 0") - } - - gl.BindTexture(gl.TEXTURE_2D, textureID) - - gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST) - gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST) - gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE) - gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE) - - gl.PixelStorei(gl.UNPACK_ALIGNMENT, 1) - - expectedPitch := surfaceToUpload.W * int32(surfaceToUpload.Format.BytesPerPixel) - if int32(surfaceToUpload.Pitch) != expectedPitch { - gl.PixelStorei(gl.UNPACK_ROW_LENGTH, surfaceToUpload.Pitch/int32(surfaceToUpload.Format.BytesPerPixel)) - } else { - gl.PixelStorei(gl.UNPACK_ROW_LENGTH, 0) - } - - gl.TexImage2D( - gl.TEXTURE_2D, - 0, - internalFormat, - surfaceToUpload.W, - surfaceToUpload.H, - 0, - dataFormat, - gl.UNSIGNED_BYTE, - gl.Ptr(surfaceToUpload.Pixels()), - ) - - gl.PixelStorei(gl.UNPACK_ALIGNMENT, 4) - gl.PixelStorei(gl.UNPACK_ROW_LENGTH, 0) - - if glErr := gl.GetError(); glErr != gl.NO_ERROR { - gl.DeleteTextures(1, &textureID) - return 0, fmt.Errorf("OpenGL error 0x%X after TexImage2D", glErr) - } - - gl.BindTexture(gl.TEXTURE_2D, 0) - return textureID, nil -} - -// CheckGLError queries OpenGL for errors and returns the first one found. -func CheckGLError() error { - errCode := gl.GetError() - if errCode == gl.NO_ERROR { - return nil - } - - switch errCode { - case gl.INVALID_ENUM: - return fmt.Errorf("INVALID_ENUM") - case gl.INVALID_VALUE: - return fmt.Errorf("INVALID_VALUE") - case gl.INVALID_OPERATION: - return fmt.Errorf("INVALID_OPERATION") - case gl.STACK_OVERFLOW: - return fmt.Errorf("STACK_OVERFLOW") - case gl.STACK_UNDERFLOW: - return fmt.Errorf("STACK_UNDERFLOW") - case gl.OUT_OF_MEMORY: - return fmt.Errorf("OUT_OF_MEMORY") - case gl.INVALID_FRAMEBUFFER_OPERATION: - return fmt.Errorf("INVALID_FRAMEBUFFER_OPERATION") - default: - return fmt.Errorf("Unknown GL error: 0x%X", errCode) - } -} diff --git a/archive/internal/graphics/virtual_framebuffer.go b/archive/internal/graphics/virtual_framebuffer.go deleted file mode 100644 index 27d3663..0000000 --- a/archive/internal/graphics/virtual_framebuffer.go +++ /dev/null @@ -1,193 +0,0 @@ -package graphics - -import ( - "fmt" - - "github.com/go-gl/gl/v3.3-core/gl" -) - -const ( - VirtualWidth int32 = 1920 - VirtualHeight int32 = 1080 -) - -var ( - vfbo uint32 - vfboTexture uint32 - vfboDepthStencil uint32 - blitVAO uint32 - blitVBO uint32 - blitShaderProg uint32 - nativeW, nativeH int32 -) - -// InitVirtualFramebuffer creates the FBO at virtual resolution and the blit shader. -// Call after OpenGL context is created. -func InitVirtualFramebuffer(displayW, displayH int32) error { - nativeW = displayW - nativeH = displayH - - // --- FBO setup --- - gl.GenFramebuffers(1, &vfbo) - gl.BindFramebuffer(gl.FRAMEBUFFER, vfbo) - - gl.GenTextures(1, &vfboTexture) - gl.BindTexture(gl.TEXTURE_2D, vfboTexture) - gl.TexImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, VirtualWidth, VirtualHeight, 0, gl.RGBA, gl.UNSIGNED_BYTE, nil) - gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR) - gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR) - gl.FramebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, vfboTexture, 0) - - // Depth/stencil renderbuffer - gl.GenRenderbuffers(1, &vfboDepthStencil) - gl.BindRenderbuffer(gl.RENDERBUFFER, vfboDepthStencil) - gl.RenderbufferStorage(gl.RENDERBUFFER, gl.DEPTH24_STENCIL8, VirtualWidth, VirtualHeight) - gl.FramebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_STENCIL_ATTACHMENT, gl.RENDERBUFFER, vfboDepthStencil) - - if status := gl.CheckFramebufferStatus(gl.FRAMEBUFFER); status != gl.FRAMEBUFFER_COMPLETE { - return fmt.Errorf("virtual framebuffer incomplete: 0x%X", status) - } - - gl.BindFramebuffer(gl.FRAMEBUFFER, 0) - - // --- Fullscreen quad --- - vertices := []float32{ - // pos (x,y) texcoord (u,v) - -1, -1, 0, 0, - 1, -1, 1, 0, - -1, 1, 0, 1, - 1, 1, 1, 1, - } - - gl.GenVertexArrays(1, &blitVAO) - gl.GenBuffers(1, &blitVBO) - gl.BindVertexArray(blitVAO) - gl.BindBuffer(gl.ARRAY_BUFFER, blitVBO) - gl.BufferData(gl.ARRAY_BUFFER, len(vertices)*4, gl.Ptr(vertices), gl.STATIC_DRAW) - - gl.EnableVertexAttribArray(0) - gl.VertexAttribPointerWithOffset(0, 2, gl.FLOAT, false, 4*4, 0) - gl.EnableVertexAttribArray(1) - gl.VertexAttribPointerWithOffset(1, 2, gl.FLOAT, false, 4*4, 2*4) - - gl.BindVertexArray(0) - - // --- Blit shader --- - var err error - blitShaderProg, err = compileBlitShader() - if err != nil { - return fmt.Errorf("blit shader compilation failed: %w", err) - } - - return nil -} - -// BeginVirtualRender binds the FBO so all subsequent draws go to the virtual 1080p buffer. -func BeginVirtualRender() { - gl.BindFramebuffer(gl.FRAMEBUFFER, vfbo) - gl.Viewport(0, 0, VirtualWidth, VirtualHeight) -} - -// BlitToScreen draws the virtual buffer to the actual screen at native resolution. -func BlitToScreen() { - gl.BindFramebuffer(gl.FRAMEBUFFER, 0) - gl.Viewport(0, 0, nativeW, nativeH) - gl.ClearColor(0, 0, 0, 1) - gl.Clear(gl.COLOR_BUFFER_BIT) - - gl.UseProgram(blitShaderProg) - gl.ActiveTexture(gl.TEXTURE0) - gl.BindTexture(gl.TEXTURE_2D, vfboTexture) - - gl.BindVertexArray(blitVAO) - gl.DrawArrays(gl.TRIANGLE_STRIP, 0, 4) - gl.BindVertexArray(0) -} - -// DestroyVirtualFramebuffer cleans up all FBO resources. -func DestroyVirtualFramebuffer() { - if blitShaderProg != 0 { - gl.DeleteProgram(blitShaderProg) - } - if blitVBO != 0 { - gl.DeleteBuffers(1, &blitVBO) - } - if blitVAO != 0 { - gl.DeleteVertexArrays(1, &blitVAO) - } - if vfboDepthStencil != 0 { - gl.DeleteRenderbuffers(1, &vfboDepthStencil) - } - if vfboTexture != 0 { - gl.DeleteTextures(1, &vfboTexture) - } - if vfbo != 0 { - gl.DeleteFramebuffers(1, &vfbo) - } -} - -func compileBlitShader() (uint32, error) { - vertSrc := `#version 330 core -layout(location = 0) in vec2 aPos; -layout(location = 1) in vec2 aTexCoord; -out vec2 vTexCoord; -void main() { - vTexCoord = aTexCoord; - gl_Position = vec4(aPos, 0.0, 1.0); -}` + "\x00" - - fragSrc := `#version 330 core -in vec2 vTexCoord; -out vec4 FragColor; -uniform sampler2D screenTexture; -void main() { - FragColor = texture(screenTexture, vTexCoord); -}` + "\x00" - - vertShader, err := compileShader(vertSrc, gl.VERTEX_SHADER) - if err != nil { - return 0, err - } - fragShader, err := compileShader(fragSrc, gl.FRAGMENT_SHADER) - if err != nil { - return 0, err - } - - prog := gl.CreateProgram() - gl.AttachShader(prog, vertShader) - gl.AttachShader(prog, fragShader) - gl.LinkProgram(prog) - - var status int32 - gl.GetProgramiv(prog, gl.LINK_STATUS, &status) - if status == gl.FALSE { - var logLen int32 - gl.GetProgramiv(prog, gl.INFO_LOG_LENGTH, &logLen) - logStr := make([]byte, logLen) - gl.GetProgramInfoLog(prog, logLen, nil, &logStr[0]) - return 0, fmt.Errorf("shader link error: %s", string(logStr)) - } - - gl.DeleteShader(vertShader) - gl.DeleteShader(fragShader) - return prog, nil -} - -func compileShader(src string, shaderType uint32) (uint32, error) { - shader := gl.CreateShader(shaderType) - csrc, free := gl.Strs(src) - gl.ShaderSource(shader, 1, csrc, nil) - free() - gl.CompileShader(shader) - - var status int32 - gl.GetShaderiv(shader, gl.COMPILE_STATUS, &status) - if status == gl.FALSE { - var logLen int32 - gl.GetShaderiv(shader, gl.INFO_LOG_LENGTH, &logLen) - logStr := make([]byte, logLen) - gl.GetShaderInfoLog(shader, logLen, nil, &logStr[0]) - return 0, fmt.Errorf("shader compile error: %s", string(logStr)) - } - return shader, nil -} diff --git a/archive/internal/keybinds/keybinds.go b/archive/internal/keybinds/keybinds.go deleted file mode 100644 index 96b3636..0000000 --- a/archive/internal/keybinds/keybinds.go +++ /dev/null @@ -1,79 +0,0 @@ -// package keybinds provides keybind functionality for the entire application -package keybinds - -import ( - "github.com/veandco/go-sdl2/sdl" -) - -// KeyCombo represents a combination of a key and it's active modifier. -type KeyCombo struct { - Key sdl.Keycode - Mod sdl.Keymod -} - -// Binds contains all currently registered Keybinds. -var Binds = make(map[KeyCombo]func()) - -// Enabled controls whether PerformAction dispatches keybinds. Set to false -// to temporarily suppress all global keybinds (e.g. during a modal page). -var Enabled = true - -// Regiser registers a new keybind in Binds -func Register(k sdl.Keycode, m sdl.Keymod, action func()) { - mod := NormalizeModifiers(m) - newCombo := KeyCombo{k, mod} - _, ok := Binds[newCombo] - if ok { - } - Binds[newCombo] = action -} - -// BindKey adds a new KeyCombo and Function (the action to take) to the keybinds.Binds -func BindKey(k KeyCombo, f func()) { - k.Mod = NormalizeModifiers(k.Mod) - Binds[k] = f -} - -// UnbindKey removes a KeyCombo from the available binds -func UnbindKey(k KeyCombo) { - k.Mod = NormalizeModifiers(k.Mod) - delete(Binds, k) -} - -func PerformAction(event sdl.Event) { - if !Enabled { - return - } - switch e := event.(type) { - case *sdl.KeyboardEvent: - if e.Type == sdl.KEYDOWN { - key := e.Keysym.Sym - rawMod := e.Keysym.Mod - mod := NormalizeModifiers(sdl.Keymod(rawMod)) - combo := KeyCombo{key, mod} - action, ok := Binds[combo] - if ok { - action() - } - } - } -} - -// NormalizeModifiers converts a raw SDL modifier bitmask into a canonical form -// focusing on common modifiers (SHIFT, CTRL, ALT, GUI) and ignoring others like CapsLock. -func NormalizeModifiers(mod sdl.Keymod) sdl.Keymod { - var normalized sdl.Keymod = sdl.KMOD_NONE - if mod&sdl.KMOD_SHIFT != 0 { - normalized |= sdl.KMOD_SHIFT - } - if mod&sdl.KMOD_CTRL != 0 { - normalized |= sdl.KMOD_CTRL - } - if mod&sdl.KMOD_ALT != 0 { - normalized |= sdl.KMOD_ALT - } - if mod&sdl.KMOD_GUI != 0 { - normalized |= sdl.KMOD_GUI - } - return normalized -} diff --git a/archive/internal/license/license.go b/archive/internal/license/license.go deleted file mode 100644 index dbee2cf..0000000 --- a/archive/internal/license/license.go +++ /dev/null @@ -1,73 +0,0 @@ -package license - -import ( - "encoding/json" - "fmt" - "net/http" - "net/url" - "path" - "time" -) - -var Level int - -type Validator struct { - client *http.Client - apiURL string - apiPath string -} - -type Response struct { - Key string `json:"key"` - Email string `json:"email"` - Active bool `json:"active"` - Type string `json:"type"` - Level string `json:"level"` - LastCheck time.Time `json:"lastCheck"` - NewCheck time.Time `json:"newCheck"` -} - -func NewValidator(apiURL, apiPath string) *Validator { - return &Validator{ - client: &http.Client{ - Timeout: 10 * time.Second, - Transport: &http.Transport{ - MaxIdleConns: 10, - IdleConnTimeout: 30 * time.Second, - DisableCompression: true, - }, - }, - apiURL: apiURL, - apiPath: apiPath, - } -} - -func (v *Validator) Validate(key, email string) (*Response, error) { - u, err := url.Parse(v.apiURL) - if err != nil { - return nil, err - } - u.Path = path.Join(u.Path, v.apiPath) - - q := u.Query() - q.Set("licenseKey", key) - q.Set("emailAddress", email) - u.RawQuery = q.Encode() - - resp, err := v.client.Get(u.String()) - if err != nil { - return nil, fmt.Errorf("request failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("unexpected status: %w", err) - } - - var result Response - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return nil, fmt.Errorf("failed ot decode response: %w", err) - } - - return &result, nil -} diff --git a/archive/internal/logger/logger.go b/archive/internal/logger/logger.go deleted file mode 100644 index 8214b06..0000000 --- a/archive/internal/logger/logger.go +++ /dev/null @@ -1,231 +0,0 @@ -package logger - -import ( - "fmt" - "log" - "os" - "time" - - "gorm.io/gorm" - "rhemawavetech.com/rhemawave/internal/db" -) - -// ─── Level ─────────────────────────────────────────────────────────────────── - -type Level int - -const ( - LevelDebug Level = iota - LevelInfo - LevelWarning - LevelError - LevelFatal -) - -func (l Level) String() string { - return [...]string{"DEBUG", "INFO", "WARNING", "ERROR", "FATAL"}[l] -} - -// ─── Internal entry type ────────────────────────────────────────────────────── - -type entry struct { - level Level - message string - timestamp time.Time - fatal bool -} - -// ─── Package state ──────────────────────────────────────────────────────────── - -var ( - SetLevel Level = LevelInfo - console = log.New(os.Stderr, "", 0) - - ch chan entry // unbuffered send is a bug; always buffered - done chan struct{} - db_ *gorm.DB - file *os.File -) - -func init() { - ch = make(chan entry, 4096) - done = make(chan struct{}) - go drain() -} - -// ─── Configuration ──────────────────────────────────────────────────────────── - -// AddFile attaches or replaces the file backend. Safe to call at any time. -func AddFile(path string) error { - f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) - if err != nil { - return fmt.Errorf("logger: open file: %w", err) - } - // Send a sentinel that carries the new file handle. - // We do this through the channel so the drain goroutine owns - // all file state — no mutex needed. - fileSwapCh <- f - return nil -} - -// AddDatabase attaches or replaces the database backend. Safe to call at any time. -func AddDatabase(d *gorm.DB) { - dbSwapCh <- d -} - -// Close drains remaining entries and shuts down the logger. -// Call this with defer in main(). -func Close() { - close(done) - <-drainDone // wait for drain goroutine to finish flushing - if file != nil { - file.Close() - file = nil - } -} - -// ─── Swap channels (owned exclusively by drain goroutine) ──────────────────── - -var ( - fileSwapCh = make(chan *os.File, 1) - dbSwapCh = make(chan *gorm.DB, 1) - drainDone = make(chan struct{}) -) - -// ─── Drain goroutine ────────────────────────────────────────────────────────── -// -// This is the ONLY goroutine that touches file, db_, or does DB writes. -// Everything else just sends to ch. - -func drain() { - defer close(drainDone) - - batch := make([]entry, 0, 64) - ticker := time.NewTicker(250 * time.Millisecond) - defer ticker.Stop() - - flush := func() { - if len(batch) == 0 { - return - } - writeBatchToDB(batch) - batch = batch[:0] - } - - for { - select { - - // New log entry - case e := <-ch: - writeToFile(e) - if db_ != nil { - batch = append(batch, e) - if len(batch) >= 64 { - flush() - } - } - - // Periodic DB flush so low-volume logs don't sit in the batch forever - case <-ticker.C: - flush() - - // Hot-swap the file backend - case f := <-fileSwapCh: - if file != nil { - file.Close() - } - file = f - - // Hot-swap the DB backend - case d := <-dbSwapCh: - flush() // flush any pending entries to the old db first - db_ = d - console.Println("logger: database backend attached") - - // Shutdown: drain what's left, then exit - case <-done: - // Pull any entries still sitting in the channel - for len(ch) > 0 { - e := <-ch - writeToFile(e) - batch = append(batch, e) - } - flush() - return - } - } -} - -// ─── Writers (called only from drain goroutine) ─────────────────────────────── - -func writeToFile(e entry) { - line := formatLine(e) - if file != nil { - fmt.Fprintln(file, line) - } -} - -func writeBatchToDB(batch []entry) { - if db_ == nil || len(batch) == 0 { - return - } - records := make([]db.Log, len(batch)) - for i, e := range batch { - records[i] = db.Log{ - Timestamp: e.timestamp.UnixMilli(), - Level: e.level.String(), - Message: e.message, - } - } - // Single INSERT ... VALUES (...), (...), (...) instead of N individual saves. - if result := db_.CreateInBatches(records, 64); result.Error != nil { - console.Printf("logger: db batch write failed: %v\n", result.Error) - } -} - -func formatLine(e entry) string { - return fmt.Sprintf("%s [%s] %s", - e.timestamp.Format("2006-01-02 15:04:05.000"), - e.level, - e.message, - ) -} - -// ─── Public API ─────────────────────────────────────────────────────────────── - -func logMsg(level Level, fatal bool, format string, args ...any) { - if level == LevelDebug && SetLevel != LevelDebug { - return - } - e := entry{ - level: level, - message: fmt.Sprintf(format, args...), - timestamp: time.Now(), // captured here, in the caller's goroutine - fatal: fatal, - } - - // Console is synchronous — log.Logger is internally mutex-protected, - // so this is safe from any goroutine and gives you immediate output. - console.Println(formatLine(e)) - - // Non-blocking send: if the buffer is full, we drop rather than deadlock. - // 4096 entries of headroom means this should never happen in practice. - select { - case ch <- e: - default: - console.Println("logger: channel full, entry dropped") - } - - if fatal { - // Give the drain goroutine a moment to flush before we exit. - // Close() is cleaner but Fatal has to exit immediately. - time.Sleep(150 * time.Millisecond) - os.Exit(1) - } -} - -func Debug(format string, args ...any) { logMsg(LevelDebug, false, format, args...) } -func Info(format string, args ...any) { logMsg(LevelInfo, false, format, args...) } -func Warning(format string, args ...any) { logMsg(LevelWarning, false, format, args...) } -func Error(format string, args ...any) { logMsg(LevelError, false, format, args...) } -func Fatal(format string, args ...any) { logMsg(LevelFatal, true, format, args...) } diff --git a/archive/internal/mixer/mixer.go b/archive/internal/mixer/mixer.go deleted file mode 100644 index 5d4f086..0000000 --- a/archive/internal/mixer/mixer.go +++ /dev/null @@ -1,496 +0,0 @@ -// internal/audio/mixer/mixer.go -package mixer - -import ( - "bufio" - "bytes" - "errors" - "fmt" - "os/exec" - "strconv" - "strings" - "sync" - - "github.com/veandco/go-sdl2/mix" - "github.com/veandco/go-sdl2/sdl" - "rhemawavetech.com/rhemawave/internal/db" - "rhemawavetech.com/rhemawave/internal/logger" -) - -var ( - mu sync.Mutex - inited bool - deviceOpen bool - deviceName string - - currentMusic *mix.Music - currentPath string - currentLoops int // 0 = once, -1 = forever - - // Config knobs (could be made public or read from your config) - defaultFreq = 48000 - defaultFormat = uint16(sdl.AUDIO_F32) - defaultChannels = 2 - defaultChunkSize = 1024 - defaultAllow = sdl.AUDIO_ALLOW_ANY_CHANGE - - refreshCacheCallback func() -) - -type Sink struct { - ID int - Name string - Volume float64 - IsDefault bool -} - -// Init initializes SDL audio + SDL_mixer. Safe to call multiple times. -func Init(cfg *db.AppConfig, onUpdate func()) error { - mu.Lock() - defer mu.Unlock() - if inited { - return nil - } - - refreshCacheCallback = onUpdate - - if err := sdl.InitSubSystem(sdl.INIT_AUDIO); err != nil { - return fmt.Errorf("SDL audio init failed: %w", err) - } - - // FIX 1: Initialize MP3 and OGG decoders - err := mix.Init(mix.INIT_MP3 | mix.INIT_OGG) - if err != nil { - logger.Error("Failed to initialize MP3/OGG support: %v", err) - } - - deviceName := cfg.SelectedAudioDevice - if deviceName == "default" { - deviceName = "" - } - if err := openDeviceLocked(deviceName); err != nil { - sdl.QuitSubSystem(sdl.INIT_AUDIO) - return err - } - - SetAllSinkVolumes(cfg.SavedVolume) - - inited = true - return nil -} - -// openDeviceLocked tries to open the named device; if it fails and the name wasn't empty, falls back to default. -func openDeviceLocked(name string) error { - if err := mix.OpenAudioDevice(defaultFreq, defaultFormat, defaultChannels, defaultChunkSize, name, defaultAllow); err != nil { - if name != "" { - // Log the failure of the requested device - - // Fallback to default device - if err2 := mix.OpenAudioDevice(defaultFreq, defaultFormat, defaultChannels, defaultChunkSize, "", defaultAllow); err2 != nil { - return fmt.Errorf("failed to open device %q and default device: %w", name, err2) - } - deviceName = "" // actually opened default - } else { - return fmt.Errorf("failed to open default audio device: %w", err) - } - } else { - if name != "" { - } else { - } - deviceName = name - } - deviceOpen = true - return nil -} - -// Play loads a WAV (as music) and plays it. If loopForever is true, loops forever. -// If something is already playing, it stops and replaces it. -func Play(path string, loopForever bool, cfg *db.AppConfig) error { - // Check if audio is enabled or disabled - if !cfg.AudioEnabled { - return nil - } - - // Lock the mutex - mu.Lock() - defer mu.Unlock() - - // Check if mixer has been initialized and that a device is open - if !inited { - logger.Error("audio mixer not initialized") - return errors.New("audio mixer not initialized") - } - if !deviceOpen { - if err := openDeviceLocked(""); err != nil { - return fmt.Errorf("failed to open audio device: %w", err) - } - } - if path == "" { - logger.Error("empty audio path") - return errors.New("empty audio path") - } - - // Stop and free any prior music - mix.HaltMusic() - if currentMusic != nil { - currentMusic.Free() - currentMusic = nil - } - - // Load the music file - m, err := mix.LoadMUS(path) - if err != nil { - return fmt.Errorf("LoadMUS(%q): %w", path, err) - } - // Set current music to the loaded music file - currentMusic = m - currentPath = path - if loopForever { - currentLoops = -1 - } else { - currentLoops = 0 - } - - mix.VolumeMusic(mix.MAX_VOLUME) - volume := mix.VolumeMusic(-1) - logger.Info("Current volume is %d", volume) - - if err := currentMusic.Play(currentLoops); err != nil { - currentMusic.Free() - currentMusic = nil - logger.Error("music.Play failed: %v", err) - return fmt.Errorf("music.Play: %w", err) - } - return nil -} - -// Stop stops playback. If fadeMs > 0, fades out asynchronously; otherwise halts immediately. -// It also frees the currently loaded music (so a new Play will reload). -func Stop(fadeMs int, cfg *db.AppConfig) { - mu.Lock() - defer mu.Unlock() - if !inited { - return - } - - if fadeMs > 0 && mix.PlayingMusic() { - _ = mix.FadeOutMusic(fadeMs) // async fade; we’ll still free below - } - - mix.HaltMusic() - if currentMusic != nil { - currentMusic.Free() - currentMusic = nil - } - currentPath = "" - currentLoops = 0 -} - -// IsPlaying reports whether music is currently playing (or fading). -func IsPlaying() bool { - mu.Lock() - defer mu.Unlock() - if !inited { - return false - } - return mix.PlayingMusic() -} - -// SetDefaultSinkVolume sets volume 0.0–1.0 on the default sink -func SetDefaultSinkVolume(level float64) error { - if level < 0 || level > 1 { - return fmt.Errorf("volume must be between 0.0 and 1.0, got %f", level) - } - pct := fmt.Sprintf("%.2f", level) - cmd := exec.Command("wpctl", "set-volume", "@DEFAULT_AUDIO_SINK@", pct) - if out, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("wpctl failed: %w\noutput: %s", err, out) - } - return nil -} - -// listSinkIDs returns just the numeric IDs of all audio sinks. -func listSinkIDs() ([]int, error) { - sinks, err := listSinks() - if err != nil { - return nil, err - } - ids := make([]int, len(sinks)) - for i, s := range sinks { - ids[i] = s.ID - } - return ids, nil -} - -// listSinks returns full Sink info for all audio sinks. -func listSinks() ([]Sink, error) { - out, err := exec.Command("wpctl", "status").Output() - if err != nil { - return nil, fmt.Errorf("wpctl status: %w", err) - } - return parseSinks(out) -} - -// SetAllSinkVolumes sets volume on every audio sink found via wpctl status -func SetAllSinkVolumes(level float64) error { - ids, err := listSinkIDs() - if err != nil { - return err - } - pct := fmt.Sprintf("%.2f", level) - for _, id := range ids { - cmd := exec.Command("wpctl", "set-volume", strconv.Itoa(id), pct) - if out, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("wpctl set-volume %d failed: %w\noutput: %s", id, err, out) - } - } - return nil -} - -// ConvertVolume converts a desired volume percentage (0%, 25%, 33%, 90%, etc) to -// the float64 value needed to pass to SetAllSinkVolumes 0..1.0 -// Percentages should be passed as integers. 25% -> 25 -func ConvertVolume(volPerc int) float64 { - if volPerc == 0 { - return 0.0 - } - return float64(volPerc) / 100.0 -} - -// parseSinks parses the output of `wpctl status` and extracts audio sinks. -func parseSinks(data []byte) ([]Sink, error) { - var sinks []Sink - - scanner := bufio.NewScanner(bytes.NewReader(data)) - - inAudioSection := false - inSinksSection := false - - for scanner.Scan() { - line := scanner.Text() - stripped := strings.TrimLeft(line, " │├└─") - - // Detect top-level sections: "Audio", "Video", "Settings", etc. - // These are lines with no leading tree characters. - if !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "│") { - inAudioSection = strings.TrimSpace(line) == "Audio" - inSinksSection = false - continue - } - - if !inAudioSection { - continue - } - - // Detect subsection headers within Audio - if strings.Contains(stripped, "Sinks:") { - inSinksSection = true - continue - } - // Any other subsection header resets sink parsing - if strings.HasSuffix(strings.TrimSpace(stripped), ":") { - inSinksSection = false - continue - } - - if !inSinksSection { - continue - } - - sink, ok := parseSinkLine(stripped) - if ok { - sinks = append(sinks, sink) - } - } - - if err := scanner.Err(); err != nil { - return nil, fmt.Errorf("scanning wpctl output: %w", err) - } - - return sinks, nil -} - -// parseSinkLine parses a single sink entry line like: -// -// "*51. USB Audio [vol: 1.00]" -// "33. Built-in Audio Analog Stereo [vol: 0.65]" -func parseSinkLine(s string) (Sink, bool) { - s = strings.TrimSpace(s) - if s == "" { - return Sink{}, false - } - - var sink Sink - - // Leading '*' marks the default sink - if strings.HasPrefix(s, "*") { - sink.IsDefault = true - s = strings.TrimPrefix(s, "*") - s = strings.TrimSpace(s) - } - - // Expect ". ..." - 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 deleted file mode 100644 index 0e97ef7..0000000 --- a/archive/internal/mixer/mixer_internal_test.go +++ /dev/null @@ -1,312 +0,0 @@ -package mixer - -import ( - "testing" - "time" - - "rhemawavetech.com/rhemawave/internal/db" -) - -// Regression tests for critical audio bugs (v0.5) - -// Regression: Stop() used to return early when AudioEnabled was false, -// leaving music playing after the user toggled audio off mid-playback. -func TestStopCleansUpWhenAudioDisabled(t *testing.T) { - mu.Lock() - wasInited := inited - inited = true - deviceOpen = false - currentPath = "/some/track.wav" - currentLoops = -1 - mu.Unlock() - - defer func() { - mu.Lock() - inited = wasInited - currentPath = "" - currentLoops = 0 - mu.Unlock() - }() - - cfg := &db.AppConfig{AudioEnabled: false} - Stop(0, cfg) - - mu.Lock() - defer mu.Unlock() - if currentPath != "" { - t.Errorf("currentPath = %q, want empty after Stop with AudioEnabled=false", currentPath) - } - if currentLoops != 0 { - t.Errorf("currentLoops = %d, want 0 after Stop with AudioEnabled=false", currentLoops) - } -} - -// Regression: SwitchDevice used to convert oldDevice="" to "system default" -// and pass that string to openDeviceLocked, which SDL does not recognize. -func TestSwitchDeviceReturnsErrorWhenUninitialized(t *testing.T) { - mu.Lock() - wasInited := inited - inited = false - deviceOpen = false - mu.Unlock() - - defer func() { - mu.Lock() - inited = wasInited - mu.Unlock() - }() - - cfg := &db.AppConfig{AudioEnabled: true, SavedVolume: 0.5} - err := SwitchDevice("Some Device", cfg) - if err == nil { - t.Fatal("SwitchDevice on uninitialized mixer should return error") - } -} - -// Regression: SwitchDevice with nil config must not panic when attempting -// to restore volume after reopening the device. -func TestSwitchDeviceNilConfigDoesNotPanic(t *testing.T) { - mu.Lock() - wasInited := inited - inited = false - deviceOpen = false - mu.Unlock() - - defer func() { - mu.Lock() - inited = wasInited - mu.Unlock() - }() - - err := SwitchDevice("Some Device", nil) - if err == nil { - t.Fatal("SwitchDevice on uninitialized mixer should return error") - } -} - -// Regression: SwitchDevice with empty device name (system default) should -// not be converted to "system default" before passing to openDeviceLocked. -func TestSwitchDeviceAcceptsEmptyDeviceName(t *testing.T) { - mu.Lock() - wasInited := inited - inited = false - deviceOpen = false - mu.Unlock() - - defer func() { - mu.Lock() - inited = wasInited - mu.Unlock() - }() - - cfg := &db.AppConfig{SavedVolume: 0.75} - err := SwitchDevice("", cfg) - // Returns error because mixer is not initialized, but should not panic - // and should handle the empty name gracefully. - if err == nil { - t.Fatal("SwitchDevice on uninitialized mixer should return error") - } -} - -// TestPlayDoesNotDeadlockWhenUninitialized verifies that Play() returns an error -// (not a deadlock) when the mixer has not been initialized. -// The old code would deadlock because Play() held mu then called SwitchDevice() -// which also tried to acquire mu. -func TestPlayDoesNotDeadlockWhenUninitialized(t *testing.T) { - // Ensure mixer is in uninitialized state - mu.Lock() - wasInited := inited - inited = false - deviceOpen = false - mu.Unlock() - - defer func() { - mu.Lock() - inited = wasInited - mu.Unlock() - }() - - cfg := &db.AppConfig{AudioEnabled: true} - done := make(chan error, 1) - go func() { - done <- Play("nonexistent.wav", false, cfg) - }() - - select { - case err := <-done: - if err == nil { - t.Fatal("Play() returned nil error on uninitialized mixer, want error") - } - case <-time.After(5 * time.Second): - t.Fatal("Play() deadlocked on uninitialized mixer (did not return within 5s)") - } -} - -func TestParseSinkLine(t *testing.T) { - tests := []struct { - name string - input string - wantOK bool - wantID int - wantName string - wantVol float64 - wantDef bool - }{ - { - name: "default sink with volume", - input: "*51. USB Audio [vol: 1.00]", - wantOK: true, - wantID: 51, - wantName: "USB Audio", - wantVol: 1.00, - wantDef: true, - }, - { - name: "non-default sink with volume", - input: "33. Built-in Audio Analog Stereo [vol: 0.65]", - wantOK: true, - wantID: 33, - wantName: "Built-in Audio Analog Stereo", - wantVol: 0.65, - wantDef: false, - }, - { - name: "default sink with muted tag", - input: "*51. USB Audio [vol: 0.50 MUTED]", - wantOK: true, - wantID: 51, - wantName: "USB Audio", - wantVol: 0.50, - wantDef: true, - }, - { - name: "sink without volume field", - input: "33. Built-in Audio Analog Stereo", - wantOK: true, - wantID: 33, - wantName: "Built-in Audio Analog Stereo", - wantVol: 0.0, - wantDef: false, - }, - { - name: "empty string", - input: "", - wantOK: false, - }, - { - name: "no dot separator", - input: "no dot here", - wantOK: false, - }, - { - name: "non-numeric ID", - input: "abc. Some Device [vol: 0.80]", - wantOK: false, - }, - { - name: "zero volume", - input: "1. Muted Device [vol: 0.00]", - wantOK: true, - wantID: 1, - wantName: "Muted Device", - wantVol: 0.0, - wantDef: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - sink, ok := parseSinkLine(tt.input) - if ok != tt.wantOK { - t.Fatalf("parseSinkLine(%q) ok = %v, want %v", tt.input, ok, tt.wantOK) - } - if !ok { - return - } - if sink.ID != tt.wantID { - t.Errorf("ID = %d, want %d", sink.ID, tt.wantID) - } - if sink.Name != tt.wantName { - t.Errorf("Name = %q, want %q", sink.Name, tt.wantName) - } - if sink.Volume != tt.wantVol { - t.Errorf("Volume = %f, want %f", sink.Volume, tt.wantVol) - } - if sink.IsDefault != tt.wantDef { - t.Errorf("IsDefault = %v, want %v", sink.IsDefault, tt.wantDef) - } - }) - } -} - -func TestParseSinks(t *testing.T) { - tests := []struct { - name string - input string - wantCount int - wantNames []string - }{ - { - name: "two sinks", - input: `Audio - Sinks: - *51. USB Audio [vol: 1.00] - 33. Built-in Audio Analog Stereo [vol: 0.65] - Sources: - 52. Microphone [vol: 0.80]`, - wantCount: 2, - wantNames: []string{"USB Audio", "Built-in Audio Analog Stereo"}, - }, - { - name: "no sinks section", - input: `Audio - Sources: - 52. Microphone [vol: 0.80]`, - wantCount: 0, - }, - { - name: "sinks with tree characters", - input: `Audio - ├─Sinks: - │ ├─*51. USB Audio [vol: 1.00] - │ └─33. Built-in Audio Analog Stereo [vol: 0.65] - └─Sources: - └─52. Microphone [vol: 0.80]`, - wantCount: 2, - wantNames: []string{"USB Audio", "Built-in Audio Analog Stereo"}, - }, - { - name: "empty input", - input: ``, - wantCount: 0, - }, - { - name: "video section ignored", - input: `Video - Sinks: - 100. HDMI Output [vol: 1.00] -Audio - Sinks: - 51. USB Audio [vol: 1.00]`, - wantCount: 1, - wantNames: []string{"USB Audio"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - sinks, err := parseSinks([]byte(tt.input)) - if err != nil { - t.Fatalf("parseSinks returned error: %v", err) - } - if len(sinks) != tt.wantCount { - t.Fatalf("got %d sinks, want %d", len(sinks), tt.wantCount) - } - for i, name := range tt.wantNames { - if sinks[i].Name != name { - t.Errorf("sink[%d].Name = %q, want %q", i, sinks[i].Name, name) - } - } - }) - } -} diff --git a/archive/internal/mixer/mixer_test.go b/archive/internal/mixer/mixer_test.go deleted file mode 100644 index 796f032..0000000 --- a/archive/internal/mixer/mixer_test.go +++ /dev/null @@ -1,38 +0,0 @@ -package mixer_test - -import ( - "math" - "testing" - - "rhemawavetech.com/rhemawave/internal/mixer" -) - -func TestConvertVolume(t *testing.T) { - tests := []struct { - name string - input int - expected float64 - }{ - {"0 percent", 0, 0.0}, - {"25 percent", 25, 0.25}, - {"33 percent", 33, 0.33}, - {"50 percent", 50, 0.50}, - {"90 percent", 90, 0.90}, - {"100 percent", 100, 1.0}, - {"1 percent", 1, 0.01}, - {"99 percent", 99, 0.99}, - {"negative 10", -10, -0.10}, - {"over 100", 150, 1.50}, - } - - const epsilon = 1e-9 - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := mixer.ConvertVolume(tt.input) - if math.Abs(got-tt.expected) > epsilon { - t.Errorf("ConvertVolume(%d) = %v, want %v", tt.input, got, tt.expected) - } - }) - } -} diff --git a/archive/internal/netreq/netReq.go b/archive/internal/netreq/netReq.go deleted file mode 100644 index f9c4b4c..0000000 --- a/archive/internal/netreq/netReq.go +++ /dev/null @@ -1,83 +0,0 @@ -package netreq - -import ( - "bytes" - "encoding/json" - "fmt" - "io" - "net/http" - "os" - "path/filepath" -) - -// Makes a GET request to the specified URL and returns the JSON response -// as a byte slice. If the request fails, it returns an error. -func GetJSON[T any](url string) ([]T, error) { - fmt.Println("Fetching:", url) - res, err := http.Get(url) - if err != nil { - return nil, err - } - defer res.Body.Close() - - body, err := io.ReadAll(res.Body) - if err != nil { - return nil, err - } - - if res.StatusCode > 299 { - return nil, fmt.Errorf("status %d: %s", res.StatusCode, string(body)) - } - - // 1. Trim whitespace to find the first actual JSON character - trimmed := bytes.TrimSpace(body) - if len(trimmed) == 0 { - return nil, nil - } - - var result []T - - // 2. Determine if we are looking at an Array [ ] or an Object { } - if trimmed[0] == '[' { - // It's already an array, unmarshal directly - err = json.Unmarshal(body, &result) - } else { - // It's a single object, unmarshal into a single instance and wrap it - var single T - err = json.Unmarshal(body, &single) - result = []T{single} - } - - if err != nil { - return nil, fmt.Errorf("failed to parse JSON: %w", err) - } - - return result, nil -} - -// Download downloads a specified object at a given url and places it in -// the provided dest directory. -func Download(name string, path string, url string) error { - fullFilePath := filepath.Join(path, name) - if err := os.MkdirAll(path, os.ModeDir); err != nil { - return err - } - file, err := os.Create(fullFilePath) - if err != nil { - return err - } - defer file.Close() - - resp, err := http.Get(url) - if err != nil { - return err - } - defer resp.Body.Close() - - _, err = io.Copy(file, resp.Body) - if err != nil { - return err - } - - return nil -} diff --git a/archive/internal/network/network.go b/archive/internal/network/network.go deleted file mode 100644 index 1510fc3..0000000 --- a/archive/internal/network/network.go +++ /dev/null @@ -1,577 +0,0 @@ -package network - -import ( - "context" - "fmt" - "net" - "net/http" - "time" - - gonm "github.com/Wifx/gonetworkmanager/v2" - "github.com/google/uuid" - "rhemawavetech.com/rhemawave/internal/logger" -) - -// ─── Public types ───────────────────────────────────────────────────────────── - -type SecurityType string - -const ( - SecurityOpen SecurityType = "Open" - SecurityWEP SecurityType = "WEP" - SecurityWPA SecurityType = "WPA" - SecurityWPA2 SecurityType = "WPA2" - SecurityWPA3 SecurityType = "WPA3" -) - -type Network struct { - SSID string - SignalStrength uint8 - Security SecurityType - Saved bool - Connected bool -} - -// ─── Package init ───────────────────────────────────────────────────────────── - -var nm gonm.NetworkManager - -func init() { - var err error - nm, err = gonm.NewNetworkManager() - if err != nil { - logger.Warning("network: NetworkManager unavailable, network features disabled: %v", err) - } -} - -// ─── ScanForNetworks ────────────────────────────────────────────────────────── - -func ScanForNetworks(ctx context.Context) (<-chan []Network, error) { - dev, err := wifiDevice() - if err != nil { - return nil, err - } - - ch := make(chan []Network, 1) - - go func() { - defer close(ch) - - ticker := time.NewTicker(15 * time.Second) - defer ticker.Stop() - - scan := func() { - if err := dev.RequestScan(); err != nil { - logger.Warning("network: scan request failed: %v", err) - return - } - time.Sleep(2 * time.Second) - - nets, err := buildNetworkList(dev) - if err != nil { - logger.Warning("network: building network list failed: %v", err) - return - } - - select { - case ch <- nets: - case <-ctx.Done(): - } - } - - scan() - - for { - select { - case <-ticker.C: - scan() - case <-ctx.Done(): - return - } - } - }() - - return ch, nil -} - -// ─── ConnectToNetwork ───────────────────────────────────────────────────────── - -func ConnectToNetwork(ssid, password string) error { - dev, err := wifiDevice() - if err != nil { - return err - } - - ifaceName, err := dev.GetPropertyInterface() - if err != nil { - return fmt.Errorf("network: cannot get interface name: %w", err) - } - - ap, err := findAccessPoint(dev, ssid) - if err != nil { - return fmt.Errorf("network: access point %q not found: %w", ssid, err) - } - - // Delete any existing profiles for this SSID before creating a new one. - // Stale/duplicate profiles cause NM to fail silently. - if err := deleteExistingConnections(ssid); err != nil { - logger.Warning("network: could not clean up old profiles for %q: %v", ssid, err) - } - - security := detectSecurity(ap) - settings, err := buildConnectionSettings(ssid, password, security, ifaceName) - if err != nil { - return err - } - - logger.Info("network: connecting to %q (%s)", ssid, security) - - _, err = nm.AddAndActivateConnection(settings, dev) - if err != nil { - return fmt.Errorf("network: failed to connect to %q: %w", ssid, err) - } - - return waitForConnection(dev, ssid, 20*time.Second) -} - -// deleteExistingConnections removes all stored NM profiles for the given SSID. -func deleteExistingConnections(ssid string) error { - s, err := gonm.NewSettings() - if err != nil { - return err - } - connections, err := s.ListConnections() - if err != nil { - return err - } - for _, conn := range connections { - cfg, err := conn.GetSettings() - if err != nil { - continue - } - wireless, ok := cfg["802-11-wireless"] - if !ok { - continue - } - rawSSID, ok := wireless["ssid"] - if !ok { - continue - } - if string(rawSSID.([]byte)) != ssid { - continue - } - logger.Debug("network: removing existing profile for %q", ssid) - if err := conn.Delete(); err != nil { - logger.Warning("network: failed to delete profile for %q: %v", ssid, err) - } - } - return nil -} - -// ─── ForgetNetwork ──────────────────────────────────────────────────────────── - -func ForgetNetwork(ssid string) error { - s, err := gonm.NewSettings() - if err != nil { - return fmt.Errorf("network: cannot access NM settings: %w", err) - } - - connections, err := s.ListConnections() - if err != nil { - return fmt.Errorf("network: cannot list connections: %w", err) - } - - found := false - for _, conn := range connections { - settings, err := conn.GetSettings() - if err != nil { - continue - } - wireless, ok := settings["802-11-wireless"] - if !ok { - continue - } - rawSSID, ok := wireless["ssid"] - if !ok { - continue - } - if string(rawSSID.([]byte)) != ssid { - continue - } - - found = true - logger.Info("network: forgetting %q", ssid) - if err := conn.Delete(); err != nil { - return fmt.Errorf("network: failed to delete connection %q: %w", ssid, err) - } - } - - if !found { - return fmt.Errorf("network: no saved connection found for %q", ssid) - } - - return nil -} - -// ─── CurrentNetwork ─────────────────────────────────────────────────────────── - -func CurrentNetwork() (string, error) { - dev, err := wifiDevice() - if err != nil { - return "", err - } - - ap, err := dev.GetPropertyActiveAccessPoint() - if err != nil || ap == nil { - return "", nil - } - - ssid, err := ap.GetPropertySSID() - if err != nil { - return "", err - } - - return ssid, nil -} - -// ─── ListSavedNetworks ──────────────────────────────────────────────────────── - -func ListSavedNetworks() ([]string, error) { - s, err := gonm.NewSettings() - if err != nil { - return nil, fmt.Errorf("network: cannot access NM settings: %w", err) - } - - connections, err := s.ListConnections() - if err != nil { - return nil, fmt.Errorf("network: cannot list connections: %w", err) - } - - var ssids []string - for _, conn := range connections { - settings, err := conn.GetSettings() - if err != nil { - continue - } - wireless, ok := settings["802-11-wireless"] - if !ok { - continue - } - rawSSID, ok := wireless["ssid"] - if !ok { - continue - } - ssids = append(ssids, string(rawSSID.([]byte))) - } - - return ssids, nil -} - -// ─── IsConnected ────────────────────────────────────────────────────────────── - -func IsConnected() (bool, error) { - if nm == nil { - return false, fmt.Errorf("network: NetworkManager not available") - } - - state, err := nm.GetPropertyState() - if err != nil { - return false, fmt.Errorf("network: cannot get NM state: %w", err) - } - - return state >= gonm.NmStateConnectedLocal, nil -} - -// ─── HasInternet ────────────────────────────────────────────────────────────── - -func HasInternet() bool { - targets := []string{ - "1.1.1.1:53", - "1.0.0.1:53", - "8.8.8.8:53", - "9.9.9.9:53", - "8.8.4.4:53", - "google.com:443", - "cloudflare.com:443", - } - - results := make(chan bool, len(targets)) - - for _, target := range targets { - go func(addr string) { - conn, err := net.DialTimeout("tcp", addr, 2*time.Second) - if err == nil { - conn.Close() - results <- true - } else { - results <- false - } - }(target) - } - - for range targets { - if <-results { - return true - } - } - return false -} - -// ─── WatchState ─────────────────────────────────────────────────────────────── - -func WatchState(ctx context.Context) (<-chan bool, error) { - if nm == nil { - return nil, fmt.Errorf("network: NetworkManager not available") - } - - ch := make(chan bool, 1) - - go func() { - defer close(ch) - - ticker := time.NewTicker(3 * time.Second) - defer ticker.Stop() - - var last *bool - - for { - select { - case <-ticker.C: - connected, err := IsConnected() - if err != nil { - logger.Warning("network: WatchState poll failed: %v", err) - continue - } - if last == nil || *last != connected { - last = &connected - select { - case ch <- connected: - case <-ctx.Done(): - return - } - } - case <-ctx.Done(): - return - } - } - }() - - return ch, nil -} - -// ─── Internal helpers ───────────────────────────────────────────────────────── - -func wifiDevice() (gonm.DeviceWireless, error) { - if nm == nil { - return nil, fmt.Errorf("network: NetworkManager not available") - } - - devices, err := nm.GetPropertyAllDevices() - if err != nil { - return nil, fmt.Errorf("network: cannot list devices: %w", err) - } - - for _, dev := range devices { - devType, err := dev.GetPropertyDeviceType() - if err != nil { - continue - } - if devType == gonm.NmDeviceTypeWifi { - return gonm.NewDeviceWireless(dev.GetPath()) - } - } - - return nil, fmt.Errorf("network: no WiFi device found") -} - -func findAccessPoint(dev gonm.DeviceWireless, ssid string) (gonm.AccessPoint, error) { - aps, err := dev.GetPropertyAccessPoints() - if err != nil { - return nil, err - } - - for _, ap := range aps { - apSSID, err := ap.GetPropertySSID() - if err != nil { - continue - } - if apSSID == ssid { - return ap, nil - } - } - - return nil, fmt.Errorf("not found") -} - -func detectSecurity(ap gonm.AccessPoint) SecurityType { - flags, _ := ap.GetPropertyFlags() - wpaFlags, _ := ap.GetPropertyWPAFlags() - rsnFlags, _ := ap.GetPropertyRSNFlags() - - // WPA3 uses SAE (0x400) - if rsnFlags&0x400 != 0 { - return SecurityWPA3 - } - // WPA2 uses PSK (0x100) - if rsnFlags&0x100 != 0 { - return SecurityWPA2 - } - // WPA uses WPA flags (not RSN) - if wpaFlags != 0 { - return SecurityWPA - } - // WEP uses privacy flag - if flags&0x1 != 0 { - return SecurityWEP - } - - return SecurityOpen -} - -func buildConnectionSettings(ssid, password string, security SecurityType, interfaceName string) (map[string]map[string]interface{}, error) { - settings := map[string]map[string]interface{}{ - "connection": { - "id": ssid, - "type": "802-11-wireless", - "uuid": uuid.NewString(), - "interface-name": interfaceName, - "autoconnect": true, - "permissions": []string{}, - }, - "802-11-wireless": { - "ssid": []byte(ssid), - "mode": "infrastructure", - }, - "ipv4": {"method": "auto"}, - "ipv6": {"method": "auto"}, - } - - switch security { - case SecurityOpen: - // nothing - - case SecurityWEP: - if password == "" { - return nil, fmt.Errorf("network: WEP network requires a password") - } - settings["802-11-wireless-security"] = map[string]interface{}{ - "key-mgmt": "none", - "wep-key0": password, - "wep-key-type": uint32(1), - } - - case SecurityWPA, SecurityWPA2: - if password == "" { - return nil, fmt.Errorf("network: WPA/WPA2 network requires a password") - } - settings["802-11-wireless-security"] = map[string]interface{}{ - "key-mgmt": "wpa-psk", - "auth-alg": "open", - "psk": password, - "psk-flags": uint32(0), - } - - case SecurityWPA3: - if password == "" { - return nil, fmt.Errorf("network: WPA3 network requires a password") - } - settings["802-11-wireless-security"] = map[string]interface{}{ - "key-mgmt": "sae", - "psk": password, - } - } - - return settings, nil -} - -func buildNetworkList(dev gonm.DeviceWireless) ([]Network, error) { - aps, err := dev.GetPropertyAccessPoints() - if err != nil { - return nil, err - } - - activeAP, _ := dev.GetPropertyActiveAccessPoint() - var activeSSID string - if activeAP != nil { - activeSSID, _ = activeAP.GetPropertySSID() - } - - saved, _ := ListSavedNetworks() - savedSet := make(map[string]bool, len(saved)) - for _, s := range saved { - savedSet[s] = true - } - - var networks []Network - seen := make(map[string]bool) - - for _, ap := range aps { - ssid, err := ap.GetPropertySSID() - if err != nil || ssid == "" { - continue - } - if seen[ssid] { - continue - } - seen[ssid] = true - - strength, _ := ap.GetPropertyStrength() - - networks = append(networks, Network{ - SSID: ssid, - SignalStrength: strength, - Security: detectSecurity(ap), - Saved: savedSet[ssid], - Connected: ssid == activeSSID, - }) - } - - return networks, nil -} - -// waitForConnection waits until the wifi device reaches the Activated state, -// meaning DHCP has completed and the connection is fully up. -func waitForConnection(dev gonm.DeviceWireless, ssid string, timeout time.Duration) error { - deadline := time.Now().Add(timeout) - for time.Now().Before(deadline) { - state, err := dev.GetPropertyState() - if err != nil { - time.Sleep(500 * time.Millisecond) - continue - } - - logger.Debug("network: device state %d, waiting...", state) - - switch state { - case gonm.NmDeviceStateActivated: - current, err := CurrentNetwork() - if err == nil && current == ssid { - logger.Info("network: fully connected to %q (DHCP complete)", ssid) - return nil - } - - case gonm.NmDeviceStateFailed: - return fmt.Errorf("network: connection to %q failed (wrong password?)", ssid) - - case gonm.NmDeviceStateDisconnected: - return fmt.Errorf("network: connection to %q was rejected by the network", ssid) - } - - time.Sleep(500 * time.Millisecond) - } - return fmt.Errorf("network: timed out waiting for connection to %q", ssid) -} - -var internetCheckURL = "https://clients3.google.com/generate_204" - -func httpCheck() bool { - client := http.Client{Timeout: 3 * time.Second} - resp, err := client.Get(internetCheckURL) - if err != nil { - return false - } - resp.Body.Close() - return resp.StatusCode == 204 -} - -var _ = httpCheck diff --git a/archive/internal/page/AudioOptions.go b/archive/internal/page/AudioOptions.go deleted file mode 100644 index c38a093..0000000 --- a/archive/internal/page/AudioOptions.go +++ /dev/null @@ -1,626 +0,0 @@ -package page - -import ( - "fmt" - "math" - - "github.com/go-gl/gl/v3.3-core/gl" - "github.com/go-gl/mathgl/mgl32" - "github.com/veandco/go-sdl2/sdl" - "rhemawavetech.com/rhemawave/internal/colors" - "rhemawavetech.com/rhemawave/internal/db" - "rhemawavetech.com/rhemawave/internal/fontManager" - "rhemawavetech.com/rhemawave/internal/logger" - "rhemawavetech.com/rhemawave/internal/mixer" - "rhemawavetech.com/rhemawave/internal/shaderManager" -) - -// focusToggle is the sentinel focusState value meaning the audio on/off toggle -// row has keyboard focus. Device indices (0..n-1) are used for everything below. -const focusToggle = -1 - -// AudioFileList is kept as the canonical seed data for the audio_files table. -// Track selection UI no longer lives on this page, but other parts of the -// app still read these rows from the database. -var AudioFileList = []db.AudioFile{ - {ID: 14, Name: "Original Audio", Path: "OriginalAudio.wav", Selected: false, Order: 1}, - {ID: 1, Name: "174 Hz", Path: "174Hz.wav", Selected: false, Order: 2}, - {ID: 2, Name: "396 Hz", Path: "396Hz.wav", Selected: false, Order: 3}, - {ID: 3, Name: "417 Hz", Path: "417Hz.wav", Selected: false, Order: 4}, - {ID: 4, Name: "432 Hz", Path: "432Hz.wav", Selected: false, Order: 5}, - {ID: 5, Name: "639 Hz", Path: "639Hz.wav", Selected: false, Order: 6}, - {ID: 6, Name: "741 Hz", Path: "741Hz.wav", Selected: false, Order: 7}, - {ID: 7, Name: "852 Hz", Path: "852Hz.wav", Selected: false, Order: 8}, - {ID: 8, Name: "963 Hz", Path: "963Hz.wav", Selected: false, Order: 9}, - {ID: 9, Name: "1111 Hz", Path: "1111Hz.wav", Selected: false, Order: 10}, - {ID: 10, Name: "14 Hz Binaural Beat", Path: "14Hz_Binaural.wav", Selected: false, Order: 11}, - {ID: 11, Name: "40 Hz Binaural Beat", Path: "40Hz_Binaural.wav", Selected: false, Order: 12}, - {ID: 12, Name: "Alpha Waves", Path: "AlphaWaves.wav", Selected: false, Order: 13}, - {ID: 13, Name: "Pineal Gland", Path: "PinealGland.wav", Selected: false, Order: 14}, -} - -const triangleSize = float32(16) - -type AudioDevice struct { - Label string - ID uint32 - index int -} - -type AudioOptions struct { - // Page Attributes - Base - font *fontManager.FontEntry - textShader *shaderManager.Shader - solidShader *shaderManager.Shader - CfgPtr *db.AppConfig - - // Page Textures - title StringItem - prompt StringItem - - // Layout - verticalMargin int32 - ItemScale float32 - - // Audio toggle - AudioStatusToggle StringItem - - // Audio Devices - activeDeviceIndex int - NumAudioDevices int - AudioDevices []AudioDevice - AudioDeviceTextures []StringItem - - // Volume row - VolumeLabel StringItem - volumePercent StringItem - volumeBarX int32 - volumeBarY int32 - volumeBarW int32 - volumeBarH int32 - - // Triangle cursor - triVAO uint32 - triVBO uint32 - triX float32 - triCenterY float32 - spinElapsed float32 - - // Focus: focusToggle (-1) = toggle row, 0..n-1 = device at that index - focusState int -} - -// ── Init ────────────────────────────────────────────────────────────────────── - -func (p *AudioOptions) Init(ctx *PageCtx) error { - var err error - p.verticalMargin = int32(20) - p.focusState = focusToggle - - // Font - fm := fontManager.Get() - font, ok := fm.GetFont("SpaceMonoNerdFont-Regular") - if !ok { - return fmt.Errorf("failed to get font: %w", sdl.GetError()) - } - p.font = font - - // Shaders - sm := shaderManager.Get() - p.textShader, ok = sm.Get("text") - if !ok { - return fmt.Errorf("failed to get text shader: %w", sdl.GetError()) - } - p.solidShader, ok = sm.Get("solid_color") - if !ok { - return fmt.Errorf("failed to get solid_color shader: %w", sdl.GetError()) - } - - if err = p.Base.Init(ctx); err != nil { - return fmt.Errorf("base init failed: %w", err) - } - - config, err := db.GetConfig(p.Context.DbPtr) - if err != nil { - panic(err) - } - - baseFontSize := config.StandardFontSize - titleScale := float32(64) / float32(baseFontSize) - p.ItemScale = float32(32) / float32(baseFontSize) - - // Title - p.title, err = NewStringItem("Audio Options", p.font, colors.White) - if err != nil { - return fmt.Errorf("creating title: %w", err) - } - p.title.Scale(titleScale) - - // Prompt - p.prompt, err = NewStringItem( - "Enter to select -- Up/Down to navigate -- Left/Right to change volume -- Backspace to go back", - p.font, colors.White, - ) - if err != nil { - return fmt.Errorf("creating prompt: %w", err) - } - p.prompt.Scale(p.ItemScale) - - // Volume row - p.VolumeLabel, err = NewStringItem("Volume", p.font, colors.White) - if err != nil { - return fmt.Errorf("creating volume label: %w", err) - } - p.VolumeLabel.Scale(p.ItemScale) - if err = p.CreateVolumePercentItem(); err != nil { - logger.Error("failed to create VolumePercentItem in AudioOptions.Init()") - } - - // Audio toggle - if err = p.CreateAudioStatusItem(); err != nil { - logger.Error("failed to create AudioStatusItem in AudioOptions.Init()") - } - - // Audio output devices - if err = p.GetAudioDevices(); err != nil { - logger.Error("failed to enumerate audio devices: %v", err) - } - - // Set active device index based on mixer's current device - currentName := mixer.CurrentDevice() - config, err = db.GetConfig(p.Context.DbPtr) - if err != nil { - panic(err) - } - p.activeDeviceIndex = -1 // default to not found - for i, dev := range p.AudioDevices { - if dev.Label == currentName { - p.activeDeviceIndex = i - break - } - } - // If not found and current device is default (""), check saved config - if p.activeDeviceIndex == -1 && currentName == "" && config.SelectedAudioDevice == "default" { - // Default device is selected - first device in SDL list is typically the default - if len(p.AudioDevices) > 0 { - p.activeDeviceIndex = 0 - } - } - - // Make sure the audio_files table is populated. - if err = p.seedAudioFiles(); err != nil { - logger.Error("failed to seed audio files: %v", err) - } - - // Triangle cursor VAO/VBO — stride is vec2 only, no tex coords - triVerts := []float32{ - 0.5, 0.0, - -0.5, 0.5, - -0.5, -0.5, - } - gl.GenVertexArrays(1, &p.triVAO) - gl.GenBuffers(1, &p.triVBO) - gl.BindVertexArray(p.triVAO) - gl.BindBuffer(gl.ARRAY_BUFFER, p.triVBO) - gl.BufferData(gl.ARRAY_BUFFER, len(triVerts)*4, gl.Ptr(triVerts), gl.STATIC_DRAW) - gl.EnableVertexAttribArray(0) - gl.VertexAttribPointerWithOffset(0, 2, gl.FLOAT, false, 2*4, 0) - gl.BindBuffer(gl.ARRAY_BUFFER, 0) - gl.BindVertexArray(0) - - p.layout() - return nil -} - -// seedAudioFiles ensures the audio_files table has its default rows. -func (p *AudioOptions) seedAudioFiles() error { - var existing []db.AudioFile - if err := p.Context.DbPtr.Find(&existing).Error; err != nil { - return err - } - if len(existing) == 0 { - return p.Context.DbPtr.Save(&AudioFileList).Error - } - return nil -} - -// ── Audio status toggle ─────────────────────────────────────────────────────── - -func (p *AudioOptions) CreateAudioStatusItem() error { - config, err := db.GetConfig(p.Context.DbPtr) - if err != nil { - panic(err) - } - on := config.AudioEnabled - statusString := "Turn Audio On" - color := colors.Green - if on { - statusString = "Turn Audio Off" - color = colors.Red - } - item, err := NewStringItem(statusString, p.font, color) - if err != nil { - return fmt.Errorf("failed to create '%s' string item: %v", statusString, err) - } - item.Scale(p.ItemScale) - p.AudioStatusToggle = item - return nil -} - -// ── Audio Output Devices ────────────────────────────────────────────────────── - -func (p *AudioOptions) GetAudioDevices() error { - numAudioDevices := sdl.GetNumAudioDevices(false) - if numAudioDevices == 0 { - return nil - } - - // Pre-allocate with zero length so append doesn't produce leading zero-value - // elements (the old code used make([]AudioDevice, n) then appended, which - // doubled the slice length with n zero-value entries at the front). - audioDevices := make([]AudioDevice, 0, numAudioDevices) - for i := range numAudioDevices { - label := sdl.GetAudioDeviceName(i, false) - audioDevices = append(audioDevices, AudioDevice{Label: label, ID: uint32(i)}) - } - p.AudioDevices = audioDevices - - for i := range audioDevices { - item, err := NewStringItem(p.AudioDevices[i].Label, p.font, colors.White) - if err != nil { - return err - } - item.Scale(p.ItemScale) - p.AudioDeviceTextures = append(p.AudioDeviceTextures, item) - } - p.NumAudioDevices = numAudioDevices - return nil -} - -// SelectAudioDevice is called when the user confirms a device from the list. -// The device-switching implementation goes here; for now we just log the choice. -func (p *AudioOptions) SelectAudioDevice(deviceIndex int) error { - if deviceIndex < 0 || deviceIndex >= len(p.AudioDevices) { - return fmt.Errorf("invalid device index: %d", deviceIndex) - } - device := p.AudioDevices[deviceIndex] - - config, err := db.GetConfig(p.Context.DbPtr) - if err != nil { - return fmt.Errorf("failed to get config for device switch: %w", err) - } - - if err := mixer.SwitchDevice(device.Label, config); err != nil { - return fmt.Errorf("failed to switch to device %s: %w", device.Label, err) - } - - // Persist the selected device to the database - config.SelectedAudioDevice = device.Label - if err := p.Context.DbPtr.Save(config).Error; err != nil { - logger.Error("failed to persist device selection: %v", err) - } - - p.activeDeviceIndex = deviceIndex - return nil -} - -// ── Volume ──────────────────────────────────────────────────────────────────── - -func (p *AudioOptions) CreateVolumePercentItem() error { - if p.volumePercent.ID != 0 { - gl.DeleteTextures(1, &p.volumePercent.ID) - } - config, err := db.GetConfig(p.Context.DbPtr) - if err != nil { - panic(err) - } - pct := int(config.SavedVolume * 100) - item, err := NewStringItem(fmt.Sprintf("%d%%", pct), p.font, colors.White) - if err != nil { - return fmt.Errorf("failed to create volume percent item: %v", err) - } - item.Scale(p.ItemScale) - p.volumePercent = item - return nil -} - -func (p *AudioOptions) UpdateVolume(level int) { - config, err := db.GetConfig(p.Context.DbPtr) - if err != nil { - panic(err) - } - newVol := mixer.ConvertVolume(level) + config.SavedVolume - if newVol < 0.0 || newVol > 1.0 { - return - } - if newVol > .99 { - newVol = 1.0 - } - config.SavedVolume = newVol - p.Context.DbPtr.Save(&config) - if err := mixer.SetAllSinkVolumes(newVol); err != nil { - logger.Error("Error setting allSinkVolumes: %v", err) - } - if err := p.CreateVolumePercentItem(); err != nil { - logger.Error("failed to refresh volume percent item: %v", err) - } - gap := int32(12) - p.volumePercent.X = p.volumeBarX + p.volumeBarW + gap - p.volumePercent.Y = p.volumeBarY + (p.volumeBarH-p.volumePercent.H)/2 -} - -// ── Layout ──────────────────────────────────────────────────────────────────── - -func (p *AudioOptions) layout() { - config, err := db.GetConfig(p.Context.DbPtr) - if err != nil { - panic(err) - } - - // Title - p.title.X = p.ScreenCenterX - p.title.W/2 - p.title.Y = p.ScreenHeight - p.title.H - p.verticalMargin*2 - - // Prompt - p.prompt.X = p.ScreenCenterX - p.prompt.W/2 - p.prompt.Y = p.title.Y - p.verticalMargin - p.prompt.H - - // Volume row - barH := int32(float32(config.StandardFontSize) * p.ItemScale) - barW := int32(float32(p.ScreenWidth) * 0.30) - barX := p.ScreenCenterX - barW/2 - barY := p.prompt.Y - p.verticalMargin*2 - barH - - p.volumeBarX = barX - p.volumeBarY = barY - p.volumeBarW = barW - p.volumeBarH = barH - - p.VolumeLabel.X = barX - p.VolumeLabel.W - 20 - p.VolumeLabel.Y = barY + (barH-p.VolumeLabel.H)/2 - - gap := int32(12) - p.volumePercent.X = barX + barW + gap - p.volumePercent.Y = barY + (barH-p.volumePercent.H)/2 - - // Audio toggle — centred below the volume row - p.AudioStatusToggle.X = p.ScreenCenterX - p.AudioStatusToggle.W/2 - p.AudioStatusToggle.Y = barY - p.verticalMargin*3 - p.AudioStatusToggle.H - - // Device list — each item centred and stacked below the toggle - nextY := p.AudioStatusToggle.Y - p.verticalMargin - for i := range p.AudioDeviceTextures { - item := &p.AudioDeviceTextures[i] - item.X = p.ScreenCenterX - item.W/2 - item.Y = nextY - item.H - nextY = item.Y - p.verticalMargin - } - - // Reposition the triangle cursor for whatever row currently has focus - p.updateCursorPosition() -} - -// updateCursorPosition moves triX / triCenterY to sit just left of the -// focused row. Call this any time focusState changes or layout() reruns. -func (p *AudioOptions) updateCursorPosition() { - cursorGap := int32(12) - - var item *StringItem - switch { - case p.focusState == focusToggle: - item = &p.AudioStatusToggle - case p.focusState >= 0 && p.focusState < len(p.AudioDeviceTextures): - item = &p.AudioDeviceTextures[p.focusState] - default: - return - } - - p.triX = float32(item.X) - triangleSize - float32(cursorGap) - p.triCenterY = float32(item.Y) + float32(item.H)/2 -} - -// ── Toggle ──────────────────────────────────────────────────────────────────── - -func (p *AudioOptions) ToggleAudio() { - config, err := db.GetConfig(p.Context.DbPtr) - if err != nil { - logger.Error("panic! Cannot query database for configuration") - panic(err) - } - config.AudioEnabled = !config.AudioEnabled - p.Context.DbPtr.Save(config) - p.CreateAudioStatusItem() - p.layout() -} - -// ── Events ──────────────────────────────────────────────────────────────────── - -func (p *AudioOptions) HandleEvent(event *sdl.Event) error { - e, ok := (*event).(*sdl.KeyboardEvent) - if !ok || e.Type != sdl.KEYDOWN { - return nil - } - - switch e.Keysym.Sym { - case sdl.K_RETURN: - if p.focusState == focusToggle { - p.ToggleAudio() - } else if p.focusState >= 0 && p.focusState < len(p.AudioDevices) { - if err := p.SelectAudioDevice(p.focusState); err != nil { - logger.Error("SelectAudioDevice: %v", err) - } - } - - case sdl.K_DOWN: - if p.focusState == focusToggle { - if len(p.AudioDeviceTextures) > 0 { - p.focusState = 0 - p.updateCursorPosition() - } - } else if p.focusState < len(p.AudioDeviceTextures)-1 { - p.focusState++ - p.updateCursorPosition() - } - - case sdl.K_UP: - if p.focusState == 0 { - p.focusState = focusToggle - p.updateCursorPosition() - } else if p.focusState > 0 { - p.focusState-- - p.updateCursorPosition() - } - - case sdl.K_RIGHT: - p.UpdateVolume(10) - - case sdl.K_LEFT: - p.UpdateVolume(-10) - - case sdl.K_BACKSPACE: - p.Context.NavigateTo(SETTINGS) - } - - return nil -} - -// ── Update ──────────────────────────────────────────────────────────────────── - -func (p *AudioOptions) Update(dt float32) error { - p.spinElapsed += dt - if p.spinElapsed >= 1.0 { - p.spinElapsed -= 1.0 - } - return nil -} - -// ── Render helpers ──────────────────────────────────────────────────────────── - -func (p *AudioOptions) renderVolumeBar() error { - config, err := db.GetConfig(p.Context.DbPtr) - if err != nil { - panic(err) - } - vol := config.SavedVolume - - fillW := float32(p.volumeBarW) * float32(vol) - if fillW > 0 { - if err := p.RenderSolidColorQuad( - p.solidShader, - mgl32.Vec2{float32(p.volumeBarX), float32(p.volumeBarY)}, - mgl32.Vec2{fillW, float32(p.volumeBarH)}, - colors.White, - ); err != nil { - return fmt.Errorf("rendering volume fill: %w", err) - } - } - - thickness := float32(2) - bx := float32(p.volumeBarX) - by := float32(p.volumeBarY) - bw := float32(p.volumeBarW) - bh := float32(p.volumeBarH) - - outlineRects := [4][2]mgl32.Vec2{ - {{bx, by}, {bw, thickness}}, - {{bx, by + bh - thickness}, {bw, thickness}}, - {{bx, by}, {thickness, bh}}, - {{bx + bw - thickness, by}, {thickness, bh}}, // fix: was missing `by` as Y component - } - for _, r := range outlineRects { - if err := p.RenderSolidColorQuad(p.solidShader, r[0], r[1], colors.Green); err != nil { - return fmt.Errorf("rendering volume outline: %w", err) - } - } - return nil -} - -// renderCursor draws the spinning triangle cursor next to the focused row. -func (p *AudioOptions) renderCursor() error { - scaleY := float32(math.Cos(2 * math.Pi * float64(p.spinElapsed))) - - model := mgl32.Ident4() - model = model.Mul4(mgl32.Translate3D(p.triX+triangleSize/2, p.triCenterY, 0)) - model = model.Mul4(mgl32.Scale3D(triangleSize, triangleSize*scaleY, 1)) - mvp := p.Projection.Mul4(model) - - gl.UseProgram(p.solidShader.ProgramID) - gl.UniformMatrix4fv(p.solidShader.LocMVPMatrix, 1, false, &mvp[0]) - gl.Uniform4f( - p.solidShader.LocColor, - float32(colors.White.R)/255.0, - float32(colors.White.G)/255.0, - float32(colors.White.B)/255.0, - float32(colors.White.A)/255.0, - ) - - gl.BindVertexArray(p.triVAO) - gl.DrawArrays(gl.TRIANGLES, 0, 3) - gl.BindVertexArray(0) - gl.UseProgram(0) - - return nil -} - -// ── Render ──────────────────────────────────────────────────────────────────── - -func (p *AudioOptions) Render() error { - if err := p.RenderTexture(p.textShader, p.title.ID, p.title.Position(), p.title.RawDimensions(), p.title.Color); err != nil { - return fmt.Errorf("render title: %w", err) - } - if err := p.RenderTexture(p.textShader, p.prompt.ID, p.prompt.Position(), p.prompt.RawDimensions(), p.prompt.Color); err != nil { - return fmt.Errorf("render prompt: %w", err) - } - - // Volume row - if err := p.RenderTexture(p.textShader, p.VolumeLabel.ID, p.VolumeLabel.Position(), p.VolumeLabel.RawDimensions(), p.VolumeLabel.Color); err != nil { - return fmt.Errorf("render volume label: %w", err) - } - if err := p.renderVolumeBar(); err != nil { - return fmt.Errorf("render volume bar: %w", err) - } - if err := p.RenderTexture(p.textShader, p.volumePercent.ID, p.volumePercent.Position(), p.volumePercent.RawDimensions(), p.volumePercent.Color); err != nil { - return fmt.Errorf("render volume percent: %w", err) - } - - // Toggle - if err := p.RenderTexture(p.textShader, p.AudioStatusToggle.ID, p.AudioStatusToggle.Position(), p.AudioStatusToggle.RawDimensions(), p.AudioStatusToggle.Color); err != nil { - return fmt.Errorf("render audio toggle: %w", err) - } - - // Device list - for i := range p.AudioDeviceTextures { - item := &p.AudioDeviceTextures[i] - color := colors.White - if i == p.activeDeviceIndex { - color = colors.Green - } - if err := p.RenderTexture(p.textShader, item.ID, item.Position(), item.RawDimensions(), color); err != nil { - return fmt.Errorf("render audio device %d: %w", i, err) - } - } - - // Spinning triangle cursor — always on top - if err := p.renderCursor(); err != nil { - return fmt.Errorf("render cursor: %w", err) - } - - return nil -} - -// ── Destroy ─────────────────────────────────────────────────────────────────── - -func (p *AudioOptions) Destroy() error { - gl.DeleteTextures(1, &p.title.ID) - gl.DeleteTextures(1, &p.prompt.ID) - gl.DeleteTextures(1, &p.VolumeLabel.ID) - gl.DeleteTextures(1, &p.volumePercent.ID) - gl.DeleteTextures(1, &p.AudioStatusToggle.ID) - for i := range p.AudioDeviceTextures { - gl.DeleteTextures(1, &p.AudioDeviceTextures[i].ID) - } - gl.DeleteBuffers(1, &p.triVBO) - gl.DeleteVertexArrays(1, &p.triVAO) - return nil -} diff --git a/archive/internal/page/DownloadingAssets.go b/archive/internal/page/DownloadingAssets.go deleted file mode 100644 index 912a6a8..0000000 --- a/archive/internal/page/DownloadingAssets.go +++ /dev/null @@ -1,28 +0,0 @@ -package page - -import ( - "github.com/veandco/go-sdl2/sdl" -) - -type DownloadingAssets struct { -} - -func (p *DownloadingAssets) Init(ctx *PageCtx) error { - return nil -} - -func (p *DownloadingAssets) HandleEvent(event *sdl.Event) error { - return nil -} - -func (p *DownloadingAssets) Update(dt float32) error { - return nil -} - -func (p *DownloadingAssets) Render() error { - return nil -} - -func (p *DownloadingAssets) Destroy() error { - return nil -} diff --git a/archive/internal/page/Message.go b/archive/internal/page/Message.go deleted file mode 100644 index c010f22..0000000 --- a/archive/internal/page/Message.go +++ /dev/null @@ -1,29 +0,0 @@ -package page - -import ( - "github.com/veandco/go-sdl2/sdl" -) - -type MessagePage struct { - Msg string -} - -func (p *MessagePage) Init(ctx *PageCtx) error { - return nil -} - -func (p *MessagePage) HandleEvent(event *sdl.Event) error { - return nil -} - -func (p *MessagePage) Update(dt float32) error { - return nil -} - -func (p *MessagePage) Render() error { - return nil -} - -func (p *MessagePage) Destroy() error { - return nil -} diff --git a/archive/internal/page/emailAddressPage.go b/archive/internal/page/emailAddressPage.go deleted file mode 100644 index 14b274c..0000000 --- a/archive/internal/page/emailAddressPage.go +++ /dev/null @@ -1,407 +0,0 @@ -package page - -import ( - "fmt" - "regexp" - - "github.com/go-gl/gl/v3.3-core/gl" - "github.com/go-gl/mathgl/mgl32" - "github.com/veandco/go-sdl2/sdl" - "rhemawavetech.com/rhemawave/internal/colors" - "rhemawavetech.com/rhemawave/internal/db" - "rhemawavetech.com/rhemawave/internal/fontManager" - "rhemawavetech.com/rhemawave/internal/shaderManager" -) - -// emailRegex validates email format -var emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`) - -type EmailAddressPage struct { - Base - - title string - titleTextureID uint32 - titleTextureWidth int32 - titleTextureHeight int32 - - currentEmailDisplayTextureID uint32 - currentEmailDisplayTextureWidth int32 - currentEmailDisplayTextureHeight int32 - - inputText string - inputTextTextureID uint32 - inputTextTextureWidth int32 - inputTextTextureHeight int32 - - // Error message handling - errorMessage string - errorTextureID uint32 - errorTextureWidth int32 - errorTextureHeight int32 - - // isFirstTimeSetup tracks whether page was loaded empty (first-time setup) - isFirstTimeSetup bool - - // Text input deduplication - textInputSupported bool -} - -// isValidEmail checks if the given string is a valid email format -func isValidEmail(email string) bool { - return emailRegex.MatchString(email) -} - -// Init performs basic page initialization and creation of static assets. -func (p *EmailAddressPage) Init(ctx *PageCtx) error { - - if err := p.Base.Init(ctx); err != nil { - return fmt.Errorf("failed base page initialization: %w", err) - } - - p.title = "Your Email Address" - - applicationFont := "SpaceMonoNerdFont-Regular" - - fm := fontManager.Get() - textShader, ok := shaderManager.Get().Get("text") - if !ok { - return fmt.Errorf("unable to fetch text shader") - } - - // Create title texture - titleTextureID, titleWidth, titleHeight, err := fm.CreateStringTexture(applicationFont, p.title, textShader) - if err != nil { - return fmt.Errorf("failed to create title texture: %w", err) - } - if titleTextureID == 0 { - return fmt.Errorf("CreateStringTexture returned a nil texture ID") - } - - p.titleTextureID = titleTextureID - p.titleTextureWidth = titleWidth - p.titleTextureHeight = titleHeight - - // Load existing email address from database - config, err := db.GetConfig(p.Context.DbPtr) - if err != nil { - panic(err) - } - emailAddress := config.EmailAddress - - if emailAddress != "" { - p.inputText = emailAddress - p.isFirstTimeSetup = false - - // Create current email display texture - currentEmailText := fmt.Sprintf("Current Email: %s", emailAddress) - currentEmailTextureID, currentEmailWidth, currentEmailHeight, err := fm.CreateStringTexture(applicationFont, currentEmailText, textShader) - if err != nil { - return fmt.Errorf("failed to create current email display texture: %w", err) - } - if currentEmailTextureID == 0 { - return fmt.Errorf("CreateStringTexture returned a nil texture ID for current email display") - } - - p.currentEmailDisplayTextureID = currentEmailTextureID - p.currentEmailDisplayTextureWidth = currentEmailWidth - p.currentEmailDisplayTextureHeight = currentEmailHeight - } else { - p.inputText = "" - p.isFirstTimeSetup = true - } - - sdl.StartTextInput() - - return nil -} - -// HandleEvent processes SDL events and handles them appropriately for the EmailAddressPage. -func (p *EmailAddressPage) HandleEvent(event *sdl.Event) error { - actualEvent := *event - if !sdl.IsTextInputActive() { - sdl.StartTextInput() - } - - switch e := actualEvent.(type) { - case *sdl.TextInputEvent: - if !p.textInputSupported { - // First TextInputEvent: the keyboard fallback already - // handled this character, so just record that text input - // is supported and skip the duplicate append. - p.textInputSupported = true - } else { - p.inputText += e.GetText() - // Clear error when user starts typing - p.errorMessage = "" - } - case *sdl.KeyboardEvent: - if e.Type == sdl.KEYDOWN { - switch e.Keysym.Sym { - case sdl.K_ESCAPE: - // Block escape during first-time setup - if p.isFirstTimeSetup { - return nil - } - // Allow escape back to settings if coming from settings - sdl.StopTextInput() - p.Context.NavigateTo(SETTINGS) - case sdl.K_RETURN: - // Validate email - check empty first, then format - if p.inputText == "" { - p.errorMessage = "Email address cannot be empty" - return nil - } - if !isValidEmail(p.inputText) { - p.errorMessage = "Please enter a valid email address" - return nil - } - - sdl.StopTextInput() - - // Save email address to database - config, err := db.GetConfig(p.Context.DbPtr) - if err != nil { - panic(err) - } - config.EmailAddress = p.inputText - p.Context.DbPtr.Save(config) - - if p.isFirstTimeSetup { - // First-time setup: navigate to License Key page - p.Context.NavigateTo(LICENSE_KEY) - } else { - p.Context.NavigateTo(SETTINGS) - } - case sdl.K_BACKSPACE: - if len(p.inputText) > 0 { - p.inputText = p.inputText[:len(p.inputText)-1] - // Clear error when user edits input - p.errorMessage = "" - } - default: - // Keyboard fallback: if TextInputEvent is not supported, - // convert printable keycodes to characters directly. - // This handles kiosk systems without an input method framework. - if !p.textInputSupported { - if ch := keycodeToChar(e.Keysym.Sym, e.Keysym.Mod); ch != 0 { - p.inputText += string(ch) - p.errorMessage = "" - } - } - } - } - } - return nil -} - -// Update updates animations and changing textures and other changes using `dt` which represents a time delta in float32. -func (p *EmailAddressPage) Update(dt float32) error { - // Get application font from database - applicationFont := "SpaceMonoNerdFont-Regular" - - fm := fontManager.Get() - sm := shaderManager.Get() - shader, ok := sm.Get("text") - if !ok { - return fmt.Errorf("unable to fetch text shader") - } - - // Update input text texture - if len(p.inputText) > 0 { - // Create the new inputText texture - inputTextTextureID, inputTextTextureWidth, inputTextTextureHeight, err := fm.CreateStringTexture( - applicationFont, - p.inputText, - shader, - ) - if err != nil { - return fmt.Errorf("unable to create new input text texture: %w", err) - } - - // Delete old texture if it exists - if p.inputTextTextureID != 0 { - gl.DeleteTextures(1, &p.inputTextTextureID) - } - - p.inputTextTextureID = inputTextTextureID - p.inputTextTextureWidth = inputTextTextureWidth - p.inputTextTextureHeight = inputTextTextureHeight - } - - // Update error message texture - if p.errorMessage != "" { - errorTextureID, errorWidth, errorHeight, err := fm.CreateStringTexture( - applicationFont, - p.errorMessage, - shader, - ) - if err != nil { - return fmt.Errorf("unable to create error text texture: %w", err) - } - - // Delete old error texture if it exists - if p.errorTextureID != 0 { - gl.DeleteTextures(1, &p.errorTextureID) - } - - p.errorTextureID = errorTextureID - p.errorTextureWidth = errorWidth - p.errorTextureHeight = errorHeight - } else { - // Clear error texture if no error message - if p.errorTextureID != 0 { - gl.DeleteTextures(1, &p.errorTextureID) - p.errorTextureID = 0 - } - } - - return nil -} - -// Render performs page drawing logic. -func (p *EmailAddressPage) Render() error { - config, err := db.GetConfig(p.Context.DbPtr) - if err != nil { - panic(err) - } - // Get base font size from database - baseFontSize := config.StandardFontSize - - sm := shaderManager.Get() - - // 1. Calculate Scale - titleFontSize := int32(64) - titleScale := float32(titleFontSize) / float32(baseFontSize) - currentEmailFontSize := int32(28) - currentEmailScale := float32(currentEmailFontSize) / float32(baseFontSize) - padding := int32(4) - verticalMargin := int32(20) - - // 2. Define dimensions - targetFontSize := int32(36) - scale := float32(targetFontSize) / float32(baseFontSize) - border := int32(3) - boxH := targetFontSize + (padding * 2) - boxW := int32(600) + (padding * 2) - boxDimensions := mgl32.Vec2{float32(boxW), float32(boxH)} - borderBoxH := boxH + (border * 2) - borderBoxW := boxW + (border * 2) - borderBoxDimensions := mgl32.Vec2{float32(borderBoxW), float32(borderBoxH)} - titleH := float32(p.titleTextureHeight) * titleScale - titleW := float32(p.titleTextureWidth) * titleScale - titleDimensions := mgl32.Vec2{titleW, titleH} - inputTextH := float32(p.inputTextTextureHeight) * scale - inputTextW := float32(p.inputTextTextureWidth) * scale - inputTextDimensions := mgl32.Vec2{inputTextW, inputTextH} - - var currentEmailDisplayH, currentEmailDisplayW float32 - var currentEmailDisplayDimensions mgl32.Vec2 - if p.currentEmailDisplayTextureID != 0 { - currentEmailDisplayH = float32(p.currentEmailDisplayTextureHeight) * currentEmailScale - currentEmailDisplayW = float32(p.currentEmailDisplayTextureWidth) * currentEmailScale - currentEmailDisplayDimensions = mgl32.Vec2{currentEmailDisplayW, currentEmailDisplayH} - } - - // Error text dimensions - errorFontSize := int32(20) - errorScale := float32(errorFontSize) / float32(baseFontSize) - var errorTextH, errorTextW float32 - var errorTextDimensions mgl32.Vec2 - if p.errorTextureID != 0 { - errorTextH = float32(p.errorTextureHeight) * errorScale - errorTextW = float32(p.errorTextureWidth) * errorScale - errorTextDimensions = mgl32.Vec2{errorTextW, errorTextH} - } - - // 3. Define positions - borderBoxY := p.Base.ScreenCenterY - (borderBoxH / 2) - borderBoxX := p.Base.ScreenCenterX - (borderBoxW / 2) - borderBoxPosition := mgl32.Vec2{float32(borderBoxX), float32(borderBoxY)} - boxY := p.Base.ScreenCenterY - (boxH / 2) - boxX := p.Base.ScreenCenterX - (boxW / 2) - boxPosition := mgl32.Vec2{float32(boxX), float32(boxY)} - titleY := borderBoxY + borderBoxH + verticalMargin - titleX := p.Base.ScreenCenterX - int32(titleW/2) - titlePosition := mgl32.Vec2{float32(titleX), float32(titleY)} - inputTextY := boxY + padding - inputTextX := boxX + padding - inputTextPosition := mgl32.Vec2{float32(inputTextX), float32(inputTextY)} - - var currentEmailDisplayPosition mgl32.Vec2 - if p.currentEmailDisplayTextureID != 0 { - currentEmailDisplayY := borderBoxY - int32(currentEmailDisplayH) - verticalMargin - currentEmailDisplayX := p.Base.ScreenCenterX - int32(currentEmailDisplayW/2) - currentEmailDisplayPosition = mgl32.Vec2{float32(currentEmailDisplayX), float32(currentEmailDisplayY)} - } - - // Error text position (below current email display to avoid overlap) - var errorTextPosition mgl32.Vec2 - if p.errorTextureID != 0 { - var errorTextY int32 - if p.currentEmailDisplayTextureID != 0 { - // Position below the current email display - errorTextY = borderBoxY - int32(currentEmailDisplayH) - verticalMargin - int32(errorTextH) - (verticalMargin / 2) - } else { - // No current email display, position below the input box - errorTextY = borderBoxY - int32(errorTextH) - (verticalMargin / 2) - } - errorTextX := p.Base.ScreenCenterX - int32(errorTextW/2) - errorTextPosition = mgl32.Vec2{float32(errorTextX), float32(errorTextY)} - } - - // 4. Define Colors - white := sdl.Color{R: 255, G: 255, B: 255, A: 255} - grey := sdl.Color{R: 120, G: 120, B: 120, A: 255} - darkGrey := sdl.Color{R: 30, G: 30, B: 30, A: 255} - - // 5. Get Shaders - textShader, _ := sm.Get("text") - solidShader, _ := sm.Get("solid_color") - - // 6. Draw everything - p.Base.RenderSolidColorQuad(solidShader, borderBoxPosition, borderBoxDimensions, grey) - p.Base.RenderSolidColorQuad(solidShader, boxPosition, boxDimensions, darkGrey) - p.Base.RenderTexture(textShader, p.titleTextureID, titlePosition, titleDimensions, white) - - // Draw current email display if it exists - if p.currentEmailDisplayTextureID != 0 { - p.Base.RenderTexture(textShader, p.currentEmailDisplayTextureID, currentEmailDisplayPosition, currentEmailDisplayDimensions, white) - } - - if len(p.inputText) > 0 { - p.Base.RenderTexture(textShader, p.inputTextTextureID, inputTextPosition, inputTextDimensions, white) - } - - // Draw error message if it exists - if p.errorTextureID != 0 { - p.Base.RenderTexture(textShader, p.errorTextureID, errorTextPosition, errorTextDimensions, colors.ErrorRed) - } - return nil -} - -// Destroy cleans up page-specific resources. -func (p *EmailAddressPage) Destroy() error { - - gl.DeleteTextures(1, &p.titleTextureID) - - // Delete current email display texture if it exists - if p.currentEmailDisplayTextureID != 0 { - gl.DeleteTextures(1, &p.currentEmailDisplayTextureID) - p.currentEmailDisplayTextureID = 0 - } - - // Delete input text texture if it exists - if p.inputTextTextureID != 0 { - gl.DeleteTextures(1, &p.inputTextTextureID) - p.inputTextTextureID = 0 - } - - // Delete error texture if it exists - if p.errorTextureID != 0 { - gl.DeleteTextures(1, &p.errorTextureID) - p.errorTextureID = 0 - } - - sdl.StopTextInput() - return p.Base.Destroy() -} diff --git a/archive/internal/page/home.go b/archive/internal/page/home.go deleted file mode 100644 index 087828b..0000000 --- a/archive/internal/page/home.go +++ /dev/null @@ -1,419 +0,0 @@ -package page - -import ( - "fmt" - - "github.com/go-gl/gl/v3.3-core/gl" - "github.com/go-gl/mathgl/mgl32" - "github.com/veandco/go-sdl2/sdl" - "rhemawavetech.com/rhemawave/internal/colors" - "rhemawavetech.com/rhemawave/internal/db" - "rhemawavetech.com/rhemawave/internal/download" - "rhemawavetech.com/rhemawave/internal/fontManager" - "rhemawavetech.com/rhemawave/internal/shaderManager" -) - -var GitVersion = "07112026-1710" - -type Home struct { - Base - fm *fontManager.FontManager - font *fontManager.FontEntry - title StringItem - version StringItem - prompt StringItem - note StringItem - message StringItem - downloadProgressChan chan download.DownloadStatus - licenseInfoHeading StringItem - emailDisplay StringItem - licenseKeyDisplay StringItem - hasLicenseInfo bool - baseFontSize int - isDownloadComplete bool - downloadFailed bool - downloadDone bool -} - -// Init performs basic page initialization and creation of static assets. -func (p *Home) Init(ctx *PageCtx) error { - title := "Radiant Wave" - version := GitVersion - prompt := "Press F2 to Start, or F3 for Settings" - note := "You can always press F1 to return here" - - if err := p.Base.Init(ctx); err != nil { - return fmt.Errorf("failed base page initialization: %w", err) - } - - // Get font - p.fm = fontManager.Get() - font, ok := p.fm.GetFont("SpaceMonoNerdFont-Regular") - if !ok { - return fmt.Errorf("unable to fetch font") - } - p.font = font - - // Create title texture - var err error - p.title, err = NewStringItem(title, font, colors.White) - if err != nil { - return fmt.Errorf("unable to create title string texture: %w", err) - } - - // Create version texture - versionString := "Version: " + version - p.version, err = NewStringItem(versionString, font, colors.White) - if err != nil { - return fmt.Errorf("unable to create version string texture: %w", err) - } - - // Create prompt texture - p.prompt, err = NewStringItem(prompt, font, colors.White) - if err != nil { - return fmt.Errorf("unable to create prompt string texture: %w", err) - } - - // Create note texture - p.note, err = NewStringItem(note, font, colors.White) - if err != nil { - return fmt.Errorf("unable to create note string texture: %w", err) - } - - // Create download channel and message - p.downloadProgressChan = make(chan download.DownloadStatus) - - // Initialize message - p.message, err = NewStringItem("Status: Initializing...", font, colors.White) - if err != nil { - return fmt.Errorf("unable to create initial message texture: %w", err) - } - - config, err := db.GetConfig(p.Context.DbPtr) - if err != nil { - panic(err) - } - - // Create license info textures if data exists - if config.EmailAddress != "" || config.LicenseKey != "" { - p.hasLicenseInfo = true - - // Create heading - p.licenseInfoHeading, err = NewStringItem("License Info", font, colors.White) - if err != nil { - return fmt.Errorf("unable to create license info heading texture: %w", err) - } - - // Create email display if exists - if config.EmailAddress != "" { - emailText := fmt.Sprintf("Email: %s", config.EmailAddress) - p.emailDisplay, err = NewStringItem(emailText, font, colors.White) - if err != nil { - return fmt.Errorf("unable to create email display texture: %w", err) - } - } - - // Create license key display if exists - if config.LicenseKey != "" { - licenseKeyText := fmt.Sprintf("License Key: %s", config.LicenseKey) - p.licenseKeyDisplay, err = NewStringItem(licenseKeyText, font, colors.White) - if err != nil { - return fmt.Errorf("unable to create license key display texture: %w", err) - } - } - } - - // Get base font size from database - baseFontSize := config.StandardFontSize - p.baseFontSize = baseFontSize - - // Scale the note text - fontSize := int32(32) - scale := float32(fontSize) / float32(baseFontSize) - p.note.W = int32(float32(p.note.W) * scale * 0.75) - p.note.H = int32(float32(p.note.H) * scale * 0.75) - - // Scale license info texts - if p.hasLicenseInfo { - licenseInfoScale := float32(28) / float32(baseFontSize) - p.licenseInfoHeading.W = int32(float32(p.licenseInfoHeading.W) * licenseInfoScale) - p.licenseInfoHeading.H = int32(float32(p.licenseInfoHeading.H) * licenseInfoScale) - if p.emailDisplay.ID != 0 { - p.emailDisplay.W = int32(float32(p.emailDisplay.W) * licenseInfoScale * 0.85) - p.emailDisplay.H = int32(float32(p.emailDisplay.H) * licenseInfoScale * 0.85) - } - if p.licenseKeyDisplay.ID != 0 { - p.licenseKeyDisplay.W = int32(float32(p.licenseKeyDisplay.W) * licenseInfoScale * 0.85) - p.licenseKeyDisplay.H = int32(float32(p.licenseKeyDisplay.H) * licenseInfoScale * 0.85) - } - } - - // Scale message text - messageScale := float32(28) / float32(baseFontSize) - p.message.W = int32(float32(p.message.W) * messageScale) - p.message.H = int32(float32(p.message.H) * messageScale) - - return nil -} - -// HandleEvent processes SDL events and handles them appropriately for the Home page. -func (p *Home) HandleEvent(event *sdl.Event) error { - return nil -} - -func (p *Home) Update(dt float32) error { - var ( - progressUpdate download.DownloadStatus - ok bool - gotUpdate bool - ) - - // Drain the channel, keeping only the newest update. - for { - select { - case progressUpdate, ok = <-p.Context.MessageChannel: - gotUpdate = true - if !ok { - if p.downloadFailed { - p.setMessage("Download failed — restart to retry", colors.Red) - } else { - p.setMessage("Status: READY", colors.Green) - p.isDownloadComplete = true - if !p.downloadDone { - p.downloadDone = true - close(p.Context.DownloadDone) - } - } - return nil - } - default: - goto drained - } - } - -drained: - if !gotUpdate { - return nil - } - - if progressUpdate.Err != nil { - p.downloadFailed = true - p.setMessage(fmt.Sprintf("Error: %s", progressUpdate.Err.Error()), colors.Red) - return nil - } - - // Build the display string based on what the download package tells us. - var msg string - if progressUpdate.Max <= 0 { - // No total known yet — show the descriptive message from the package. - // This covers: "Fetching file manifest", "Checking local files", - // "Comparing checksums", "Building download list", etc. - msg = fmt.Sprintf("Status: %s", progressUpdate.Msg) - } else { - // We have a known total — show percentage. - pct := float64(progressUpdate.Cur) / float64(progressUpdate.Max) * 100 - msg = fmt.Sprintf("Status: Downloading Assets — %.1f%%", pct) - } - - p.setMessage(msg, colors.White) - return nil -} - -// setMessage deletes the old message texture and builds a new one. -func (p *Home) setMessage(text string, color sdl.Color) { - if p.message.ID != 0 { - gl.DeleteTextures(1, &p.message.ID) - p.message.ID = 0 - } - - newMsg, err := NewStringItem(text, p.font, color) - if err != nil { - return - } - - messageScale := float32(28) / float32(p.baseFontSize) - newMsg.W = int32(float32(newMsg.W) * messageScale) - newMsg.H = int32(float32(newMsg.H) * messageScale) - p.message = newMsg -} - -func (p *Home) Render() error { - baseFontSize := p.baseFontSize - sm := shaderManager.Get() - verticalMargin := int32(20) - licenseInfoMargin := int32(10) - - // 1) Calculate scales - titleFontSize := int32(64) - bodyFontSize := int32(32) - - titleScale := float32(titleFontSize) / float32(baseFontSize) - bodyScale := float32(bodyFontSize) / float32(baseFontSize) - - // 2) Calculate dimensions (scaled) - titleW := float32(p.title.W) * titleScale - titleH := float32(p.title.H) * titleScale - titleDim := mgl32.Vec2{titleW, titleH} - - versionW := float32(p.version.W) * bodyScale - versionH := float32(p.version.H) * bodyScale - versionDim := mgl32.Vec2{versionW, versionH} - - promptW := float32(p.prompt.W) * bodyScale - promptH := float32(p.prompt.H) * bodyScale - promptDim := mgl32.Vec2{promptW, promptH} - - // Note dimensions (already scaled in Init) - noteW := float32(p.note.W) - noteH := float32(p.note.H) - - // Message dimensions (already scaled in Init/Update) - messageW := float32(p.message.W) - messageH := float32(p.message.H) - - // License info dimensions (already scaled in Init) - licenseHeadingW := float32(p.licenseInfoHeading.W) - licenseHeadingH := float32(p.licenseInfoHeading.H) - var emailDisplayW, emailDisplayH, licenseKeyDisplayW, licenseKeyDisplayH float32 - if p.emailDisplay.ID != 0 { - emailDisplayW = float32(p.emailDisplay.W) - emailDisplayH = float32(p.emailDisplay.H) - } - if p.licenseKeyDisplay.ID != 0 { - licenseKeyDisplayW = float32(p.licenseKeyDisplay.W) - licenseKeyDisplayH = float32(p.licenseKeyDisplay.H) - } - - // 3) Calculate total height of the vertical stack - totalH := int32(titleH) + verticalMargin + - int32(versionH) + verticalMargin + - int32(promptH) + verticalMargin + - int32(noteH) - - // 4) Y-up layout: start from the TOP of the centered stack and step DOWN - top := p.Base.ScreenCenterY + totalH/2 - - titleY := top - int32(titleH) - versionY := titleY - verticalMargin - int32(versionH) - p.prompt.Y = versionY - verticalMargin - int32(promptH) - p.note.Y = p.prompt.Y - verticalMargin - int32(noteH) - - // 5) Center X for each item - titleX := p.Base.ScreenCenterX - int32(titleW/2) - versionX := p.Base.ScreenCenterX - int32(versionW/2) - p.prompt.X = p.Base.ScreenCenterX - int32(promptW/2) - p.note.X = p.Base.ScreenCenterX - int32(noteW/2) - - titlePos := mgl32.Vec2{float32(titleX), float32(titleY)} - versionPos := mgl32.Vec2{float32(versionX), float32(versionY)} - - // 6) Position license info at bottom center - if p.hasLicenseInfo { - bottomMargin := int32(20) - - // Calculate total height of license info block - licenseInfoTotalH := int32(licenseHeadingH) - if p.emailDisplay.ID != 0 { - licenseInfoTotalH += int32(emailDisplayH) + licenseInfoMargin - } - if p.licenseKeyDisplay.ID != 0 { - licenseInfoTotalH += int32(licenseKeyDisplayH) + licenseInfoMargin - } - - // Start from bottom and work up - currentY := bottomMargin - - // License key (bottom-most if it exists) - if p.licenseKeyDisplay.ID != 0 { - p.licenseKeyDisplay.X = p.Base.ScreenCenterX - int32(licenseKeyDisplayW/2) - p.licenseKeyDisplay.Y = currentY - currentY += int32(licenseKeyDisplayH) + licenseInfoMargin - } - - // Email - if p.emailDisplay.ID != 0 { - p.emailDisplay.X = p.Base.ScreenCenterX - int32(emailDisplayW/2) - p.emailDisplay.Y = currentY - currentY += int32(emailDisplayH) + licenseInfoMargin - } - - // Heading (top-most) - p.licenseInfoHeading.X = p.Base.ScreenCenterX - int32(licenseHeadingW/2) - p.licenseInfoHeading.Y = currentY - } - - // 7) Position message between license info and note/prompt - if p.hasLicenseInfo { - // Calculate the top of the license info block - licenseInfoTopY := p.licenseInfoHeading.Y + int32(licenseHeadingH) - if p.emailDisplay.ID != 0 { - licenseInfoTopY += int32(emailDisplayH) + licenseInfoMargin - } - if p.licenseKeyDisplay.ID != 0 { - licenseInfoTopY += int32(licenseKeyDisplayH) + licenseInfoMargin - } - - // Center the message vertically between license info and note - availableSpace := p.note.Y - licenseInfoTopY - verticalMargin - p.message.Y = licenseInfoTopY + availableSpace/2 - int32(messageH/2) - } else { - // If no license info, place message below note - p.message.Y = p.note.Y - verticalMargin - int32(messageH) - } - p.message.X = p.Base.ScreenCenterX - int32(messageW/2) - - // 8) Get shader and render - textShader, ok := sm.Get("text") - if !ok { - return fmt.Errorf("failed to fetch text shader") - } - color := colors.White - - p.Base.RenderTexture(textShader, p.title.ID, titlePos, titleDim, color) - p.Base.RenderTexture(textShader, p.version.ID, versionPos, versionDim, color) - p.Base.RenderTexture(textShader, p.prompt.ID, mgl32.Vec2{float32(p.prompt.X), float32(p.prompt.Y)}, promptDim, color) - p.Base.RenderTexture(textShader, p.note.ID, mgl32.Vec2{float32(p.note.X), float32(p.note.Y)}, mgl32.Vec2{noteW, noteH}, color) - - // Render message - if p.message.ID != 0 { - msgColor := colors.White - if p.isDownloadComplete { - msgColor = colors.Green - } - p.Base.RenderTexture(textShader, p.message.ID, mgl32.Vec2{float32(p.message.X), float32(p.message.Y)}, mgl32.Vec2{messageW, messageH}, msgColor) - } - - // Render license info if it exists - if p.hasLicenseInfo { - p.Base.RenderTexture(textShader, p.licenseInfoHeading.ID, mgl32.Vec2{float32(p.licenseInfoHeading.X), float32(p.licenseInfoHeading.Y)}, mgl32.Vec2{licenseHeadingW, licenseHeadingH}, color) - if p.emailDisplay.ID != 0 { - p.Base.RenderTexture(textShader, p.emailDisplay.ID, mgl32.Vec2{float32(p.emailDisplay.X), float32(p.emailDisplay.Y)}, mgl32.Vec2{emailDisplayW, emailDisplayH}, color) - } - if p.licenseKeyDisplay.ID != 0 { - p.Base.RenderTexture(textShader, p.licenseKeyDisplay.ID, mgl32.Vec2{float32(p.licenseKeyDisplay.X), float32(p.licenseKeyDisplay.Y)}, mgl32.Vec2{licenseKeyDisplayW, licenseKeyDisplayH}, color) - } - } - - return nil -} - -// Destroy cleans up page-specific resources. -func (p *Home) Destroy() error { - gl.DeleteTextures(1, &p.title.ID) - gl.DeleteTextures(1, &p.version.ID) - gl.DeleteTextures(1, &p.prompt.ID) - gl.DeleteTextures(1, &p.note.ID) - gl.DeleteTextures(1, &p.message.ID) - - if p.hasLicenseInfo { - gl.DeleteTextures(1, &p.licenseInfoHeading.ID) - if p.emailDisplay.ID != 0 { - gl.DeleteTextures(1, &p.emailDisplay.ID) - } - if p.licenseKeyDisplay.ID != 0 { - gl.DeleteTextures(1, &p.licenseKeyDisplay.ID) - } - } - - return p.Base.Destroy() -} diff --git a/archive/internal/page/home_test.go b/archive/internal/page/home_test.go deleted file mode 100644 index 02d59e4..0000000 --- a/archive/internal/page/home_test.go +++ /dev/null @@ -1,107 +0,0 @@ -package page - -import ( - "testing" - - "rhemawavetech.com/rhemawave/internal/download" -) - -// Regression: DownloadDone was closed twice when checkState() re-navigated -// to HOME on the DOWNLOADING_ASSETS -> READY transition. The new Home page -// saw the already-closed MessageChannel and tried to close DownloadDone -// again, causing panic: close of closed channel. -// -// Primary fix: checkState() now skips navigation when transitioning between -// states that both map to the HOME page. The downloadDone flag on Home is -// defense-in-depth. - -// TestHomeDownloadDoneGuardPreventsDoubleClose verifies that the per-instance -// downloadDone flag prevents closing DownloadDone more than once, even if a -// new Home page somehow reaches the close path. -func TestHomeDownloadDoneGuardPreventsDoubleClose(t *testing.T) { - downloadDone := make(chan struct{}) - - // First Home closes DownloadDone. - h1 := &Home{downloadDone: false} - h1.Context = &PageCtx{DownloadDone: downloadDone} - if !h1.downloadDone { - h1.downloadDone = true - close(h1.Context.DownloadDone) - } - - // Second Home with guard already set (simulates defense-in-depth). - h2 := &Home{downloadDone: true} - h2.Context = &PageCtx{DownloadDone: downloadDone} - if !h2.downloadDone { - h2.downloadDone = true - close(h2.Context.DownloadDone) // would panic without guard - } -} - -// TestHomeDownloadDoneGuardUnsetWouldPanic proves that without the -// downloadDone guard, closing DownloadDone a second time panics. -func TestHomeDownloadDoneGuardUnsetWouldPanic(t *testing.T) { - downloadDone := make(chan struct{}) - close(downloadDone) // pre-close - - defer func() { - if r := recover(); r == nil { - t.Error("expected panic when closing already-closed channel") - } - }() - - // New Home with downloadDone=false tries to close an already-closed channel. - h := &Home{downloadDone: false} - h.Context = &PageCtx{DownloadDone: downloadDone} - if !h.downloadDone { - h.downloadDone = true - close(h.Context.DownloadDone) // panics - } -} - -// TestHomeDownloadFailedDoesNotClose verifies that when a download fails, -// DownloadDone is NOT closed (the error path should not signal success). -func TestHomeDownloadFailedDoesNotClose(t *testing.T) { - downloadDone := make(chan struct{}) - - h := &Home{ - downloadFailed: true, - downloadDone: false, - } - h.Context = &PageCtx{ - MessageChannel: make(chan download.DownloadStatus), - DownloadDone: downloadDone, - } - - // Simulate the Update logic: on failure, downloadDone should not be set - // and DownloadDone should not be closed. - if h.downloadFailed { - // Error path: do not close DownloadDone. - } else if !h.downloadDone { - h.downloadDone = true - close(h.Context.DownloadDone) - } - - select { - case <-downloadDone: - t.Error("DownloadDone should not be closed on download failure") - default: - // Correct: channel is still open. - } -} - -// TestHomeDownloadDoneAlreadyClosed verifies that a Home page with -// downloadDone=true never attempts to close the channel. -func TestHomeDownloadDoneAlreadyClosed(t *testing.T) { - downloadDone := make(chan struct{}) - close(downloadDone) - - h := &Home{downloadDone: true} - h.Context = &PageCtx{DownloadDone: downloadDone} - - // Guard should prevent any close attempt. - if !h.downloadDone { - h.downloadDone = true - close(h.Context.DownloadDone) - } -} diff --git a/archive/internal/page/licenseKeyPage.go b/archive/internal/page/licenseKeyPage.go deleted file mode 100644 index 675354e..0000000 --- a/archive/internal/page/licenseKeyPage.go +++ /dev/null @@ -1,459 +0,0 @@ -package page - -import ( - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/go-gl/gl/v3.3-core/gl" - "github.com/go-gl/mathgl/mgl32" - "github.com/veandco/go-sdl2/sdl" - "rhemawavetech.com/rhemawave/internal/colors" - "rhemawavetech.com/rhemawave/internal/db" - "rhemawavetech.com/rhemawave/internal/fontManager" - "rhemawavetech.com/rhemawave/internal/logger" - "rhemawavetech.com/rhemawave/internal/shaderManager" -) - -type LicenseKeyPage struct { - Base - - title string - titleTextureID uint32 - titleTextureWidth int32 - titleTextureHeight int32 - - currentLicenseDisplayTextureID uint32 - currentLicenseDisplayTextureWidth int32 - currentLicenseDisplayTextureHeight int32 - - inputText string - inputTextTextureID uint32 - inputTextTextureWidth int32 - inputTextTextureHeight int32 - - // Error message handling - errorMessage string - errorTextureID uint32 - errorTextureWidth int32 - errorTextureHeight int32 - - // isFirstTimeSetup tracks whether page was loaded empty (first-time setup) - isFirstTimeSetup bool - - // Text input deduplication - textInputSupported bool -} - -// Init performs basic page initialization and creation of static assets. -func (p *LicenseKeyPage) Init(ctx *PageCtx) error { - - if err := p.Base.Init(ctx); err != nil { - return fmt.Errorf("failed base page initialization: %w", err) - } - - p.title = "License Key" - - // Get application font from database - applicationFont := "SpaceMonoNerdFont-Regular" - - fm := fontManager.Get() - textShader, ok := shaderManager.Get().Get("text") - if !ok { - return fmt.Errorf("unable to fetch text shader") - } - - // Create title texture - titleTextureID, titleWidth, titleHeight, err := fm.CreateStringTexture(applicationFont, p.title, textShader) - if err != nil { - return fmt.Errorf("failed to create title texture: %w", err) - } - if titleTextureID == 0 { - return fmt.Errorf("CreateStringTexture returned a nil texture ID") - } - - p.titleTextureID = titleTextureID - p.titleTextureWidth = titleWidth - p.titleTextureHeight = titleHeight - - // Load existing license key from database - config, err := db.GetConfig(p.Context.DbPtr) - if err != nil { - panic(err) - } - licenseKey := config.LicenseKey - - if licenseKey != "" { - p.inputText = licenseKey - p.isFirstTimeSetup = false - - // Create current license key display texture - currentLicenseText := fmt.Sprintf("Current License Key: %s", licenseKey) - currentLicenseTextureID, currentLicenseWidth, currentLicenseHeight, err := fm.CreateStringTexture(applicationFont, currentLicenseText, textShader) - if err != nil { - return fmt.Errorf("failed to create current license display texture: %w", err) - } - if currentLicenseTextureID == 0 { - return fmt.Errorf("CreateStringTexture returned a nil texture ID for current license display") - } - - p.currentLicenseDisplayTextureID = currentLicenseTextureID - p.currentLicenseDisplayTextureWidth = currentLicenseWidth - p.currentLicenseDisplayTextureHeight = currentLicenseHeight - } else { - p.inputText = "" - p.isFirstTimeSetup = true - } - - sdl.StartTextInput() - - return nil -} - -// formatLicenseKey formats the input string with dashes every 4 characters -// and limits to 16 alphanumeric characters (not counting dashes) -func formatLicenseKey(input string) string { - // Remove all dashes and non-alphanumeric characters - cleaned := strings.Map(func(r rune) rune { - if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') { - return r - } - return -1 - }, input) - - // Limit to 16 characters - if len(cleaned) > 16 { - cleaned = cleaned[:16] - } - - // Insert dashes every 4 characters - var result strings.Builder - for i, char := range cleaned { - if i > 0 && i%4 == 0 { - result.WriteRune('-') - } - result.WriteRune(char) - } - - return result.String() -} - -// HandleEvent processes SDL events and handles them appropriately for the LicenseKeyPage. -func (p *LicenseKeyPage) HandleEvent(event *sdl.Event) error { - actualEvent := *event - if !sdl.IsTextInputActive() { - sdl.StartTextInput() - } - - switch e := actualEvent.(type) { - case *sdl.TextInputEvent: - if !p.textInputSupported { - // First TextInputEvent: the keyboard fallback already - // handled this character, so just record that text input - // is supported and skip the duplicate append. - p.textInputSupported = true - } else { - // Add the new character and reformat - p.inputText += e.GetText() - p.inputText = formatLicenseKey(p.inputText) - // Clear error when user starts typing - p.errorMessage = "" - } - case *sdl.KeyboardEvent: - if e.Type == sdl.KEYDOWN { - switch e.Keysym.Sym { - case sdl.K_ESCAPE: - // Block escape during first-time setup - if p.isFirstTimeSetup { - return nil - } - // Allow escape back to settings if coming from settings - sdl.StopTextInput() - p.Context.NavigateTo(SETTINGS) - case sdl.K_RETURN: - // Validate license key - check empty first, then length, then characters - cleaned := strings.ReplaceAll(p.inputText, "-", "") - if cleaned == "" { - p.errorMessage = "License key cannot be empty" - return nil - } - if len(cleaned) != 16 { - p.errorMessage = fmt.Sprintf("License key must be 16 characters (currently %d)", len(cleaned)) - return nil - } - // Check for invalid characters - for _, r := range cleaned { - if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')) { - p.errorMessage = "License key must contain only letters and numbers" - return nil - } - } - - sdl.StopTextInput() - - // Save license key to database - config, err := db.GetConfig(p.Context.DbPtr) - if err != nil { - panic(err) - } - config.LicenseKey = p.inputText - p.Context.DbPtr.Save(config) - - // Write license key to plain text file for the updater - if homeDir, err := os.UserHomeDir(); err == nil { - keyPath := filepath.Join(homeDir, ".local", "share", "rhemawave", "license_key") - if err := os.WriteFile(keyPath, []byte(p.inputText), 0644); err != nil { - logger.Error("Failed to write license key file: %v", err) - } - } - - if p.isFirstTimeSetup { - // First-time setup: rewind to Welcome page - p.Context.NavigateTo(HOME) - } else { - // Coming from settings: rewind back to settings - p.Context.NavigateTo(SETTINGS) - } - case sdl.K_BACKSPACE: - if len(p.inputText) > 0 { - // Remove last character and reformat - // Strip all dashes first to get raw characters - cleaned := strings.ReplaceAll(p.inputText, "-", "") - if len(cleaned) > 0 { - cleaned = cleaned[:len(cleaned)-1] - } - p.inputText = formatLicenseKey(cleaned) - // Clear error when user edits input - p.errorMessage = "" - } - default: - // Keyboard fallback: if TextInputEvent is not supported, - // convert printable keycodes to characters directly. - // This handles kiosk systems without an input method framework. - if !p.textInputSupported { - if ch := keycodeToChar(e.Keysym.Sym, e.Keysym.Mod); ch != 0 { - p.inputText += string(ch) - p.inputText = formatLicenseKey(p.inputText) - p.errorMessage = "" - } - } - } - } - } - return nil -} - -// Update updates animations and changing textures and other changes using `dt` which represents a time delta in float32. -func (p *LicenseKeyPage) Update(dt float32) error { - // Get application font from database - applicationFont := "SpaceMonoNerdFont-Regular" - - fm := fontManager.Get() - sm := shaderManager.Get() - shader, ok := sm.Get("text") - if !ok { - return fmt.Errorf("unable to fetch text shader") - } - - // Update input text texture - if len(p.inputText) > 0 { - // Create the new inputText texture - inputTextTextureID, inputTextTextureWidth, inputTextTextureHeight, err := fm.CreateStringTexture( - applicationFont, - p.inputText, - shader, - ) - if err != nil { - return fmt.Errorf("unable to create new input text texture: %w", err) - } - - // Delete old texture if it exists - if p.inputTextTextureID != 0 { - gl.DeleteTextures(1, &p.inputTextTextureID) - } - - p.inputTextTextureID = inputTextTextureID - p.inputTextTextureWidth = inputTextTextureWidth - p.inputTextTextureHeight = inputTextTextureHeight - } - - // Update error message texture - if p.errorMessage != "" { - errorTextureID, errorWidth, errorHeight, err := fm.CreateStringTexture( - applicationFont, - p.errorMessage, - shader, - ) - if err != nil { - return fmt.Errorf("unable to create error text texture: %w", err) - } - - // Delete old error texture if it exists - if p.errorTextureID != 0 { - gl.DeleteTextures(1, &p.errorTextureID) - } - - p.errorTextureID = errorTextureID - p.errorTextureWidth = errorWidth - p.errorTextureHeight = errorHeight - } else { - // Clear error texture if no error message - if p.errorTextureID != 0 { - gl.DeleteTextures(1, &p.errorTextureID) - p.errorTextureID = 0 - } - } - - return nil -} - -// Render performs page drawing logic. -func (p *LicenseKeyPage) Render() error { - config, err := db.GetConfig(p.Context.DbPtr) - if err != nil { - panic(err) - } - // Get base font size from database - baseFontSize := config.StandardFontSize - - sm := shaderManager.Get() - - // 1. Calculate Scale - titleFontSize := int32(64) - titleScale := float32(titleFontSize) / float32(baseFontSize) - currentLicenseFontSize := int32(28) - currentLicenseScale := float32(currentLicenseFontSize) / float32(baseFontSize) - padding := int32(4) - verticalMargin := int32(20) - - // 2. Define dimensions - targetFontSize := int32(36) - scale := float32(targetFontSize) / float32(baseFontSize) - border := int32(3) - - boxH := targetFontSize + (padding * 2) - boxW := int32(600) + (padding * 2) - boxDimensions := mgl32.Vec2{float32(boxW), float32(boxH)} - - borderBoxH := boxH + (border * 2) - borderBoxW := boxW + (border * 2) - borderBoxDimensions := mgl32.Vec2{float32(borderBoxW), float32(borderBoxH)} - - titleH := float32(p.titleTextureHeight) * titleScale - titleW := float32(p.titleTextureWidth) * titleScale - titleDimensions := mgl32.Vec2{titleW, titleH} - - inputTextH := float32(p.inputTextTextureHeight) * scale - inputTextW := float32(p.inputTextTextureWidth) * scale - inputTextDimensions := mgl32.Vec2{inputTextW, inputTextH} - - var currentLicenseDisplayH, currentLicenseDisplayW float32 - var currentLicenseDisplayDimensions mgl32.Vec2 - if p.currentLicenseDisplayTextureID != 0 { - currentLicenseDisplayH = float32(p.currentLicenseDisplayTextureHeight) * currentLicenseScale - currentLicenseDisplayW = float32(p.currentLicenseDisplayTextureWidth) * currentLicenseScale - currentLicenseDisplayDimensions = mgl32.Vec2{currentLicenseDisplayW, currentLicenseDisplayH} - } - - // Error text dimensions - errorFontSize := int32(20) - errorScale := float32(errorFontSize) / float32(baseFontSize) - var errorTextH, errorTextW float32 - var errorTextDimensions mgl32.Vec2 - if p.errorTextureID != 0 { - errorTextH = float32(p.errorTextureHeight) * errorScale - errorTextW = float32(p.errorTextureWidth) * errorScale - errorTextDimensions = mgl32.Vec2{errorTextW, errorTextH} - } - - // 3. Define positions - borderBoxY := p.Base.ScreenCenterY - (borderBoxH / 2) - borderBoxX := p.Base.ScreenCenterX - (borderBoxW / 2) - borderBoxPosition := mgl32.Vec2{float32(borderBoxX), float32(borderBoxY)} - - boxY := p.Base.ScreenCenterY - (boxH / 2) - boxX := p.Base.ScreenCenterX - (boxW / 2) - boxPosition := mgl32.Vec2{float32(boxX), float32(boxY)} - - titleY := borderBoxY + borderBoxH + verticalMargin - titleX := p.Base.ScreenCenterX - int32(titleW/2) - titlePosition := mgl32.Vec2{float32(titleX), float32(titleY)} - - inputTextY := boxY + padding - inputTextX := boxX + padding - inputTextPosition := mgl32.Vec2{float32(inputTextX), float32(inputTextY)} - - var currentLicenseDisplayPosition mgl32.Vec2 - if p.currentLicenseDisplayTextureID != 0 { - currentLicenseDisplayY := borderBoxY - int32(currentLicenseDisplayH) - verticalMargin - currentLicenseDisplayX := p.Base.ScreenCenterX - int32(currentLicenseDisplayW/2) - currentLicenseDisplayPosition = mgl32.Vec2{float32(currentLicenseDisplayX), float32(currentLicenseDisplayY)} - } - - // Error text position (below current license display to avoid overlap) - var errorTextPosition mgl32.Vec2 - if p.errorTextureID != 0 { - var errorTextY int32 - if p.currentLicenseDisplayTextureID != 0 { - // Position below the current license display - errorTextY = borderBoxY - int32(currentLicenseDisplayH) - verticalMargin - int32(errorTextH) - (verticalMargin / 2) - } else { - // No current license display, position below the input box - errorTextY = borderBoxY - int32(errorTextH) - (verticalMargin / 2) - } - errorTextX := p.Base.ScreenCenterX - int32(errorTextW/2) - errorTextPosition = mgl32.Vec2{float32(errorTextX), float32(errorTextY)} - } - - // 4. Get Shaders - textShader, _ := sm.Get("text") - solidShader, _ := sm.Get("solid_color") - - // 5. Draw everything - p.Base.RenderSolidColorQuad(solidShader, borderBoxPosition, borderBoxDimensions, colors.Gray) - p.Base.RenderSolidColorQuad(solidShader, boxPosition, boxDimensions, colors.DarkGray) - p.Base.RenderTexture(textShader, p.titleTextureID, titlePosition, titleDimensions, colors.White) - - // Draw current license display if it exists - if p.currentLicenseDisplayTextureID != 0 { - p.Base.RenderTexture(textShader, p.currentLicenseDisplayTextureID, currentLicenseDisplayPosition, currentLicenseDisplayDimensions, colors.White) - } - - if len(p.inputText) > 0 { - p.Base.RenderTexture(textShader, p.inputTextTextureID, inputTextPosition, inputTextDimensions, colors.White) - } - - // Draw error message if it exists - if p.errorTextureID != 0 { - p.Base.RenderTexture(textShader, p.errorTextureID, errorTextPosition, errorTextDimensions, colors.ErrorRed) - } - return nil -} - -// Destroy cleans up page-specific resources. -func (p *LicenseKeyPage) Destroy() error { - - gl.DeleteTextures(1, &p.titleTextureID) - - // Delete current license display texture if it exists - if p.currentLicenseDisplayTextureID != 0 { - gl.DeleteTextures(1, &p.currentLicenseDisplayTextureID) - p.currentLicenseDisplayTextureID = 0 - } - - // Delete input text texture if it exists - if p.inputTextTextureID != 0 { - gl.DeleteTextures(1, &p.inputTextTextureID) - p.inputTextTextureID = 0 - } - - // Delete error texture if it exists - if p.errorTextureID != 0 { - gl.DeleteTextures(1, &p.errorTextureID) - p.errorTextureID = 0 - } - - sdl.StopTextInput() - return p.Base.Destroy() -} diff --git a/archive/internal/page/logViewerPage.go b/archive/internal/page/logViewerPage.go deleted file mode 100644 index 6f82313..0000000 --- a/archive/internal/page/logViewerPage.go +++ /dev/null @@ -1,273 +0,0 @@ -package page - -import ( - "fmt" - "strings" - "time" - - "github.com/go-gl/gl/v3.3-core/gl" - "github.com/go-gl/mathgl/mgl32" - "github.com/veandco/go-sdl2/sdl" - "rhemawavetech.com/rhemawave/internal/colors" - "rhemawavetech.com/rhemawave/internal/db" - "rhemawavetech.com/rhemawave/internal/fontManager" - "rhemawavetech.com/rhemawave/internal/logger" - "rhemawavetech.com/rhemawave/internal/shaderManager" -) - -type LogViewerPage struct { - Base - - titleTextureID uint32 - titleTextureWidth int32 - titleTextureHeight int32 - - lines []string - lineTextureIDs []uint32 - lineTextureWidths []int32 - lineTextureHeights []int32 - lineColors []sdl.Color - - hintTextureID uint32 - hintTextureWidth int32 - hintTextureHeight int32 - - scrollOffset int32 - maxScrollOffset int32 - lineHeight int32 - visibleHeight int32 -} - -func (p *LogViewerPage) Init(ctx *PageCtx) error { - var logs []db.Log - if err := p.Base.Init(ctx); err != nil { - return fmt.Errorf("failed base page initialization: %w", err) - } - - if result := p.Context.DbPtr.Order("timestamp desc").Limit(250).Find(&logs); result.Error != nil { - logger.Error("error querying database for logs: %s", result.Error) - return fmt.Errorf("%s", result.Error) - } - - // // Format and word-wrap each log entry - // for _, entry := range logs { - // formatted := fmt.Sprintf("[%s] [%s] %s", entry.Timestamp, entry.Level, entry.Message) - // wrapped := wrapText(formatted, 100) - // p.lines = append(p.lines, strings.Split(wrapped, "\n")...) - // } - - levelColors := map[string]sdl.Color{ - "DEBUG": colors.SkyBlue, - "INFO": colors.White, - "WARNING": colors.Yellow, - "ERROR": colors.RedOrange, - } - - fallbackColor := colors.White - - p.lineColors = make([]sdl.Color, len(p.lines)) - - for _, entry := range logs { - color, ok := levelColors[entry.Level] - if !ok { - color = fallbackColor - } - logTime := time.UnixMilli(entry.Timestamp) - timeStr := logTime.Format("2006-01-02 15:04") - line := fmt.Sprintf("[%s] [%s] %s", timeStr, entry.Level, entry.Message) - p.lines = append(p.lines, line) - p.lineColors = append(p.lineColors, color) - } - - applicationFont := "SpaceMonoNerdFont-Regular" - - fm := fontManager.Get() - textShader, ok := shaderManager.Get().Get("text") - if !ok { - return fmt.Errorf("unable to fetch text shader") - } - - // Title texture - titleID, titleW, titleH, err := fm.CreateStringTexture(applicationFont, "Application Logs", textShader) - if err != nil { - return fmt.Errorf("failed to create title texture: %w", err) - } - p.titleTextureID = titleID - p.titleTextureWidth = titleW - p.titleTextureHeight = titleH - - // Line textures - p.lineTextureIDs = make([]uint32, len(p.lines)) - p.lineTextureWidths = make([]int32, len(p.lines)) - p.lineTextureHeights = make([]int32, len(p.lines)) - - for i, line := range p.lines { - if strings.TrimSpace(line) == "" { - continue - } - texID, w, h, err := fm.CreateStringTexture(applicationFont, line, textShader) - if err != nil { - return fmt.Errorf("failed to create line texture: %w", err) - } - p.lineTextureIDs[i] = texID - p.lineTextureWidths[i] = w - p.lineTextureHeights[i] = h - } - - // Hint texture - hintText := "Arrow Keys / PgUp/PgDn to scroll | F1|2|3 to Exit" - hintID, hintW, hintH, err := fm.CreateStringTexture(applicationFont, hintText, textShader) - if err != nil { - return fmt.Errorf("failed to create hint texture: %w", err) - } - p.hintTextureID = hintID - p.hintTextureWidth = hintW - p.hintTextureHeight = hintH - - return nil -} - -func (p *LogViewerPage) HandleEvent(event *sdl.Event) error { - switch e := (*event).(type) { - case *sdl.KeyboardEvent: - if e.Type == sdl.KEYDOWN { - switch e.Keysym.Sym { - case sdl.K_BACKSPACE: - p.Context.NavigateTo(SETTINGS) - case sdl.K_UP: - p.scrollOffset -= p.lineHeight - if p.scrollOffset < 0 { - p.scrollOffset = 0 - } - case sdl.K_DOWN: - p.scrollOffset += p.lineHeight - if p.scrollOffset > p.maxScrollOffset { - p.scrollOffset = p.maxScrollOffset - } - case sdl.K_PAGEUP: - p.scrollOffset -= p.visibleHeight - if p.scrollOffset < 0 { - p.scrollOffset = 0 - } - case sdl.K_PAGEDOWN: - p.scrollOffset += p.visibleHeight - if p.scrollOffset > p.maxScrollOffset { - p.scrollOffset = p.maxScrollOffset - } - case sdl.K_HOME: - p.scrollOffset = 0 - case sdl.K_END: - p.scrollOffset = p.maxScrollOffset - } - } - case *sdl.MouseWheelEvent: - p.scrollOffset -= e.Y * p.lineHeight * 3 - if p.scrollOffset < 0 { - p.scrollOffset = 0 - } - if p.scrollOffset > p.maxScrollOffset { - p.scrollOffset = p.maxScrollOffset - } - } - return nil -} - -func (p *LogViewerPage) Update(dt float32) error { - return nil -} - -func (p *LogViewerPage) Render() error { - config, err := db.GetConfig(p.Context.DbPtr) - if err != nil { - panic(err) - } - baseFontSize := config.StandardFontSize - - sm := shaderManager.Get() - textShader, _ := sm.Get("text") - - titleFontSize := int32(48) - titleScale := float32(titleFontSize) / float32(baseFontSize) - contentFontSize := int32(24) - contentScale := float32(contentFontSize) / float32(baseFontSize) - hintFontSize := int32(20) - hintScale := float32(hintFontSize) / float32(baseFontSize) - - margin := int32(40) - lineSpacing := int32(4) - p.lineHeight = contentFontSize + lineSpacing - - contentLeftX := 25 - - // Title - titleH := float32(p.titleTextureHeight) * titleScale - titleW := float32(p.titleTextureWidth) * titleScale - titleX := p.Base.ScreenCenterX - int32(titleW/2) - titleY := p.Base.ScreenHeight - margin - int32(titleH) - p.Base.RenderTexture(textShader, p.titleTextureID, mgl32.Vec2{float32(titleX), float32(titleY)}, mgl32.Vec2{titleW, titleH}, colors.White) - - // Hint at bottom - hintH := float32(p.hintTextureHeight) * hintScale - hintW := float32(p.hintTextureWidth) * hintScale - hintX := p.Base.ScreenCenterX - int32(hintW/2) - hintY := margin - p.Base.RenderTexture(textShader, p.hintTextureID, mgl32.Vec2{float32(hintX), float32(hintY)}, mgl32.Vec2{hintW, hintH}, colors.Gray) - - // Content area bounds - contentTop := titleY - margin - contentBottom := hintY + int32(hintH) + margin - p.visibleHeight = contentTop - contentBottom - - // Total content height - totalContentHeight := int32(len(p.lines)) * p.lineHeight - p.maxScrollOffset = totalContentHeight - p.visibleHeight - if p.maxScrollOffset < 0 { - p.maxScrollOffset = 0 - } - - // Render visible lines - currentY := contentTop - p.lineHeight + p.scrollOffset - - for i, line := range p.lines { - if strings.TrimSpace(line) == "" { - currentY -= p.lineHeight - continue - } - - if p.lineTextureIDs[i] == 0 { - currentY -= p.lineHeight - continue - } - - if currentY > contentTop || currentY+p.lineHeight < contentBottom { - currentY -= p.lineHeight - continue - } - - lineW := float32(p.lineTextureWidths[i]) * contentScale - lineH := float32(p.lineTextureHeights[i]) * contentScale - - color := colors.White - if i < len(p.lineColors) { - color = p.lineColors[i] - } - - p.Base.RenderTexture(textShader, p.lineTextureIDs[i], mgl32.Vec2{float32(contentLeftX), float32(currentY)}, mgl32.Vec2{lineW, lineH}, color) - currentY -= p.lineHeight - } - - return nil -} - -func (p *LogViewerPage) Destroy() error { - gl.DeleteTextures(1, &p.titleTextureID) - gl.DeleteTextures(1, &p.hintTextureID) - - for _, texID := range p.lineTextureIDs { - if texID != 0 { - gl.DeleteTextures(1, &texID) - } - } - - return p.Base.Destroy() -} diff --git a/archive/internal/page/page.go b/archive/internal/page/page.go deleted file mode 100644 index 8528d53..0000000 --- a/archive/internal/page/page.go +++ /dev/null @@ -1,236 +0,0 @@ -// Package page provides the interface for pages -package page - -import ( - "fmt" - - "github.com/go-gl/gl/v3.3-core/gl" - "github.com/go-gl/mathgl/mgl32" - "github.com/veandco/go-sdl2/sdl" - "rhemawavetech.com/rhemawave/internal/shaderManager" -) - -// Page defines the contract for all application screens. -type Page interface { - Init(ctx *PageCtx) error - HandleEvent(event *sdl.Event) error - Update(dt float32) error - Render() error - Destroy() error -} - -// Base is a helper struct that pages can embed to get common functionality. -type Base struct { - Context *PageCtx - QuadVAO uint32 - QuadVBO uint32 - Projection mgl32.Mat4 - ScreenWidth int32 - ScreenHeight int32 - ScreenCenterX int32 - ScreenCenterY int32 -} - -// Init stores the application context and sets up common resources. -func (p *Base) Init(ctx *PageCtx) error { - p.Context = ctx - vertices := []float32{ - // pos(x,y) tex(u,v) - 0.0, 1.0, 0.0, 1.0, - 1.0, 0.0, 1.0, 0.0, - 0.0, 0.0, 0.0, 0.0, - - 0.0, 1.0, 0.0, 1.0, - 1.0, 1.0, 1.0, 1.0, - 1.0, 0.0, 1.0, 0.0, - } - - W := p.Context.W - H := p.Context.H - - p.Projection = mgl32.Ortho(0, float32(W), 0, float32(H), -1, 1) - p.ScreenHeight = H - p.ScreenWidth = W - p.ScreenCenterY = H / 2 - p.ScreenCenterX = W / 2 - - gl.GenVertexArrays(1, &p.QuadVAO) - gl.GenBuffers(1, &p.QuadVBO) - gl.BindVertexArray(p.QuadVAO) - gl.BindBuffer(gl.ARRAY_BUFFER, p.QuadVBO) - gl.BufferData(gl.ARRAY_BUFFER, len(vertices)*4, gl.Ptr(vertices), gl.STATIC_DRAW) - - stride := int32(4 * 4) // Each vertex has 4 floats (pos.x, pos.y, tex.u, tex.v) - - // Attribute 0: Vertex Position (vec2) - gl.EnableVertexAttribArray(0) - gl.VertexAttribPointerWithOffset(0, 2, gl.FLOAT, false, stride, 0) - - // Attribute 1: Texture Coordinate (vec2) - gl.EnableVertexAttribArray(1) - gl.VertexAttribPointerWithOffset(1, 2, gl.FLOAT, false, stride, 2*4) // Offset by 2 floats - - gl.BindBuffer(gl.ARRAY_BUFFER, 0) - gl.BindVertexArray(0) - gl.Disable(gl.BLEND) - gl.UseProgram(0) - return nil -} - -// Destroy cleans up the common OpenGL resources. -func (p *Base) Destroy() error { - gl.DeleteBuffers(1, &p.QuadVBO) - gl.DeleteVertexArrays(1, &p.QuadVAO) - return nil -} - -// BeginTextureRendering sets up shared GL state for a batch of RenderTexture calls. -// Call this once before a series of RenderTexture calls, then EndTextureRendering after. -func (p *Base) BeginTextureRendering(shader *shaderManager.Shader) { - gl.UseProgram(shader.ProgramID) - gl.Uniform1i(shader.LocTexture, 0) - gl.ActiveTexture(gl.TEXTURE0) - gl.Enable(gl.BLEND) - gl.BlendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA) - gl.BindVertexArray(p.QuadVAO) -} - -// EndTextureRendering restores GL state after a batch of RenderTexture calls. -func (p *Base) EndTextureRendering() { - gl.BindVertexArray(0) - gl.Disable(gl.BLEND) - gl.UseProgram(0) -} - -// RenderTexture draws a given texture to the screen at a specified position and size. -// For best performance, wrap multiple calls with BeginTextureRendering/EndTextureRendering. -// This method also works standalone for one-off renders. -func (p *Base) RenderTexture(shader *shaderManager.Shader, textureID uint32, position, size mgl32.Vec2, color sdl.Color) error { - gl.UseProgram(shader.ProgramID) - gl.Uniform1i(shader.LocTexture, 0) - - if shader.LocTextColor == -1 { - return fmt.Errorf("could not find 'u_textColor' uniform in shader program %d", shader.ProgramID) - } - gl.Uniform4f(shader.LocTextColor, float32(color.R)/255.0, float32(color.G)/255.0, float32(color.B)/255.0, float32(color.A)/255.0) - - model := mgl32.Ident4() - model = model.Mul4(mgl32.Translate3D(position.X(), position.Y(), 0)) - model = model.Mul4(mgl32.Scale3D(size.X(), size.Y(), 1)) - mvp := p.Projection.Mul4(model) - gl.UniformMatrix4fv(shader.LocMVPMatrix, 1, false, &mvp[0]) - - gl.ActiveTexture(gl.TEXTURE0) - gl.BindTexture(gl.TEXTURE_2D, textureID) - gl.Enable(gl.BLEND) - gl.BlendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA) - - gl.BindVertexArray(p.QuadVAO) - gl.DrawArrays(gl.TRIANGLES, 0, 6) - - gl.BindVertexArray(0) - gl.Disable(gl.BLEND) - gl.UseProgram(0) - return nil -} - -// RenderTextureBatched is a lightweight version of RenderTexture for use inside -// a BeginTextureRendering/EndTextureRendering block. It skips redundant state setup. -func (p *Base) RenderTextureBatched(shader *shaderManager.Shader, textureID uint32, position, size mgl32.Vec2, color sdl.Color) { - gl.Uniform4f(shader.LocTextColor, float32(color.R)/255.0, float32(color.G)/255.0, float32(color.B)/255.0, float32(color.A)/255.0) - - model := mgl32.Ident4() - model = model.Mul4(mgl32.Translate3D(position.X(), position.Y(), 0)) - model = model.Mul4(mgl32.Scale3D(size.X(), size.Y(), 1)) - mvp := p.Projection.Mul4(model) - gl.UniformMatrix4fv(shader.LocMVPMatrix, 1, false, &mvp[0]) - - gl.BindTexture(gl.TEXTURE_2D, textureID) - gl.DrawArrays(gl.TRIANGLES, 0, 6) -} - -// RenderSolidColorQuad draws a rectangle of a solid color to the screen. -func (p *Base) RenderSolidColorQuad(shader *shaderManager.Shader, position, size mgl32.Vec2, color sdl.Color) error { - gl.UseProgram(shader.ProgramID) - - model := mgl32.Ident4() - model = model.Mul4(mgl32.Translate3D(position.X(), position.Y(), 0)) - model = model.Mul4(mgl32.Scale3D(size.X(), size.Y(), 1)) - mvp := p.Projection.Mul4(model) - - if shader.LocMVPMatrix == -1 { - return fmt.Errorf("RenderSolidColorQuad: could not find 'u_mvpMatrix' uniform") - } - gl.UniformMatrix4fv(shader.LocMVPMatrix, 1, false, &mvp[0]) - - if shader.LocColor == -1 { - return fmt.Errorf("RenderSolidColorQuad: could not find 'u_color' uniform") - } - gl.Uniform4f(shader.LocColor, float32(color.R)/255.0, float32(color.G)/255.0, float32(color.B)/255.0, float32(color.A)/255.0) - - gl.Enable(gl.BLEND) - gl.BlendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA) - - gl.BindVertexArray(p.QuadVAO) - gl.DrawArrays(gl.TRIANGLES, 0, 6) - - gl.BindVertexArray(0) - gl.Disable(gl.BLEND) - gl.UseProgram(0) - return nil -} - -// Provide default implementations for the Page interface. -func (p *Base) HandleEvent(event sdl.Event) error { return nil } -func (p *Base) Update(dt float32) error { return nil } -func (p *Base) Render() error { return nil } - -// --- Helper Functions --- -// keycodeToChar converts an SDL keycode to a rune, respecting SHIFT state. -// Returns 0 for non-printable keys. -func keycodeToChar(sym sdl.Keycode, mod uint16) rune { - shifted := mod&sdl.KMOD_SHIFT != 0 - - // Letters - if sym >= sdl.K_a && sym <= sdl.K_z { - ch := rune(sym) - if shifted { - ch -= 'a' - 'A' - } - return ch - } - - // Digits and their shifted symbols - if sym >= sdl.K_0 && sym <= sdl.K_9 { - if shifted { - shiftDigits := []rune{')', '!', '@', '#', '$', '%', '^', '&', '*', '('} - return shiftDigits[sym-sdl.K_0] - } - return rune(sym) - } - - // Common punctuation / symbols - type charPair struct{ normal, shifted rune } - punctuation := map[sdl.Keycode]charPair{ - sdl.K_SPACE: {' ', ' '}, - sdl.K_MINUS: {'-', '_'}, - sdl.K_EQUALS: {'=', '+'}, - sdl.K_LEFTBRACKET: {'[', '{'}, - sdl.K_RIGHTBRACKET: {']', '}'}, - sdl.K_BACKSLASH: {'\\', '|'}, - sdl.K_SEMICOLON: {';', ':'}, - sdl.K_QUOTE: {'\'', '"'}, - sdl.K_BACKQUOTE: {'`', '~'}, - sdl.K_COMMA: {',', '<'}, - sdl.K_PERIOD: {'.', '>'}, - sdl.K_SLASH: {'/', '?'}, - } - if pair, ok := punctuation[sym]; ok { - if shifted { - return pair.shifted - } - return pair.normal - } - - return 0 -} diff --git a/archive/internal/page/pageItem.go b/archive/internal/page/pageItem.go deleted file mode 100644 index e34cf2e..0000000 --- a/archive/internal/page/pageItem.go +++ /dev/null @@ -1,83 +0,0 @@ -package page - -import ( - "fmt" - - "github.com/go-gl/mathgl/mgl32" - "github.com/veandco/go-sdl2/sdl" - "rhemawavetech.com/rhemawave/internal/fontManager" - "rhemawavetech.com/rhemawave/internal/shaderManager" -) - -// PageItem defines the basic features of any item on any page -// This coupling makes creating, updating and managing pages easier -type PageItem struct { - ID uint32 - W int32 - H int32 - X int32 // Horizontal position - Y int32 // Vertical position - Color sdl.Color - // index int -} - -// Scale updates the W and H of an item based on a scale factor (usually determined by base font size / desired font size) -func (i *PageItem) Scale(scale float32) { - i.W = int32(float32(i.W) * scale) - i.H = int32(float32(i.H) * scale) -} - -// Dimensions returns an mgl32.Vec2 containing the W and H of an item, respectively -func (i *PageItem) Dimensions(scale float32) mgl32.Vec2 { - return mgl32.Vec2{float32(float32(i.W) * scale), float32(float32(i.H) * scale)} -} - -// RawDimensions returns a mgl32.Vec2 containing the W and H of an item, without scaling -// This method will replace Dimensions in the future. Future PageItems should first call Scale(), then RawDimensions() -func (i *PageItem) RawDimensions() mgl32.Vec2 { - return mgl32.Vec2{float32(i.W), float32(i.H)} -} - -// Position returns an mgl32.Vec2 contianing the X and Y positions of an item, respectively -func (i *PageItem) Position() mgl32.Vec2 { - return mgl32.Vec2{float32(i.X), float32(i.Y)} -} - -// StringItem is an extended PageItem that contains a string content and string specific methods -type StringItem struct { - PageItem - content string -} - -// NewStringItem creates a new StringItem with the given content, font, and color. -// It initializes the texture ID, width, and height by creating a string texture using the font -// and shader manager. -func NewStringItem(content string, font *fontManager.FontEntry, color sdl.Color) (StringItem, error) { - var err error - - fm := fontManager.Get() - sm := shaderManager.Get() - - shader, ok := sm.Get("text") - if !ok { - return StringItem{}, fmt.Errorf("failed to fetch shader") - } - - id, width, height, err := fm.CreateStringTexture(font.Name, content, shader) - if err != nil { - return StringItem{}, err - } - - newStringItem := StringItem{ - PageItem: PageItem{ - ID: id, - W: width, - H: height, - Color: color, - }, - content: content, - } - - return newStringItem, err - -} diff --git a/archive/internal/page/proclamationOptions.go b/archive/internal/page/proclamationOptions.go deleted file mode 100644 index c0ac305..0000000 --- a/archive/internal/page/proclamationOptions.go +++ /dev/null @@ -1,793 +0,0 @@ -package page - -import ( - "fmt" - "math" - "sort" - "strconv" - "strings" - "unicode" - - "github.com/go-gl/gl/v3.3-core/gl" - "github.com/go-gl/mathgl/mgl32" - "github.com/veandco/go-sdl2/sdl" - "gorm.io/gorm" - "rhemawavetech.com/rhemawave/internal/colors" - "rhemawavetech.com/rhemawave/internal/db" - "rhemawavetech.com/rhemawave/internal/fontManager" - "rhemawavetech.com/rhemawave/internal/logger" - "rhemawavetech.com/rhemawave/internal/shaderManager" - "rhemawavetech.com/rhemawave/internal/sync" -) - -// Scrollbar Visuals -var ( - colorScrollTrack = sdl.Color{R: 255, G: 255, B: 255, A: 30} - colorScrollThumb = sdl.Color{R: 255, G: 255, B: 255, A: 100} -) - -type FlatItem struct { - Label StringItem - ID string - IsCategory bool - IsSubCategory bool - Depth int - Expanded bool - SearchText string // lowercase name + content, pre-built at load time -} - -type ProclamationOptions struct { - Base - font *fontManager.FontEntry - textShader *shaderManager.Shader - solidShader *shaderManager.Shader - title StringItem - prompt StringItem - - // Browse Tree State - allItems []*FlatItem - activeMap map[string]bool - highlightIndex int - - // Browse Scrolling State - scrollOffset int - maxVisible int - - // Scrollbar Dimens - scrollTrackWidth int32 - scrollTrackX int32 - scrollTrackHeight int32 - scrollTrackY int32 - - indentSize float32 - verticalMargin int32 - - // Search State - isSearchActive bool - searchQuery string - searchInput StringItem - searchItems []*FlatItem // filtered view — pointers into allProclamations - searchHighlight int - searchScrollOff int - - // Full flat proclamation pool (pointers shared with allItems leaves) - allProclamations []*FlatItem - - // Spinning triangle cursor — same shape/animation as AudioOptions — - // that points at whichever row is currently highlighted. - triVAO uint32 - triVBO uint32 - triX float32 - triCenterY float32 - triVisible bool - spinElapsed float32 -} - -func (p *ProclamationOptions) Init(ctx *PageCtx) error { - p.verticalMargin = 25 - p.indentSize = 40.0 - p.activeMap = make(map[string]bool) - p.allItems = []*FlatItem{} - p.scrollOffset = 0 - p.isSearchActive = false - p.scrollTrackWidth = 30 - p.scrollTrackX = p.ScreenWidth - p.scrollTrackWidth - var err error - - // Syncronize proclamations first - var allProcs []db.Proclamation - allProcs, err = sync.SyncProclamations(ctx.DbPtr) - if err != nil { - // sync failed, fallback to database - ctx.DbPtr.Find(&allProcs) - } - - sync.SyncFrequencies(ctx.DbPtr) - - fm := fontManager.Get() - font, _ := fm.GetFont("SpaceMonoNerdFont-Regular") - p.font = font - - sm := shaderManager.Get() - p.textShader, _ = sm.Get("text") - p.solidShader, _ = sm.Get("solid_color") - - p.Base.Init(ctx) - - config, err := db.GetConfig(p.Context.DbPtr) - if err != nil { - panic(err) - } - - itemScale := float32(32) / float32(config.StandardFontSize) - p.title, _ = NewStringItem("Proclamation Browser", p.font, colors.White) - p.title.Scale(float32(64) / float32(config.StandardFontSize)) - - p.prompt, _ = NewStringItem("Type to Search Space to Select Enter to Save Escape to Exit", p.font, colors.White) - p.prompt.Scale(itemScale * 0.8) - - // Calculation for visible area - areaHeight := float32(p.ScreenHeight) - 250 - p.maxVisible = int(areaHeight / 47) - - // Load current selections - var selected []db.Proclamation - ctx.DbPtr.Where("selected = ?", true).Where("id != ?", defaultProclamationID).Find(&selected) - for _, s := range selected { - if s.ID == defaultProclamationID { - logger.Debug("Found default proclamation in selected list: %s", s.ID) - } - p.activeMap[s.ID] = true - } - - procContentMap := make(map[string]string, len(allProcs)) - for _, pr := range allProcs { - if pr.ID == defaultProclamationID { - logger.Debug("Found default proclamation in allProcs list: %s", pr.ID) - } - procContentMap[pr.ID] = pr.Content - } - p.allProclamations = []*FlatItem{} - - for _, pr := range allProcs { - fi := p.createFlatItem(pr.Name, pr.ID, false, 0) - fi.SearchText = strings.ToLower(pr.Name) - p.allProclamations = append(p.allProclamations, fi) - } - - // Load Root Categories - var parentCategories []db.ProclamationCategory - p.Context.DbPtr.Where("parent_id IS NULL OR parent_id = 0").Find(&parentCategories) - for _, c := range parentCategories { - p.allItems = append(p.allItems, p.createFlatItem(c.Name, fmt.Sprint(c.ID), true, 0)) - } - - // Triangle cursor VAO/VBO — stride is vec2 only, no tex coords. - // Mirrors the spinning cursor used in AudioOptions. - triVerts := []float32{ - 0.5, 0.0, - -0.5, 0.5, - -0.5, -0.5, - } - gl.GenVertexArrays(1, &p.triVAO) - gl.GenBuffers(1, &p.triVBO) - gl.BindVertexArray(p.triVAO) - gl.BindBuffer(gl.ARRAY_BUFFER, p.triVBO) - gl.BufferData(gl.ARRAY_BUFFER, len(triVerts)*4, gl.Ptr(triVerts), gl.STATIC_DRAW) - gl.EnableVertexAttribArray(0) - gl.VertexAttribPointerWithOffset(0, 2, gl.FLOAT, false, 2*4, 0) - gl.BindBuffer(gl.ARRAY_BUFFER, 0) - gl.BindVertexArray(0) - - p.layout() - return nil -} - -func (p *ProclamationOptions) createFlatItem(name, id string, isCat bool, depth int) *FlatItem { - text := name - if isCat && depth == 0 { - text = "[+] " + name - } - config, err := db.GetConfig(p.Context.DbPtr) - if err != nil { - panic(err) - } - itemScale := float32(32) / float32(config.StandardFontSize) - lbl, _ := NewStringItem(text, p.font, colors.White) - lbl.Scale(itemScale) - return &FlatItem{ - Label: lbl, - ID: id, - IsCategory: isCat, - IsSubCategory: isCat && depth > 0, - Depth: depth, - } -} - -func (p *ProclamationOptions) layout() { - p.title.X = p.ScreenCenterX - p.title.W/2 - p.title.Y = 40 - p.prompt.X = p.ScreenCenterX - p.prompt.W/2 - p.prompt.Y = p.ScreenHeight - 60 - - startY := float32(p.ScreenHeight - 150) - p.scrollTrackY = 130 - p.scrollTrackHeight = int32(startY) - 130 + 50 - - // Reserve room on the left of the list for the spinning cursor - // (triangleSize wide, cursorGap away from the text it points at), - // ahead of the original left margin used for list rows. - cursorGap := int32(12) - listBaseX := int32(60) + int32(triangleSize) + cursorGap - - if p.isSearchActive { - // Position search bar label - p.searchInput.X = 60 - p.searchInput.Y = 100 - - // Hide all browse items - for _, item := range p.allItems { - item.Label.X = -5000 - } - - // Position filtered search results - for i, item := range p.searchItems { - item.Label.X = -5000 - if i >= p.searchScrollOff && i < p.searchScrollOff+p.maxVisible { - visibleIdx := i - p.searchScrollOff - item.Label.X = listBaseX - item.Label.Y = int32(startY - float32(visibleIdx)*47.0) - } - } - - // Point the cursor at the highlighted search result, if it's - // currently scrolled into view. - p.triVisible = false - if len(p.searchItems) > 0 && p.searchHighlight >= 0 && p.searchHighlight < len(p.searchItems) && - p.searchHighlight >= p.searchScrollOff && p.searchHighlight < p.searchScrollOff+p.maxVisible { - target := p.searchItems[p.searchHighlight].Label - p.triX = float32(target.X) - triangleSize - float32(cursorGap) - p.triCenterY = float32(target.Y) + float32(target.H)/2 - p.triVisible = true - } - return - } - - // Browse mode - for i, item := range p.allItems { - item.Label.X = -5000 - if i >= p.scrollOffset && i < p.scrollOffset+p.maxVisible { - visibleIdx := i - p.scrollOffset - item.Label.X = listBaseX + int32(float32(item.Depth)*p.indentSize) - item.Label.Y = int32(startY - float32(visibleIdx)*47.0) - } - } - - // Point the cursor at the highlighted browse row, if it's currently - // scrolled into view. Since target.X already includes the row's depth - // indentation, the cursor shifts in and out with it automatically — - // the gap between cursor and text stays constant regardless of depth. - p.triVisible = false - if len(p.allItems) > 0 && p.highlightIndex >= 0 && p.highlightIndex < len(p.allItems) && - p.highlightIndex >= p.scrollOffset && p.highlightIndex < p.scrollOffset+p.maxVisible { - target := p.allItems[p.highlightIndex].Label - p.triX = float32(target.X) - triangleSize - float32(cursorGap) - p.triCenterY = float32(target.Y) + float32(target.H)/2 - p.triVisible = true - } -} - -func (p *ProclamationOptions) toggleExpansion(index int) { - item := p.allItems[index] - if !item.IsCategory || item.IsSubCategory { - return - } - - pureName := item.Label.content[4:] - gl.DeleteTextures(1, &item.Label.ID) - - config, err := db.GetConfig(p.Context.DbPtr) - if err != nil { - panic(err) - } - - itemScale := float32(32) / float32(config.StandardFontSize) - if item.Expanded { - p.collapse(index) - item.Label, _ = NewStringItem("[+] "+pureName, p.font, colors.White) - } else { - p.expand(index) - item.Label, _ = NewStringItem("[-] "+pureName, p.font, colors.White) - } - item.Label.Scale(itemScale) - item.Expanded = !item.Expanded - p.layout() -} - -func (p *ProclamationOptions) expand(index int) { - parent := p.allItems[index] - parentID, _ := strconv.Atoi(parent.ID) - var newItems []*FlatItem - - // Load subcategories - var cats []db.ProclamationCategory - p.Context.DbPtr.Where("parent_id = ?", parentID).Find(&cats) - for _, c := range cats { - fi := p.createFlatItem(c.Name, fmt.Sprint(c.ID), true, parent.Depth+1) - fi.Expanded = true - newItems = append(newItems, fi) - } - - // Load proclamations for this category - var procs []db.Proclamation - p.Context.DbPtr.Where("category_id = ?", parentID).Where("ID != ?", defaultProclamationID).Find(&procs) - - // Sort proclamations alphabetically by name - sort.Slice(procs, func(i, j int) bool { - return strings.ToLower(procs[i].Name) < strings.ToLower(procs[j].Name) - }) - - for _, pr := range procs { - var fi *FlatItem - // Reuse existing proclamation from allProclamations if available - for _, existing := range p.allProclamations { - if existing.ID == pr.ID { - fi = existing - break - } - } - if fi == nil { - // Safety fallback - fi = p.createFlatItem(pr.Name, pr.ID, false, parent.Depth+1) - fi.SearchText = strings.ToLower(pr.Name + " " + pr.Content) - p.allProclamations = append(p.allProclamations, fi) - } - fi.Depth = parent.Depth + 1 - newItems = append(newItems, fi) - } - - // Insert new items after parent - tail := append([]*FlatItem{}, p.allItems[index+1:]...) - p.allItems = append(p.allItems[:index+1], append(newItems, tail...)...) - - // Recurse into subcategories - i := index + 1 - for i < len(p.allItems) && p.allItems[i].Depth > parent.Depth { - if p.allItems[i].IsCategory && p.allItems[i].Depth > 0 { - p.expand(i) - } - i++ - } -} - -func (p *ProclamationOptions) collapse(index int) { - depth := p.allItems[index].Depth - i := index + 1 - for i < len(p.allItems) && p.allItems[i].Depth > depth { - // Do NOT delete textures here — leaves are shared with allProclamations. - // Only delete textures for category items that aren't in the proclamation pool. - if p.allItems[i].IsCategory { - gl.DeleteTextures(1, &p.allItems[i].Label.ID) - } - i++ - } - p.allItems = append(p.allItems[:index+1], p.allItems[i:]...) -} - -// matchesWordPrefix returns true if any word in text starts with query (case-insensitive). -func matchesWordPrefix(text, query string) bool { - query = strings.ToLower(query) - text = strings.ToLower(text) - - // Split text into words (non-alphanumeric boundaries) - words := strings.FieldsFunc(text, func(r rune) bool { - return !unicode.IsLetter(r) && !unicode.IsNumber(r) - }) - - for _, word := range words { - if strings.HasPrefix(word, query) { - return true - } - } - return false -} - -func (p *ProclamationOptions) buildSearchList() { - p.searchItems = nil - if p.searchQuery == "" { - return - } - for _, item := range p.allProclamations { - if matchesWordPrefix(item.SearchText, p.searchQuery) { - p.searchItems = append(p.searchItems, item) - } - } - if p.searchHighlight >= len(p.searchItems) { - p.searchHighlight = 0 - } -} - -func (p *ProclamationOptions) rebuildSearchLabel() { - if p.searchInput.ID != 0 { - gl.DeleteTextures(1, &p.searchInput.ID) - p.searchInput.ID = 0 - } - if p.searchQuery == "" { - return - } - config, err := db.GetConfig(p.Context.DbPtr) - if err != nil { - panic(err) - } - itemScale := float32(32) / float32(config.StandardFontSize) - p.searchInput, _ = NewStringItem("Search: "+p.searchQuery, p.font, colors.Yellow) - p.searchInput.Scale(itemScale * 1.1) -} - -func (p *ProclamationOptions) enterSearchMode(firstRune rune) { - p.searchQuery = string(firstRune) - p.isSearchActive = true - p.searchScrollOff = 0 - p.searchHighlight = 0 - p.buildSearchList() - p.rebuildSearchLabel() -} - -func (p *ProclamationOptions) exitSearchMode() { - p.searchQuery = "" - p.isSearchActive = false - p.searchItems = nil - p.searchHighlight = 0 - p.searchScrollOff = 0 - if p.searchInput.ID != 0 { - gl.DeleteTextures(1, &p.searchInput.ID) - p.searchInput.ID = 0 - } -} - -func (p *ProclamationOptions) HandleEvent(event *sdl.Event) error { - switch e := (*event).(type) { - case *sdl.KeyboardEvent: - if e.Type != sdl.KEYDOWN { - return nil - } - - sym := e.Keysym.Sym - - // --- Backspace: always handled specially --- - if sym == sdl.K_BACKSPACE { - if p.isSearchActive { - if len(p.searchQuery) > 0 { - p.searchQuery = p.searchQuery[:len(p.searchQuery)-1] - } - if p.searchQuery == "" { - p.exitSearchMode() - } else { - p.buildSearchList() - p.rebuildSearchLabel() - } - p.layout() - return nil - } - // Browse mode backspace — navigate back - return nil - } - - // --- Escape --- - if sym == sdl.K_ESCAPE { - if p.isSearchActive { - p.exitSearchMode() - p.layout() - return nil - } - p.Context.NavigateTo(SETTINGS) - return nil - } - - // --- Search mode input handling --- - if p.isSearchActive { - switch sym { - case sdl.K_UP: - if p.searchHighlight > 0 { - p.searchHighlight-- - if p.searchHighlight < p.searchScrollOff { - p.searchScrollOff-- - } - } - case sdl.K_DOWN: - if p.searchHighlight < len(p.searchItems)-1 { - p.searchHighlight++ - if p.searchHighlight >= p.searchScrollOff+p.maxVisible { - p.searchScrollOff++ - } - } - case sdl.K_PAGEUP: - p.searchHighlight = p.searchScrollOff - p.searchScrollOff -= p.maxVisible - 1 - if p.searchScrollOff < 0 { - p.searchScrollOff = 0 - p.searchHighlight = 0 - } - case sdl.K_PAGEDOWN: - bottom := p.searchScrollOff + p.maxVisible - 1 - if bottom >= len(p.searchItems) { - bottom = len(p.searchItems) - 1 - } - p.searchScrollOff = bottom - p.searchHighlight = bottom - case sdl.K_SPACE: - if len(p.searchItems) > 0 { - item := p.searchItems[p.searchHighlight] - p.activeMap[item.ID] = !p.activeMap[item.ID] - } - p.acceptChanges() - case sdl.K_RETURN: - if len(p.searchItems) > 0 { - item := p.searchItems[p.searchHighlight] - p.activeMap[item.ID] = !p.activeMap[item.ID] - } - p.acceptChanges() - default: - // Append printable characters to search query - r := rune(sym) - if r >= 33 && r <= 126 { - if e.Keysym.Mod&sdl.KMOD_SHIFT != 0 { - r = unicode.ToUpper(r) - } - p.searchQuery += string(r) - p.buildSearchList() - p.rebuildSearchLabel() - } - } - p.layout() - return nil - } - - // --- Browse mode --- - switch sym { - case sdl.K_UP: - if p.highlightIndex > 0 { - p.highlightIndex-- - if p.highlightIndex < p.scrollOffset { - p.scrollOffset-- - } - } - case sdl.K_DOWN: - if p.highlightIndex < len(p.allItems)-1 { - p.highlightIndex++ - if p.highlightIndex >= p.scrollOffset+p.maxVisible { - p.scrollOffset++ - } - } - case sdl.K_PAGEUP: - p.highlightIndex = p.scrollOffset - p.scrollOffset -= p.maxVisible - 1 - if p.scrollOffset < 0 { - p.scrollOffset = 0 - p.highlightIndex = 0 - } - case sdl.K_PAGEDOWN: - bottomIdx := p.scrollOffset + p.maxVisible - 1 - if bottomIdx >= len(p.allItems) { - bottomIdx = len(p.allItems) - 1 - } - p.scrollOffset = bottomIdx - p.highlightIndex = bottomIdx - case sdl.K_RETURN: - item := p.allItems[p.highlightIndex] - if !item.IsCategory { // If the item is NOT a Category - p.activeMap[item.ID] = !p.activeMap[item.ID] // Toggle it from the 'selected' list - p.acceptChanges() - } else if !item.IsSubCategory { - p.toggleExpansion(p.highlightIndex) - } - case sdl.K_SPACE: - item := p.allItems[p.highlightIndex] - if !item.IsCategory { // If the item is NOT a Category - p.activeMap[item.ID] = !p.activeMap[item.ID] // Toggle it from the 'selected' list - p.acceptChanges() - } else if !item.IsSubCategory { - p.toggleExpansion(p.highlightIndex) - } - default: - // Any printable character enters search mode - r := rune(sym) - if r >= 33 && r <= 126 { - if e.Keysym.Mod&sdl.KMOD_SHIFT != 0 { - r = unicode.ToUpper(r) - } - p.enterSearchMode(r) - } - } - - p.layout() - } - return nil -} - -func (p *ProclamationOptions) acceptChanges() { - p.Context.DbPtr.Transaction(func(tx *gorm.DB) error { - tx.Model(&db.Proclamation{}).Where("1 = 1").Update("selected", false) - var ids []string - for id, active := range p.activeMap { - if active { - ids = append(ids, id) - } - } - if len(ids) > 0 { - tx.Model(&db.Proclamation{}).Where("id IN ?", ids).Update("selected", true) - logger.Debug("Saving selected state for %s\n", ids) - } - return nil - }) -} - -// Update advances the spinning-cursor animation. The cursor's *position* is -// recomputed in layout() whenever the highlighted row changes; this only -// drives the spin itself, frame to frame. -func (p *ProclamationOptions) Update(dt float32) error { - p.spinElapsed += dt - if p.spinElapsed >= 1.0 { - p.spinElapsed -= 1.0 - } - return nil -} - -func (p *ProclamationOptions) Render() error { - p.RenderTexture(p.textShader, p.title.ID, p.title.Position(), p.title.RawDimensions(), p.title.Color) - p.RenderTexture(p.textShader, p.prompt.ID, p.prompt.Position(), p.prompt.RawDimensions(), p.prompt.Color) - - if p.isSearchActive { - // Search bar label - if p.searchInput.ID != 0 { - p.RenderTexture(p.textShader, p.searchInput.ID, p.searchInput.Position(), p.searchInput.RawDimensions(), p.searchInput.Color) - } - - if len(p.searchItems) == 0 { - return nil - } - - for i := p.searchScrollOff; i < p.searchScrollOff+p.maxVisible && i < len(p.searchItems); i++ { - item := p.searchItems[i] - if p.activeMap[item.ID] { - item.Label.Color = colors.Orange - } else { - item.Label.Color = colors.White - } - p.RenderTexture(p.textShader, item.Label.ID, item.Label.Position(), item.Label.RawDimensions(), item.Label.Color) - } - - // Spinning triangle cursor — always on top, points at the highlighted result - p.renderCursor() - - p.renderSearchScrollbar() - return nil - } - - // Browse mode - if len(p.allItems) == 0 { - return nil - } - - for i := p.scrollOffset; i < p.scrollOffset+p.maxVisible && i < len(p.allItems); i++ { - item := p.allItems[i] - if item.IsCategory { - item.Label.Color = colors.DodgerBlue - } else if p.activeMap[item.ID] { - item.Label.Color = colors.Orange - } else { - item.Label.Color = colors.White - } - p.RenderTexture(p.textShader, item.Label.ID, item.Label.Position(), item.Label.RawDimensions(), item.Label.Color) - } - - // Spinning triangle cursor — always on top, points at the highlighted row - p.renderCursor() - - p.renderScrollbar() - return nil -} - -// renderCursor draws the spinning triangle cursor pointing at whichever row -// is currently highlighted, in either browse or search mode. Identical -// shape/animation to the cursor in AudioOptions. -func (p *ProclamationOptions) renderCursor() error { - if !p.triVisible { - return nil - } - - scaleY := float32(math.Cos(2 * math.Pi * float64(p.spinElapsed))) - - model := mgl32.Ident4() - model = model.Mul4(mgl32.Translate3D(p.triX+triangleSize/2, p.triCenterY, 0)) - model = model.Mul4(mgl32.Scale3D(triangleSize, triangleSize*scaleY, 1)) - mvp := p.Projection.Mul4(model) - - gl.UseProgram(p.solidShader.ProgramID) - gl.UniformMatrix4fv(p.solidShader.LocMVPMatrix, 1, false, &mvp[0]) - gl.Uniform4f( - p.solidShader.LocColor, - float32(colors.White.R)/255.0, - float32(colors.White.G)/255.0, - float32(colors.White.B)/255.0, - float32(colors.White.A)/255.0, - ) - - gl.BindVertexArray(p.triVAO) - gl.DrawArrays(gl.TRIANGLES, 0, 3) - gl.BindVertexArray(0) - gl.UseProgram(0) - - return nil -} - -func (p *ProclamationOptions) renderScrollbar() { - total := len(p.allItems) - if total <= p.maxVisible { - return - } - - p.RenderSolidColorQuad(p.solidShader, - mgl32.Vec2{float32(p.scrollTrackX), float32(p.scrollTrackY)}, - mgl32.Vec2{float32(p.scrollTrackWidth), float32(p.scrollTrackHeight)}, - colorScrollTrack) - - visibleRatio := float32(p.maxVisible) / float32(total) - scrollRatio := float32(p.scrollOffset) / float32(total) - thumbHeight := float32(p.scrollTrackHeight) * visibleRatio - if thumbHeight < 20 { - thumbHeight = 20 - } - thumbY := float32(p.scrollTrackY) + float32(p.scrollTrackHeight)*scrollRatio - - p.RenderSolidColorQuad(p.solidShader, - mgl32.Vec2{float32(p.scrollTrackX + 2), thumbY}, - mgl32.Vec2{float32(p.scrollTrackWidth - 4), thumbHeight}, - colorScrollThumb) -} - -func (p *ProclamationOptions) renderSearchScrollbar() { - total := len(p.searchItems) - if total <= p.maxVisible { - return - } - - p.RenderSolidColorQuad(p.solidShader, - mgl32.Vec2{float32(p.scrollTrackX), float32(p.scrollTrackY)}, - mgl32.Vec2{float32(p.scrollTrackWidth), float32(p.scrollTrackHeight)}, - colorScrollTrack) - - visibleRatio := float32(p.maxVisible) / float32(total) - scrollRatio := float32(p.searchScrollOff) / float32(total) - thumbHeight := float32(p.scrollTrackHeight) * visibleRatio - if thumbHeight < 20 { - thumbHeight = 20 - } - thumbY := float32(p.scrollTrackY) + float32(p.scrollTrackHeight)*scrollRatio - - p.RenderSolidColorQuad(p.solidShader, - mgl32.Vec2{float32(p.scrollTrackX + 2), thumbY}, - mgl32.Vec2{float32(p.scrollTrackWidth - 4), thumbHeight}, - colorScrollThumb) -} - -func (p *ProclamationOptions) Destroy() error { - gl.DeleteTextures(1, &p.title.ID) - gl.DeleteTextures(1, &p.prompt.ID) - if p.searchInput.ID != 0 { - gl.DeleteTextures(1, &p.searchInput.ID) - } - // Delete browse category textures - for _, item := range p.allItems { - if item.IsCategory { - gl.DeleteTextures(1, &item.Label.ID) - } - } - // Delete proclamation textures via the pool (single source of truth) - for _, item := range p.allProclamations { - gl.DeleteTextures(1, &item.Label.ID) - } - gl.DeleteBuffers(1, &p.triVBO) - gl.DeleteVertexArrays(1, &p.triVAO) - return nil -} diff --git a/archive/internal/page/router.go b/archive/internal/page/router.go deleted file mode 100644 index 340b667..0000000 --- a/archive/internal/page/router.go +++ /dev/null @@ -1,79 +0,0 @@ -package page - -import ( - "gorm.io/gorm" - "rhemawavetech.com/rhemawave/internal/download" -) - -type pageType int - -const ( - HOME pageType = iota - SCROLLER - SETTINGS - WIFI - AUDIO - PROCLAMATIONS - LICENSE_KEY - EMAIL_ADDRESS - LOGS - MESSAGE - DOWNLOADING_ASSETS -) - -type PageCtx struct { - DbPtr *gorm.DB - W, H int32 - MessageChannel chan download.DownloadStatus - DownloadDone chan struct{} // closed when downloads finish (success or failure) - NavigateTo func(pageType) - Msg string // Message to be displayed by MessagePage - Inst string // Instructions to be displayed on MessagePage -} - -type Router struct { - ActivePage Page - Context PageCtx -} - -func NewRouter(pgCtx PageCtx) *Router { - r := &Router{ - Context: pgCtx, - } - r.Context.NavigateTo = r.NavigateTo - r.NavigateTo(HOME) - return r -} - -func (r *Router) NavigateTo(p pageType) { - if r.ActivePage != nil { - r.ActivePage.Destroy() - } - - switch p { - case HOME: - r.ActivePage = &Home{} - case SCROLLER: - r.ActivePage = &ScrollerPage{} - case SETTINGS: - r.ActivePage = &Settings{} - case WIFI: - r.ActivePage = &WiFiSetupPage{} - case AUDIO: - r.ActivePage = &AudioOptions{} - case PROCLAMATIONS: - r.ActivePage = &ProclamationOptions{} - case LICENSE_KEY: - r.ActivePage = &LicenseKeyPage{} - case EMAIL_ADDRESS: - r.ActivePage = &EmailAddressPage{} - case LOGS: - r.ActivePage = &LogViewerPage{} - case MESSAGE: - r.ActivePage = &MessagePage{} - case DOWNLOADING_ASSETS: - r.ActivePage = &DownloadingAssets{} - } - - r.ActivePage.Init(&r.Context) -} diff --git a/archive/internal/page/scrollerPage.go b/archive/internal/page/scrollerPage.go deleted file mode 100644 index 406f79b..0000000 --- a/archive/internal/page/scrollerPage.go +++ /dev/null @@ -1,829 +0,0 @@ -package page - -import ( - "fmt" - "math" - "os" - "path/filepath" - "strings" - - "github.com/go-gl/gl/v3.3-core/gl" - "github.com/veandco/go-sdl2/sdl" - "rhemawavetech.com/rhemawave/internal/colors" - "rhemawavetech.com/rhemawave/internal/db" - "rhemawavetech.com/rhemawave/internal/fontManager" - "rhemawavetech.com/rhemawave/internal/logger" - "rhemawavetech.com/rhemawave/internal/mixer" - colorPattern "rhemawavetech.com/rhemawave/internal/pattern/color" - linePattern "rhemawavetech.com/rhemawave/internal/pattern/line" - "rhemawavetech.com/rhemawave/internal/shaderManager" -) - -const ( - defaultProclamationID string = "f3d42354-3197-45e4-9b08-be447f14386d" - proclamationPlayCount int = 20 - // defaultProclamationPlayCount int = 1 -) - -type ScrollerPage struct { - Base - - streams []proclamationStream - streamIndex int - - // currentAudioFreq is the solfeggio Hz value of whatever track is - // currently playing. 0 means "no specific frequency" (either the - // legacy Selected-AudioFile track, or nothing yet). - currentAudioFreq int - - IsDownloadingAudio bool - - lines *LineDeque - lineHeight int32 - shader *shaderManager.Shader - activePattern linePattern.LinePattern - font *fontManager.FontEntry - spaceWidth int32 - lineCtx *linePattern.LineRenderContext - colorPattern *colorPattern.ColorPattern - scrollOffset float32 - scrollY float32 - fontScale float32 - - // Reuse one GL texture per unique word. - wordTextureCache map[string]StringItem - - increment float32 - velocity float32 - - // Time-based velocity control - velocityFloor float32 - velocityCeiling float32 - velocityStep float32 - velocityDelay float32 - velocityDirection int - velocityTimer float32 - velocityState int - autoVelocity bool - hold float32 - - // Color palette settings - useColorPalettes bool -} - -type ScrollerProclamation struct { - db.Proclamation - colorPattern.ColorPattern - ColorPalette []sdl.Color -} - -type Line struct { - words []StringItem - width int32 - color sdl.Color // Color for the entire line -} - -// proclamationStream tracks one proclamation and how many full loops it should play. -type proclamationStream struct { - name string - words []string - repeat int - completed int - wordIndex int - palette []sdl.Color // Color palette for this proclamation - colorIndex int // Track which color to use next - - // frequencyValue is the solfeggio Hz value that should be audible while - // this stream plays. 0 means "no specific frequency" (the default - // proclamation) — entering this stream never changes the audio. - frequencyValue int -} - -// LineDeque is a double-ended queue for Line values backed by a circular buffer. -// It avoids O(n) prepend and prevents capacity leaks from head-of-slice removal. -type LineDeque struct { - buf []Line - head, tail int - count int -} - -func NewLineDeque(cap int) *LineDeque { - if cap < 4 { - cap = 4 - } - return &LineDeque{buf: make([]Line, cap)} -} - -func (d *LineDeque) Len() int { return d.count } - -func (d *LineDeque) grow() { - newCap := len(d.buf) * 2 - newBuf := make([]Line, newCap) - for i := 0; i < d.count; i++ { - newBuf[i] = d.buf[(d.head+i)%len(d.buf)] - } - d.buf = newBuf - d.head = 0 - d.tail = d.count -} - -func (d *LineDeque) PushBack(l Line) { - if d.count == len(d.buf) { - d.grow() - } - d.buf[d.tail] = l - d.tail = (d.tail + 1) % len(d.buf) - d.count++ -} - -func (d *LineDeque) PushFront(l Line) { - if d.count == len(d.buf) { - d.grow() - } - d.head = (d.head - 1 + len(d.buf)) % len(d.buf) - d.buf[d.head] = l - d.count++ -} - -func (d *LineDeque) PopFront() Line { - l := d.buf[d.head] - d.buf[d.head] = Line{} // clear for GC - d.head = (d.head + 1) % len(d.buf) - d.count-- - return l -} - -func (d *LineDeque) PopBack() Line { - d.tail = (d.tail - 1 + len(d.buf)) % len(d.buf) - l := d.buf[d.tail] - d.buf[d.tail] = Line{} // clear for GC - d.count-- - return l -} - -func (d *LineDeque) Front() Line { return d.buf[d.head] } - -func (d *LineDeque) Back() Line { - return d.buf[(d.tail-1+len(d.buf))%len(d.buf)] -} - -func (d *LineDeque) Get(i int) *Line { - return &d.buf[(d.head+i)%len(d.buf)] -} - -func (d *LineDeque) Set(i int, l Line) { - d.buf[(d.head+i)%len(d.buf)] = l -} - -func (d *LineDeque) Clear() { - for i := 0; i < d.count; i++ { - d.buf[(d.head+i)%len(d.buf)] = Line{} - } - d.head, d.tail, d.count = 0, 0, 0 -} - -func (p *ScrollerPage) Init(ctx *PageCtx) error { - p.increment = 100.0 - p.velocity = 800.0 - p.SetAutoVelocity(true, -4000.0, 4000.0, 100.0, 2.5, 6.0) - p.useColorPalettes = true // Default to enabled - - sm := shaderManager.Get() - fm := fontManager.Get() - - // Get font - font, ok := fm.GetFont("Scrambled") - if !ok { - return fmt.Errorf("unable to fetch font 'Scrambled'") - } - p.font = font - logger.Debug("Font name: %s", font.Name) - - p.Base.Init(ctx) - - // Get text shader - textShader, ok := sm.Get("text") - if !ok { - return fmt.Errorf("text shader not found") - } - p.shader = textShader - logger.Debug("Shader Program ID: %d", textShader.ProgramID) - logger.Debug("p.shader Program ID: %d", p.shader.ProgramID) - - p.wordTextureCache = make(map[string]StringItem, 256) - - // Load proclamation streams from database - if err := p.loadProclamations(); err != nil { - logger.Error("Error loading proclamations: %v", err) - p.Context.NavigateTo(PROCLAMATIONS) - return nil - } - - // Get line pattern from database - config, err := db.GetConfig(p.Context.DbPtr) - if err != nil { - panic(err) - } - linePatternName := config.LinePattern - - p.activePattern, err = linePattern.Get(linePatternName) - if err != nil { - return fmt.Errorf("failed to load line pattern '%s': %w", linePatternName, err) - } - - // Initialize color pattern - baseColors := []sdl.Color{ - colors.Tan, - colors.PeachPuff, - colors.LightCyan, - colors.LightSeaGreen, - colors.MediumTurquoise, - colors.DarkCyan, - colors.MediumPurple, - } - p.colorPattern = colorPattern.NewColorPattern(baseColors) - - // Font setup - p.fontScale = float32(32) / float32(config.StandardFontSize) - nLines := int(float32(p.ScreenHeight) / 32.0) - p.lines = NewLineDeque(nLines * 2) - - // Calculate space width using the scale ratio. - spaceGlyph, ok := fm.GetGlyph(font.Name, ' ') - if !ok { - return fmt.Errorf("failed to get space glyph") - } - p.spaceWidth = int32(float32(spaceGlyph.Advance) * p.fontScale) - - // Get line height using the same scale ratio. - tmpStrID, _, H, err := fm.NewCreateStringTexture(font.Name, "Mg", p.shader) - if err != nil { - return fmt.Errorf("failed to create sample texture: %w", err) - } - p.lineHeight = int32(float32(H) * p.fontScale) - gl.DeleteTextures(1, &tmpStrID) - - p.lineCtx = &linePattern.LineRenderContext{ - ScreenWidth: p.ScreenWidth, - Font: font, - FontScale: p.fontScale, - SpaceWidth: p.spaceWidth, - } - - // Create initial lines - for i := range nLines { - if err := p.createLine(i, true); err != nil { - return fmt.Errorf("failed to create initial line %d: %w", i, err) - } - } - - // Start audio for the first proclamation in the stream. Falls back to - // whatever track is marked Selected in the audio_files table if the - // first stream has no frequency (e.g. only the default proclamation is - // queued) or its matching file doesn't exist on disk yet. - started := false - if len(p.streams) > 0 { - started = p.switchAudioForFrequency(p.streams[0].frequencyValue) - } - - if !started { - var selectedAudio db.AudioFile - if err := p.Context.DbPtr.Where("Selected = ?", true).First(&selectedAudio).Error; err != nil { - selectedAudio = db.AudioFile{ - Name: "OriginalAudio", - Path: filepath.Join(audioDir(), "OriginalAudio.wav"), - Selected: true, - Order: 0, - } - } - - logger.Info("Playing %s", selectedAudio.Path) - if err := mixer.Play(selectedAudio.Path, true, config); err != nil { - logger.Error("Mixer returned an error: %v", err) - } - } - - currentDevice := mixer.CurrentDevice() - if currentDevice == "" { - currentDevice = "default" - } - _ = currentDevice - - return nil -} - -// loadProclamations loads selected proclamations as stream units (not one giant word list). -func (p *ScrollerPage) loadProclamations() error { - p.streams = p.streams[:0] - - // Load all solfeggio frequencies - var frequencies []db.SolfeggioFrequency - if err := p.Context.DbPtr.Find(&frequencies).Error; err != nil { - return fmt.Errorf("failed to load solfeggio frequencies: %v", err) - } - - // Create a map of frequency ID to its value - frequencyMap := make(map[int]int) - for _, freq := range frequencies { - frequencyMap[freq.ID] = freq.Value - } - - // Map frequency values to their color palettes - frequencyPalettes := map[int][]sdl.Color{ - 396: colors.Freq_396_Colors[:], - 417: colors.Freq_417_Colors[:], - 528: colors.Freq_528_Colors[:], // Default proclamation - 639: colors.Freq_639_Colors[:], - 741: colors.Freq_741_Colors[:], - 852: colors.Freq_852_Colors[:], - 963: colors.Freq_963_Colors[:], - } - - // Load selected proclamations - var selected []db.Proclamation - if err := p.Context.DbPtr.Where("selected = ?", true).Find(&selected).Error; err != nil { - return err - } - - // Load default proclamation - var defaultProclamation db.Proclamation - if err := p.Context.DbPtr.Where("ID = ?", defaultProclamationID).First(&defaultProclamation).Error; err != nil { - return err - } - defaultWords := strings.Fields(defaultProclamation.Content) - - // Build interleaved stream: - // each selected proclamation repeats N times, then default repeats once. - for _, proclamation := range selected { - // Skip default proclamation if it's in the selected list - if proclamation.ID == defaultProclamationID { - continue - } - - words := strings.Fields(proclamation.Content) - if len(words) == 0 { - continue - } - - // Get the frequency value for this proclamation - freqValue, ok := frequencyMap[proclamation.SolfeggioFrequencyID] - if !ok { - logger.Warning("No frequency found for proclamation %s, using default palette", proclamation.Name) - freqValue = 528 // Default to 528 if frequency not found - } - - // Get the palette for this frequency - palette, ok := frequencyPalettes[freqValue] - if !ok { - logger.Warning("No palette found for frequency %d, using default palette", freqValue) - palette = colors.Freq_528_Colors[:] // Fallback to default palette - } - - p.streams = append(p.streams, proclamationStream{ - name: proclamation.Name, - words: words, - repeat: proclamationPlayCount, - palette: palette, - colorIndex: 0, - frequencyValue: freqValue, - }) - - if len(defaultWords) > 0 { - p.streams = append(p.streams, proclamationStream{ - name: defaultProclamation.Name, - words: defaultWords, - repeat: 1, - palette: colors.Freq_528_Colors[:], // Default palette for default proclamation - colorIndex: 0, - frequencyValue: 0, // default proclamation never changes audio - }) - } - } - - // Fallback: if no non-default selected proclamations, at least play default. - if len(p.streams) == 0 && len(defaultWords) > 0 { - p.streams = append(p.streams, proclamationStream{ - name: defaultProclamation.Name, - words: defaultWords, - repeat: 1, - palette: colors.Freq_528_Colors[:], - colorIndex: 0, - frequencyValue: 0, - }) - } - - if len(p.streams) == 0 { - return fmt.Errorf("failed to load proclamations: no proclamation words available") - } - - p.streamIndex = 0 - return nil -} - -// audioDir returns the path to the audio files directory (~/.radiantwave/audio). -func audioDir() string { - home, err := os.UserHomeDir() - if err != nil { - panic(err) - } - return filepath.Join(home, ".radiantwave", "audio") -} - -// audioPathForFrequency builds the on-disk path for a solfeggio frequency's -// audio file, e.g. 396 -> "~/.radiantwave/audio/396Hz.wav". -func (p *ScrollerPage) audioPathForFrequency(freq int) string { - return filepath.Join(audioDir(), fmt.Sprintf("%dHz.wav", freq)) -} - -// switchAudioForFrequency swaps the currently playing track to match freq. -// -// - freq == 0 ("no specific frequency", i.e. the default proclamation) is -// always a no-op — whatever is playing keeps playing. -// - If freq already matches what's playing, nothing happens. -// - If the matching file doesn't exist on disk yet, the current track -// keeps playing untouched (so missing solfeggio audio never causes -// silence — and starts working automatically once the file is added). -// - Otherwise the current track is stopped immediately and freq's track -// starts right away (hard cut, no fade). -// -// Returns true if freq's track is now playing (either just switched, or -// already was). -func (p *ScrollerPage) switchAudioForFrequency(freq int) bool { - if freq == 0 { - return false - } - if freq == p.currentAudioFreq { - return true - } - - path := p.audioPathForFrequency(freq) - if _, err := os.Stat(path); err != nil { - logger.Warning("No audio file for %dHz (%s), keeping current track", freq, path) - return false - } - config, err := db.GetConfig(p.Context.DbPtr) - if err != nil { - panic(err) - } - mixer.Stop(3000, config) - if err := mixer.Play(path, true, config); err != nil { - logger.Error("Mixer returned an error: %v", err) - return false - } - p.currentAudioFreq = freq - return true -} - -// nextLineWords gets words for the current line and advances proclamation stream when needed. -func (p *ScrollerPage) nextLineWords() []string { - if len(p.streams) == 0 { - return nil - } - - s := &p.streams[p.streamIndex] - if len(s.words) == 0 { - p.streamIndex = (p.streamIndex + 1) % len(p.streams) - return p.nextLineWords() - } - - prevIdx := s.wordIndex - words := p.activePattern.GetNextLine(s.words, &s.wordIndex, p.lineCtx) - - // If word index wraps, one full proclamation pass completed. - if s.wordIndex < prevIdx { - s.completed++ - targetRepeats := s.repeat - if targetRepeats <= 0 { - targetRepeats = 1 - } - if s.completed >= targetRepeats { - s.completed = 0 - s.wordIndex = 0 - s.colorIndex = 0 // Reset color index when proclamation restarts - p.streamIndex = (p.streamIndex + 1) % len(p.streams) - next := &p.streams[p.streamIndex] - p.switchAudioForFrequency(next.frequencyValue) - } - } - - return words -} - -// getCachedWord returns a reusable textured StringItem for a word. -func (p *ScrollerPage) getCachedWord(word string) (StringItem, error) { - if cached, ok := p.wordTextureCache[word]; ok { - return cached, nil - } - - base, err := NewStringItem(word, p.font, sdl.Color{R: 255, G: 255, B: 255, A: 255}) - if err != nil { - return StringItem{}, fmt.Errorf("unable to create cached string item for word '%s': %w", word, err) - } - base.Scale(p.fontScale) - - p.wordTextureCache[word] = base - return base, nil -} - -// buildLineFromWords builds a line using cached texture IDs and per-instance color/position. -func (p *ScrollerPage) buildLineFromWords(words []string) (Line, error) { - line := Line{words: []StringItem{}} - - // Get the current proclamation's color palette - var lineColor sdl.Color - if p.useColorPalettes && len(p.streams) > 0 { - currentStream := &p.streams[p.streamIndex] - if len(currentStream.palette) > 0 { - // Use the current color from the palette - lineColor = currentStream.palette[currentStream.colorIndex] - // Increment color index for the next line - currentStream.colorIndex = (currentStream.colorIndex + 1) % len(currentStream.palette) - } else { - // Fallback to default color if no palette is found - lineColor = p.colorPattern.Color() - } - } else { - // Use the default color pattern if useColorPalettes is false - lineColor = p.colorPattern.Color() - } - - for _, word := range words { - if word == "" { - continue - } - - cached, err := p.getCachedWord(word) - if err != nil { - return Line{}, err - } - - item := cached - item.Color = lineColor // Assign the line color to all words in the line - - if len(line.words) > 0 { - line.width += p.spaceWidth - } - line.words = append(line.words, item) - line.width += item.W - } - - line.color = lineColor // Store the line color - return line, nil -} - -// positionLine sets X/Y for all words in a line. -func (p *ScrollerPage) positionLine(line *Line, baseY int32) { - startX := p.ScreenCenterX - (line.width / 2) - for j := range line.words { - if j == 0 { - line.words[j].X = startX - } else { - line.words[j].X = line.words[j-1].X + line.words[j-1].W + p.spaceWidth - } - line.words[j].Y = baseY - } -} - -// createLine is a helper to generate a new line at a specific index. -// isInitial determines if this is during initialization (positions from top) -// or dynamic creation (positions relative to existing lines). -func (p *ScrollerPage) createLine(index int, isInitial bool) error { - words := p.nextLineWords() - line, err := p.buildLineFromWords(words) - if err != nil { - return err - } - - var baseY int32 - if isInitial { - baseY = int32(p.scrollY) - int32(p.lineHeight)*int32(index) - } else { - baseY = 0 - } - p.positionLine(&line, baseY) - - if isInitial { - p.lines.PushBack(line) - } - - return nil -} - -func (p *ScrollerPage) HandleEvent(event *sdl.Event) error { - // switch e := (*event).(type) { - // case *sdl.KeyboardEvent: - // if e.Type != sdl.KEYDOWN { - // return nil - // } - // switch e.Keysym.Sym { - // case sdl.K_UP: - // if !p.autoVelocity { - // p.velocity += p.increment - // } - // case sdl.K_DOWN: - // if !p.autoVelocity { - // p.velocity -= p.increment - // } - // } - // return nil - // } - return nil -} - -func (p *ScrollerPage) Update(dt float32) error { - p.updateAutoVelocity(dt) - - p.scrollOffset += dt * p.velocity - move := int32(p.scrollOffset) - if move == 0 { - return nil - } - p.scrollOffset -= float32(move) - - // Move all lines vertically - for i := 0; i < p.lines.Len(); i++ { - line := p.lines.Get(i) - for j := range line.words { - line.words[j].Y += move - } - } - - // Handle line replacement based on scroll direction - if p.velocity > 0 { - // Moving up: replace lines that go off top - for p.lines.Len() > 0 && len(p.lines.Front().words) > 0 && p.lines.Front().words[0].Y > p.ScreenHeight { - _ = p.lines.PopFront() // no texture delete; textures are shared via cache - - if err := p.addLineAtBottom(); err != nil { - return err - } - } - } else if p.velocity < 0 { - // Moving down: replace lines that go off bottom - for p.lines.Len() > 0 { - back := p.lines.Back() - if len(back.words) == 0 || back.words[0].Y+back.words[0].H >= 0 { - break - } - - _ = p.lines.PopBack() // no texture delete; textures are shared via cache - - if err := p.addLineAtTop(); err != nil { - return err - } - } - } - - return nil -} - -// addLineAtBottom creates and adds a new line at the bottom of the display. -func (p *ScrollerPage) addLineAtBottom() error { - newWords := p.nextLineWords() - line, err := p.buildLineFromWords(newWords) - if err != nil { - return err - } - - var baseY int32 - if p.lines.Len() > 0 && len(p.lines.Back().words) > 0 { - baseY = p.lines.Back().words[0].Y - int32(p.lineHeight) - } else { - baseY = -int32(p.lineHeight) - } - - p.positionLine(&line, baseY) - p.lines.PushBack(line) - return nil -} - -// addLineAtTop creates and adds a new line at the top of the display. -func (p *ScrollerPage) addLineAtTop() error { - newWords := p.nextLineWords() - line, err := p.buildLineFromWords(newWords) - if err != nil { - return err - } - - var baseY int32 - if p.lines.Len() > 0 && len(p.lines.Front().words) > 0 { - baseY = p.lines.Front().words[0].Y + int32(p.lineHeight) - } else { - baseY = p.ScreenHeight - } - - p.positionLine(&line, baseY) - p.lines.PushFront(line) - return nil -} - -func (p *ScrollerPage) Render() error { - p.BeginTextureRendering(p.shader) - for i := 0; i < p.lines.Len(); i++ { - line := p.lines.Get(i) - for _, word := range line.words { - if word.ID == 0 { - continue - } - p.RenderTextureBatched(p.shader, word.ID, word.Position(), word.RawDimensions(), word.Color) - } - } - p.EndTextureRendering() - return nil -} - -func (p *ScrollerPage) Destroy() error { - if p == nil { - return nil - } - - config, err := db.GetConfig(p.Context.DbPtr) - if err != nil { - panic(err) - } - - mixer.Stop(3000, config) - - // Delete each cached word texture exactly once. - for k, item := range p.wordTextureCache { - if item.ID != 0 { - id := item.ID - gl.DeleteTextures(1, &id) - } - delete(p.wordTextureCache, k) - } - - gl.Flush() - - // Drop references for GC safely - p.lines = nil - p.streams = nil - p.lineCtx = nil - p.font = nil - p.shader = nil - p.colorPattern = nil - p.wordTextureCache = nil - - return nil -} - -// SetAutoVelocity configures the automatic velocity system for the ScrollerPage. -func (p *ScrollerPage) SetAutoVelocity(enabled bool, floor, ceiling, step, delay, hold float32) { - p.autoVelocity = enabled - p.velocityFloor = -float32(math.Abs(float64(floor))) - p.velocityCeiling = float32(math.Abs(float64(ceiling))) - p.velocityStep = float32(math.Abs(float64(step))) - p.velocityDelay = delay - p.hold = hold - - // Initialize state - p.velocityDirection = 1 - p.velocityState = 0 - p.velocityTimer = 0.0 - p.velocity = p.velocityFloor -} - -// SetUseColorPalettes enables or disables the use of color palettes for lines. -func (p *ScrollerPage) SetUseColorPalettes(enabled bool) { - p.useColorPalettes = enabled -} - -// updateAutoVelocity handles automatic velocity changes. -func (p *ScrollerPage) updateAutoVelocity(dt float32) { - if !p.autoVelocity { - return - } - - switch p.velocityState { - case 0: // Changing velocity - if p.velocityDirection == 1 { - p.velocity += p.velocityStep * dt - if p.velocity >= p.velocityCeiling { - p.velocity = p.velocityCeiling - p.velocityState = 1 - p.velocityTimer = 0.0 - } - } else { - p.velocity -= p.velocityStep * dt - if p.velocity <= p.velocityFloor { - p.velocity = p.velocityFloor - p.velocityState = 2 - p.velocityTimer = 0.0 - } - } - - case 1: // Waiting at ceiling - p.velocityTimer += dt - if p.velocityTimer >= p.velocityDelay { - p.velocityDirection = -1 - p.velocityState = 0 - } - - case 2: // Waiting at floor - p.velocityTimer += dt - if p.velocityTimer >= p.velocityDelay { - p.velocityDirection = 1 - p.velocityState = 0 - } - } -} diff --git a/archive/internal/page/settingsPage.go b/archive/internal/page/settingsPage.go deleted file mode 100644 index 93eb689..0000000 --- a/archive/internal/page/settingsPage.go +++ /dev/null @@ -1,255 +0,0 @@ -package page - -import ( - "fmt" - - "github.com/go-gl/gl/v3.3-core/gl" - "github.com/veandco/go-sdl2/sdl" - "rhemawavetech.com/rhemawave/internal/colors" - "rhemawavetech.com/rhemawave/internal/db" - "rhemawavetech.com/rhemawave/internal/fontManager" - "rhemawavetech.com/rhemawave/internal/shaderManager" -) - -type Settings struct { - Base - font *fontManager.FontEntry - textShader *shaderManager.Shader - solidShader *shaderManager.Shader - title StringItem - prompt StringItem - options []Option - verticalMargin int32 - bgHighlight PageItem - highlightIndex int -} - -type Option struct { - target pageType - label StringItem -} - -func (p *Settings) Init(ctx *PageCtx) error { - var err error - p.verticalMargin = int32(20) - - // Get font - fm := fontManager.Get() - font, ok := fm.GetFont("SpaceMonoNerdFont-Regular") - if !ok { - return fmt.Errorf("failed to get font: %w", sdl.GetError()) - } - p.font = font - - // Get shaders - sm := shaderManager.Get() - p.textShader, ok = sm.Get("text") - if !ok { - return fmt.Errorf("failed to get text shader: %w", sdl.GetError()) - } - p.solidShader, ok = sm.Get("solid_color") - if !ok { - return fmt.Errorf("failed to get solid_color shader: %w", sdl.GetError()) - } - - // Initialize base - if err := p.Base.Init(ctx); err != nil { - return fmt.Errorf("failed base page initialization: %w", err) - } - - // Create title - p.title, err = NewStringItem("Settings", font, colors.White) - if err != nil { - return fmt.Errorf("creating title: %w", err) - } - - // Create prompt - p.prompt, err = NewStringItem("Use Arrow Keys to Navigate, Enter to Select, or F1 to return to the Main Menu", font, colors.White) - if err != nil { - return fmt.Errorf("creating prompt: %w", err) - } - - // Create options - proclamationsLabel, err := NewStringItem("Proclamations", font, colors.White) - if err != nil { - return fmt.Errorf("creating proclamations label: %w", err) - } - p.options = append(p.options, Option{ - target: PROCLAMATIONS, - label: proclamationsLabel, - }) - - audioLabel, err := NewStringItem("Audio", font, colors.White) - if err != nil { - return fmt.Errorf("creating audio label: %w", err) - } - p.options = append(p.options, Option{ - target: AUDIO, - label: audioLabel, - }) - - emailLabel, err := NewStringItem("Email Address", font, colors.White) - if err != nil { - return fmt.Errorf("creating email label: %w", err) - } - p.options = append(p.options, Option{ - target: EMAIL_ADDRESS, - label: emailLabel, - }) - - licenseKeyLabel, err := NewStringItem("License Key", font, colors.White) - if err != nil { - return fmt.Errorf("creating license key label: %w", err) - } - p.options = append(p.options, Option{ - target: LICENSE_KEY, - label: licenseKeyLabel, - }) - - wifiLabel, err := NewStringItem("WiFi Setup", font, colors.White) - if err != nil { - return fmt.Errorf("creating wifi label: %w", err) - } - p.options = append(p.options, Option{ - target: WIFI, - label: wifiLabel, - }) - - logsLabel, err := NewStringItem("Logs", font, colors.White) - if err != nil { - return fmt.Errorf("creating logs label: %w", err) - } - p.options = append(p.options, Option{ - target: LOGS, - label: logsLabel, - }) - - // TODO: Add entry for "Tutorial" - // TODO: Add entry for "About" - - // Get base font size from database - config, err := db.GetConfig(p.Context.DbPtr) - if err != nil { - panic(err) - } - baseFontSize := config.StandardFontSize - - // Calculate scales - titleScale := float32(64) / float32(baseFontSize) - scale := float32(32) / float32(baseFontSize) - - // Apply scales - p.title.Scale(titleScale) - p.prompt.Scale(scale) - - for i := range p.options { - p.options[i].label.Scale(scale) - } - - // Create background highlight using first option's dimensions - p.bgHighlight = PageItem{ - ID: 0, - W: p.options[0].label.W, - H: p.options[0].label.H, - Color: colors.White, - } - p.highlightIndex = 0 - - // Position title at bottom - p.title.X = p.Base.ScreenCenterX - p.title.W/2 - p.title.Y = p.ScreenHeight - p.title.H - p.verticalMargin*2 - - // Position prompt above title - p.prompt.X = p.ScreenCenterX - int32(float32(p.prompt.W)/2) - p.prompt.Y = p.title.Y - p.verticalMargin*2 - p.prompt.H - - // If this value starts at 0, nothing is further left than that... - var furthestLeft int32 = p.ScreenWidth // < more to the left, > more to the right, = same - - // Position options stacked upward from prompt - for i, option := range p.options { - p.options[i].label.X = p.ScreenCenterX - option.label.W/2 // Calculate horizontal center posision - if p.options[i].label.X < furthestLeft { // If the left most pixel is further left than furthestLeft, indicating a longer line: - // Then update furthestLeft - furthestLeft = p.options[i].label.X - } - if i == 0 { - p.options[i].label.Y = int32(float32(p.prompt.Y)-(float32(p.verticalMargin)*4)) - option.label.H - } else { - p.options[i].label.Y = p.options[i-1].label.Y - option.label.H - 10 - } - } - - // Now, re-iterate over the options and justify everything to furthestLeft - for i := range p.options { - p.options[i].label.X = furthestLeft - } - - return nil -} - -func (p *Settings) Update(dt float32) error { - return nil -} - -func (p *Settings) HandleEvent(event *sdl.Event) error { - switch e := (*event).(type) { - case *sdl.KeyboardEvent: - if e.Type != sdl.KEYDOWN { - return nil - } - switch e.Keysym.Sym { - case sdl.K_UP: - if p.highlightIndex > 0 { - p.highlightIndex-- - } - case sdl.K_DOWN: - if p.highlightIndex < len(p.options)-1 { - p.highlightIndex++ - } - case sdl.K_RETURN: - if p.highlightIndex >= 0 && p.highlightIndex < len(p.options) { - selectedOption := p.options[p.highlightIndex] - p.Context.NavigateTo(selectedOption.target) - } - case sdl.K_BACKSPACE: - p.Context.NavigateTo(HOME) - } - } - return nil -} - -func (p *Settings) Render() error { - // Render title and prompt - p.RenderTexture(p.textShader, p.title.ID, p.title.Position(), p.title.RawDimensions(), p.title.Color) - p.RenderTexture(p.textShader, p.prompt.ID, p.prompt.Position(), p.prompt.RawDimensions(), p.prompt.Color) - - // Render highlight behind selected option - p.RenderSolidColorQuad( - p.solidShader, - p.options[p.highlightIndex].label.Position(), - p.options[p.highlightIndex].label.RawDimensions(), - p.bgHighlight.Color, - ) - - // Render all options with appropriate colors - for i, option := range p.options { - if p.highlightIndex == i { - option.label.Color = colors.Black - } else { - option.label.Color = colors.White - } - p.RenderTexture(p.textShader, option.label.ID, option.label.Position(), option.label.RawDimensions(), option.label.Color) - } - - return nil -} - -func (p *Settings) Destroy() error { - gl.DeleteTextures(1, &p.title.ID) - gl.DeleteTextures(1, &p.prompt.ID) - for i := range p.options { - gl.DeleteTextures(1, &p.options[i].label.ID) - } - return nil -} diff --git a/archive/internal/page/wifiSetupPage.go b/archive/internal/page/wifiSetupPage.go deleted file mode 100644 index e6755d4..0000000 --- a/archive/internal/page/wifiSetupPage.go +++ /dev/null @@ -1,656 +0,0 @@ -package page - -import ( - "context" - "fmt" - - "github.com/go-gl/gl/v3.3-core/gl" - "github.com/go-gl/mathgl/mgl32" - "github.com/veandco/go-sdl2/sdl" - "rhemawavetech.com/rhemawave/internal/colors" - "rhemawavetech.com/rhemawave/internal/db" - "rhemawavetech.com/rhemawave/internal/fontManager" - "rhemawavetech.com/rhemawave/internal/keybinds" - "rhemawavetech.com/rhemawave/internal/logger" - "rhemawavetech.com/rhemawave/internal/network" - "rhemawavetech.com/rhemawave/internal/shaderManager" -) - -// ─── Types ──────────────────────────────────────────────────────────────────── - -type networkUIItem struct { - Net network.Network - Item StringItem -} - -type wifiStatus int - -const ( - wifiStatusIdle wifiStatus = iota - wifiStatusScanning - wifiStatusConnecting - wifiStatusConnected - wifiStatusError -) - -// ─── Page ───────────────────────────────────────────────────────────────────── - -type WiFiSetupPage struct { - Base - applicationFont *fontManager.FontEntry - textInputSupported bool - - // UI elements - BaseFontSize int - titleItem StringItem - scanningStatusItem StringItem - connectionStatusItem StringItem - passwordPromptItem StringItem - passwordInputItem StringItem - passwordErrorItem StringItem - - // Network list - networks []networkUIItem - selectedIndex int - - // State - status wifiStatus - statusMessage string - connectError string - - // Password entry - isEnteringPassword bool - passwordInput string - selectedNet *network.Network - - // Async - ctx context.Context - cancel context.CancelFunc - networksCh <-chan []network.Network - connectResultCh chan error -} - -// ─── Init ───────────────────────────────────────────────────────────────────── - -func (p *WiFiSetupPage) Init(ctx *PageCtx) error { - if err := p.Base.Init(ctx); err != nil { - return fmt.Errorf("failed base page initialization: %w", err) - } - - fm := fontManager.Get() - font, ok := fm.GetFont("SpaceMonoNerdFont-Regular") - if !ok { - return fmt.Errorf("could not find application font: SpaceMonoNerdFont-Regular") - } - p.applicationFont = font - - var err error - - p.titleItem, err = NewStringItem("Wi-Fi Setup", p.applicationFont, colors.White) - if err != nil { - return fmt.Errorf("failed to create title item: %w", err) - } - p.scanningStatusItem, err = NewStringItem("Scanning for networks...", p.applicationFont, colors.White) - if err != nil { - return fmt.Errorf("failed to create scanning status item: %w", err) - } - - config, err := db.GetConfig(p.Context.DbPtr) - if err != nil { - panic(err) - } - p.BaseFontSize = config.StandardFontSize - - p.connectResultCh = make(chan error, 1) - p.ctx, p.cancel = context.WithCancel(context.Background()) - - p.startScan() - - return nil -} - -// ─── Scan ───────────────────────────────────────────────────────────────────── - -func (p *WiFiSetupPage) startScan() { - p.status = wifiStatusScanning - p.statusMessage = "Scanning for networks..." - p.selectedIndex = 0 - p.networks = nil - - var err error - p.networksCh, err = network.ScanForNetworks(p.ctx) - if err != nil { - logger.Error("wifi: failed to start scan: %v", err) - p.status = wifiStatusError - p.statusMessage = "Failed to start network scan. No Wi-Fi adapter found." - p.rebuildStatusTexture() - } -} - -// ─── Input ──────────────────────────────────────────────────────────────────── - -func (p *WiFiSetupPage) HandleEvent(event *sdl.Event) error { - switch e := (*event).(type) { - case *sdl.KeyboardEvent: - if e.Type != sdl.KEYDOWN { - return nil - } - if p.isEnteringPassword { - p.handlePasswordKey(e.Keysym.Sym, e.Keysym.Mod) - } else { - p.handleNavKey(e.Keysym.Sym) - } - } - return nil -} - -func (p *WiFiSetupPage) handlePasswordKey(sym sdl.Keycode, mod uint16) { - switch sym { - case sdl.K_RETURN: - p.submitPassword() - case sdl.K_BACKSPACE: - if len(p.passwordInput) > 0 { - runes := []rune(p.passwordInput) - p.passwordInput = string(runes[:len(runes)-1]) - p.rebuildPasswordTexture() - } - case sdl.K_ESCAPE: - logger.Info("wifi: password entry cancelled") - p.exitPasswordMode() - p.connectError = "" - p.selectedNet = nil - default: - if ch := keycodeToChar(sym, mod); ch != 0 { - p.passwordInput += string(ch) - p.rebuildPasswordTexture() - } - } -} - -func (p *WiFiSetupPage) handleNavKey(sym sdl.Keycode) { - switch sym { - case sdl.K_UP: - if p.selectedIndex > 0 { - p.selectedIndex-- - } - - case sdl.K_DOWN: - if p.selectedIndex < len(p.networks)-1 { - p.selectedIndex++ - } - - case sdl.K_RETURN: - if len(p.networks) == 0 || p.selectedIndex >= len(p.networks) { - return - } - selected := p.networks[p.selectedIndex].Net - p.selectedNet = &p.networks[p.selectedIndex].Net - p.connectError = "" - - if selected.Security != network.SecurityOpen { - p.passwordInput = "" - p.enterPasswordMode() - } else { - p.connect("") - } - - case sdl.K_r: - if p.status != wifiStatusScanning && p.status != wifiStatusConnecting { - p.startScan() - } - - case sdl.K_ESCAPE: - p.selectedNet = nil - p.connectError = "" - } -} - -func (p *WiFiSetupPage) enterPasswordMode() { - keybinds.Enabled = false - p.isEnteringPassword = true - p.textInputSupported = false - p.rebuildPasswordPromptTexture() - p.rebuildPasswordTexture() - sdl.StartTextInput() - logger.Info("wifi: password entry started for %q", p.selectedNet.SSID) -} - -func (p *WiFiSetupPage) exitPasswordMode() { - keybinds.Enabled = true - p.isEnteringPassword = false - sdl.StopTextInput() - logger.Info("wifi: password entry finished") -} - -func (p *WiFiSetupPage) submitPassword() { - p.exitPasswordMode() - p.connect(p.passwordInput) -} - -// ─── Connect ────────────────────────────────────────────────────────────────── - -func (p *WiFiSetupPage) connect(password string) { - if p.selectedNet == nil { - return - } - - ssid := p.selectedNet.SSID - p.status = wifiStatusConnecting - p.statusMessage = fmt.Sprintf("Connecting to \"%s\"...", ssid) - logger.Info("wifi: connecting to %q", ssid) - - // ConnectToNetwork blocks (up to 15s), so run it off the main goroutine. - go func() { - err := network.ConnectToNetwork(ssid, password) - p.connectResultCh <- err - }() -} - -// ─── Update ─────────────────────────────────────────────────────────────────── - -func (p *WiFiSetupPage) Update(dt float32) error { - select { - - case nets, ok := <-p.networksCh: - if ok { - p.rebuildNetworkList(nets) - if p.status == wifiStatusScanning { - p.status = wifiStatusIdle - p.statusMessage = "" - } - } - - case err := <-p.connectResultCh: - if err != nil { - logger.Error("wifi: connection failed: %v", err) - p.status = wifiStatusError - p.connectError = fmt.Sprintf("Could not connect: %s", err.Error()) - p.statusMessage = "" - - // Drop back into password entry so the user can try again. - if p.selectedNet != nil && p.selectedNet.Security != network.SecurityOpen { - p.passwordInput = "" - p.rebuildPasswordErrorTexture() - p.enterPasswordMode() - } - } else { - ssid := "" - if p.selectedNet != nil { - ssid = p.selectedNet.SSID - } - logger.Info("wifi: connected to %q", ssid) - p.status = wifiStatusConnected - p.connectError = "" - p.statusMessage = fmt.Sprintf("Connected to \"%s\" — press F1 to continue", ssid) - // Trigger a fresh scan so the list shows the connected state. - p.startScan() - } - - default: - } - - p.rebuildStatusTexture() - - return nil -} - -// ─── Texture builders ───────────────────────────────────────────────────────── - -func (p *WiFiSetupPage) rebuildNetworkList(nets []network.Network) { - for _, item := range p.networks { - if item.Item.ID != 0 { - gl.DeleteTextures(1, &item.Item.ID) - } - } - - p.networks = make([]networkUIItem, 0, len(nets)) - - for _, n := range nets { - label := n.SSID - switch { - case n.Connected: - label += " ✓" - case n.Saved: - label += " ★" - } - if n.Security != network.SecurityOpen { - var secStr string - switch n.Security { - case network.SecurityWEP: - secStr = "WEP" - case network.SecurityWPA: - secStr = "WPA" - case network.SecurityWPA2: - secStr = "WPA2" - case network.SecurityWPA3: - secStr = "WPA3" - default: - secStr = "Secured" - } - label += " [" + secStr + "]" - } - - c := colors.White - if n.Connected { - c = colors.LawnGreen - } - - item, err := NewStringItem(label, p.applicationFont, c) - if err != nil { - logger.Warning("wifi: failed to create item for %q: %v", n.SSID, err) - continue - } - p.networks = append(p.networks, networkUIItem{Net: n, Item: item}) - } -} - -func (p *WiFiSetupPage) rebuildStatusTexture() { - if p.connectionStatusItem.ID != 0 { - gl.DeleteTextures(1, &p.connectionStatusItem.ID) - p.connectionStatusItem.ID = 0 - } - - var msg string - var c sdl.Color - - switch p.status { - case wifiStatusScanning: - msg = "Scanning for nearby networks, please wait..." - c = colors.White - case wifiStatusConnecting: - msg = p.statusMessage - c = colors.Yellow - case wifiStatusConnected: - msg = p.statusMessage - c = colors.LawnGreen - case wifiStatusError: - msg = p.connectError - if msg == "" { - msg = "Something went wrong. Press R to scan again." - } - c = colors.Red - default: - if len(p.networks) == 0 { - msg = "No networks found. Press R to scan again." - c = colors.Yellow - } else { - msg = "Select a network to connect — press R to rescan" - c = colors.White - } - } - - if msg == "" { - return - } - - item, err := NewStringItem(msg, p.applicationFont, c) - if err == nil { - p.connectionStatusItem = item - } -} - -func (p *WiFiSetupPage) rebuildPasswordTexture() { - if p.passwordInputItem.ID != 0 { - gl.DeleteTextures(1, &p.passwordInputItem.ID) - } - text := p.passwordInput - if text == "" { - text = " " // prevent empty texture - } - item, err := NewStringItem(text, p.applicationFont, colors.White) - if err == nil { - p.passwordInputItem = item - } -} - -func (p *WiFiSetupPage) rebuildPasswordPromptTexture() { - if p.passwordPromptItem.ID != 0 { - gl.DeleteTextures(1, &p.passwordPromptItem.ID) - } - if p.selectedNet == nil { - return - } - prompt := fmt.Sprintf("Enter password for \"%s\":", p.selectedNet.SSID) - item, err := NewStringItem(prompt, p.applicationFont, colors.White) - if err == nil { - p.passwordPromptItem = item - } -} - -func (p *WiFiSetupPage) rebuildPasswordErrorTexture() { - if p.passwordErrorItem.ID != 0 { - gl.DeleteTextures(1, &p.passwordErrorItem.ID) - p.passwordErrorItem.ID = 0 - } - if p.connectError == "" { - return - } - item, err := NewStringItem(p.connectError, p.applicationFont, colors.Red) - if err == nil { - p.passwordErrorItem = item - } -} - -// ─── Render ─────────────────────────────────────────────────────────────────── - -func (p *WiFiSetupPage) Render() error { - solidShader, _ := shaderManager.Get().Get("solid_color") - textShader, _ := shaderManager.Get().Get("text") - - bgColor := sdl.Color{R: 0, G: 0, B: 0, A: 255} - selColor := sdl.Color{R: 68, G: 71, B: 90, A: 255} - white := colors.White - vertMargin := float32(20) - - // Background - p.Base.RenderSolidColorQuad(solidShader, - mgl32.Vec2{0, 0}, - mgl32.Vec2{float32(p.ScreenWidth), float32(p.ScreenHeight)}, - bgColor, - ) - - // Title — 48pt - titleScale := float32(48) / float32(p.BaseFontSize) - titleW := float32(p.titleItem.W) * titleScale - titleH := float32(p.titleItem.H) * titleScale - titleX := float32(p.ScreenCenterX) - titleW/2 - titleY := float32(p.ScreenHeight) - titleH - 40 - p.Base.RenderTexture(textShader, p.titleItem.ID, - mgl32.Vec2{titleX, titleY}, - mgl32.Vec2{titleW, titleH}, - white, - ) - - // Status message — 30pt (was 24pt, now 1.25x) - statusY := titleY - titleH - vertMargin - if p.status == wifiStatusScanning { - scale := float32(30) / float32(p.BaseFontSize) - w := float32(p.scanningStatusItem.W) * scale - h := float32(p.scanningStatusItem.H) * scale - x := float32(p.ScreenCenterX) - w/2 - p.Base.RenderTexture(textShader, p.scanningStatusItem.ID, - mgl32.Vec2{x, statusY}, - mgl32.Vec2{w, h}, - white, - ) - } else if p.connectionStatusItem.ID != 0 { - scale := float32(30) / float32(p.BaseFontSize) - w := float32(p.connectionStatusItem.W) * scale - h := float32(p.connectionStatusItem.H) * scale - x := float32(p.ScreenCenterX) - w/2 - p.Base.RenderTexture(textShader, p.connectionStatusItem.ID, - mgl32.Vec2{x, statusY}, - mgl32.Vec2{w, h}, - p.connectionStatusItem.Color, - ) - } - - // Network list - listY := statusY - vertMargin - itemH := int32(40) - listPad := int32(10) - listW := float32(700) - listX := float32(p.ScreenCenterX) - listW/2 - - for i, item := range p.networks { - rowY := listY - float32(int32(i+1)*itemH) - - if i == p.selectedIndex { - p.Base.RenderSolidColorQuad(solidShader, - mgl32.Vec2{listX, rowY}, - mgl32.Vec2{listW, float32(itemH)}, - selColor, - ) - } - - scale := float32(24) / float32(p.BaseFontSize) - tw := float32(item.Item.W) * scale - th := float32(item.Item.H) * scale - tx := listX + float32(listPad) - ty := rowY + (float32(itemH)/2 - th/2) - p.Base.RenderTexture(textShader, item.Item.ID, - mgl32.Vec2{tx, ty}, - mgl32.Vec2{tw, th}, - item.Item.Color, - ) - } - - // Password dialog - if p.isEnteringPassword { - if err := p.renderPasswordDialog(solidShader, textShader, float32(p.BaseFontSize)); err != nil { - return err - } - } - - return nil -} - -func (p *WiFiSetupPage) renderPasswordDialog(solidShader, textShader *shaderManager.Shader, baseFontSize float32) error { - overlayColor := sdl.Color{R: 0, G: 0, B: 0, A: 180} - dialogBgColor := sdl.Color{R: 40, G: 42, B: 54, A: 255} - boxBorderColor := sdl.Color{R: 120, G: 120, B: 120, A: 255} - boxBgColor := sdl.Color{R: 30, G: 30, B: 30, A: 255} - white := colors.White - - dialogW := int32(600) - dialogH := int32(250) - if p.connectError != "" { - dialogH = 310 - } - dialogX := p.ScreenCenterX - dialogW/2 - dialogY := p.ScreenCenterY - dialogH/2 - - // Overlay + dialog box - p.Base.RenderSolidColorQuad(solidShader, - mgl32.Vec2{0, 0}, - mgl32.Vec2{float32(p.ScreenWidth), float32(p.ScreenHeight)}, - overlayColor, - ) - p.Base.RenderSolidColorQuad(solidShader, - mgl32.Vec2{float32(dialogX), float32(dialogY)}, - mgl32.Vec2{float32(dialogW), float32(dialogH)}, - dialogBgColor, - ) - - currentY := float32(dialogY+dialogH) - 30 - - // Error message - if p.passwordErrorItem.ID != 0 { - scale := float32(20) / baseFontSize - w := float32(p.passwordErrorItem.W) * scale - h := float32(p.passwordErrorItem.H) * scale - x := float32(p.ScreenCenterX) - w/2 - currentY -= h - p.Base.RenderTexture(textShader, p.passwordErrorItem.ID, - mgl32.Vec2{x, currentY}, - mgl32.Vec2{w, h}, - colors.Red, - ) - currentY -= 20 - } - - // Prompt - if p.passwordPromptItem.ID != 0 { - scale := float32(24) / baseFontSize - w := float32(p.passwordPromptItem.W) * scale - h := float32(p.passwordPromptItem.H) * scale - x := float32(p.ScreenCenterX) - w/2 - currentY -= h - p.Base.RenderTexture(textShader, p.passwordPromptItem.ID, - mgl32.Vec2{x, currentY}, - mgl32.Vec2{w, h}, - white, - ) - currentY -= 30 - } - - // Input box - padding := int32(4) - border := int32(3) - boxW := int32(500) - fontSize := int32(36) - boxH := fontSize + padding*2 - bboxW := boxW + border*2 - bboxH := boxH + border*2 - bboxX := p.ScreenCenterX - bboxW/2 - bboxY := int32(currentY) - boxH - padding*2 - boxX := p.ScreenCenterX - boxW/2 - boxY := int32(currentY) - boxH - padding - - p.Base.RenderSolidColorQuad(solidShader, - mgl32.Vec2{float32(bboxX), float32(bboxY)}, - mgl32.Vec2{float32(bboxW), float32(bboxH)}, - boxBorderColor, - ) - p.Base.RenderSolidColorQuad(solidShader, - mgl32.Vec2{float32(boxX), float32(boxY)}, - mgl32.Vec2{float32(boxW), float32(boxH)}, - boxBgColor, - ) - - // Password text (plain, unmasked) - if p.passwordInputItem.ID != 0 { - scale := float32(fontSize) / baseFontSize - w := float32(p.passwordInputItem.W) * scale - h := float32(p.passwordInputItem.H) * scale - p.Base.RenderTexture(textShader, p.passwordInputItem.ID, - mgl32.Vec2{float32(boxX + padding), float32(boxY + padding)}, - mgl32.Vec2{w, h}, - white, - ) - } - - return nil -} - -// ─── Destroy ────────────────────────────────────────────────────────────────── - -func (p *WiFiSetupPage) Destroy() error { - keybinds.Enabled = true - sdl.StopTextInput() - - // Cancel the scan context — stops ScanForNetworks goroutine. - if p.cancel != nil { - p.cancel() - } - - deleteTexture := func(id *uint32) { - if *id != 0 { - gl.DeleteTextures(1, id) - *id = 0 - } - } - - deleteTexture(&p.titleItem.ID) - deleteTexture(&p.scanningStatusItem.ID) - deleteTexture(&p.connectionStatusItem.ID) - deleteTexture(&p.passwordPromptItem.ID) - deleteTexture(&p.passwordInputItem.ID) - deleteTexture(&p.passwordErrorItem.ID) - - for _, item := range p.networks { - deleteTexture(&item.Item.ID) - } - - return p.Base.Destroy() -} diff --git a/archive/internal/pattern/color/colorPatterns.go b/archive/internal/pattern/color/colorPatterns.go deleted file mode 100644 index 196c750..0000000 --- a/archive/internal/pattern/color/colorPatterns.go +++ /dev/null @@ -1,100 +0,0 @@ -package pattern - -import ( - "github.com/veandco/go-sdl2/sdl" - "rhemawavetech.com/rhemawave/internal/colors" -) - -type ColorPattern struct { - Colors []sdl.Color - colorIndex int - repeatsLeft int - a, b int - increasing bool -} - -func NewColorPattern(colors []sdl.Color) *ColorPattern { - if len(colors) < 2 { - panic("ColorPattern requires at least 2 colors") - } - return &ColorPattern{ - Colors: colors, - a: 0, - b: 1, - increasing: true, - repeatsLeft: 1, // Start with b (1) - } -} - -// ATTENTION: TED -// -// # Fibonacci-Oscillating Temporal Color Distribution for Sequential Text Elements -// -// This method distributes intent-based colors across sequential text elements (words) using an -// oscillating Fibonacci sequence to control temporal color duration, creating rhythmic color -// patterns that maintain visual balance while varying exposure times. -// -// The technology addresses the challenge of applying multiple psychologically-selected colors -// to scrolling text in a way that maintains even distribution over time while creating natural -// rhythm. Conventional approaches either apply colors randomly (creating unbalanced temporal -// distribution) or use fixed intervals (producing mechanical, unnatural patterns that lack -// harmonic variation). Our method ensures each color in the palette appears throughout the -// entire display duration while varying its temporal presence according to natural mathematical -// progression. -// -// The approach uses a bidirectional Fibonacci sequence to determine how many consecutive words -// receive each color. The sequence increases (1, 1, 2, 3, 5, 8, 13, 21, 34, 55 words) then -// decreases back down (55, 34, 21, 13, 8, 5, 3, 2, 1, 1 words), creating an oscillating pattern. -// Colors from the intent-based palette are applied in sequence, with each color's duration -// determined by the current Fibonacci value. This creates a "breathing" rhythm where colors -// appear briefly, expand to longer durations, then contract back to brief appearances before -// cycling to the next color. -// -// Key differentiators from existing solutions: -// - Uses bidirectional Fibonacci sequence (ascending then descending) rather than fixed intervals -// - Creates natural rhythmic variation in color exposure duration -// - Ensures all palette colors appear throughout entire display timeline, not in isolated sections -// - Combines intent-based color selection with mathematical temporal distribution -// - Produces harmonic color rhythm synchronized with natural numerical progression -// -// Within our Rhema Wave generation system, this color distribution works in conjunction with -// the spatially-scrambled character patterns. While the scrambling creates spatial frequency -// signatures, the Fibonacci-oscillating color pattern creates temporal frequency modulation. -// The colors themselves are selected according to color theory principles based on the proclamation's -// psychological intent, and the oscillating Fibonacci distribution ensures these therapeutic colors -// maintain presence throughout the viewing session while creating harmonic temporal rhythm that -// reinforces the overall Rhema Wave effect. -func (c *ColorPattern) Color() sdl.Color { - // 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, 987, 1597, 2584, 4181, 6765, 10946, 17711, 28657, 46368, 75025, 121393, 196418 - // This is a failsafe in case this pattern is used but colors are not supplied. Without this check, no text will display due to not having a color - // and becoming translucent. - if len(c.Colors) == 0 { - return colors.White // fallback to white - } - - color := c.Colors[c.colorIndex%len(c.Colors)] - c.repeatsLeft-- - - if c.repeatsLeft <= 0 { - // Move to next color in the palette - c.colorIndex = (c.colorIndex + 1) % len(c.Colors) - - // Update Fibonacci sequence for repeat count - if c.increasing { - c.a, c.b = c.b, c.a+c.b - if c.b > 55 { // Update this threahold to adjust the number of repeats - c.increasing = false - } - } else { - c.a, c.b = c.b-c.a, c.a - if c.a <= 0 { - c.a, c.b = 0, 1 - c.increasing = true - } - } - - c.repeatsLeft = c.b - } - - return color -} diff --git a/archive/internal/pattern/line/fibonacci.go b/archive/internal/pattern/line/fibonacci.go deleted file mode 100644 index f63af7a..0000000 --- a/archive/internal/pattern/line/fibonacci.go +++ /dev/null @@ -1,127 +0,0 @@ -package pattern - -type FibonacciPattern struct { - masterSequence []int - sequence []int - increasing bool -} - -func init() { - Register("fibonacci", func() LinePattern { - return &FibonacciPattern{ - masterSequence: []int{1, 2, 3, 5, 8, 13, 21}, - sequence: []int{1}, - increasing: true, - } - }) -} - -func (f *FibonacciPattern) Name() string { return "Fibonacci" } - -// ATTENTION: TED -// -// # Fibonacci-Oscillating Spatial Distribution for Sequential Text Display -// -// This method arranges sequential text elements (words from proclamations) into display lines -// where the word count per line follows an oscillating Fibonacci sequence, creating both a -// visual wave pattern in two-dimensional space and ensuring even temporal distribution of -// content segments. -// -// The technology addresses the challenge of displaying repeated proclamation content in a way -// that maintains balanced exposure of all content portions while creating visually dynamic -// spatial patterns. Conventional scrolling text approaches either use fixed line lengths -// (creating uniform, non-dynamic displays) or random word counts (producing unbalanced content -// distribution where some portions appear more frequently than others). Our method ensures -// proclamation content is consumed in progressively sized segments that balance over time while -// creating a natural wave-like visual rhythm. -// -// The approach uses a bidirectional Fibonacci sequence to determine words per line. Starting -// with minimal word count (1, 1 words), the sequence expands (2, 3, 5, 8, 13, 21... words per -// line) until reaching a threshold, then contracts back down (21, 13, 8, 5, 3, 2, 1, 1 words), -// creating an oscillating wave. As lines scroll vertically, this varying horizontal word count -// creates a lateral wave pattern in the two-dimensional display space. Words are drawn sequentially -// from the proclamation text, and the varying segment sizes (1 word, then 2, then 3, then 5, etc.) -// ensure that over multiple oscillation cycles, the entire proclamation content receives balanced -// temporal exposure. -// -// Key differentiators from existing solutions: -// - Uses oscillating Fibonacci sequence for line word counts rather than fixed or random lengths -// - Creates two-dimensional wave pattern from one-dimensional text sequence -// - Ensures mathematical content distribution balance over time through progressive segmentation -// - Produces natural expansion-contraction rhythm synchronized with Fibonacci progression -// - Combines spatial wave formation with temporal content distribution in single mechanism -// -// Within our Rhema Wave generation system, this spatial distribution pattern works in concert -// with the character-level scrambling and color distribution methods. While character scrambling -// creates local spatial frequency patterns and color oscillation creates temporal color rhythm, -// the Fibonacci line length pattern generates a macro-level spatial wave that modulates the -// overall display geometry. The mathematical relationship between line lengths creates harmonic -// spatial progression, and the oscillating nature ensures proclamation content cycles through -// varying exposure durations, contributing to the layered frequency composition of the complete -// Rhema Wave output. -func (f *FibonacciPattern) GetNextLine(words []string, wordIndex *int, ctx *LineRenderContext) []string { - if ctx == nil || len(words) == 0 { - return nil - } - - numWords := f.sequence[len(f.sequence)-1] - targetWidth := int32(ctx.ScreenWidth) - - var line []string - var currentWidth int32 - wordsAdded := 0 - - for wordsAdded < numWords && len(words) > 0 { - if int(*wordIndex) > len(words) { - *wordIndex = 0 - } - word := words[*wordIndex%len(words)] - *wordIndex++ - - if word == "" { - continue - } - - wordWidth := estimateWordWidth(word, ctx.Font, ctx.FontScale) - if len(line) > 0 { - wordWidth += ctx.SpaceWidth - } - - if len(line) == 0 || currentWidth+wordWidth <= int32(float32(targetWidth)*float32(.9)) { - line = append(line, word) - currentWidth += wordWidth - wordsAdded++ - } else { - break - } - } - - // TODO: What should we do here instead? - // A line being longer than the display still needs to be handled, though, it should never happen - // if currentWidth >= ctx.ScreenWidth { - // syscall.Kill(syscall.Getpid(), syscall.SIGTRAP) - // } - - // Update the sequence based on direction - if f.increasing { - if len(f.sequence) < len(f.masterSequence) { - // Grow sequence by appending next Fibonacci number - f.sequence = append(f.sequence, f.masterSequence[len(f.sequence)]) - } else { - // Reached the top, now start shrinking - f.increasing = false - f.sequence = f.sequence[:len(f.sequence)-1] - } - } else { - if len(f.sequence) > 1 { - // Shrink the sequence - f.sequence = f.sequence[:len(f.sequence)-1] - } else { - // Hit bottom, start growing again - f.increasing = true - f.sequence = append(f.sequence, f.masterSequence[1]) // index 1 = second element = 2 - } - } - - return line -} diff --git a/archive/internal/pattern/line/linePatterns.go b/archive/internal/pattern/line/linePatterns.go deleted file mode 100644 index 4a04bc8..0000000 --- a/archive/internal/pattern/line/linePatterns.go +++ /dev/null @@ -1,71 +0,0 @@ -package pattern - -import ( - "fmt" - - "rhemawavetech.com/rhemawave/internal/fontManager" -) - -type LinePattern interface { - GetNextLine(words []string, wordIndex *int, ctx *LineRenderContext) []string // Generates the next line in the pattern - Name() string // Used to display the name of a LinePattern -} - -// PatternInfo holds the public information about a registered pattern. -type PatternInfo struct { - Key string // The machine-readable key (e.g., "sine") - DisplayName string // The human-readable name for the UI (e.g., "Sine Wave") -} - -type LineRenderContext struct { - ScreenWidth int32 - Font *fontManager.FontEntry - FontScale float32 - SpaceWidth int32 -} - -var Registry = make(map[string]func() LinePattern) - -func Register(name string, constructor func() LinePattern) { - Registry[name] = constructor -} - -func Get(name string) (LinePattern, error) { - constructor, ok := Registry[name] - if !ok { - return nil, fmt.Errorf("failed to find line pattern %s in Registry", name) - } - return constructor(), nil -} - -// ListAvailable returns a slice of info for all registered patterns. -// This is used to build a settings menu. -func ListAvailable() []PatternInfo { - var list []PatternInfo - for key, constructor := range Registry { - pattern := constructor() // Create a temporary instance to get its name - list = append(list, PatternInfo{ - Key: key, - DisplayName: pattern.Name(), - }) - } - return list -} - -// estimateWordWidth provides a basic estimate of the width of a word based on the font and scale. -func estimateWordWidth(word string, font *fontManager.FontEntry, scale float32) int32 { - if font == nil { - // Basic fallback estimate - return int32(float32(len(word)) * 10 * scale) - } - - total := int32(0) - for _, ch := range word { - glyph, ok := fontManager.Get().GetGlyph(font.Name, ch) - if !ok { - continue - } - total += int32(float32(glyph.Advance) * scale) - } - return total -} diff --git a/archive/internal/pattern/line/sineWavePattern.go b/archive/internal/pattern/line/sineWavePattern.go deleted file mode 100644 index fd73e3d..0000000 --- a/archive/internal/pattern/line/sineWavePattern.go +++ /dev/null @@ -1,80 +0,0 @@ -package pattern - -import ( - "math" - - "rhemawavetech.com/rhemawave/internal/fontManager" -) - -type SineWavePattern struct { - waveStepCounter int -} - -func init() { - Register("sine", func() LinePattern { return &SineWavePattern{} }) -} - -func (s *SineWavePattern) Name() string { return "Sine Wave" } -func (s *SineWavePattern) GetNextLine(words []string, wordIndex *int, ctx *LineRenderContext) []string { - const ( - totalLines = 67 - frequency = 5.0 // cycles across all lines - amplitude = 0.85 // how much wave can scale - baseRatio = 0.03 // minimum line width as % of screen - sharpness = 3.0 // sharpness of the curve - ) - - screenWidth := int32(800) - spaceWidth := int32(10) - font := (*fontManager.FontEntry)(nil) - fontScale := float32(1.0) - - if ctx != nil { - screenWidth = ctx.ScreenWidth - spaceWidth = ctx.SpaceWidth - font = ctx.Font - fontScale = ctx.FontScale - } - - // Sharpen the wave (skinnier troughs) - // raw := (math.Sin(2*math.Pi*frequency*float64(s.waveStepCounter)/totalLines) + 1.0) / 2.0 - // waveVal := math.Pow(raw, sharpness) - angle := 2 * math.Pi * frequency * float64(s.waveStepCounter) / totalLines - waveVal := (math.Sin(angle) + 1.0) / 2.0 - - // Target width in pixels - targetWidth := int32(float64(screenWidth) * (baseRatio + amplitude*waveVal)) - - var line []string - var currentWidth int32 - - for len(words) > 0 { - word := words[*wordIndex%len(words)] - *wordIndex++ - - if word == "" { - continue - } - - wordWidth := estimateWordWidth(word, font, fontScale) - if len(line) > 0 { - wordWidth += spaceWidth - } - - if len(line) == 0 { - // Always include the first word - line = append(line, word) - currentWidth += wordWidth - } else { - // Only add next word if it fits - if currentWidth+wordWidth > targetWidth { - break - } - line = append(line, word) - currentWidth += wordWidth - } - } - - s.waveStepCounter++ - return line -} diff --git a/archive/internal/shaderManager/shaderManager.go b/archive/internal/shaderManager/shaderManager.go deleted file mode 100644 index f4b6396..0000000 --- a/archive/internal/shaderManager/shaderManager.go +++ /dev/null @@ -1,199 +0,0 @@ -package shaderManager - -import ( - "fmt" - "os" - "path/filepath" - "strings" - "sync" - - "github.com/go-gl/gl/v3.3-core/gl" -) - -// Shader represents a compiled GLSL program and its associated data. -type Shader struct { - Name string - ProgramID uint32 - Render func() // This will be used in the future. - - // Cached uniform locations (looked up once after linking) - LocMVPMatrix int32 - LocTexture int32 - LocTextColor int32 - LocColor int32 -} - -// ShaderManager handles the loading, compiling, and management of GLSL shader programs. -type ShaderManager struct { - shaders map[string]*Shader -} - -var ( - instance *ShaderManager - once sync.Once -) - -// InitShaderManager initializes the ShaderManager singleton. -func InitShaderManager(shaderDir string) error { - var err error - once.Do(func() { - sm := &ShaderManager{ - shaders: make(map[string]*Shader), - } - if e := sm.LoadShaders(shaderDir); e != nil { - err = fmt.Errorf("could not load shaders from %s: %w", shaderDir, e) - return - } - instance = sm - }) - return err -} - -// Get returns the singleton ShaderManager instance. -func Get() *ShaderManager { - if instance == nil { - panic("ShaderManager has not been initialized. Call InitShaderManager at application startup.") - } - return instance -} - -// Get retrieves a compiled Shader object by its name. -func (sm *ShaderManager) Get(name string) (*Shader, bool) { - shader, ok := sm.shaders[name] - return shader, ok -} - -// ListShaders returns all shaders. -func (sm *ShaderManager) ListShaders() map[string]*Shader { - return sm.shaders -} - -// Close deletes all loaded shader programs from the GPU to free up resources. -func (sm *ShaderManager) Close() { - for name, shader := range sm.shaders { - gl.DeleteProgram(shader.ProgramID) - delete(sm.shaders, name) - } -} - -// LoadShaders scans a directory for .vert and .frag files and creates a shader program for each pair. -func (sm *ShaderManager) LoadShaders(shaderDir string) error { - // Get assets directory from database - shaderFiles := make(map[string]string) - - err := filepath.Walk(shaderDir, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - if !info.IsDir() && strings.HasSuffix(info.Name(), ".vert") { - name := strings.TrimSuffix(info.Name(), ".vert") - shaderFiles[name] = path - } - return nil - }) - if err != nil { - return fmt.Errorf("error walking shader directory: %w", err) - } - - for name, vertPath := range shaderFiles { - fragPath := strings.Replace(vertPath, ".vert", ".frag", 1) - if _, err := os.Stat(fragPath); os.IsNotExist(err) { - continue - } - - programID, err := createShaderProgram(vertPath, fragPath) - if err != nil { - continue - } - - newShader := &Shader{ - Name: name, - ProgramID: programID, - Render: nil, // Render function is not yet assigned. - } - newShader.cacheUniformLocations() - sm.shaders[name] = newShader - } - - return nil -} - -// cacheUniformLocations looks up common uniform locations once and stores them on the Shader. -func (s *Shader) cacheUniformLocations() { - s.LocMVPMatrix = gl.GetUniformLocation(s.ProgramID, gl.Str("u_mvpMatrix\x00")) - s.LocTexture = gl.GetUniformLocation(s.ProgramID, gl.Str("u_texture\x00")) - s.LocTextColor = gl.GetUniformLocation(s.ProgramID, gl.Str("u_textColor\x00")) - s.LocColor = gl.GetUniformLocation(s.ProgramID, gl.Str("u_color\x00")) -} - -// createShaderProgram reads, compiles, and links a vertex and fragment shader from file paths. -func createShaderProgram(vertexPath, fragmentPath string) (uint32, error) { - vertexSource, err := os.ReadFile(vertexPath) - if err != nil { - return 0, fmt.Errorf("reading vertex shader %s: %w", vertexPath, err) - } - fragmentSource, err := os.ReadFile(fragmentPath) - if err != nil { - return 0, fmt.Errorf("reading fragment shader %s: %w", fragmentPath, err) - } - - vertexShader, err := compileShader(string(vertexSource)+"\x00", gl.VERTEX_SHADER) - if err != nil { - return 0, err - } - defer gl.DeleteShader(vertexShader) - - fragmentShader, err := compileShader(string(fragmentSource)+"\x00", gl.FRAGMENT_SHADER) - if err != nil { - return 0, err - } - defer gl.DeleteShader(fragmentShader) - - program := gl.CreateProgram() - gl.AttachShader(program, vertexShader) - gl.AttachShader(program, fragmentShader) - gl.LinkProgram(program) - - var status int32 - gl.GetProgramiv(program, gl.LINK_STATUS, &status) - if status == gl.FALSE { - var logLength int32 - gl.GetProgramiv(program, gl.INFO_LOG_LENGTH, &logLength) - log := strings.Repeat("\x00", int(logLength+1)) - gl.GetProgramInfoLog(program, logLength, nil, gl.Str(log)) - return 0, fmt.Errorf("failed to link program: %v", log) - } - - return program, nil -} - -// compileShader is a helper function that compiles a single shader source string. -func compileShader(source string, shaderType uint32) (uint32, error) { - shader := gl.CreateShader(shaderType) - csources, free := gl.Strs(source) - gl.ShaderSource(shader, 1, csources, nil) - free() - gl.CompileShader(shader) - - var status int32 - gl.GetShaderiv(shader, gl.COMPILE_STATUS, &status) - if status == gl.FALSE { - var logLength int32 - gl.GetShaderiv(shader, gl.INFO_LOG_LENGTH, &logLength) - log := strings.Repeat("\x00", int(logLength+1)) - gl.GetShaderInfoLog(shader, logLength, nil, gl.Str(log)) - - var shaderTypeName string - switch shaderType { - case gl.VERTEX_SHADER: - shaderTypeName = "Vertex" - case gl.FRAGMENT_SHADER: - shaderTypeName = "Fragment" - default: - shaderTypeName = "Unknown" - } - return 0, fmt.Errorf("failed to compile %s shader: %v", shaderTypeName, log) - } - - return shader, nil -} diff --git a/archive/internal/sync/sync.go b/archive/internal/sync/sync.go deleted file mode 100644 index 3aa2d4c..0000000 --- a/archive/internal/sync/sync.go +++ /dev/null @@ -1,144 +0,0 @@ -package sync - -import ( - "regexp" - - "gorm.io/gorm" - "gorm.io/gorm/clause" - "rhemawavetech.com/rhemawave/internal/db" - "rhemawavetech.com/rhemawave/internal/logger" - "rhemawavetech.com/rhemawave/internal/netreq" -) - -var htmlRegex = regexp.MustCompile(`<[^>]*>`) - -// SyncProclamations fetches data from the API, cleans it, and updates the local DB. -// It returns the slice of proclamations so the UI can update immediately. -func SyncProclamations(database *gorm.DB) ([]db.Proclamation, error) { - fullURL := "https://portal.radiantwavetech.com/api/v1/proclamations" - logger.Debug("Syncronizing proclamations. URL: %s", fullURL) - - proclamations, err := netreq.GetJSON[db.Proclamation](fullURL) - if err != nil { - // If we're offline or API is down, we log it but don't crash. - // The UI can still use whatever is currently in the DB. - logger.Error("Proclamation sync failed, skipping update: %v", err) - return nil, err - } - logger.Debug("Request Granted") - - for i := range proclamations { - proclamations[i].Content = htmlRegex.ReplaceAllString(proclamations[i].Content, "") - } - - var storedProclamationSettings []db.Proclamation - database.Select("ID", "Selected").Find(&storedProclamationSettings) - - // [ID]Selected - stash := make(map[string]bool) - for _, row := range storedProclamationSettings { - stash[row.ID] = row.Selected - } - - for i := range proclamations { - proclamations[i].Selected = stash[proclamations[i].ID] - } - - err = database.Transaction(func(tx *gorm.DB) error { - ids := make([]string, len(proclamations)) - for i, p := range proclamations { - ids[i] = p.ID - } - - if len(ids) == 0 { - // API returned nothing → DB should be empty - if err := tx.Where("1 = 1").Delete(&db.Proclamation{}).Error; err != nil { - return err - } - } else { - // Remove anything not in API - if err := tx.Where("id NOT IN ?", ids).Delete(&db.Proclamation{}).Error; err != nil { - return err - } - } - - // Upsert (preserve Selected automatically) - if err := tx.Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "id"}}, - DoUpdates: clause.AssignmentColumns([]string{ - "name", - "content", - "solfeggio_frequency_id", - "category_id", - "available", - "price", - }), - }).Create(&proclamations).Error; err != nil { - return err - } - - // Return nil as err for this statement, does not exit the function - return nil - }) - - logger.Debug("Proclamations stored to database") - - if err != nil { - logger.Error("Failed to save synced proclamations: %v", err) - return nil, err - } - - logger.Info("Successfully synced proclamations: %d", len(proclamations)) - return proclamations, nil -} - -func SyncCategories(database *gorm.DB) ([]db.ProclamationCategory, error) { - fullURL := "https://portal.radiantwavetech.com/api/v1/categories" - categories, err := netreq.GetJSON[db.ProclamationCategory](fullURL) - if err != nil { - logger.Error("Category sync failed, skipping update: %v", err) - return nil, err - } - - err = database.Transaction(func(tx *gorm.DB) error { - if err := tx.Where("1 = 1").Delete(&db.ProclamationCategory{}).Error; err != nil { - return err - } - if err := tx.Save(&categories).Error; err != nil { - return err - } - return nil - }) - - if err != nil { - logger.Error("failed to sync proclamation categories: %v", err) - return nil, err - } - - logger.Info("Successfully synced proclamation categories: %d", len(categories)) - return categories, nil -} - -func SyncFrequencies(database *gorm.DB) ([]db.SolfeggioFrequency, error) { - fullURL := "https://portal.radiantwavetech.com/api/v1/frequencies" - frequencies, err := netreq.GetJSON[db.SolfeggioFrequency](fullURL) - if err != nil { - logger.Error("Frequency sync failed, skipping update: %v", err) - return nil, err - } - err = database.Transaction(func(tx *gorm.DB) error { - if err := tx.Where("1 = 1").Delete(&db.SolfeggioFrequency{}).Error; err != nil { - return err - } - if err := tx.Save(&frequencies).Error; err != nil { - return err - } - return nil - }) - if err != nil { - logger.Error("failed to sync solfeggio frequencies: %v", err) - return nil, err - } - logger.Info("Succesfully synced solfeggio frequencies: %d", len(frequencies)) - return frequencies, nil -} diff --git a/archive/nixos/module.nix b/archive/nixos/module.nix deleted file mode 100644 index 1ace79a..0000000 --- a/archive/nixos/module.nix +++ /dev/null @@ -1,31 +0,0 @@ -self: - -{ config, lib, pkgs, ... }: - -let - cfg = config.services.rhemawave; -in -{ - options.services.rhemawave = { - enable = lib.mkEnableOption "RhemaWave kiosk application"; - - package = lib.mkOption { - type = lib.types.package; - # Points to the default package in your flake - default = self.packages.${pkgs.system}.default; - description = "The RhemaWave package to use."; - }; - - user = lib.mkOption { - type = lib.types.str; - default = "kiosk"; - description = "The user account name."; - }; - }; - - config = lib.mkIf cfg.enable { - # Just put the package in the system path. - # The machine (configuration.nix) handles the service and the files. - environment.systemPackages = [ cfg.package ]; - }; -} \ No newline at end of file diff --git a/archive/nixos/vm.nix b/archive/nixos/vm.nix deleted file mode 100644 index 968760e..0000000 --- a/archive/nixos/vm.nix +++ /dev/null @@ -1,144 +0,0 @@ -# NixOS test VM for RadiantWave — mirrors production kiosk setup. -# -# Build & run: -# nixos-rebuild build-vm --flake .#test-vm -# ./result/bin/run-test-vm-vm -# -# This replicates the production kiosk config from KioskConfig: -# - cage as Wayland compositor (fullscreen kiosk) -# - PipeWire audio (with a virtual sink for device-switch testing) -# - NetworkManager -# - Auto-login as kiosk user -# - RadiantWave started via loginShellInit (same as production) - -{ config, lib, pkgs, ... }: - -let - rhemaApp = config.services.rhemawave.package; -in -{ - # ── Hostname ────────────────────────────────────────────────────────────── - networking.hostName = "radiantwave-test"; - - # ── RadiantWave module ──────────────────────────────────────────────────── - services.rhemawave.enable = true; - - # ── Boot ────────────────────────────────────────────────────────────────── - boot.loader.systemd-boot.enable = lib.mkForce false; - boot.loader.efi.canTouchEfiVariables = lib.mkForce false; - - # ── Packages (matches production) ───────────────────────────────────────── - environment.systemPackages = [ - pkgs.cage pkgs.rsync pkgs.dbus pkgs.mesa pkgs.git pkgs.tailscale pkgs.sqlite - ]; - - # ── Audio (PipeWire + virtual sink for device-switch testing) ───────────── - services.pipewire = { - enable = true; - alsa.enable = true; - pulse.enable = true; - extraConfig.pipewire."30-virtual-sink" = { - "context.modules" = [ - { - name = "libpipewire-module-loopback"; - args = { - "node.name" = "virtual-speaker"; - "node.description" = "Virtual Speaker"; - "audio.position" = [ "FL" "FR" ]; - }; - } - ]; - }; - }; - - # ── Seat (matches production) ───────────────────────────────────────────── - services.seatd.enable = true; - hardware.graphics.enable = true; - - # ── Networking ──────────────────────────────────────────────────────────── - services.resolved.enable = false; - networking.networkmanager.enable = true; - systemd.services.NetworkManager-wait-online.enable = false; - systemd.network.wait-online.enable = false; - - # ── SSH ─────────────────────────────────────────────────────────────────── - services.openssh = { - enable = true; - settings = { - PermitRootLogin = "prohibit-password"; - PasswordAuthentication = true; - }; - }; - - # ── User (matches production) ───────────────────────────────────────────── - users.users.kiosk = { - isNormalUser = true; - uid = 1000; - extraGroups = [ "audio" "video" "input" "networkmanager" "wheel" ]; - password = "kiosk"; - }; - services.getty.autologinUser = "kiosk"; - - # ── Data directory ──────────────────────────────────────────────────────── - systemd.tmpfiles.rules = [ - "d /home/kiosk/.local/share/rhemawave 0770 kiosk kiosk -" - "d /home/kiosk/.radiantwave/audio 0755 kiosk kiosk -" - ]; - - # ── RadiantWave service (via loginShellInit, same as production) ────────── - environment.loginShellInit = '' - if [ "$(tty)" = "/dev/tty1" ]; then - export GODEBUG=netdns=cgo - export SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt - - # Smart Network Wait - for i in {1..10}; do - if ${pkgs.iputils}/bin/ping -c 1 8.8.8.8 &> /dev/null; then - break - fi - sleep 1 - done - - # Graphics fixes (matches production) - export SDL_VIDEODRIVER=wayland - export QT_QPA_PLATFORM=wayland - export XDG_SESSION_TYPE=wayland - export WLR_RENDERER=pixman - export WLR_NO_HARDWARE_CURSORS=1 - - # Asset sync + start app (same as production) - mkdir -p /home/kiosk/.local/share/rhemawave - ${pkgs.rsync}/bin/rsync -ru ${rhemaApp}/share/rhemawave/ /home/kiosk/.local/share/rhemawave/ - mkdir -p /home/kiosk/.radiantwave/audio - ${pkgs.rsync}/bin/rsync -ru ${rhemaApp}/share/rhemawave/audio/ /home/kiosk/.radiantwave/audio/ - - cage -- ${rhemaApp}/bin/rhemawave -d -s > /tmp/kiosk.log 2>&1 - fi - ''; - - # ── VM settings ─────────────────────────────────────────────────────────── - virtualisation = { - memorySize = 4096; - cores = 4; - graphics = true; - forwardPorts = [ - { from = "host"; host.port = 2222; guest.port = 22; } - ]; - qemu.options = [ - "-device intel-hda" - "-device hda-duplex" - "-vga virtio" - ]; - }; - - services.logrotate.enable = false; - - # ── Nix settings ────────────────────────────────────────────────────────── - nix.settings.experimental-features = [ "nix-command" "flakes" ]; - nix.settings.accept-flake-config = true; - - # ── Firewall (open for local dev) ───────────────────────────────────────── - networking.firewall.enable = false; - - system.stateVersion = "24.11"; -} diff --git a/archive/notes.org b/archive/notes.org deleted file mode 100644 index 7df70a0..0000000 --- a/archive/notes.org +++ /dev/null @@ -1,3 +0,0 @@ -* 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 deleted file mode 100644 index 9cdbc4f..0000000 --- a/archive/packaging/kiosk/99-rhemawave-updater.rules +++ /dev/null @@ -1,55 +0,0 @@ -// Allow kiosk user to run apt update, apt install for rhemawave packages, -// and restart getty for application reloading -polkit.addRule(function(action, subject) { - if (action.id == "org.freedesktop.policykit.exec" && - subject.user == "kiosk") { - - var program = action.lookup("program"); - var cmdline = action.lookup("command_line"); - - // Allow dpkg --configure -a - if (program == "/usr/bin/dpkg") { - if (cmdline && cmdline.indexOf("--configure") >= 0) { - return polkit.Result.YES; - } - } - - // Allow apt update - if (program == "/usr/bin/apt") { - if (cmdline && cmdline.indexOf("update") >= 0) { - return polkit.Result.YES; - } - - // Allow: apt install --only-upgrade rhemawave[-dev] - if (cmdline && - cmdline.indexOf("install") >= 0 && - cmdline.indexOf("--only-upgrade") >= 0 && - cmdline.indexOf("rhemawave") >= 0) { - return polkit.Result.YES; - } - } - - // Allow systemctl restart getty@tty1.service - if (program == "/usr/bin/systemctl") { - if (cmdline && - cmdline.indexOf("restart") >= 0 && - cmdline.indexOf("getty@tty1.service") >= 0) { - return polkit.Result.YES; - } - } - - // Allow sh for Tailscale install script - if (program == "/usr/bin/sh" || program == "/bin/sh") { - if (cmdline && cmdline.indexOf("tailscale.com/install.sh") >= 0) { - return polkit.Result.YES; - } - } - - // Allow tailscale set --operator - if (program == "/usr/bin/tailscale") { - if (cmdline && cmdline.indexOf("set") >= 0 && cmdline.indexOf("--operator") >= 0) { - return polkit.Result.YES; - } - } - } -}); \ No newline at end of file diff --git a/archive/packaging/kiosk/override.conf b/archive/packaging/kiosk/override.conf deleted file mode 100644 index 5470b4e..0000000 --- a/archive/packaging/kiosk/override.conf +++ /dev/null @@ -1,3 +0,0 @@ -[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 deleted file mode 100755 index b1cb9aa..0000000 --- a/archive/packaging/kiosk/post-install.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/bin/bash -# Post-installation script for RhemaWave -set -e - -# Create kiosk group if it doesn't exist -if ! getent group kiosk > /dev/null 2>&1; then - echo "Creating kiosk group..." - groupadd --system kiosk -fi - -# Create kiosk user if it doesn't exist -if ! getent passwd kiosk > /dev/null 2>&1; then - echo "Creating kiosk user..." - useradd --system --gid kiosk --create-home --home-dir /home/kiosk \ - --shell /bin/bash --comment "RhemaWave Kiosk User" kiosk -fi - -# Set proper group ownership for writable directories -chgrp -R kiosk /usr/local/share/rhemawave - -# Ensure kiosk user can write to the data directory -chmod g+w /usr/local/share/rhemawave - -echo "✓ RhemaWave post-install complete" \ No newline at end of file diff --git a/archive/packaging/kiosk/rhemawave-kiosk.sh b/archive/packaging/kiosk/rhemawave-kiosk.sh deleted file mode 100755 index 911f67d..0000000 --- a/archive/packaging/kiosk/rhemawave-kiosk.sh +++ /dev/null @@ -1,16 +0,0 @@ -# RhemaWave kiosk auto-launch on tty1 -if [ -z "$WAYLAND_DISPLAY" ] && [ "$(tty)" = "/dev/tty1" ]; then - export XDG_RUNTIME_DIR=/run/user/$(id -u) - export SDL_VIDEODRIVER=wayland - - # Check for updates before launching - /usr/bin/python3 /usr/local/bin/rhemawave-updater.py || true - - # Set all sinks to full volume - for sink in $(wpctl status | grep -oP '^\s+\K\d+(?=\.)' | head -10); do - wpctl set-volume "$sink" 1.0 2>/dev/null - done - - exec cage -- /usr/local/bin/rhemawave -fi - diff --git a/archive/packaging/rhemawave-updater.py b/archive/packaging/rhemawave-updater.py deleted file mode 100755 index 42ed895..0000000 --- a/archive/packaging/rhemawave-updater.py +++ /dev/null @@ -1,564 +0,0 @@ -#!/usr/bin/env python3 -""" -rhemawave-updater.py - -Auto-updater for RhemaWave kiosk application. -Uses apt to check for and install updates from the configured repository. - -USAGE: - Run as kiosk user: - python3 /usr/local/bin/rhemawave-updater.py -""" - -import sys -import subprocess -import json -import socket -import urllib.request -from pathlib import Path -from datetime import datetime - -# Configuration - templated by build script -PACKAGE_NAME = "__CHANNEL__" -HEADSCALE_URL = "https://headscale.rhemawavetech.com" -HEADSCALE_AUTHKEY = "803130d5b2e38bffa9fa5b6921a3d6403100e2e30fea1bd5" - -# Paths -LOG_FILE = Path("/home/kiosk/rhemawave-updater.log") -LICENSE_KEY_PATH = Path.home() / ".local/share/rhemawave/license_key" - - -class UpdaterLogger: - """Simple logger that writes to both stdout and file""" - - def __init__(self, log_file): - self.log_file = log_file - log_file.parent.mkdir(parents=True, exist_ok=True) - log_file.touch(exist_ok=True) - - def log(self, message): - timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - formatted = f"[{timestamp}] {message}" - print(formatted) - try: - with open(self.log_file, 'a') as f: - f.write(formatted + '\n') - except IOError: - pass - - def error(self, message): - self.log(f"ERROR: {message}") - - def debug(self, message): - self.log(f"DEBUG: {message}") - - def success(self, message): - self.log(f"✓ {message}") - - -logger = UpdaterLogger(LOG_FILE) - - -def check_network(timeout=2): - """Quick check for internet connectivity.""" - logger.log("Checking network connectivity...") - try: - socket.create_connection(("1.1.1.1", 53), timeout=timeout) - logger.success("Network check passed (1.1.1.1:53 reachable)") - return True - except OSError as e: - logger.error(f"Network check failed: {e}") - return False - - -def run_command(cmd, description, use_pkexec=False): - """Run a command and return success status""" - if use_pkexec: - cmd = ["pkexec"] + cmd - - logger.log(f"{description}...") - logger.debug(f"Command: {' '.join(cmd)}") - - try: - result = subprocess.run( - cmd, - capture_output=True, - text=True, - timeout=300 - ) - - logger.debug(f"Return code: {result.returncode}") - - if result.stdout: - for line in result.stdout.splitlines(): - logger.log(f" {line}") - - if result.returncode != 0: - logger.error(f"{description} failed with return code {result.returncode}") - if result.stderr: - for line in result.stderr.splitlines(): - logger.error(f" {line}") - return False - - logger.success(f"{description} completed successfully") - return True - - except subprocess.TimeoutExpired: - logger.error(f"{description} timed out") - return False - except Exception as e: - logger.error(f"{description} failed: {e}") - return False - - -def configure_dpkg(): - logger.log("Checking for dpkg errors") - if not run_command( - ["dpkg", "--configure", "-a"], - 'Check for dpkg configuration errors and resolve them before performing updates', - use_pkexec=True): - logger.error("Unable to reconfigure system, please reach out to support!") - return - - -def is_tailscale_installed(): - """Check if Tailscale is installed.""" - logger.log("Checking if Tailscale is installed...") - try: - result = subprocess.run( - ["tailscale", "version"], - capture_output=True, - text=True, - check=True - ) - version = result.stdout.strip() - logger.success(f"Tailscale is installed: {version}") - return True - except FileNotFoundError: - logger.log("Tailscale binary not found") - return False - except subprocess.CalledProcessError as e: - logger.error(f"Tailscale version check failed: {e}") - return False - - -def install_tailscale(): - """Install Tailscale.""" - logger.log("Installing Tailscale...") - try: - logger.debug("Running: pkexec sh -c 'curl -fsSL https://tailscale.com/install.sh | sh'") - result = subprocess.run( - ["pkexec", "sh", "-c", "curl -fsSL https://tailscale.com/install.sh | sh"], - capture_output=True, - text=True, - timeout=120 - ) - logger.debug(f"Install return code: {result.returncode}") - if result.stdout: - for line in result.stdout.splitlines(): - logger.log(f" {line}") - if result.stderr: - for line in result.stderr.splitlines(): - logger.log(f" [stderr] {line}") - - if result.returncode != 0: - logger.error("Tailscale installation failed") - return False - - logger.success("Tailscale installed successfully") - return True - except subprocess.TimeoutExpired: - logger.error("Tailscale installation timed out") - return False - except subprocess.CalledProcessError as e: - logger.error(f"Tailscale installation failed: {e}") - if e.stderr: - logger.error(f"stderr: {e.stderr}") - return False - - -def set_tailscale_operator(): - """Set Tailscale operator to kiosk.""" - logger.log("Setting Tailscale operator to 'kiosk'...") - try: - result = subprocess.run( - ["pkexec", "tailscale", "set", "--operator=kiosk"], - capture_output=True, - text=True, - timeout=30 - ) - logger.debug(f"Return code: {result.returncode}") - if result.stdout: - logger.debug(f"stdout: {result.stdout}") - if result.stderr: - logger.debug(f"stderr: {result.stderr}") - - if result.returncode != 0: - logger.error(f"Failed to set operator: {result.stderr}") - return False - - logger.success("Tailscale operator set to 'kiosk'") - return True - except Exception as e: - logger.error(f"Failed to set Tailscale operator: {e}") - return False - - -def get_tailscale_status(): - """Get full Tailscale status as dict.""" - logger.log("Getting Tailscale status...") - try: - result = subprocess.run( - ["tailscale", "status", "--json"], - capture_output=True, - text=True, - timeout=10 - ) - logger.debug(f"Return code: {result.returncode}") - - if result.returncode != 0: - logger.error(f"tailscale status failed: {result.stderr}") - return None - - status = json.loads(result.stdout) - - # Log key status info - backend_state = status.get("BackendState", "unknown") - logger.log(f" BackendState: {backend_state}") - - self_info = status.get("Self", {}) - if self_info: - logger.log(f" HostName: {self_info.get('HostName', 'unknown')}") - logger.log(f" DNSName: {self_info.get('DNSName', 'unknown')}") - logger.log(f" Online: {self_info.get('Online', 'unknown')}") - tailscale_ips = self_info.get("TailscaleIPs", []) - logger.log(f" TailscaleIPs: {tailscale_ips}") - - # Check for health issues - health = status.get("Health", []) - if health: - logger.log(" Health issues:") - for issue in health: - logger.log(f" - {issue}") - - return status - except json.JSONDecodeError as e: - logger.error(f"Failed to parse Tailscale status JSON: {e}") - return None - except subprocess.TimeoutExpired: - logger.error("Tailscale status timed out") - return None - except Exception as e: - logger.error(f"Failed to get Tailscale status: {e}") - return None - - -def is_tailscale_logged_in(status=None): - """Check if Tailscale is logged in.""" - if status is None: - status = get_tailscale_status() - - if status is None: - logger.error("Cannot determine login status - no status available") - return False - - backend_state = status.get("BackendState", "") - logged_in = backend_state == "Running" - - if logged_in: - logger.success("Tailscale is logged in (BackendState=Running)") - else: - logger.log(f"Tailscale is NOT logged in (BackendState={backend_state})") - - return logged_in - - -def tailscale_login(hostname=None): - """Log in to Tailscale/Headscale.""" - logger.log("Logging in to Headscale...") - logger.debug(f" Server: {HEADSCALE_URL}") - logger.debug(f" AuthKey: {HEADSCALE_AUTHKEY[:8]}...{HEADSCALE_AUTHKEY[-4:]}") - if hostname: - logger.debug(f" Hostname: {hostname}") - - cmd = [ - "tailscale", "up", - "--login-server", HEADSCALE_URL, - "--authkey", HEADSCALE_AUTHKEY, - "--force-reauth" - ] - if hostname: - cmd.extend(["--hostname", hostname]) - - logger.debug(f"Command: {' '.join(cmd)}") - - try: - result = subprocess.run( - cmd, - capture_output=True, - text=True, - timeout=30 - ) - logger.debug(f"Return code: {result.returncode}") - if result.stdout: - logger.log(f" stdout: {result.stdout}") - if result.stderr: - logger.log(f" stderr: {result.stderr}") - - if result.returncode != 0: - logger.error(f"Tailscale login failed with code {result.returncode}") - return False - - logger.success("Tailscale login successful") - return True - except subprocess.TimeoutExpired: - logger.error("Tailscale login timed out") - return False - except Exception as e: - logger.error(f"Tailscale login failed: {e}") - return False - - -def get_current_tailscale_hostname(status=None): - """Get the hostname we're currently advertising.""" - if status is None: - status = get_tailscale_status() - - if status is None: - return None - - hostname = status.get("Self", {}).get("HostName") - logger.debug(f"Current Tailscale hostname: {hostname}") - return hostname - - -def get_license_key(): - """Read license key from plain text file.""" - logger.log("Fetching license key...") - logger.debug(f" Path: {LICENSE_KEY_PATH}") - - if not LICENSE_KEY_PATH.exists(): - logger.log(" License key file does not exist") - return None - - try: - license_key = LICENSE_KEY_PATH.read_text().strip() - if not license_key: - logger.log(" License key file is empty") - return None - - logger.success(f"License key found: {license_key}") - return license_key - except Exception as e: - logger.error(f"Failed to read license key: {e}") - return None - - -def ensure_tailscale_installed(): - """Ensure Tailscale is installed, install if needed.""" - logger.log("=== Ensuring Tailscale is installed ===") - - if is_tailscale_installed(): - return True - - logger.log("Tailscale not installed, attempting installation...") - - if not install_tailscale(): - return False - - # Verify installation - if not is_tailscale_installed(): - logger.error("Tailscale installation verification failed") - return False - - # Set operator - if not set_tailscale_operator(): - logger.error("Failed to set Tailscale operator, continuing anyway...") - - return True - - -def ensure_tailscale_connected(): - """Ensure Tailscale is logged in and connected.""" - logger.log("=== Ensuring Tailscale is connected ===") - - status = get_tailscale_status() - - if not is_tailscale_logged_in(status): - logger.log("Tailscale not logged in, attempting login...") - - # Get license key to use as hostname if available - license_key = get_license_key() - - if not tailscale_login(hostname=license_key): - logger.error("Failed to log in to Tailscale") - return False - - # Re-check status after login - status = get_tailscale_status() - if not is_tailscale_logged_in(status): - logger.error("Still not logged in after login attempt") - return False - - logger.success("Tailscale is connected") - return True - - -def sync_tailscale_hostname(): - """Update tailscale hostname to match license key if needed.""" - logger.log("=== Syncing Tailscale hostname ===") - - license_key = get_license_key() - - if not license_key: - logger.log("No license key found, skipping hostname sync") - return - - status = get_tailscale_status() - current = get_current_tailscale_hostname(status) - - logger.log(f" Current hostname: {current}") - logger.log(f" Desired hostname: {license_key}") - - if current == license_key: - logger.success(f"Hostname already correct: {license_key}") - return - - logger.log(f"Hostname mismatch, updating: {current} -> {license_key}") - - if tailscale_login(hostname=license_key): - logger.success("Hostname updated successfully") - # Verify the change - new_status = get_tailscale_status() - new_hostname = get_current_tailscale_hostname(new_status) - logger.log(f" Verified new hostname: {new_hostname}") - else: - logger.error("Failed to update hostname") - - -def sync_ssh_keys(): - """Fetch SSH public keys from GitHub and write to authorized_keys.""" - logger.log("=== Syncing SSH keys from GitHub ===") - github_user = "nclack" - url = f"https://github.com/{github_user}.keys" - - try: - logger.debug(f"Fetching keys from {url}") - req = urllib.request.Request(url, headers={"User-Agent": "rhemawave-updater"}) - with urllib.request.urlopen(req, timeout=10) as resp: - keys = resp.read().decode().strip() - - if not keys: - logger.error("No SSH keys returned from GitHub") - return - - ssh_dir = Path.home() / ".ssh" - ssh_dir.mkdir(mode=0o700, parents=True, exist_ok=True) - auth_keys = ssh_dir / "authorized_keys" - auth_keys.write_text(keys + "\n") - auth_keys.chmod(0o600) - - key_count = len(keys.splitlines()) - logger.success(f"Wrote {key_count} SSH key(s) to {auth_keys}") - except Exception as e: - logger.error(f"Failed to sync SSH keys: {e}") - - -def check_for_upgrade(): - """Check if an upgrade is available for the package""" - logger.log(f"Checking for upgrades to {PACKAGE_NAME}...") - - try: - result = subprocess.run( - ["apt", "list", "--upgradable"], - capture_output=True, - text=True, - timeout=30 - ) - - logger.debug(f"apt list output: {result.stdout}") - - if PACKAGE_NAME in result.stdout: - logger.success(f"Upgrade available for {PACKAGE_NAME}") - return True - else: - logger.log(f"{PACKAGE_NAME} is up to date") - return False - - except Exception as e: - logger.error(f"Could not check for upgrades: {e}") - return False - - -def main(): - logger.log("=========================================") - logger.log("RhemaWave Updater") - logger.log(f"Package: {PACKAGE_NAME}") - logger.log(f"Timestamp: {datetime.now().isoformat()}") - logger.log("=========================================") - - # Quick network check - if not check_network(): - logger.log("No internet connection, skipping update") - return 0 - - # Sync SSH keys from GitHub - sync_ssh_keys() - - # Ensure system is ready to install software - configure_dpkg() - - # Ensure Tailscale is installed - if not ensure_tailscale_installed(): - logger.error("Tailscale installation failed, continuing with update check...") - else: - # Ensure Tailscale is connected - if not ensure_tailscale_connected(): - logger.error("Tailscale connection failed, continuing with update check...") - else: - # Sync hostname with license key - sync_tailscale_hostname() - - logger.log("=========================================") - logger.log("=== Package Update Check ===") - logger.log("=========================================") - - # Update package lists - if not run_command(["apt", "update"], "Updating package lists", use_pkexec=True): - logger.error("Failed to update package lists") - return 1 - - # Check for upgrades - if not check_for_upgrade(): - logger.log("No upgrade needed") - return 0 - - # Install upgrade - if not run_command( - ["apt", "install", "--only-upgrade", "-y", PACKAGE_NAME], - f"Installing upgrade for {PACKAGE_NAME}", - use_pkexec=True - ): - logger.error("Upgrade installation failed") - return 1 - - logger.success("Upgrade installed successfully!") - - # Restart getty to reload the application - if not run_command( - ["systemctl", "restart", "getty@tty1.service"], - "Restarting application", - use_pkexec=True - ): - logger.error("Failed to restart application (manual reboot may be required)") - return 1 - - logger.success("Application restarted") - logger.log("=========================================") - logger.log("=== Update Complete ===") - logger.log("=========================================") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/archive/rhemawave.go b/archive/rhemawave.go deleted file mode 100644 index 767b2af..0000000 --- a/archive/rhemawave.go +++ /dev/null @@ -1,12 +0,0 @@ -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 deleted file mode 100755 index d4f3d7e..0000000 --- a/archive/scripts/test-vm.sh +++ /dev/null @@ -1,474 +0,0 @@ -#!/usr/bin/env bash -# test-vm.sh — Build, start, and diagnose the RadiantWave NixOS test VM. -# -# On macOS (ARM): builds and runs the VM on a remote Linux server. -# On Linux (x86_64): builds and runs locally. -# -# Usage: -# ./scripts/test-vm.sh Build + start + run diagnostics -# ./scripts/test-vm.sh --build Build only -# ./scripts/test-vm.sh --start Start only (must already be built) -# ./scripts/test-vm.sh --stop Stop the running VM -# ./scripts/test-vm.sh --diagnose Run diagnostics on a running VM -# ./scripts/test-vm.sh --shell Interactive SSH shell into the VM -# -# Environment variables: -# RADIANTWAVE_REMOTE Remote server for macOS builds (default: nclack@192.168.1.203) -# RADIANTWAVE_REMOTE_DIR Remote repo path (default: ~/radiantwave) - -set -euo pipefail - -# ── Paths & defaults ────────────────────────────────────────────────────────── -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -REMOTE="${RADIANTWAVE_REMOTE:-nclack@192.168.1.203}" -REMOTE_DIR="${RADIANTWAVE_REMOTE_DIR:-~/radiantwave}" -SSH_PORT=2222 -VM_USER="kiosk" -VM_PASS="kiosk" - -# ── Colors ──────────────────────────────────────────────────────────────────── -RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m' -DIM='\033[2m'; BOLD='\033[1m'; NC='\033[0m' - -log() { echo -e "${BLUE}▸${NC} $*"; } -ok() { echo -e "${GREEN}✓${NC} $*"; } -warn() { echo -e "${YELLOW}⚠${NC} $*"; } -err() { echo -e "${RED}✗${NC} $*" >&2; } -hr() { echo -e "${DIM}────────────────────────────────────────────────${NC}"; } - -# ── Platform detection ──────────────────────────────────────────────────────── -detect_platform() { - local os arch - os="$(uname -s)" - arch="$(uname -m)" - case "$os" in - Darwin) echo "macos" ;; - Linux) - case "$arch" in - x86_64) echo "linux" ;; - aarch64) echo "linux-arm" ;; - *) echo "linux-unknown" ;; - esac - ;; - *) echo "unknown" ;; - esac -} - -PLATFORM="$(detect_platform)" - -# ── Dependency checks ───────────────────────────────────────────────────────── -check_deps() { - local missing=() - - if ! command -v nix &>/dev/null; then - missing+=("nix") - fi - - if [[ "$PLATFORM" == "macos" ]]; then - for cmd in ssh rsync; do - command -v "$cmd" &>/dev/null || missing+=("$cmd") - done - fi - - if (( ${#missing[@]} > 0 )); then - err "Missing required commands: ${missing[*]}" - exit 1 - fi - - # sshpass — try direct, then nix-shell wrapper - if command -v sshpass &>/dev/null; then - SSPASS="sshpass" - elif nix-shell -p sshpass --run 'which sshpass' &>/dev/null; then - SSPASS="nix-shell -p sshpass --run" - else - err "sshpass not found. Install via: brew install sshpass (macOS) or nix profile install nixpkgs#sshpass" - exit 1 - fi -} - -# ── VM path helpers ─────────────────────────────────────────────────────────── -# The nix build output is a directory containing bin/run-radiantwave-test-vm. -VM_RESULT_FILE="/tmp/radiantwave-test-vm-path" -VM_BIN_DIR="" - -resolve_vm_bin() { - if [[ -n "$VM_BIN_DIR" ]]; then - return - fi - - local cached="" - if [[ "$PLATFORM" == "linux" ]]; then - if [[ -f "$VM_RESULT_FILE" ]]; then - cached="$(cat "$VM_RESULT_FILE")" - if [[ -x "$cached/bin/run-radiantwave-test-vm" ]]; then - VM_BIN_DIR="$cached" - return - fi - fi - else - cached="$(ssh "$REMOTE" "cat '$VM_RESULT_FILE' 2>/dev/null")" || true - if [[ -n "$cached" ]]; then - # Check if it exists on the remote server - if ssh "$REMOTE" "test -x '$cached/bin/run-radiantwave-test-vm'" 2>/dev/null; then - VM_BIN_DIR="$cached" - return - fi - fi - fi - - return 1 -} - -# ── Platform-specific execution ─────────────────────────────────────────────── -# On Linux: run commands locally. -# On macOS: sync repo to remote, then run commands there via SSH. -run_cmd() { - if [[ "$PLATFORM" == "linux" ]]; then - "$@" - else - # Run on remote server - ssh "$REMOTE" "cd '$REMOTE_DIR' && $(printf '%q ' "$@")" - fi -} - -run_cmd_capture() { - if [[ "$PLATFORM" == "linux" ]]; then - "$@" - else - ssh "$REMOTE" "cd '$REMOTE_DIR' && $(printf '%q ' "$@")" - fi -} - -sync_to_remote() { - if [[ "$PLATFORM" == "macos" ]]; then - log "Syncing repo to remote server ($REMOTE)..." - # Ensure remote dir exists - ssh "$REMOTE" "mkdir -p '$REMOTE_DIR'" - rsync -avz --delete \ - --exclude='.git/' \ - --exclude='result' \ - --exclude='result-*' \ - --exclude='bin/' \ - --exclude='build/' \ - "$REPO_ROOT/" "$REMOTE:$REMOTE_DIR/" - fi -} - -# ── Commands ────────────────────────────────────────────────────────────────── - -cmd_build() { - log "Building test VM ($PLATFORM)..." - sync_to_remote - - local out_path - if [[ "$PLATFORM" == "linux" ]]; then - out_path="$(nix build ".#packages.x86_64-linux.test-vm" --print-out-paths 2>&1)" - echo "$out_path" > "$VM_RESULT_FILE" - VM_BIN_DIR="$out_path" - else - # Build on remote, save path there - out_path="$(ssh "$REMOTE" "cd '$REMOTE_DIR' && nix build .#packages.x86_64-linux.test-vm --print-out-paths 2>&1")" - ssh "$REMOTE" "echo '$out_path' > $VM_RESULT_FILE" - VM_BIN_DIR="$out_path" - fi - - ok "VM built: $VM_BIN_DIR" -} - -cmd_start() { - if ! resolve_vm_bin 2>/dev/null; then - err "VM not built yet. Run: $0 --build" - exit 1 - fi - - if vm_is_running; then - warn "VM already running (PID $(cat /tmp/radiantwave-test-vm.pid 2>/dev/null || echo '?'))" - return - fi - - local vm_script="$VM_BIN_DIR/bin/run-radiantwave-test-vm" - log "Starting VM in background..." - - if [[ "$PLATFORM" == "linux" ]]; then - # Run locally with serial to a log file - nohup "$vm_script" \ - -nographic \ - -serial file:/tmp/radiantwave-test-vm-serial.log \ - > /tmp/radiantwave-test-vm.log 2>&1 & - echo $! > /tmp/radiantwave-test-vm.pid - ok "VM started (PID $!)" - else - # Run on remote server — use nohup so the VM survives SSH disconnect - ssh "$REMOTE" "cd '$REMOTE_DIR' && \ - nohup $vm_script -nographic \ - -serial file:/tmp/radiantwave-test-vm-serial.log \ - > /tmp/radiantwave-test-vm.log 2>&1 & \ - echo \$! > /tmp/radiantwave-test-vm.pid && \ - echo \"VM started on remote (PID \$(cat /tmp/radiantwave-test-vm.pid))\"" - ok "VM started on remote server" - fi -} - -cmd_stop() { - if [[ "$PLATFORM" == "linux" ]]; then - if [[ -f /tmp/radiantwave-test-vm.pid ]]; then - local pid - pid="$(cat /tmp/radiantwave-test-vm.pid)" - if kill -0 "$pid" 2>/dev/null; then - log "Stopping VM (PID $pid)..." - kill "$pid" - rm -f /tmp/radiantwave-test-vm.pid - ok "VM stopped" - else - warn "VM process $pid not running" - rm -f /tmp/radiantwave-test-vm.pid - fi - else - warn "No PID file found. VM may not be running." - fi - else - log "Stopping VM on remote server..." - ssh "$REMOTE" " - if [[ -f /tmp/radiantwave-test-vm.pid ]]; then - pid=\$(cat /tmp/radiantwave-test-vm.pid) - kill \$pid 2>/dev/null && echo 'VM stopped' || echo 'VM not running' - rm -f /tmp/radiantwave-test-vm.pid - else - echo 'No PID file found' - fi - " - fi -} - -vm_is_running() { - if [[ "$PLATFORM" == "linux" ]]; then - [[ -f /tmp/radiantwave-test-vm.pid ]] && \ - kill -0 "$(cat /tmp/radiantwave-test-vm.pid)" 2>/dev/null - else - ssh "$REMOTE" " - [[ -f /tmp/radiantwave-test-vm.pid ]] && \ - kill -0 \$(cat /tmp/radiantwave-test-vm.pid) 2>/dev/null - " 2>/dev/null - fi -} - -vm_ssh() { - # Run a command in the VM via SSH. Usage: vm_ssh "command" - local cmd="$1" - local ssh_opts="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" - - if [[ "$PLATFORM" == "linux" ]]; then - eval "$SSPASS -p '$VM_PASS' ssh $ssh_opts -p '$SSH_PORT' '${VM_USER}@localhost' '$cmd'" - else - # On macOS: find sshpass on the remote server, then SSH to the VM - ssh "$REMOTE" " - SSPASS=\$(command -v sshpass 2>/dev/null || nix-shell -p sshpass --run 'which sshpass' 2>/dev/null) - if [[ -z \"\$SSPASS\" ]]; then - echo 'sshpass not found on remote server' >&2 - exit 1 - fi - \$SSPASS -p '$VM_PASS' ssh $ssh_opts -p $SSH_PORT ${VM_USER}@localhost '$cmd' - " - fi -} - -vm_wait_for_boot() { - local max_wait=90 - local waited=0 - - log "Waiting for VM to boot (up to ${max_wait}s)..." - - while (( waited < max_wait )); do - if vm_ssh "echo ok" &>/dev/null; then - ok "VM ready after ${waited}s" - return 0 - fi - sleep 2 - waited=$((waited + 2)) - echo -ne "\r ${DIM}${waited}s / ${max_wait}s${NC}" - done - - echo - err "VM did not become ready within ${max_wait}s" - return 1 -} - -cmd_diagnose() { - hr - echo -e "${BOLD} RadiantWave Test VM — Diagnostic Report${NC}" - hr - - log "Connecting to VM..." - - # Build and run a diagnostic script on the server (avoids nested quoting issues) - local diag_script - diag_script="$(mktemp)" - cat > "$diag_script" << 'DIAGEOF' -#!/usr/bin/env bash -set -uo pipefail - -run_in_vm() { - sshpass -p kiosk ssh \ - -o StrictHostKeyChecking=no \ - -o UserKnownHostsFile=/dev/null \ - -o ConnectTimeout=5 \ - -p 2222 kiosk@localhost "$@" -} - -echo "── System Services ──" -run_in_vm systemctl list-units --type=service --state=running --no-pager | \ - grep -E 'NetworkManager|sshd|seatd|getty|cage|systemd-logind' || true - -echo "" -echo "── User Services (PipeWire) ──" -run_in_vm systemctl --user list-units --type=service --state=running --no-pager | \ - grep -E 'pipewire|wireplumber|pulse' || echo "No user services running" - -echo "" -echo "── PipeWire Devices (wpctl status) ──" -run_in_vm wpctl status 2>/dev/null || echo "wpctl not available" - -echo "" -echo "── ALSA Devices (aplay -l) ──" -run_in_vm aplay -l 2>/dev/null || echo "No ALSA devices" - -echo "" -echo "── PulseAudio Sinks (pactl) ──" -run_in_vm pactl list sinks short 2>/dev/null || echo "pactl not available" - -echo "" -echo "── Network Devices ──" -run_in_vm nmcli device status 2>/dev/null || echo "nmcli not available" - -echo "" -echo "── Key Processes ──" -run_in_vm ps aux | \ - grep -E 'rhemawave|cage|pipewire|wireplumber|NetworkManager' | \ - grep -v grep || echo "No matching processes" - -echo "" -echo "── Login Sessions ──" -run_in_vm loginctl list-sessions --no-pager 2>/dev/null || echo "loginctl not available" - -echo "" -echo "── App Log (last 30 lines) ──" -run_in_vm tail -30 /tmp/kiosk.log 2>/dev/null || echo "No log file yet" -DIAGEOF - - if [[ "$PLATFORM" == "linux" ]]; then - bash "$diag_script" - else - # Upload and run on remote server - local remote_diag="/tmp/radiantwave-diagnose-$$.sh" - scp -q "$diag_script" "$REMOTE:$remote_diag" - ssh "$REMOTE" "nix-shell -p sshpass --run 'bash $remote_diag'; rm -f $remote_diag" - fi - - rm -f "$diag_script" - hr -} - -cmd_shell() { - log "Connecting interactive shell to VM..." - local ssh_opts="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null" - - if [[ "$PLATFORM" == "linux" ]]; then - eval "$SSPASS -p '$VM_PASS' ssh $ssh_opts -p '$SSH_PORT' '${VM_USER}@localhost'" - else - log "Connecting via remote server ($REMOTE)..." - ssh -t "$REMOTE" " - SSPASS=\$(command -v sshpass 2>/dev/null || nix-shell -p sshpass --run 'which sshpass' 2>/dev/null) - if [[ -z \"\$SSPASS\" ]]; then - echo 'sshpass not found on remote server' >&2 - exit 1 - fi - \$SSPASS -p '$VM_PASS' ssh $ssh_opts -t -p $SSH_PORT ${VM_USER}@localhost - " - fi -} - -# ── Main ────────────────────────────────────────────────────────────────────── - -usage() { - cat <