Remove archive code: Accidental commit
This commit is contained in:
@@ -1 +0,0 @@
|
|||||||
use flake
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
# This action builds the `dev` branch any time a Pull Request is submitted to dev.
|
|
||||||
# The purpose of this is two fold:
|
|
||||||
# - Prevent breaking changes from being merged to dev
|
|
||||||
# - Automates the building of RadiantWave-Dev which can then be uploaded
|
|
||||||
# to the Attic server, and the development machines can pull prebuilt
|
|
||||||
# binaries instead of building everything from source, resulting in faster
|
|
||||||
# updates, once updates work again that is...
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- dev
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- dev
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: neils-server-runner
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
|
|
||||||
- name: Test
|
|
||||||
run: |
|
|
||||||
. /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh
|
|
||||||
nix develop --command go test ./internal/mixer/ -v -timeout 30s
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
run: |
|
|
||||||
. /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh
|
|
||||||
nix build -L
|
|
||||||
echo "Working dir: $(pwd)"
|
|
||||||
echo "Result symlink: $(readlink -f result)"
|
|
||||||
find result/ -type f
|
|
||||||
|
|
||||||
- name: Notify
|
|
||||||
if: always()
|
|
||||||
uses: https://github.com/dawidd6/action-send-mail@v3
|
|
||||||
with:
|
|
||||||
server_address: smtp.gmail.com
|
|
||||||
server_port: 465
|
|
||||||
username: ${{ secrets.MAIL_USERNAME }}
|
|
||||||
password: ${{ secrets.MAIL_PASSWORD }}
|
|
||||||
subject: "RadiantWave build ${{ job.status }}"
|
|
||||||
to: cnclack@pm.me
|
|
||||||
from: ci@radiantwavetech.com
|
|
||||||
body: "Build ${{ job.status }} for commit ${{ github.sha }} on branch dev"
|
|
||||||
|
|
||||||
# - name: Trigger KioskConfig dev build
|
|
||||||
# if: success()
|
|
||||||
# run: |
|
|
||||||
# curl -X POST \
|
|
||||||
# -H "Authorization: token ${{ secrets.TOKEN }}" \
|
|
||||||
# -H "Content-Type: application/json" \
|
|
||||||
# -d '{"ref": "main"}' \
|
|
||||||
# https://codeberg.org/api/v1/repos/RhemaWave-Technologies/KioskConfig/actions/workflows/update-dev.yaml/dispatches
|
|
||||||
|
|
||||||
# - name: Trigger KioskConfig dev build
|
|
||||||
# if: success()
|
|
||||||
# run: |
|
|
||||||
# curl -X POST \
|
|
||||||
# -H "Authorization: token ${{ secrets.FORGEJO_TOKEN }}" \
|
|
||||||
# -H "Accept: application/json" \
|
|
||||||
# -H "Content-Type: application/json" \
|
|
||||||
# https://codeberg.org/api/v1/repos/RhemaWave-Technologies/KioskConfig/dispatches \
|
|
||||||
# -d '{"type": "radiantwave-dev-built"}'
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
# This action builds the `dev` branch any time a Pull Request is submitted to dev.
|
|
||||||
# The purpose of this is two fold:
|
|
||||||
# - Prevent breaking changes from being merged to dev
|
|
||||||
# - Automates the building of RadiantWave-Dev which can then be uploaded
|
|
||||||
# to the Attic server, and the development machines can pull prebuilt
|
|
||||||
# binaries instead of building everything from source, resulting in faster
|
|
||||||
# updates, once updates work again that is...
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: neils-server-runner
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
|
|
||||||
- name: Test
|
|
||||||
run: |
|
|
||||||
. /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh
|
|
||||||
nix develop --command go test ./internal/mixer/ -v -timeout 30s
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
run: |
|
|
||||||
. /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh
|
|
||||||
nix build -L
|
|
||||||
echo "Working dir: $(pwd)"
|
|
||||||
echo "Result symlink: $(readlink -f result)"
|
|
||||||
find result/ -type f
|
|
||||||
|
|
||||||
- name: Notify
|
|
||||||
if: always()
|
|
||||||
uses: https://github.com/dawidd6/action-send-mail@v3
|
|
||||||
with:
|
|
||||||
server_address: smtp.gmail.com
|
|
||||||
server_port: 465
|
|
||||||
username: ${{ secrets.MAIL_USERNAME }}
|
|
||||||
password: ${{ secrets.MAIL_PASSWORD }}
|
|
||||||
subject: "RadiantWave build ${{ job.status }}"
|
|
||||||
to: cnclack@pm.me
|
|
||||||
from: ci@radiantwavetech.com
|
|
||||||
body: "Build ${{ job.status }} for commit ${{ github.sha }} on branch main"
|
|
||||||
|
|
||||||
# - name: Trigger KioskConfig dev build
|
|
||||||
# if: success()
|
|
||||||
# run: |
|
|
||||||
# curl -X POST \
|
|
||||||
# -H "Authorization: token ${{ secrets.TOKEN }}" \
|
|
||||||
# -H "Content-Type: application/json" \
|
|
||||||
# -d '{"ref": "main"}' \
|
|
||||||
# https://codeberg.org/api/v1/repos/RhemaWave-Technologies/KioskConfig/actions/workflows/update-dev.yaml/dispatches
|
|
||||||
|
|
||||||
# - name: Trigger KioskConfig main build
|
|
||||||
# if: success()
|
|
||||||
# run: |
|
|
||||||
# curl -X POST \
|
|
||||||
# -H "Authorization: token ${{ secrets.FORGEJO_TOKEN }}" \
|
|
||||||
# -H "Accept: application/json" \
|
|
||||||
# -H "Content-Type: application/json" \
|
|
||||||
# https://codeberg.org/api/v1/repos/RhemaWave-Technologies/KioskConfig/dispatches \
|
|
||||||
# -d '{"type": "radiantwave-dev-built"}'
|
|
||||||
@@ -1,261 +0,0 @@
|
|||||||
# RadiantWave — Codebase Architecture
|
|
||||||
|
|
||||||
## What This Is
|
|
||||||
|
|
||||||
A kiosk application for "RadiantWave" wellness technology. It displays scrolling
|
|
||||||
proclamation text rendered with custom shaders, plays audio at solfeggio
|
|
||||||
frequencies, and manages WiFi/provisioning on a headless Linux kiosk.
|
|
||||||
|
|
||||||
**Core libraries:** SDL2, OpenGL 3.3, SQLite (GORM), D-Bus (NetworkManager).
|
|
||||||
**Build system:** Nix flake. **CI:** Forgejo on a self-hosted runner.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Application Lifecycle
|
|
||||||
|
|
||||||
```
|
|
||||||
rhemawave.go → internal/application.Run()
|
|
||||||
```
|
|
||||||
|
|
||||||
`Run()` performs:
|
|
||||||
1. SDL2 init (video, audio, events)
|
|
||||||
2. Create window with `SDL_WINDOW_OPENGL | SDL_WINDOW_SHOWN`
|
|
||||||
3. OpenGL 3.3 context
|
|
||||||
4. ShaderManager singleton init
|
|
||||||
5. FontManager singleton init
|
|
||||||
6. Mixer singleton init
|
|
||||||
7. SQLite connect + GORM auto-migrate
|
|
||||||
8. Sync proclamations from remote API
|
|
||||||
9. Start audio download goroutine
|
|
||||||
10. Enter SDL event loop
|
|
||||||
|
|
||||||
The event loop polls SDL events, calls `Router.Update()` + `Router.Render()`,
|
|
||||||
and runs `checkState()` to drive the provisioning state machine.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Provisioning State Machine
|
|
||||||
|
|
||||||
Defined in `internal/application/application.go`:
|
|
||||||
|
|
||||||
```
|
|
||||||
FIRST_RUN → NEEDS_INTERNET → NEEDS_LICENSE_KEY → NEEDS_EMAIL_ADDRESS → DOWNLOADING_ASSETS → READY
|
|
||||||
```
|
|
||||||
|
|
||||||
Each state maps to a page. `checkState()` evaluates DB values and network
|
|
||||||
status to decide which page to show.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Page System (`internal/page/`)
|
|
||||||
|
|
||||||
All pages implement the `Page` interface:
|
|
||||||
|
|
||||||
```go
|
|
||||||
type Page interface {
|
|
||||||
Init(ctx *PageCtx)
|
|
||||||
HandleEvent(event sdl.Event) error
|
|
||||||
Update(dt float64)
|
|
||||||
Render()
|
|
||||||
Destroy()
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`PageCtx` provides: `*gorm.DB`, screen dimensions, `DownloadDone` channel,
|
|
||||||
`NavigateTo(pageType)` callback.
|
|
||||||
|
|
||||||
`Router` (`router.go`) maps `pageType` enum → concrete page struct, manages
|
|
||||||
lifecycle (Destroy old → Init new).
|
|
||||||
|
|
||||||
| Page | Purpose |
|
|
||||||
|------|---------|
|
|
||||||
| `Home` | Version info, download progress, license status, F2/F3 keybinds |
|
|
||||||
| `ScrollerPage` | **Main screen:** scrolling proclamation text with shaders, line patterns, color patterns, audio playback, velocity control |
|
|
||||||
| `SettingsPage` | Menu: audio, WiFi, proclamations, license, email, logs, volume |
|
|
||||||
| `AudioOptions` | Audio device selection (via PipeWire pactl), on/off, volume |
|
|
||||||
| `ProclamationOptions` | Browse/search/select proclamations from DB |
|
|
||||||
| `WiFiSetupPage` | WiFi scan, WPA connect, password entry |
|
|
||||||
| `LicenseKeyPage` | License key text input, saves to DB |
|
|
||||||
| `EmailAddressPage` | Email text input with regex validation |
|
|
||||||
| `LogViewerPage` | Scrollable log viewer from DB |
|
|
||||||
| `DownloadingAssets` | Stub during download phase |
|
|
||||||
| `Message` | Generic message page (stub) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## OpenGL Rendering
|
|
||||||
|
|
||||||
### Virtual Framebuffer (`internal/graphics/virtual_framebuffer.go`)
|
|
||||||
Renders to a 1920x1080 FBO, then `glBlitFramebuffer()` to the native window
|
|
||||||
resolution. This decouples layout from display size.
|
|
||||||
|
|
||||||
### Shaders (`share/rhemawave/shaders/`)
|
|
||||||
| Shader | Purpose |
|
|
||||||
|--------|---------|
|
|
||||||
| `scroller.vert/frag` | Text with font texture; uniform `u_textColor` for per-word coloring |
|
|
||||||
| `text.vert/frag` | Non-scrolling text elements |
|
|
||||||
| `solid_color.vert/frag` | Flat color (backgrounds, UI) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Font System (`internal/fontManager/`)
|
|
||||||
|
|
||||||
**FontManager** (singleton) wraps SDL_ttf + OpenGL textures:
|
|
||||||
- Loads TTF/OTF fonts, creates GL texture atlas per font.
|
|
||||||
- `GetGlyph(fontName, rune)` → glyph metrics (advance, bearing, texture coords).
|
|
||||||
- `RenderString(fontName, text, x, y, color, scale)` → renders to GL.
|
|
||||||
- **Scrambled font mode:** Characters are spatially rearranged in the atlas, creating visual "frequency signatures" when rendered.
|
|
||||||
|
|
||||||
### Fonts (`share/rhemawave/fonts/`)
|
|
||||||
| File | Use |
|
|
||||||
|------|-----|
|
|
||||||
| `Phibonacci_regular.otf` | Decorative display font |
|
|
||||||
| `Roboto-Regular.ttf` | Clean sans-serif (UI) |
|
|
||||||
| `SpaceMonoNerdFont-Regular.ttf` | Monospace with icons |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Pattern System (`internal/pattern/`)
|
|
||||||
|
|
||||||
### Line Patterns (`internal/pattern/line/`)
|
|
||||||
`LinePattern` interface: `GetNextLine(words, wordIndex, ctx) []string`.
|
|
||||||
Registered via `init()` (plugin-style Registry map).
|
|
||||||
|
|
||||||
| Pattern | Behavior |
|
|
||||||
|---------|----------|
|
|
||||||
| Fibonacci | Oscillating sequence (1,2,3,5,8,13,21) for words-per-line; creates wave in 2D |
|
|
||||||
| Sine Wave | Sine function → line width as % of screen; 5 cycles over 67 lines |
|
|
||||||
|
|
||||||
### Color Patterns (`internal/pattern/color/`)
|
|
||||||
Distributes colors across words using Fibonacci-oscillating durations
|
|
||||||
(1,1,2,3,5,8,13,21,34,55 words per color, then reverses).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Audio System
|
|
||||||
|
|
||||||
### Mixer (`internal/mixer/`)
|
|
||||||
Singleton wrapping SDL_mixer:
|
|
||||||
- PipeWire sink enumeration via `pactl list sinks short`
|
|
||||||
- Volume persistence to DB
|
|
||||||
- Device switching with volume restore
|
|
||||||
- `Play(wavPath)`, `Stop()`, `SwitchDevice(deviceName)`
|
|
||||||
|
|
||||||
### Audio Files
|
|
||||||
`.wav` files are gitignored. Downloaded at runtime from
|
|
||||||
`files.radiantwavetech.com`, verified against `checksums.md5`, stored in
|
|
||||||
`~/.radiantwave/audio/`. A goroutine handles async download; a channel
|
|
||||||
signals completion.
|
|
||||||
|
|
||||||
### Solfeggio Frequencies
|
|
||||||
9 presets (174Hz–963Hz), each mapped to a "healing intention". Stored in DB,
|
|
||||||
selectable in AudioOptions page.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Database (`internal/db/`)
|
|
||||||
|
|
||||||
SQLite at `~/.local/share/rhemawave/database.sqlite` (WAL mode).
|
|
||||||
|
|
||||||
| Model | Purpose |
|
|
||||||
|-------|---------|
|
|
||||||
| `AppConfig` | Key-value store: license key, email, audio device, volume, download state |
|
|
||||||
| `Log` | Application logs (level, message, timestamp) |
|
|
||||||
| `Proclamation` | Synced proclamation text (title, content, category, intent colors) |
|
|
||||||
| `ProclamationCategory` | Category metadata |
|
|
||||||
| `SolfeggioFrequency` | 9 frequency presets |
|
|
||||||
| `AudioFile` | Downloaded audio files (path, checksum, status) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Network (`internal/network/`)
|
|
||||||
|
|
||||||
D-Bus integration via `gonetworkmanager`:
|
|
||||||
- Async WiFi scan (channel-based results)
|
|
||||||
- WPA/WPA2 connection support
|
|
||||||
- Captive portal detection
|
|
||||||
|
|
||||||
### Remote APIs
|
|
||||||
| Endpoint | Purpose |
|
|
||||||
|----------|---------|
|
|
||||||
| `portal.radiantwavetech.com/api/v1/proclamations` | Sync proclamations |
|
|
||||||
| `files.radiantwavetech.com/files/` | Audio file downloads |
|
|
||||||
| HTTP API | License validation |
|
|
||||||
|
|
||||||
Generic HTTP client in `internal/netreq/netReq.go`: typed `GetJSON[T]()` and
|
|
||||||
`Download()` with progress callback.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Assets (`share/rhemawave/`)
|
|
||||||
|
|
||||||
Copied into nix store at build time:
|
|
||||||
- `fonts/` — 3 font files (see above)
|
|
||||||
- `shaders/` — 3 GLSL shader pairs
|
|
||||||
- `proclamations/home/` — 16 text files (healing, wellness, productivity)
|
|
||||||
- `proclamations/commercial/` — 1 text file
|
|
||||||
- `tos/` — 3 HTML files (Welcome, Notice, Getting Started)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## C++ Rebuild Map
|
|
||||||
|
|
||||||
These are the components that need to be reimplemented in C++ with CMake/Ninja:
|
|
||||||
|
|
||||||
| Component | Go Package | C++ Equivalent | External Deps |
|
|
||||||
|-----------|-----------|----------------|---------------|
|
|
||||||
| Entry / Event Loop | `rhemawave.go`, `application.go` | `main.cpp` | SDL2 |
|
|
||||||
| Provisioning FSM | `application.go` | State machine class | — |
|
|
||||||
| Page Router | `page/router.go` | Router class | — |
|
|
||||||
| Page (11 types) | `page/*.go` | 11 page classes implementing Page interface | — |
|
|
||||||
| OpenGL Context | `application.go` | GL context init | SDL2, OpenGL 3.3 |
|
|
||||||
| Virtual Framebuffer | `graphics/virtual_framebuffer.go` | FBO + blit | OpenGL |
|
|
||||||
| Shader Manager | `graphics/shaderManager.go` | Shader class | OpenGL |
|
|
||||||
| Font Manager | `fontManager/fontManager.go` | Font atlas class | SDL_ttf, OpenGL |
|
|
||||||
| Line Patterns | `pattern/line/*.go` | Pattern classes + registry | — |
|
|
||||||
| Color Patterns | `pattern/color/*.go` | Color pattern class | — |
|
|
||||||
| Mixer | `mixer/mixer.go` | Mixer class | SDL_mixer |
|
|
||||||
| Network / WiFi | `network/network.go` | NetworkManager wrapper | D-Bus (sdbus-c++) |
|
|
||||||
| Database | `db/*.go` | SQLite wrapper | SQLite3 (direct) or ORM |
|
|
||||||
| HTTP Client | `netreq/netReq.go` | HTTP client | libcurl or cpp-httplib |
|
|
||||||
| Logging | `logger/logger.go` | Logger class | — |
|
|
||||||
| Config / App State | `db/models.go` | Config struct + DB | SQLite3 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Build & Run
|
|
||||||
|
|
||||||
Dev shell via direnv (`use flake` in `.envrc`). Inside nix dev shell:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
go build -o bin/rhemawave rhemawave.go # Build
|
|
||||||
./bin/rhemawave # Run
|
|
||||||
nix build -L # Nix build (CI)
|
|
||||||
```
|
|
||||||
|
|
||||||
CGO required (`CGO_ENABLED=1`). The nix shell sets `CGO_CFLAGS`/`CGO_LDFLAGS`
|
|
||||||
for SDL2. Do not `go build` outside the nix shell.
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
|
|
||||||
```sh
|
|
||||||
go test ./internal/mixer/
|
|
||||||
```
|
|
||||||
|
|
||||||
CI runs `go test` then `nix build -L`.
|
|
||||||
|
|
||||||
## Branch Model
|
|
||||||
|
|
||||||
`dev` → `main` via PRs. CI in `.forgejo/workflows/`:
|
|
||||||
- `build-dev.yml`: PRs to `dev`
|
|
||||||
- `build-prod.yml`: PRs to `main`
|
|
||||||
|
|
||||||
Both run `nix build -L` on self-hosted `neils-server-runner`.
|
|
||||||
|
|
||||||
## Common Pitfalls
|
|
||||||
|
|
||||||
- `dev_scripts/reset.sh` referenced in nix devShell hook does not exist.
|
|
||||||
- `share/rhemawave/audio/*.wav` is gitignored — never commit audio assets.
|
|
||||||
- `vendor/` is committed — run `go mod vendor` after `go mod tidy`.
|
|
||||||
- CI uses a self-hosted Forgejo runner, not GitHub-hosted runners.
|
|
||||||
- `packaging/` contains a kiosk-specific updater, not part of the main build.
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
FROM ubuntu:24.04
|
|
||||||
|
|
||||||
ENV DEBIAN_FRONTEND=noninteractive
|
|
||||||
ENV USER=root
|
|
||||||
ENV NIX_CONFIG="experimental-features = nix-command flakes"
|
|
||||||
ENV PATH="/nix/var/nix/profiles/default/bin:${PATH}"
|
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y \
|
|
||||||
curl \
|
|
||||||
xz-utils \
|
|
||||||
ca-certificates \
|
|
||||||
git \
|
|
||||||
build-essential \
|
|
||||||
pkg-config \
|
|
||||||
rsync \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Install Nix
|
|
||||||
RUN curl -L https://nixos.org/nix/install | sh -s -- --daemon
|
|
||||||
|
|
||||||
# Install Go
|
|
||||||
RUN nix profile install nixpkgs#go
|
|
||||||
|
|
||||||
WORKDIR /workspace
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
#+TITLE: Radiantwave & KioskConfig Development & Release Pipeline
|
|
||||||
#+AUTHOR: Neil C.
|
|
||||||
#+DATE: 2026-06-23
|
|
||||||
#+OPTIONS: toc:nil num:nil
|
|
||||||
#+TAGS: development nixos radiantwave kiosk automation
|
|
||||||
|
|
||||||
* Radiant Wave
|
|
||||||
|
|
||||||
* Full Pipeline Workflow (Detailed)
|
|
||||||
|
|
||||||
** Development Phase
|
|
||||||
1. Create a new branch for the issue
|
|
||||||
2. Implement the changes for the issue
|
|
||||||
|
|
||||||
** Integration to =dev=
|
|
||||||
3. Open a pull request to =dev=
|
|
||||||
4. CI: Run =nix build= to verify no errors
|
|
||||||
5. CI: Run linting and unit tests
|
|
||||||
6. CI: Auto-update =rhemawave-dev= flake input
|
|
||||||
7. CI: Build all =dev= VMs in parallel (Kiosk, Commercial, RPi)
|
|
||||||
8. Merge the pull request to =dev= after CI passes
|
|
||||||
|
|
||||||
** Testing Phase
|
|
||||||
9. Run all VMs (Kiosk, Commercial, RPi) and test all functionality of the application on each VM
|
|
||||||
10. Install =dev-iso= to a test machine and verify installation and application functionality
|
|
||||||
|
|
||||||
** Release to =main=
|
|
||||||
11. Open a pull request from =dev= to =main=
|
|
||||||
12. CI: Build all =main= VMs in parallel (Kiosk, Commercial, RPi)
|
|
||||||
13. CI: Build all ISO images in parallel
|
|
||||||
14. CI: Auto-rename ISO images to format =radiantwave-{system}-{YYYY-MM-DD}-{git-hash}.iso=
|
|
||||||
15. CI: Upload ISO images to file server
|
|
||||||
16. CI: Move old ISO images to =archives/= on file server
|
|
||||||
17. Merge the pull request to =main= after CI passes
|
|
||||||
18. Tag the release (optional, for traceability)
|
|
||||||
|
|
||||||
* Automation Opportunities
|
|
||||||
|
|
||||||
This section describes which parts of the workflow can be automated via CI/CD, and how.
|
|
||||||
|
|
||||||
** Group 1: Development Validation (Steps 3-8)
|
|
||||||
- =What to automate=: PR triggering, =nix build=, linting, unit tests, flake updates, dev VM builds
|
|
||||||
- =How=: GitHub Actions workflow triggered on PR to =dev=
|
|
||||||
- =Why=: Catch integration errors before manual testing; ensure =dev= is always buildable
|
|
||||||
|
|
||||||
** Group 2: Testing (Steps 9-10)
|
|
||||||
- =What to automate=: VM functionality testing, ISO installation verification
|
|
||||||
- =How=: Scripted test suites, automated ISO installation in VMs
|
|
||||||
- =Why=: Reduce manual toil; increase test coverage and consistency
|
|
||||||
|
|
||||||
** Group 3: Release Pipeline (Steps 11-16)
|
|
||||||
- =What to automate=: Main VM/ISO builds, ISO renaming, file server uploads, archiving
|
|
||||||
- =How=: GitHub Actions workflow triggered on PR to =main=
|
|
||||||
- =Why=: Ensure consistent releases; eliminate human error in naming/uploading
|
|
||||||
|
|
||||||
* Preflight Checklist
|
|
||||||
|
|
||||||
- [ ] Create a new branch for the issue
|
|
||||||
- [ ] Implement the changes for the issue
|
|
||||||
- [ ] Open a pull request to =dev=
|
|
||||||
- [ ] Merge the pull request to =dev= after CI passes
|
|
||||||
- [ ] Run all VMs (Kiosk, Commercial, RPi) and test all functionality of the application on each VM
|
|
||||||
- [ ] Install =dev-iso= to a test machine and verify installation and application functionality
|
|
||||||
- [ ] Open a pull request from =dev= to =main=
|
|
||||||
- [ ] Merge the pull request to =main= after CI passes
|
|
||||||
- [ ] Tag the release (optional, for traceability)
|
|
||||||
|
|
||||||
* Automation Task List
|
|
||||||
|
|
||||||
** Group 1: Development Validation
|
|
||||||
- [ ] Set up GitHub Actions workflow for PRs to =dev=
|
|
||||||
- [ ] Configure =nix build= step
|
|
||||||
- [ ] Add linting and unit test steps
|
|
||||||
- [ ] Add flake auto-update step
|
|
||||||
- [ ] Configure parallel VM builds (Kiosk, Commercial, RPi)
|
|
||||||
- [ ] Verify workflow blocks PR merge on failure
|
|
||||||
|
|
||||||
** Group 2: Testing
|
|
||||||
- [ ] Create automated test scripts for VM functionality
|
|
||||||
- [ ] Set up automated ISO installation testing
|
|
||||||
- [ ] Integrate with CI for optional automated testing
|
|
||||||
|
|
||||||
** Group 3: Release Pipeline
|
|
||||||
- [ ] Set up GitHub Actions workflow for PRs to =main=
|
|
||||||
- [ ] Configure parallel VM builds
|
|
||||||
- [ ] Configure parallel ISO builds
|
|
||||||
- [ ] Add ISO auto-renaming step (using =git rev-parse HEAD= and =date +%Y-%m-%d=)
|
|
||||||
- [ ] Add file server upload step (=rsync= or similar)
|
|
||||||
- [ ] Add archive old ISOs step
|
|
||||||
- [ ] Verify workflow blocks PR merge on failure
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
* Virtual Machine Testing
|
|
||||||
To be completed after updating rhemawave-dev, deleting nixos.qcow2, and building rhemawavekiosk-dev
|
|
||||||
|
|
||||||
- [ ] Closure builds
|
|
||||||
- [ ] VM Starts
|
|
||||||
- [ ] RadiantWave starts
|
|
||||||
- [ ] WiFi screen does NOT appear (no wifi nic in vm)
|
|
||||||
- [ ] Email Address scren appears
|
|
||||||
- [ ] I can type an email address
|
|
||||||
- [ ] I cannot submit an incomplete email
|
|
||||||
- [ ] I can submit a complete email
|
|
||||||
- [ ] License Key screen appears
|
|
||||||
- [ ] I can type a license key
|
|
||||||
- [ ] I cannot submit an incomplete license
|
|
||||||
- [ ] I can submit a complete license
|
|
||||||
- [ ] Home screen appears
|
|
||||||
- [ ] Version string is correct
|
|
||||||
- [ ] Status: Downloading files
|
|
||||||
-
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
- [X] Version string is correct
|
|
||||||
- [X] Status shows READY or Downloading Assets
|
|
||||||
- [X] Email address is present and correct
|
|
||||||
- [X] License key is present and correct
|
|
||||||
- [X] Settings menu opens
|
|
||||||
- [X] Logs open
|
|
||||||
- [X] No proprietary data is exposed in logs
|
|
||||||
- [X] Logs are colored correctly
|
|
||||||
- [X] WiFi Setup opens
|
|
||||||
- [X] WiFi page shows "Something went wrong" because there are no WiFi nics available
|
|
||||||
- [X] License key opens
|
|
||||||
- [X] License key displays current license key
|
|
||||||
- [X] Backspace deletes characters
|
|
||||||
- [X] I can type in a new license key
|
|
||||||
- [X] I can submit a license key
|
|
||||||
- [X] License Key shows new license key
|
|
||||||
- [X] I cannot submit an incomplete license key
|
|
||||||
- [X] Email address opens
|
|
||||||
- [X] Email addrss displays current email address
|
|
||||||
- [X] Backspace deletes characters
|
|
||||||
- [X] I can type a new email address
|
|
||||||
- [X] I can submit an email address
|
|
||||||
- [X] Email address shows new email address
|
|
||||||
- [X] I cannot submit an invalid email address
|
|
||||||
- [X] Audio opens
|
|
||||||
- [X] I can toggle audio
|
|
||||||
- [X] At least one audio device is listed
|
|
||||||
- [X] Volume is at 100%
|
|
||||||
- [X] I can change the volume
|
|
||||||
- [X] Returning to the Audio page shows toggle and volume updated values
|
|
||||||
- [X] Proclamation Browser opens
|
|
||||||
- [X] Proclamation Browser is populated
|
|
||||||
- [X] Space bar opens and closes categories
|
|
||||||
- [X] Enter opens and closes categories
|
|
||||||
- [X] Space toggles a proclamation
|
|
||||||
- [X] Enter toggles a proclamation
|
|
||||||
- [X] Typing characters starts a search
|
|
||||||
- [X] I can search for Confidence and ADHD
|
|
||||||
- [X] Enter during search toggles selection
|
|
||||||
- [X] Space during search toggles selection
|
|
||||||
- [X] Returning to the Proclamation browser shows my saved proclamations
|
|
||||||
- [X] Scroller opens and starts playing
|
|
||||||
- [X] Audio is playing
|
|
||||||
- [X] Colors appear correct
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
# Changelog
|
|
||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
|
||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/).
|
|
||||||
|
|
||||||
## [Unreleased]
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
### Changed (Major Refactors)
|
|
||||||
- Mixer package: extract wpctl calls behind an interface
|
|
||||||
|
|
||||||
## [07112026-1710]
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Play() deadlocks when called before device is open (recursive SwitchDevice -> Play)
|
|
||||||
- Audio volume silently zeroed after switching output devices (Init used hardcoded 0.20; SwitchDevice did not restore saved volume)
|
|
||||||
- SwitchDevice not persisted to database across restarts (caller now saves to AppConfig)
|
|
||||||
- Device restore path passes invalid device name "system default" to SDL (now passes "" for default)
|
|
||||||
- Stop() no-op when audio toggled off mid-playback (removed AudioEnabled guard from Stop)
|
|
||||||
- App shows "READY" before audio files finish downloading (state machine now gates READY on download completion via DOWNLOADING_ASSETS state)
|
|
||||||
- Download failure no longer silently allows READY (downloadFailed flag prevents premature READY)
|
|
||||||
- App restarts repeatedly after downloads complete (checkState re-navigated to HOME, closing DownloadDone channel twice -> panic)
|
|
||||||
- F2/F3 keybinds now work during DOWNLOADING_ASSETS (blocked only during early prerequisites: no wifi, no email, no license key)
|
|
||||||
- Active device highlight incorrect when using default device (default device now maps to first SDL device)
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- Human-readable audio device names (Built-in Speakers, HDMI/TV/Monitor)
|
|
||||||
- Unit tests for mixer package (parseSinks, parseSinkLine, ConvertVolume)
|
|
||||||
- Regression tests for state machine (download gating, failure handling, prerequisite priority, non-blocking channel read)
|
|
||||||
- Regression tests for mixer (Stop with AudioEnabled=false, SwitchDevice edge cases)
|
|
||||||
- Regression tests for Home page (double-close guard, download failure path)
|
|
||||||
|
|
||||||
### Changed (Major Refactors)
|
|
||||||
- Mixer package: inject audio backend dependencies for testability
|
|
||||||
- SwitchDevice now takes *db.AppConfig to access saved volume
|
|
||||||
- Audio files moved from ~/.local/share/rhemawave/audio to ~/.radiantwave/audio
|
|
||||||
- State machine uses DOWNLOADING_ASSETS state before READY
|
|
||||||
- checkState() skips re-navigation when transitioning between HOME-mapped states
|
|
||||||
- network.init() logs warning instead of crashing when D-Bus is unavailable
|
|
||||||
- CI now runs go test before nix build
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
services:
|
|
||||||
builder:
|
|
||||||
build: .
|
|
||||||
working_dir: /workspace
|
|
||||||
init: true
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
- .:/workspace
|
|
||||||
- nix-store:/nix
|
|
||||||
|
|
||||||
command: >
|
|
||||||
bash -lc '
|
|
||||||
set -euo pipefail
|
|
||||||
rm -rf build
|
|
||||||
mkdir -p build
|
|
||||||
OUT=$$(nix build . --print-out-paths)
|
|
||||||
echo "Build output: $$OUT"
|
|
||||||
rsync -a --chmod=ugo=rwX --no-owner --no-group "$$OUT"/ build/
|
|
||||||
'
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
nix-store:
|
|
||||||
Generated
-61
@@ -1,61 +0,0 @@
|
|||||||
{
|
|
||||||
"nodes": {
|
|
||||||
"flake-utils": {
|
|
||||||
"inputs": {
|
|
||||||
"systems": "systems"
|
|
||||||
},
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1731533236,
|
|
||||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
|
||||||
"owner": "numtide",
|
|
||||||
"repo": "flake-utils",
|
|
||||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "numtide",
|
|
||||||
"repo": "flake-utils",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"nixpkgs": {
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1776877367,
|
|
||||||
"narHash": "sha256-EHq1/OX139R1RvBzOJ0aMRT3xnWyqtHBRUBuO1gFzjI=",
|
|
||||||
"owner": "NixOS",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"rev": "0726a0ecb6d4e08f6adced58726b95db924cef57",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "NixOS",
|
|
||||||
"ref": "nixos-unstable",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"root": {
|
|
||||||
"inputs": {
|
|
||||||
"flake-utils": "flake-utils",
|
|
||||||
"nixpkgs": "nixpkgs"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"systems": {
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1681028828,
|
|
||||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
|
||||||
"owner": "nix-systems",
|
|
||||||
"repo": "default",
|
|
||||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "nix-systems",
|
|
||||||
"repo": "default",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"root": "root",
|
|
||||||
"version": 7
|
|
||||||
}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
{
|
|
||||||
description = "RhemaWave";
|
|
||||||
|
|
||||||
inputs = {
|
|
||||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
|
||||||
flake-utils.url = "github:numtide/flake-utils";
|
|
||||||
};
|
|
||||||
|
|
||||||
outputs =
|
|
||||||
{
|
|
||||||
self,
|
|
||||||
nixpkgs,
|
|
||||||
flake-utils,
|
|
||||||
}:
|
|
||||||
nixpkgs.lib.recursiveUpdate
|
|
||||||
(flake-utils.lib.eachDefaultSystem (
|
|
||||||
system:
|
|
||||||
let
|
|
||||||
pkgs = import nixpkgs {
|
|
||||||
inherit system;
|
|
||||||
config.allowUnfreePredicate = pkg: (nixpkgs.lib.getName pkg) == "rhemawave";
|
|
||||||
};
|
|
||||||
|
|
||||||
buildInputs = with pkgs; [
|
|
||||||
SDL2
|
|
||||||
SDL2_ttf
|
|
||||||
SDL2_image
|
|
||||||
SDL2_mixer
|
|
||||||
libGL
|
|
||||||
sqlite
|
|
||||||
dbus
|
|
||||||
] ++ pkgs.lib.optionals pkgs.stdenv.isLinux [
|
|
||||||
wayland
|
|
||||||
wayland-protocols
|
|
||||||
libxkbcommon
|
|
||||||
libdecor
|
|
||||||
libx11
|
|
||||||
libxext
|
|
||||||
];
|
|
||||||
|
|
||||||
nativeBuildInputs = with pkgs; [
|
|
||||||
go
|
|
||||||
pkg-config
|
|
||||||
];
|
|
||||||
|
|
||||||
in
|
|
||||||
{
|
|
||||||
packages.default = pkgs.buildGoModule {
|
|
||||||
pname = "rhemawave";
|
|
||||||
version = "v1.1.1";
|
|
||||||
src = ./.;
|
|
||||||
|
|
||||||
vendorHash = null;
|
|
||||||
|
|
||||||
inherit buildInputs nativeBuildInputs;
|
|
||||||
|
|
||||||
env.CGO_ENABLED = "1";
|
|
||||||
|
|
||||||
preBuild = ''
|
|
||||||
export CGO_CFLAGS="$(pkg-config --cflags SDL2_mixer SDL2_ttf SDL2_image) $CGO_CFLAGS"
|
|
||||||
export CGO_LDFLAGS="$(pkg-config --libs SDL2_mixer SDL2_ttf SDL2_image) $CGO_LDFLAGS"
|
|
||||||
'';
|
|
||||||
|
|
||||||
ldflags = [
|
|
||||||
"-s"
|
|
||||||
"-w"
|
|
||||||
];
|
|
||||||
|
|
||||||
postInstall = ''
|
|
||||||
mkdir -p $out/share/rhemawave
|
|
||||||
cp -r share/rhemawave/* $out/share/rhemawave/
|
|
||||||
'';
|
|
||||||
|
|
||||||
meta = with pkgs.lib; {
|
|
||||||
description = "RhemaWave kiosk application";
|
|
||||||
license = licenses.unfree;
|
|
||||||
platforms = platforms.linux;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
devShells.default = pkgs.mkShell {
|
|
||||||
inherit buildInputs nativeBuildInputs;
|
|
||||||
|
|
||||||
shellHook = ''
|
|
||||||
export OLD_PS1="$PS1"
|
|
||||||
export PS1="[nix-env] $OLD_PS1"
|
|
||||||
|
|
||||||
export CGO_CFLAGS="$(pkg-config --cflags SDL2_mixer SDL2_ttf SDL2_image) $CGO_CFLAGS"
|
|
||||||
export CGO_LDFLAGS="$(pkg-config --libs SDL2_mixer SDL2_ttf SDL2_image) $CGO_LDFLAGS"
|
|
||||||
|
|
||||||
echo "RhemaWave dev shell"
|
|
||||||
echo " go version: $(go version)"
|
|
||||||
echo " CGO_ENABLED=1"
|
|
||||||
echo ""
|
|
||||||
echo "Build: go build -o bin/rhemawave rhemawave.go"
|
|
||||||
echo "Run: ./bin/rhemawave"
|
|
||||||
'';
|
|
||||||
};
|
|
||||||
}
|
|
||||||
))
|
|
||||||
{
|
|
||||||
nixosModules.default = import ./nixos/module.nix self;
|
|
||||||
|
|
||||||
nixosConfigurations.test-vm = nixpkgs.lib.nixosSystem {
|
|
||||||
system = "x86_64-linux";
|
|
||||||
modules = [
|
|
||||||
"${nixpkgs}/nixos/modules/virtualisation/qemu-vm.nix"
|
|
||||||
self.nixosModules.default
|
|
||||||
./nixos/vm.nix
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
packages.x86_64-linux.test-vm =
|
|
||||||
self.nixosConfigurations.test-vm.config.system.build.vm;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
module rhemawavetech.com/rhemawave
|
|
||||||
|
|
||||||
go 1.24.0
|
|
||||||
|
|
||||||
toolchain go1.24.2
|
|
||||||
|
|
||||||
require github.com/veandco/go-sdl2 v0.4.40
|
|
||||||
|
|
||||||
require github.com/go-gl/mathgl v1.2.0
|
|
||||||
|
|
||||||
require github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71
|
|
||||||
|
|
||||||
require (
|
|
||||||
github.com/Wifx/gonetworkmanager/v2 v2.2.0
|
|
||||||
github.com/google/uuid v1.3.0
|
|
||||||
)
|
|
||||||
|
|
||||||
require github.com/godbus/dbus/v5 v5.1.0 // indirect
|
|
||||||
|
|
||||||
require (
|
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
|
||||||
github.com/mattn/go-sqlite3 v1.14.32 // indirect
|
|
||||||
golang.org/x/text v0.31.0 // indirect
|
|
||||||
gorm.io/driver/sqlite v1.6.0
|
|
||||||
gorm.io/gorm v1.31.1
|
|
||||||
)
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
github.com/Wifx/gonetworkmanager/v2 v2.2.0 h1:kPstgsQtY8CmDOOFZd81ytM9Gi3f6ImzPCKF7nNhQ2U=
|
|
||||||
github.com/Wifx/gonetworkmanager/v2 v2.2.0/go.mod h1:fMDb//SHsKWxyDUAwXvCqurV3npbIyyaQWenGpZ/uXg=
|
|
||||||
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA=
|
|
||||||
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw=
|
|
||||||
github.com/go-gl/mathgl v1.2.0 h1:v2eOj/y1B2afDxF6URV1qCYmo1KW08lAMtTbOn3KXCY=
|
|
||||||
github.com/go-gl/mathgl v1.2.0/go.mod h1:pf9+b5J3LFP7iZ4XXaVzZrCle0Q/vNpB/vDe5+3ulRE=
|
|
||||||
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
|
|
||||||
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
|
||||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
|
||||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
|
||||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
|
||||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
|
||||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
|
||||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
|
||||||
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
|
|
||||||
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
|
||||||
github.com/veandco/go-sdl2 v0.4.40 h1:fZv6wC3zz1Xt167P09gazawnpa0KY5LM7JAvKpX9d/U=
|
|
||||||
github.com/veandco/go-sdl2 v0.4.40/go.mod h1:OROqMhHD43nT4/i9crJukyVecjPNYYuCofep6SNiAjY=
|
|
||||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
|
||||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
|
||||||
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
|
||||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
|
||||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
|
||||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
|
||||||
@@ -1,548 +0,0 @@
|
|||||||
package application
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/go-gl/gl/v3.3-core/gl"
|
|
||||||
"github.com/veandco/go-sdl2/sdl"
|
|
||||||
"github.com/veandco/go-sdl2/ttf"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/db"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/download"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/fontManager"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/graphics"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/keybinds"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/license"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/logger"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/mixer"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/network"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/page"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/sync"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
targetFPS = 60.0
|
|
||||||
openGLMajorVersion = 3
|
|
||||||
openGLMinorVersion = 3
|
|
||||||
// volumeStepSize = 16
|
|
||||||
// apiURL = "https://portal.rhemawavetech.com/api/v1"
|
|
||||||
)
|
|
||||||
|
|
||||||
type State int
|
|
||||||
|
|
||||||
const (
|
|
||||||
FIRST_RUN State = iota
|
|
||||||
NEEDS_INTERNET
|
|
||||||
NEEDS_LICENSE_KEY
|
|
||||||
NEEDS_EMAIL_ADDRESS
|
|
||||||
DOWNLOADING_ASSETS
|
|
||||||
READY
|
|
||||||
)
|
|
||||||
|
|
||||||
type Application struct {
|
|
||||||
Window *sdl.Window
|
|
||||||
License *license.Response
|
|
||||||
pageStack []page.Page
|
|
||||||
pendingAction func()
|
|
||||||
running bool
|
|
||||||
DB *gorm.DB
|
|
||||||
PageRouter *page.Router
|
|
||||||
state State
|
|
||||||
downloadsComplete bool
|
|
||||||
downloadFailed bool
|
|
||||||
}
|
|
||||||
|
|
||||||
var lastNetworkCheckTime time.Time
|
|
||||||
var networkIsUp bool
|
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
|
||||||
// Main Entry Point
|
|
||||||
// ----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func Run() {
|
|
||||||
var err error
|
|
||||||
var homeDir string
|
|
||||||
var dataDir string
|
|
||||||
var DB *gorm.DB
|
|
||||||
|
|
||||||
networkIsUp = false
|
|
||||||
|
|
||||||
logger.Info("Starting Application")
|
|
||||||
|
|
||||||
homeDir, err = os.UserHomeDir()
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("unable to fetch user $HOME directory: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
dataDir = filepath.Join(homeDir, ".local", "share", "rhemawave")
|
|
||||||
if err := os.MkdirAll(dataDir, 0755); err != nil {
|
|
||||||
logger.Error("unable to create data directory %s %v", dataDir, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
testFile := filepath.Join(dataDir, ".access_test")
|
|
||||||
f, err := os.Create(testFile)
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("data %s is not writeable: %v", dataDir, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
f.Close()
|
|
||||||
os.Remove(testFile)
|
|
||||||
|
|
||||||
logger.AddFile(filepath.Join(dataDir, "log.txt"))
|
|
||||||
logger.SetLevel = logger.LevelInfo
|
|
||||||
|
|
||||||
DB, err = db.Connect(filepath.Join(dataDir, "database.sqlite"))
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("application failed to connect to the database: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err = db.Migrate(DB); err != nil {
|
|
||||||
logger.Error("application failed to perform database migrations: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
firstRun := false
|
|
||||||
|
|
||||||
// Populate the initial Config cache OR create defaults
|
|
||||||
cfg, err := db.GetConfig(DB)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
||||||
firstRun = true
|
|
||||||
cfg = &db.AppConfig{
|
|
||||||
Model: gorm.Model{ID: 1},
|
|
||||||
DataDir: dataDir,
|
|
||||||
AssetsDir: dataDir,
|
|
||||||
SystemLevel: "BASIC",
|
|
||||||
SelectedAudioDevice: "default",
|
|
||||||
InitialFontSize: 128,
|
|
||||||
StandardFontSize: 32,
|
|
||||||
LinePattern: "fibonacci",
|
|
||||||
AudioEnabled: true,
|
|
||||||
SavedVolume: 1.0,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := DB.Create(cfg).Error; err != nil {
|
|
||||||
logger.Error("failed to create config: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.AddDatabase(DB)
|
|
||||||
|
|
||||||
app := &Application{
|
|
||||||
DB: DB,
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Debug("==== Debug Statement ====")
|
|
||||||
|
|
||||||
if firstRun {
|
|
||||||
app.state = FIRST_RUN
|
|
||||||
}
|
|
||||||
|
|
||||||
w, h := app.initializeSDLAndOpenGL()
|
|
||||||
defer app.cleanup()
|
|
||||||
|
|
||||||
app.initializeManagers()
|
|
||||||
app.initializeAudio()
|
|
||||||
// Create a buffered channel so log writes don't slow down downloads.
|
|
||||||
downloadProgressChan := make(chan download.DownloadStatus, 1000)
|
|
||||||
downloadDone := make(chan struct{})
|
|
||||||
|
|
||||||
app.PageRouter = page.NewRouter(page.PageCtx{
|
|
||||||
DbPtr: app.DB,
|
|
||||||
W: w,
|
|
||||||
H: h,
|
|
||||||
MessageChannel: downloadProgressChan,
|
|
||||||
DownloadDone: downloadDone,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Sync the proclamations from the API to the Database
|
|
||||||
sync.SyncProclamations(DB)
|
|
||||||
sync.SyncCategories(DB)
|
|
||||||
sync.SyncFrequencies(DB)
|
|
||||||
|
|
||||||
// audioFileNames := []string{
|
|
||||||
// "OriginalAudio.wav",
|
|
||||||
// "174Hz.wav",
|
|
||||||
// "396Hz.wav",
|
|
||||||
// "417Hz.wav",
|
|
||||||
// "432Hz.wav",
|
|
||||||
// "639Hz.wav",
|
|
||||||
// "741Hz.wav",
|
|
||||||
// "852Hz.wav",
|
|
||||||
// "963Hz.wav",
|
|
||||||
// "1111Hz.wav",
|
|
||||||
// "14Hz_Binaural.wav",
|
|
||||||
// "40Hz_Binaural.wav",
|
|
||||||
// "AlphaWaves.wav",
|
|
||||||
// "PinealGland.wav",
|
|
||||||
// }
|
|
||||||
|
|
||||||
audioDir := filepath.Join(homeDir, ".radiantwave", "audio")
|
|
||||||
|
|
||||||
_, err = os.Stat(audioDir)
|
|
||||||
if os.IsNotExist(err) {
|
|
||||||
if err = os.MkdirAll(audioDir, 0755); err != nil {
|
|
||||||
logger.Error("unable to create audio directory: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
go download.DownloadAudioFiles(app.PageRouter.Context.MessageChannel, audioDir, "https://files.radiantwavetech.com/audio/")
|
|
||||||
|
|
||||||
app.runEventLoop()
|
|
||||||
}
|
|
||||||
|
|
||||||
// computeState determines what state the application should be in based on
|
|
||||||
// current conditions, without performing any navigation. It returns the first
|
|
||||||
// unmet prerequisite in priority order: network > email > license > downloads > ready.
|
|
||||||
func (app *Application) computeState() State {
|
|
||||||
if app.state == READY {
|
|
||||||
return READY
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if downloads have finished (non-blocking)
|
|
||||||
if !app.downloadsComplete {
|
|
||||||
select {
|
|
||||||
case <-app.PageRouter.Context.DownloadDone:
|
|
||||||
app.downloadsComplete = true
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if there is an internet connection
|
|
||||||
if !networkIsUp {
|
|
||||||
if lastNetworkCheckTime.IsZero() || time.Since(lastNetworkCheckTime) >= time.Second {
|
|
||||||
lastNetworkCheckTime = time.Now()
|
|
||||||
if !network.HasInternet() {
|
|
||||||
return NEEDS_INTERNET
|
|
||||||
}
|
|
||||||
networkIsUp = true // internet confirmed, don't check again
|
|
||||||
} else {
|
|
||||||
return app.state // throttled, can't determine yet
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for email address
|
|
||||||
config, err := db.GetConfig(app.DB)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A rather annoying, insecure, but sometimes necessary "print the config" statement
|
|
||||||
// data, err := json.MarshalIndent(config, "", " ")
|
|
||||||
// if err != nil {
|
|
||||||
// logger.Error("failed to marshal config: %v", err)
|
|
||||||
// } else {
|
|
||||||
// logger.Debug("AppConfig: %s", data)
|
|
||||||
// }
|
|
||||||
|
|
||||||
if config.EmailAddress == "" {
|
|
||||||
return NEEDS_EMAIL_ADDRESS
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for license key
|
|
||||||
config, err = db.GetConfig(app.DB)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
if config.LicenseKey == "" {
|
|
||||||
return NEEDS_LICENSE_KEY
|
|
||||||
}
|
|
||||||
|
|
||||||
// All config prerequisites met -- now wait for downloads to finish
|
|
||||||
if !app.downloadsComplete {
|
|
||||||
return DOWNLOADING_ASSETS
|
|
||||||
}
|
|
||||||
|
|
||||||
return READY
|
|
||||||
}
|
|
||||||
|
|
||||||
// checkState keeps watch of the application state and sets pages
|
|
||||||
// accordingly. This function runs once per frame.
|
|
||||||
func (app *Application) checkState() {
|
|
||||||
newState := app.computeState()
|
|
||||||
|
|
||||||
if newState == app.state {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
oldState := app.state
|
|
||||||
app.state = newState
|
|
||||||
|
|
||||||
// Both DOWNLOADING_ASSETS and READY map to the HOME page.
|
|
||||||
// Skip re-navigation when transitioning between them to avoid
|
|
||||||
// destroying and recreating the Home page (which would close
|
|
||||||
// the already-closed DownloadDone channel).
|
|
||||||
oldIsHome := oldState == DOWNLOADING_ASSETS || oldState == READY
|
|
||||||
newIsHome := newState == DOWNLOADING_ASSETS || newState == READY
|
|
||||||
if oldIsHome && newIsHome {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
switch newState {
|
|
||||||
case NEEDS_INTERNET:
|
|
||||||
app.PageRouter.NavigateTo(page.WIFI)
|
|
||||||
case NEEDS_EMAIL_ADDRESS:
|
|
||||||
logger.Warning("No email address detected")
|
|
||||||
app.PageRouter.NavigateTo(page.EMAIL_ADDRESS)
|
|
||||||
case NEEDS_LICENSE_KEY:
|
|
||||||
app.PageRouter.NavigateTo(page.LICENSE_KEY)
|
|
||||||
case DOWNLOADING_ASSETS, READY:
|
|
||||||
app.PageRouter.NavigateTo(page.HOME)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *Application) initializeSDLAndOpenGL() (W, H int32) {
|
|
||||||
logger.Info("Initializing SDL")
|
|
||||||
if err := sdl.Init(sdl.INIT_VIDEO | sdl.INIT_AUDIO); err != nil {
|
|
||||||
logger.Fatal("SDL init failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Info("Initializing TTF")
|
|
||||||
if err := ttf.Init(); err != nil {
|
|
||||||
logger.Fatal("TTF init failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
sdl.GLSetAttribute(sdl.GL_CONTEXT_MAJOR_VERSION, openGLMajorVersion)
|
|
||||||
sdl.GLSetAttribute(sdl.GL_CONTEXT_MINOR_VERSION, openGLMinorVersion)
|
|
||||||
sdl.GLSetAttribute(sdl.GL_CONTEXT_PROFILE_MASK, sdl.GL_CONTEXT_PROFILE_CORE)
|
|
||||||
sdl.ShowCursor(sdl.DISABLE)
|
|
||||||
sdl.SetRelativeMouseMode(true)
|
|
||||||
|
|
||||||
logger.Info("Querying display mode")
|
|
||||||
displayMode, err := sdl.GetDisplayMode(0, 0)
|
|
||||||
if err != nil {
|
|
||||||
logger.Fatal("Display mode query failed: %v", err)
|
|
||||||
}
|
|
||||||
if displayMode.W == 0 || displayMode.H == 0 {
|
|
||||||
logger.Fatal("Invalid display dimensions: %dx%d", displayMode.W, displayMode.H)
|
|
||||||
}
|
|
||||||
logger.Info("Display: %dx%d @ %dHz", displayMode.W, displayMode.H, displayMode.RefreshRate)
|
|
||||||
|
|
||||||
logger.Info("Creating window")
|
|
||||||
window, err := sdl.CreateWindow(
|
|
||||||
"Rhema Wave",
|
|
||||||
sdl.WINDOWPOS_UNDEFINED,
|
|
||||||
sdl.WINDOWPOS_UNDEFINED,
|
|
||||||
displayMode.W,
|
|
||||||
displayMode.H,
|
|
||||||
sdl.WINDOW_OPENGL|sdl.WINDOW_FULLSCREEN_DESKTOP|sdl.WINDOW_SHOWN,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
logger.Fatal("Window creation failed: %v", err)
|
|
||||||
}
|
|
||||||
app.Window = window
|
|
||||||
|
|
||||||
logger.Info("Creating OpenGL context")
|
|
||||||
if _, err := app.Window.GLCreateContext(); err != nil {
|
|
||||||
logger.Fatal("OpenGL context creation failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Info("Initializing go-gl")
|
|
||||||
if err := gl.InitWithProcAddrFunc(sdl.GLGetProcAddress); err != nil {
|
|
||||||
logger.Fatal("go-gl initialization failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Info("OpenGL version: %s", gl.GoStr(gl.GetString(gl.VERSION)))
|
|
||||||
logger.Info("OpenGL renderer: %s", gl.GoStr(gl.GetString(gl.RENDERER)))
|
|
||||||
|
|
||||||
if err := sdl.GLSetSwapInterval(1); err != nil {
|
|
||||||
logger.Warning("Failed to enable vsync: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
w, h := app.Window.GLGetDrawableSize()
|
|
||||||
logger.Info("Drawable size: %dx%d", w, h)
|
|
||||||
|
|
||||||
logger.Info("Initializing virtual framebuffer (%dx%d -> %dx%d)",
|
|
||||||
graphics.VirtualWidth, graphics.VirtualHeight, w, h)
|
|
||||||
if err := graphics.InitVirtualFramebuffer(w, h); err != nil {
|
|
||||||
logger.Fatal("Virtual framebuffer init failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
app.initKeybinds()
|
|
||||||
logger.Info("SDL and OpenGL initialization complete")
|
|
||||||
return graphics.VirtualWidth, graphics.VirtualHeight
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *Application) initializeManagers() {
|
|
||||||
logger.Info("Initializing ShaderManager")
|
|
||||||
config, err := db.GetConfig(app.DB)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
shaderDir := filepath.Join(config.AssetsDir, "shaders")
|
|
||||||
logger.Info("Loading shaders from %s", shaderDir)
|
|
||||||
if err := shaderManager.InitShaderManager(shaderDir); err != nil {
|
|
||||||
logger.Fatal("ShaderManager initialization failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Info("Initializing FontManager")
|
|
||||||
if err := fontManager.InitFontManager(config); err != nil {
|
|
||||||
logger.Fatal("FontManager initialization failed: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *Application) initializeAudio() {
|
|
||||||
logger.Info("Initializing audio")
|
|
||||||
config, err := db.GetConfig(app.DB)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
if err := mixer.Init(config, app.refreshCache); err != nil {
|
|
||||||
logger.Error("Unable to initialize audio device '%s': %v", config.SelectedAudioDevice, err)
|
|
||||||
logger.Error("Audio will be unavailable until a device is configured")
|
|
||||||
} else {
|
|
||||||
logger.Info("Audio initialized with device: '%s'", config.SelectedAudioDevice)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *Application) refreshCache() {}
|
|
||||||
|
|
||||||
func (app *Application) cleanup() {
|
|
||||||
logger.Info("Cleaning up")
|
|
||||||
|
|
||||||
if app.PageRouter != nil && app.PageRouter.ActivePage != nil {
|
|
||||||
app.PageRouter.ActivePage.Destroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
graphics.DestroyVirtualFramebuffer()
|
|
||||||
|
|
||||||
if app.Window != nil {
|
|
||||||
app.Window.Destroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
ttf.Quit()
|
|
||||||
sdl.Quit()
|
|
||||||
logger.Info("Cleanup complete")
|
|
||||||
logger.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
|
||||||
// Event Loop
|
|
||||||
// ----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (app *Application) runEventLoop() {
|
|
||||||
if app.PageRouter == nil || app.PageRouter.ActivePage == nil {
|
|
||||||
logger.Fatal("No initial page loaded, Router is empty")
|
|
||||||
}
|
|
||||||
const frameDelay = time.Second / targetFPS
|
|
||||||
app.running = true
|
|
||||||
lastTime := time.Now()
|
|
||||||
logger.Info("Entering event loop")
|
|
||||||
|
|
||||||
for app.running {
|
|
||||||
frameStart := time.Now()
|
|
||||||
|
|
||||||
app.checkState()
|
|
||||||
app.handleEvents()
|
|
||||||
if !app.running {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
deltaTime := float32(time.Since(lastTime).Seconds())
|
|
||||||
lastTime = time.Now()
|
|
||||||
|
|
||||||
app.updateCurrentPage(deltaTime)
|
|
||||||
app.renderFrame()
|
|
||||||
|
|
||||||
frameTime := time.Since(frameStart)
|
|
||||||
if frameTime < frameDelay {
|
|
||||||
time.Sleep(frameDelay - frameTime)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Info("Event loop exited")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *Application) handleEvents() {
|
|
||||||
for event := sdl.PollEvent(); event != nil; event = sdl.PollEvent() {
|
|
||||||
switch e := event.(type) {
|
|
||||||
case *sdl.QuitEvent:
|
|
||||||
app.running = false
|
|
||||||
return
|
|
||||||
|
|
||||||
case *sdl.KeyboardEvent:
|
|
||||||
if e.Type == sdl.KEYDOWN && e.Repeat == 0 {
|
|
||||||
isCtrlPressed := (e.Keysym.Mod&sdl.KMOD_CTRL) != 0 || (e.Keysym.Mod&sdl.KMOD_GUI) != 0
|
|
||||||
if e.Keysym.Sym == sdl.K_c && isCtrlPressed {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
keybinds.PerformAction(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if currentPage := app.PageRouter.ActivePage; currentPage != nil {
|
|
||||||
if err := currentPage.HandleEvent(&event); err != nil {
|
|
||||||
logger.Error("Page event handler error: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *Application) updateCurrentPage(deltaTime float32) {
|
|
||||||
if currentPage := app.PageRouter.ActivePage; currentPage != nil {
|
|
||||||
currentPage.Update(deltaTime)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *Application) renderFrame() {
|
|
||||||
graphics.BeginVirtualRender()
|
|
||||||
gl.ClearColor(0.0, 0.0, 0.0, 1.0)
|
|
||||||
gl.Clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)
|
|
||||||
|
|
||||||
if currentPage := app.PageRouter.ActivePage; currentPage != nil {
|
|
||||||
currentPage.Render()
|
|
||||||
}
|
|
||||||
|
|
||||||
graphics.BlitToScreen()
|
|
||||||
app.Window.GLSwap()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
|
||||||
// Keybind Registration
|
|
||||||
// ----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func (app *Application) initKeybinds() {
|
|
||||||
keybinds.Register(sdl.K_F3, sdl.KMOD_NONE, func() {
|
|
||||||
if app.state != READY && app.state != DOWNLOADING_ASSETS {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
app.PageRouter.NavigateTo(page.SETTINGS)
|
|
||||||
})
|
|
||||||
|
|
||||||
keybinds.Register(sdl.K_F2, sdl.KMOD_NONE, func() {
|
|
||||||
if app.state != READY && app.state != DOWNLOADING_ASSETS {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
app.PageRouter.NavigateTo(page.SCROLLER)
|
|
||||||
})
|
|
||||||
|
|
||||||
keybinds.Register(sdl.K_F1, sdl.KMOD_NONE, func() {
|
|
||||||
if app.state != READY && app.state != DOWNLOADING_ASSETS {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
app.PageRouter.NavigateTo(page.HOME)
|
|
||||||
|
|
||||||
})
|
|
||||||
|
|
||||||
keybinds.Register(sdl.K_q, sdl.KMOD_RCTRL, func() {
|
|
||||||
app.Stop()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *Application) Stop() {
|
|
||||||
app.running = false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (app *Application) Quit() {
|
|
||||||
logger.Info("User has quit the application")
|
|
||||||
app.running = false
|
|
||||||
}
|
|
||||||
@@ -1,475 +0,0 @@
|
|||||||
package application
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/db"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/page"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Regression: The app used to reach READY before audio downloads completed,
|
|
||||||
// showing "Status: READY" while "0 / 14 Files Downloaded" was still in progress.
|
|
||||||
// The fix gates READY on downloadsComplete being true.
|
|
||||||
|
|
||||||
// TestStateTransitionsToDownloadingAssets verifies that when all config
|
|
||||||
// prerequisites are met but downloads are not complete, the state transitions
|
|
||||||
// to DOWNLOADING_ASSETS instead of READY.
|
|
||||||
func TestStateTransitionsToDownloadingAssets(t *testing.T) {
|
|
||||||
app := &Application{
|
|
||||||
state: FIRST_RUN,
|
|
||||||
DB: setupTestDB(t),
|
|
||||||
}
|
|
||||||
|
|
||||||
networkIsUp = true
|
|
||||||
config, err := db.GetConfig(app.DB)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
config.EmailAddress = "test@example.com"
|
|
||||||
config.LicenseKey = "test-key-123"
|
|
||||||
app.DB.Save(config)
|
|
||||||
|
|
||||||
app.downloadsComplete = false
|
|
||||||
|
|
||||||
downloadDone := make(chan struct{})
|
|
||||||
app.PageRouter = &page.Router{
|
|
||||||
Context: page.PageCtx{
|
|
||||||
DbPtr: app.DB,
|
|
||||||
DownloadDone: downloadDone,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
result := app.computeState()
|
|
||||||
|
|
||||||
if result != DOWNLOADING_ASSETS {
|
|
||||||
t.Errorf("state = %v, want DOWNLOADING_ASSETS (downloads not complete)", result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestStateTransitionsToReadyWhenDownloadsComplete verifies that the state
|
|
||||||
// transitions to READY only after downloads are complete.
|
|
||||||
func TestStateTransitionsToReadyWhenDownloadsComplete(t *testing.T) {
|
|
||||||
app := &Application{
|
|
||||||
state: FIRST_RUN,
|
|
||||||
DB: setupTestDB(t),
|
|
||||||
}
|
|
||||||
|
|
||||||
networkIsUp = true
|
|
||||||
config, err := db.GetConfig(app.DB)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
config.EmailAddress = "test@example.com"
|
|
||||||
config.LicenseKey = "test-key-123"
|
|
||||||
app.DB.Save(config)
|
|
||||||
|
|
||||||
app.downloadsComplete = true
|
|
||||||
|
|
||||||
downloadDone := make(chan struct{})
|
|
||||||
app.PageRouter = &page.Router{
|
|
||||||
Context: page.PageCtx{
|
|
||||||
DbPtr: app.DB,
|
|
||||||
DownloadDone: downloadDone,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
result := app.computeState()
|
|
||||||
|
|
||||||
if result != READY {
|
|
||||||
t.Errorf("state = %v, want READY", result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regression: Download failure should prevent the app from reaching READY.
|
|
||||||
// If downloads fail, the user sees an error and must restart to retry.
|
|
||||||
func TestDownloadFailurePreventsReady(t *testing.T) {
|
|
||||||
app := &Application{
|
|
||||||
state: FIRST_RUN,
|
|
||||||
DB: setupTestDB(t),
|
|
||||||
}
|
|
||||||
|
|
||||||
networkIsUp = true
|
|
||||||
config, err := db.GetConfig(app.DB)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
config.EmailAddress = "test@example.com"
|
|
||||||
config.LicenseKey = "test-key-123"
|
|
||||||
app.DB.Save(config)
|
|
||||||
|
|
||||||
app.downloadsComplete = false
|
|
||||||
app.downloadFailed = true
|
|
||||||
|
|
||||||
downloadDone := make(chan struct{})
|
|
||||||
app.PageRouter = &page.Router{
|
|
||||||
Context: page.PageCtx{
|
|
||||||
DbPtr: app.DB,
|
|
||||||
DownloadDone: downloadDone,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
result := app.computeState()
|
|
||||||
|
|
||||||
if result == READY {
|
|
||||||
t.Error("state should not be READY when download has failed")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestStateStaysReadyOnceSet verifies that once the state reaches READY,
|
|
||||||
// computeState() returns READY without checking other prerequisites.
|
|
||||||
func TestStateStaysReadyOnceSet(t *testing.T) {
|
|
||||||
app := &Application{
|
|
||||||
state: READY,
|
|
||||||
DB: setupTestDB(t),
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadDone := make(chan struct{})
|
|
||||||
app.PageRouter = &page.Router{
|
|
||||||
Context: page.PageCtx{
|
|
||||||
DbPtr: app.DB,
|
|
||||||
DownloadDone: downloadDone,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
result := app.computeState()
|
|
||||||
|
|
||||||
if result != READY {
|
|
||||||
t.Errorf("state = %v, want READY (should not change once set)", result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestEmailMissingPreventsReady verifies that missing email blocks progress
|
|
||||||
// even when network is up and downloads are complete.
|
|
||||||
func TestEmailMissingPreventsReady(t *testing.T) {
|
|
||||||
app := &Application{
|
|
||||||
state: FIRST_RUN,
|
|
||||||
DB: setupTestDB(t),
|
|
||||||
}
|
|
||||||
|
|
||||||
networkIsUp = true
|
|
||||||
config, err := db.GetConfig(app.DB)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
config.EmailAddress = ""
|
|
||||||
config.LicenseKey = "test-key-123"
|
|
||||||
app.DB.Save(config)
|
|
||||||
|
|
||||||
app.downloadsComplete = true
|
|
||||||
|
|
||||||
downloadDone := make(chan struct{})
|
|
||||||
app.PageRouter = &page.Router{
|
|
||||||
Context: page.PageCtx{
|
|
||||||
DbPtr: app.DB,
|
|
||||||
DownloadDone: downloadDone,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
result := app.computeState()
|
|
||||||
|
|
||||||
if result != NEEDS_EMAIL_ADDRESS {
|
|
||||||
t.Errorf("state = %v, want NEEDS_EMAIL_ADDRESS", result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLicenseMissingPreventsReady verifies that missing license key blocks
|
|
||||||
// progress even when email and network are present.
|
|
||||||
func TestLicenseMissingPreventsReady(t *testing.T) {
|
|
||||||
app := &Application{
|
|
||||||
state: FIRST_RUN,
|
|
||||||
DB: setupTestDB(t),
|
|
||||||
}
|
|
||||||
|
|
||||||
networkIsUp = true
|
|
||||||
config, err := db.GetConfig(app.DB)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
config.EmailAddress = "test@example.com"
|
|
||||||
config.LicenseKey = ""
|
|
||||||
app.DB.Save(config)
|
|
||||||
|
|
||||||
app.downloadsComplete = true
|
|
||||||
|
|
||||||
downloadDone := make(chan struct{})
|
|
||||||
app.PageRouter = &page.Router{
|
|
||||||
Context: page.PageCtx{
|
|
||||||
DbPtr: app.DB,
|
|
||||||
DownloadDone: downloadDone,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
result := app.computeState()
|
|
||||||
|
|
||||||
if result != NEEDS_LICENSE_KEY {
|
|
||||||
t.Errorf("state = %v, want NEEDS_LICENSE_KEY", result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestDownloadDoneChannelSetsDownloadsComplete verifies that receiving on
|
|
||||||
// the DownloadDone channel sets downloadsComplete to true.
|
|
||||||
func TestDownloadDoneChannelSetsDownloadsComplete(t *testing.T) {
|
|
||||||
app := &Application{
|
|
||||||
state: FIRST_RUN,
|
|
||||||
DB: setupTestDB(t),
|
|
||||||
}
|
|
||||||
|
|
||||||
networkIsUp = true
|
|
||||||
config, err := db.GetConfig(app.DB)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
config.EmailAddress = "test@example.com"
|
|
||||||
config.LicenseKey = "test-key-123"
|
|
||||||
app.DB.Save(config)
|
|
||||||
|
|
||||||
app.downloadsComplete = false
|
|
||||||
|
|
||||||
downloadDone := make(chan struct{})
|
|
||||||
close(downloadDone)
|
|
||||||
app.PageRouter = &page.Router{
|
|
||||||
Context: page.PageCtx{
|
|
||||||
DbPtr: app.DB,
|
|
||||||
DownloadDone: downloadDone,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
result := app.computeState()
|
|
||||||
|
|
||||||
if !app.downloadsComplete {
|
|
||||||
t.Error("downloadsComplete should be true after DownloadDone channel is closed")
|
|
||||||
}
|
|
||||||
if result != READY {
|
|
||||||
t.Errorf("state = %v, want READY after downloads complete", result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestPrerequisitePriorityEmailBeforeLicense verifies that missing email
|
|
||||||
// is reported before missing license key, even when both are absent.
|
|
||||||
func TestPrerequisitePriorityEmailBeforeLicense(t *testing.T) {
|
|
||||||
app := &Application{
|
|
||||||
state: FIRST_RUN,
|
|
||||||
DB: setupTestDB(t),
|
|
||||||
}
|
|
||||||
|
|
||||||
networkIsUp = true
|
|
||||||
config, err := db.GetConfig(app.DB)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
config.EmailAddress = ""
|
|
||||||
config.LicenseKey = ""
|
|
||||||
app.DB.Save(config)
|
|
||||||
|
|
||||||
app.downloadsComplete = true
|
|
||||||
|
|
||||||
downloadDone := make(chan struct{})
|
|
||||||
app.PageRouter = &page.Router{
|
|
||||||
Context: page.PageCtx{
|
|
||||||
DbPtr: app.DB,
|
|
||||||
DownloadDone: downloadDone,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
result := app.computeState()
|
|
||||||
|
|
||||||
if result != NEEDS_EMAIL_ADDRESS {
|
|
||||||
t.Errorf("state = %v, want NEEDS_EMAIL_ADDRESS (email should be checked before license)", result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestPrerequisitePriorityLicenseAfterEmail verifies that when email is
|
|
||||||
// present but license is missing, the state is NEEDS_LICENSE_KEY.
|
|
||||||
func TestPrerequisitePriorityLicenseAfterEmail(t *testing.T) {
|
|
||||||
app := &Application{
|
|
||||||
state: FIRST_RUN,
|
|
||||||
DB: setupTestDB(t),
|
|
||||||
}
|
|
||||||
|
|
||||||
networkIsUp = true
|
|
||||||
config, err := db.GetConfig(app.DB)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
config.EmailAddress = "test@example.com"
|
|
||||||
config.LicenseKey = ""
|
|
||||||
app.DB.Save(config)
|
|
||||||
|
|
||||||
app.downloadsComplete = true
|
|
||||||
|
|
||||||
downloadDone := make(chan struct{})
|
|
||||||
app.PageRouter = &page.Router{
|
|
||||||
Context: page.PageCtx{
|
|
||||||
DbPtr: app.DB,
|
|
||||||
DownloadDone: downloadDone,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
result := app.computeState()
|
|
||||||
|
|
||||||
if result != NEEDS_LICENSE_KEY {
|
|
||||||
t.Errorf("state = %v, want NEEDS_LICENSE_KEY", result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestStateBlocksOnDownloadUntilChannelClosed verifies that computeState
|
|
||||||
// returns DOWNLOADING_ASSETS repeatedly until DownloadDone is closed.
|
|
||||||
func TestStateBlocksOnDownloadUntilChannelClosed(t *testing.T) {
|
|
||||||
app := &Application{
|
|
||||||
state: FIRST_RUN,
|
|
||||||
DB: setupTestDB(t),
|
|
||||||
}
|
|
||||||
|
|
||||||
networkIsUp = true
|
|
||||||
config, err := db.GetConfig(app.DB)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
config.EmailAddress = "test@example.com"
|
|
||||||
config.LicenseKey = "test-key-123"
|
|
||||||
app.DB.Save(config)
|
|
||||||
|
|
||||||
app.downloadsComplete = false
|
|
||||||
|
|
||||||
downloadDone := make(chan struct{})
|
|
||||||
app.PageRouter = &page.Router{
|
|
||||||
Context: page.PageCtx{
|
|
||||||
DbPtr: app.DB,
|
|
||||||
DownloadDone: downloadDone,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should return DOWNLOADING_ASSETS multiple times before channel closes.
|
|
||||||
for i := 0; i < 5; i++ {
|
|
||||||
result := app.computeState()
|
|
||||||
if result != DOWNLOADING_ASSETS {
|
|
||||||
t.Fatalf("iteration %d: state = %v, want DOWNLOADING_ASSETS", i, result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now close the channel.
|
|
||||||
close(downloadDone)
|
|
||||||
|
|
||||||
result := app.computeState()
|
|
||||||
if result != READY {
|
|
||||||
t.Errorf("after close: state = %v, want READY", result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestDownloadDoneNonBlocking verifies that computeState does not block
|
|
||||||
// when DownloadDone channel is open (non-blocking select).
|
|
||||||
func TestDownloadDoneNonBlocking(t *testing.T) {
|
|
||||||
app := &Application{
|
|
||||||
state: FIRST_RUN,
|
|
||||||
DB: setupTestDB(t),
|
|
||||||
}
|
|
||||||
|
|
||||||
networkIsUp = true
|
|
||||||
config, err := db.GetConfig(app.DB)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
config.EmailAddress = "test@example.com"
|
|
||||||
config.LicenseKey = "test-key-123"
|
|
||||||
app.DB.Save(config)
|
|
||||||
|
|
||||||
app.downloadsComplete = false
|
|
||||||
|
|
||||||
downloadDone := make(chan struct{})
|
|
||||||
app.PageRouter = &page.Router{
|
|
||||||
Context: page.PageCtx{
|
|
||||||
DbPtr: app.DB,
|
|
||||||
DownloadDone: downloadDone,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// computeState must return promptly even though DownloadDone is not closed.
|
|
||||||
done := make(chan struct{})
|
|
||||||
go func() {
|
|
||||||
result := app.computeState()
|
|
||||||
if result != DOWNLOADING_ASSETS {
|
|
||||||
t.Errorf("state = %v, want DOWNLOADING_ASSETS", result)
|
|
||||||
}
|
|
||||||
close(done)
|
|
||||||
}()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-done:
|
|
||||||
// Good: returned promptly.
|
|
||||||
case <-time.After(2 * time.Second):
|
|
||||||
t.Fatal("computeState blocked on DownloadDone channel (should be non-blocking)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestMultipleComputeStateCallsAfterDownloadComplete verifies that calling
|
|
||||||
// computeState repeatedly after downloads finish does not panic or change state.
|
|
||||||
func TestMultipleComputeStateCallsAfterDownloadComplete(t *testing.T) {
|
|
||||||
app := &Application{
|
|
||||||
state: FIRST_RUN,
|
|
||||||
DB: setupTestDB(t),
|
|
||||||
}
|
|
||||||
|
|
||||||
networkIsUp = true
|
|
||||||
config, err := db.GetConfig(app.DB)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
config.EmailAddress = "test@example.com"
|
|
||||||
config.LicenseKey = "test-key-123"
|
|
||||||
app.DB.Save(config)
|
|
||||||
|
|
||||||
downloadDone := make(chan struct{})
|
|
||||||
close(downloadDone)
|
|
||||||
app.PageRouter = &page.Router{
|
|
||||||
Context: page.PageCtx{
|
|
||||||
DbPtr: app.DB,
|
|
||||||
DownloadDone: downloadDone,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
// First call transitions to READY.
|
|
||||||
result := app.computeState()
|
|
||||||
if result != READY {
|
|
||||||
t.Fatalf("first call: state = %v, want READY", result)
|
|
||||||
}
|
|
||||||
app.state = result
|
|
||||||
|
|
||||||
// Subsequent calls should return READY without panic.
|
|
||||||
for i := 0; i < 10; i++ {
|
|
||||||
result = app.computeState()
|
|
||||||
if result != READY {
|
|
||||||
t.Fatalf("iteration %d: state = %v, want READY", i, result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func setupTestDB(t *testing.T) *gorm.DB {
|
|
||||||
t.Helper()
|
|
||||||
d, err := db.Connect(":memory:")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if err := db.Migrate(d); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
cfg := &db.AppConfig{
|
|
||||||
Model: gorm.Model{ID: 1},
|
|
||||||
DataDir: t.TempDir(),
|
|
||||||
AssetsDir: t.TempDir(),
|
|
||||||
SystemLevel: "BASIC",
|
|
||||||
SelectedAudioDevice: "default",
|
|
||||||
InitialFontSize: 128,
|
|
||||||
StandardFontSize: 32,
|
|
||||||
LinePattern: "fibonacci",
|
|
||||||
AudioEnabled: true,
|
|
||||||
SavedVolume: 1.0,
|
|
||||||
EmailAddress: "",
|
|
||||||
LicenseKey: "",
|
|
||||||
}
|
|
||||||
if err := d.Create(cfg).Error; err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return d
|
|
||||||
}
|
|
||||||
@@ -1,214 +0,0 @@
|
|||||||
// Package sdlcolors provides a comprehensive palette of 256 standard colors
|
|
||||||
// for use with the Go SDL2 bindings. Each color is a public variable of
|
|
||||||
// type sdl.Color, making them easily accessible for rendering tasks.
|
|
||||||
package colors
|
|
||||||
|
|
||||||
import "github.com/veandco/go-sdl2/sdl"
|
|
||||||
|
|
||||||
// Basic Colors
|
|
||||||
var (
|
|
||||||
White = sdl.Color{R: 255, G: 255, B: 255, A: 255}
|
|
||||||
Silver = sdl.Color{R: 192, G: 192, B: 192, A: 255}
|
|
||||||
Gray = sdl.Color{R: 128, G: 128, B: 128, A: 255}
|
|
||||||
Black = sdl.Color{R: 0, G: 0, B: 0, A: 255}
|
|
||||||
Red = sdl.Color{R: 255, G: 0, B: 0, A: 255}
|
|
||||||
Maroon = sdl.Color{R: 128, G: 0, B: 0, A: 255}
|
|
||||||
Yellow = sdl.Color{R: 255, G: 255, B: 0, A: 255}
|
|
||||||
Olive = sdl.Color{R: 128, G: 128, B: 0, A: 255}
|
|
||||||
Lime = sdl.Color{R: 0, G: 255, B: 0, A: 255}
|
|
||||||
Green = sdl.Color{R: 0, G: 128, B: 0, A: 255}
|
|
||||||
Aqua = sdl.Color{R: 0, G: 255, B: 255, A: 255}
|
|
||||||
Teal = sdl.Color{R: 0, G: 128, B: 128, A: 255}
|
|
||||||
Blue = sdl.Color{R: 0, G: 0, B: 255, A: 255}
|
|
||||||
Navy = sdl.Color{R: 0, G: 0, B: 128, A: 255}
|
|
||||||
Fuchsia = sdl.Color{R: 255, G: 0, B: 255, A: 255}
|
|
||||||
Purple = sdl.Color{R: 128, G: 0, B: 128, A: 255}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Grayscale
|
|
||||||
var (
|
|
||||||
Gainsboro = sdl.Color{R: 220, G: 220, B: 220, A: 255}
|
|
||||||
LightGray = sdl.Color{R: 211, G: 211, B: 211, A: 255}
|
|
||||||
DarkGray = sdl.Color{R: 169, G: 169, B: 169, A: 255}
|
|
||||||
DimGray = sdl.Color{R: 105, G: 105, B: 105, A: 255}
|
|
||||||
LightSlateGray = sdl.Color{R: 119, G: 136, B: 153, A: 255}
|
|
||||||
SlateGray = sdl.Color{R: 112, G: 128, B: 144, A: 255}
|
|
||||||
DarkSlateGray = sdl.Color{R: 47, G: 79, B: 79, A: 255}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Error Colors
|
|
||||||
var (
|
|
||||||
ErrorRed = sdl.Color{R: 255, G: 80, B: 80, A: 255}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Pinks & Reds
|
|
||||||
var (
|
|
||||||
Pink = sdl.Color{R: 255, G: 192, B: 203, A: 255}
|
|
||||||
LightPink = sdl.Color{R: 255, G: 182, B: 193, A: 255}
|
|
||||||
HotPink = sdl.Color{R: 255, G: 105, B: 180, A: 255}
|
|
||||||
DeepPink = sdl.Color{R: 255, G: 20, B: 147, A: 255}
|
|
||||||
PaleVioletRed = sdl.Color{R: 219, G: 112, B: 147, A: 255}
|
|
||||||
MediumVioletRed = sdl.Color{R: 199, G: 21, B: 133, A: 255}
|
|
||||||
LightSalmon = sdl.Color{R: 255, G: 160, B: 122, A: 255}
|
|
||||||
Salmon = sdl.Color{R: 250, G: 128, B: 114, A: 255}
|
|
||||||
DarkSalmon = sdl.Color{R: 233, G: 150, B: 122, A: 255}
|
|
||||||
LightCoral = sdl.Color{R: 240, G: 128, B: 128, A: 255}
|
|
||||||
IndianRed = sdl.Color{R: 205, G: 92, B: 92, A: 255}
|
|
||||||
Crimson = sdl.Color{R: 220, G: 20, B: 60, A: 255}
|
|
||||||
Firebrick = sdl.Color{R: 178, G: 34, B: 34, A: 255}
|
|
||||||
DarkRed = sdl.Color{R: 139, G: 0, B: 0, A: 255}
|
|
||||||
DeepBloodRed = sdl.Color{R: 139, G: 0, B: 0, A: 255}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Oranges
|
|
||||||
var (
|
|
||||||
LightOrange = sdl.Color{R: 255, G: 200, B: 127, A: 255}
|
|
||||||
Orange = sdl.Color{R: 255, G: 165, B: 0, A: 255}
|
|
||||||
OrangeRed = sdl.Color{R: 255, G: 69, B: 0, A: 255}
|
|
||||||
DarkOrange = sdl.Color{R: 255, G: 140, B: 0, A: 255}
|
|
||||||
DarkOrangeRed = sdl.Color{R: 255, G: 140, B: 105, A: 255}
|
|
||||||
Tomato = sdl.Color{R: 255, G: 99, B: 71, A: 255}
|
|
||||||
Coral = sdl.Color{R: 255, G: 127, B: 80, A: 255}
|
|
||||||
PaleOrange = sdl.Color{R: 255, G: 223, B: 186, A: 255}
|
|
||||||
RedOrange = sdl.Color{R: 255, G: 69, B: 0, A: 255}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Yellows
|
|
||||||
var (
|
|
||||||
Gold = sdl.Color{R: 255, G: 215, B: 0, A: 255}
|
|
||||||
LightYellow = sdl.Color{R: 255, G: 255, B: 224, A: 255}
|
|
||||||
LemonChiffon = sdl.Color{R: 255, G: 250, B: 205, A: 255}
|
|
||||||
LightGoldenrodYellow = sdl.Color{R: 250, G: 250, B: 210, A: 255}
|
|
||||||
PapayaWhip = sdl.Color{R: 255, G: 239, B: 213, A: 255}
|
|
||||||
Moccasin = sdl.Color{R: 255, G: 228, B: 181, A: 255}
|
|
||||||
PeachPuff = sdl.Color{R: 255, G: 218, B: 185, A: 255}
|
|
||||||
PaleGoldenrod = sdl.Color{R: 238, G: 232, B: 170, A: 255}
|
|
||||||
Khaki = sdl.Color{R: 240, G: 230, B: 140, A: 255}
|
|
||||||
DarkKhaki = sdl.Color{R: 189, G: 183, B: 107, A: 255}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Purples & Violets
|
|
||||||
var (
|
|
||||||
Lavender = sdl.Color{R: 230, G: 230, B: 250, A: 255}
|
|
||||||
Thistle = sdl.Color{R: 216, G: 191, B: 216, A: 255}
|
|
||||||
Plum = sdl.Color{R: 221, G: 160, B: 221, A: 255}
|
|
||||||
Violet = sdl.Color{R: 238, G: 130, B: 238, A: 255}
|
|
||||||
Orchid = sdl.Color{R: 218, G: 112, B: 214, A: 255}
|
|
||||||
Magenta = sdl.Color{R: 255, G: 0, B: 255, A: 255}
|
|
||||||
MediumOrchid = sdl.Color{R: 186, G: 85, B: 211, A: 255}
|
|
||||||
MediumPurple = sdl.Color{R: 147, G: 112, B: 219, A: 255}
|
|
||||||
RebeccaPurple = sdl.Color{R: 102, G: 51, B: 153, A: 255}
|
|
||||||
BlueViolet = sdl.Color{R: 138, G: 43, B: 226, A: 255}
|
|
||||||
DarkViolet = sdl.Color{R: 148, G: 0, B: 211, A: 255}
|
|
||||||
DarkOrchid = sdl.Color{R: 153, G: 50, B: 204, A: 255}
|
|
||||||
DarkMagenta = sdl.Color{R: 139, G: 0, B: 139, A: 255}
|
|
||||||
Indigo = sdl.Color{R: 75, G: 0, B: 130, A: 255}
|
|
||||||
SlateBlue = sdl.Color{R: 106, G: 90, B: 205, A: 255}
|
|
||||||
DarkSlateBlue = sdl.Color{R: 72, G: 61, B: 139, A: 255}
|
|
||||||
MediumSlateBlue = sdl.Color{R: 123, G: 104, B: 238, A: 255}
|
|
||||||
LavenderBlush = sdl.Color{R: 255, G: 240, B: 245, A: 255}
|
|
||||||
LavenderGray = sdl.Color{R: 250, G: 240, B: 250, A: 255}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Greens
|
|
||||||
var (
|
|
||||||
GreenYellow = sdl.Color{R: 173, G: 255, B: 47, A: 255}
|
|
||||||
Chartreuse = sdl.Color{R: 127, G: 255, B: 0, A: 255}
|
|
||||||
LawnGreen = sdl.Color{R: 124, G: 252, B: 0, A: 255}
|
|
||||||
LimeGreen = sdl.Color{R: 50, G: 205, B: 50, A: 255}
|
|
||||||
PaleGreen = sdl.Color{R: 152, G: 251, B: 152, A: 255}
|
|
||||||
LightGreen = sdl.Color{R: 144, G: 238, B: 144, A: 255}
|
|
||||||
MediumSpringGreen = sdl.Color{R: 0, G: 250, B: 154, A: 255}
|
|
||||||
SpringGreen = sdl.Color{R: 0, G: 255, B: 127, A: 255}
|
|
||||||
MediumSeaGreen = sdl.Color{R: 60, G: 179, B: 113, A: 255}
|
|
||||||
SeaGreen = sdl.Color{R: 46, G: 139, B: 87, A: 255}
|
|
||||||
ForestGreen = sdl.Color{R: 34, G: 139, B: 34, A: 255}
|
|
||||||
DarkGreen = sdl.Color{R: 0, G: 100, B: 0, A: 255}
|
|
||||||
YellowGreen = sdl.Color{R: 154, G: 205, B: 50, A: 255}
|
|
||||||
OliveDrab = sdl.Color{R: 107, G: 142, B: 35, A: 255}
|
|
||||||
DarkOliveGreen = sdl.Color{R: 85, G: 107, B: 47, A: 255}
|
|
||||||
MediumAquamarine = sdl.Color{R: 102, G: 205, B: 170, A: 255}
|
|
||||||
DarkSeaGreen = sdl.Color{R: 143, G: 188, B: 143, A: 255}
|
|
||||||
LightSeaGreen = sdl.Color{R: 32, G: 178, B: 170, A: 255}
|
|
||||||
DarkCyan = sdl.Color{R: 0, G: 139, B: 139, A: 255}
|
|
||||||
MintGreen = sdl.Color{R: 173, G: 255, B: 169, A: 255}
|
|
||||||
VeryPaleGreen = sdl.Color{R: 200, G: 255, B: 210, A: 255}
|
|
||||||
Honeydew = sdl.Color{R: 240, G: 255, B: 240, A: 255}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Blues & Cyans
|
|
||||||
var (
|
|
||||||
LightCyan = sdl.Color{R: 224, G: 255, B: 255, A: 255}
|
|
||||||
Cyan = sdl.Color{R: 0, G: 255, B: 255, A: 255}
|
|
||||||
AquaMarine = sdl.Color{R: 127, G: 255, B: 212, A: 255}
|
|
||||||
PaleTurquoise = sdl.Color{R: 175, G: 238, B: 238, A: 255}
|
|
||||||
Turquoise = sdl.Color{R: 64, G: 224, B: 208, A: 255}
|
|
||||||
MediumTurquoise = sdl.Color{R: 72, G: 209, B: 204, A: 255}
|
|
||||||
DarkTurquoise = sdl.Color{R: 0, G: 206, B: 209, A: 255}
|
|
||||||
CadetBlue = sdl.Color{R: 95, G: 158, B: 160, A: 255}
|
|
||||||
SteelBlue = sdl.Color{R: 70, G: 130, B: 180, A: 255}
|
|
||||||
LightSteelBlue = sdl.Color{R: 176, G: 196, B: 222, A: 255}
|
|
||||||
PowderBlue = sdl.Color{R: 176, G: 224, B: 230, A: 255}
|
|
||||||
LightBlue = sdl.Color{R: 173, G: 216, B: 230, A: 255}
|
|
||||||
SkyBlue = sdl.Color{R: 135, G: 206, B: 235, A: 255}
|
|
||||||
LightSkyBlue = sdl.Color{R: 135, G: 206, B: 250, A: 255}
|
|
||||||
DeepSkyBlue = sdl.Color{R: 0, G: 191, B: 255, A: 255}
|
|
||||||
DodgerBlue = sdl.Color{R: 30, G: 144, B: 255, A: 255}
|
|
||||||
CornflowerBlue = sdl.Color{R: 100, G: 149, B: 237, A: 255}
|
|
||||||
MediumBlue = sdl.Color{R: 0, G: 0, B: 205, A: 255}
|
|
||||||
DarkBlue = sdl.Color{R: 0, G: 0, B: 139, A: 255}
|
|
||||||
MidnightBlue = sdl.Color{R: 25, G: 25, B: 112, A: 255}
|
|
||||||
RoyalBlue = sdl.Color{R: 65, G: 105, B: 225, A: 255}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Browns
|
|
||||||
var (
|
|
||||||
Cornsilk = sdl.Color{R: 255, G: 248, B: 220, A: 255}
|
|
||||||
BlanchedAlmond = sdl.Color{R: 255, G: 235, B: 205, A: 255}
|
|
||||||
Bisque = sdl.Color{R: 255, G: 228, B: 196, A: 255}
|
|
||||||
NavajoWhite = sdl.Color{R: 255, G: 222, B: 173, A: 255}
|
|
||||||
Wheat = sdl.Color{R: 245, G: 222, B: 179, A: 255}
|
|
||||||
BurlyWood = sdl.Color{R: 222, G: 184, B: 135, A: 255}
|
|
||||||
Tan = sdl.Color{R: 210, G: 180, B: 140, A: 255}
|
|
||||||
RosyBrown = sdl.Color{R: 188, G: 143, B: 143, A: 255}
|
|
||||||
SandyBrown = sdl.Color{R: 244, G: 164, B: 96, A: 255}
|
|
||||||
Goldenrod = sdl.Color{R: 218, G: 165, B: 32, A: 255}
|
|
||||||
DarkGoldenrod = sdl.Color{R: 184, G: 134, B: 11, A: 255}
|
|
||||||
Peru = sdl.Color{R: 205, G: 133, B: 63, A: 255}
|
|
||||||
Chocolate = sdl.Color{R: 210, G: 105, B: 30, A: 255}
|
|
||||||
SaddleBrown = sdl.Color{R: 139, G: 69, B: 19, A: 255}
|
|
||||||
Sienna = sdl.Color{R: 160, G: 82, B: 45, A: 255}
|
|
||||||
Brown = sdl.Color{R: 165, G: 42, B: 42, A: 255}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Whites
|
|
||||||
var (
|
|
||||||
Snow = sdl.Color{R: 255, G: 250, B: 250, A: 255}
|
|
||||||
MintCream = sdl.Color{R: 245, G: 255, B: 250, A: 255}
|
|
||||||
Azure = sdl.Color{R: 240, G: 255, B: 255, A: 255}
|
|
||||||
AliceBlue = sdl.Color{R: 240, G: 248, B: 255, A: 255}
|
|
||||||
GhostWhite = sdl.Color{R: 248, G: 248, B: 255, A: 255}
|
|
||||||
WhiteSmoke = sdl.Color{R: 245, G: 245, B: 245, A: 255}
|
|
||||||
Seashell = sdl.Color{R: 255, G: 245, B: 238, A: 255}
|
|
||||||
Beige = sdl.Color{R: 245, G: 245, B: 220, A: 255}
|
|
||||||
OldLace = sdl.Color{R: 253, G: 245, B: 230, A: 255}
|
|
||||||
FloralWhite = sdl.Color{R: 255, G: 250, B: 240, A: 255}
|
|
||||||
Ivory = sdl.Color{R: 255, G: 255, B: 240, A: 255}
|
|
||||||
AntiqueWhite = sdl.Color{R: 250, G: 235, B: 215, A: 255}
|
|
||||||
Linen = sdl.Color{R: 250, G: 240, B: 230, A: 255}
|
|
||||||
MistyRose = sdl.Color{R: 255, G: 228, B: 225, A: 255}
|
|
||||||
)
|
|
||||||
|
|
||||||
// Groups
|
|
||||||
|
|
||||||
// Based on Solfeggio Frequencies
|
|
||||||
var (
|
|
||||||
Freq_396_Colors = [7]sdl.Color{DeepBloodRed, Firebrick, Crimson, Tomato, DarkOrangeRed, Orange, PeachPuff}
|
|
||||||
Freq_417_Colors = [7]sdl.Color{RedOrange, Coral, DarkOrange, Orange, LightOrange, PaleOrange, BlanchedAlmond}
|
|
||||||
// Default proclamation needs to be using this
|
|
||||||
Freq_528_Colors = [7]sdl.Color{LimeGreen, MediumSeaGreen, LightGreen, PaleGreen, MintGreen, VeryPaleGreen, Honeydew}
|
|
||||||
Freq_639_Colors = [7]sdl.Color{SpringGreen, DarkTurquoise, Turquoise, CadetBlue, AquaMarine, PaleTurquoise, LightCyan}
|
|
||||||
Freq_741_Colors = [7]sdl.Color{DeepSkyBlue, DodgerBlue, RoyalBlue, CornflowerBlue, SkyBlue, LightBlue, AliceBlue}
|
|
||||||
Freq_852_Colors = [7]sdl.Color{Indigo, MediumPurple, BlueViolet, MediumSlateBlue, Orchid, Thistle, Lavender}
|
|
||||||
Freq_963_Colors = [7]sdl.Color{DarkViolet, MediumOrchid, MediumVioletRed, Plum, Violet, LavenderBlush, LavenderGray}
|
|
||||||
)
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
// Package db provides a database connection and functions for interacting with the database.
|
|
||||||
// The DB object should be held and maintained by the application, not by this package.
|
|
||||||
// The application should be responsible for connecting to, migrating, and initial population
|
|
||||||
// of the database. This package should only provide the schema definitions and helper functions.
|
|
||||||
// Schema definitions should clone API response structures as closely as possible.
|
|
||||||
|
|
||||||
package db
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gorm.io/driver/sqlite"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
connectMaxAttempts = 5
|
|
||||||
connectRetryDelay = 500 * time.Millisecond
|
|
||||||
)
|
|
||||||
|
|
||||||
// Connect opens the SQLite database at dbpath with WAL mode and a hardened
|
|
||||||
// lock-recovery strategy suitable for kiosk use where the process may have
|
|
||||||
// crashed and left stale lock artefacts behind.
|
|
||||||
func Connect(dbpath string) (*gorm.DB, error) {
|
|
||||||
|
|
||||||
// ── Stale lock recovery ───────────────────────────────────────────────
|
|
||||||
// SQLite WAL mode leaves a .db-wal and .db-shm alongside the main file.
|
|
||||||
// If the previous process crashed, the OS releases file locks but the
|
|
||||||
// artefacts remain. SQLite recovers them automatically on the next open,
|
|
||||||
// UNLESS an OS-level advisory lock is still held (rare but possible on
|
|
||||||
// Linux with certain crash patterns). Removing them before opening is
|
|
||||||
// safe: SQLite treats absent WAL/SHM files as a clean slate and will
|
|
||||||
// recreate them. We only do this if the main DB file already exists
|
|
||||||
// (i.e. this is not a first-run) to avoid masking legitimate errors.
|
|
||||||
// if _, err := os.Stat(dbpath); err == nil {
|
|
||||||
// for _, ext := range []string{"-wal", "-shm"} {
|
|
||||||
// artefact := dbpath + ext
|
|
||||||
// if _, err := os.Stat(artefact); err == nil {
|
|
||||||
// if removeErr := os.Remove(artefact); removeErr != nil {
|
|
||||||
// // Non-fatal: log and continue — SQLite may still recover.
|
|
||||||
// fmt.Printf("[DB] warning: could not remove stale %s: %v\n", ext, removeErr)
|
|
||||||
// } else {
|
|
||||||
// fmt.Printf("[DB] removed stale lock artefact: %s\n", artefact)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// ── Connection with retry ─────────────────────────────────────────────
|
|
||||||
// Even after removing WAL/SHM, the main DB file itself may still be
|
|
||||||
// briefly locked if another process is shutting down. Retry with
|
|
||||||
// backoff before giving up.
|
|
||||||
var (
|
|
||||||
db *gorm.DB
|
|
||||||
err error
|
|
||||||
)
|
|
||||||
for attempt := 1; attempt <= connectMaxAttempts; attempt++ {
|
|
||||||
db, err = gorm.Open(sqlite.Open(dbpath), &gorm.Config{})
|
|
||||||
if err == nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if attempt == connectMaxAttempts {
|
|
||||||
return nil, fmt.Errorf("database still locked after %d attempts: %w", connectMaxAttempts, err)
|
|
||||||
}
|
|
||||||
fmt.Printf("[DB] open attempt %d/%d failed (%v), retrying in %s\n",
|
|
||||||
attempt, connectMaxAttempts, err, connectRetryDelay)
|
|
||||||
time.Sleep(connectRetryDelay)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Connection pool ───────────────────────────────────────────────────
|
|
||||||
// SQLite does not support concurrent writers. Setting MaxOpenConns(1)
|
|
||||||
// forces gorm to serialise all queries through a single connection,
|
|
||||||
// preventing "database is locked" errors from competing goroutines
|
|
||||||
// within this process. This is the single most important setting for
|
|
||||||
// SQLite in a multi-goroutine application.
|
|
||||||
sqlDB, err := db.DB()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("could not get underlying sql.DB: %w", err)
|
|
||||||
}
|
|
||||||
sqlDB.SetMaxOpenConns(1)
|
|
||||||
sqlDB.SetMaxIdleConns(1)
|
|
||||||
|
|
||||||
// ── Pragmas ───────────────────────────────────────────────────────────
|
|
||||||
pragmas := []string{
|
|
||||||
"PRAGMA journal_mode=WAL",
|
|
||||||
"PRAGMA synchronous=FULL",
|
|
||||||
"PRAGMA foreign_keys=ON",
|
|
||||||
// 5 s gives the serialised connection time to finish a long write
|
|
||||||
// before returning SQLITE_BUSY to the application layer.
|
|
||||||
"PRAGMA busy_timeout=5000",
|
|
||||||
}
|
|
||||||
for _, pragma := range pragmas {
|
|
||||||
if _, err := sqlDB.Exec(pragma); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to set %q: %w", pragma, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return db, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Migrate the database schema.
|
|
||||||
func Migrate(db *gorm.DB) error {
|
|
||||||
return db.AutoMigrate(
|
|
||||||
&AppConfig{},
|
|
||||||
&Log{},
|
|
||||||
&Proclamation{},
|
|
||||||
&ProclamationCategory{},
|
|
||||||
&SolfeggioFrequency{},
|
|
||||||
&AudioFile{},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
// schema.go defines the database schema for the GORM models.
|
|
||||||
package db
|
|
||||||
|
|
||||||
import "gorm.io/gorm"
|
|
||||||
|
|
||||||
type AppConfig struct {
|
|
||||||
gorm.Model
|
|
||||||
DataDir string
|
|
||||||
AssetsDir string
|
|
||||||
EmailAddress string
|
|
||||||
LicenseKey string
|
|
||||||
SystemLevel string // BASIC, ADVANCED, ELITE, COMMERCIAL
|
|
||||||
SelectedAudioDevice string // The pipewire device name
|
|
||||||
InitialFontSize int // How large to create the font atlas
|
|
||||||
StandardFontSize int // The font size used for most text rendering
|
|
||||||
LinePattern string // "fibonacci"
|
|
||||||
AudioEnabled bool
|
|
||||||
SavedVolume float64 // Last saved volume as a percentage
|
|
||||||
FirstRun bool // Is this the first time the application has been run?
|
|
||||||
}
|
|
||||||
|
|
||||||
type Log struct {
|
|
||||||
gorm.Model
|
|
||||||
Timestamp int64
|
|
||||||
Level string
|
|
||||||
Message string
|
|
||||||
}
|
|
||||||
|
|
||||||
type Proclamation struct {
|
|
||||||
ID string `gorm:"primaryKey"`
|
|
||||||
Name string
|
|
||||||
Content string
|
|
||||||
SolfeggioFrequencyID int
|
|
||||||
CategoryID int
|
|
||||||
Selected bool
|
|
||||||
Available bool
|
|
||||||
Price float64
|
|
||||||
}
|
|
||||||
|
|
||||||
type ProclamationCategory struct {
|
|
||||||
ID int `gorm:"primaryKey"`
|
|
||||||
Name string
|
|
||||||
ParentId int
|
|
||||||
}
|
|
||||||
|
|
||||||
type SolfeggioFrequency struct {
|
|
||||||
ID int `gorm:"primaryKey"`
|
|
||||||
Name string
|
|
||||||
Value int
|
|
||||||
}
|
|
||||||
|
|
||||||
type AudioFile struct {
|
|
||||||
ID int `gorm:"primaryKey"`
|
|
||||||
Name string
|
|
||||||
Path string
|
|
||||||
Selected bool
|
|
||||||
Order int
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
package db
|
|
||||||
|
|
||||||
import "gorm.io/gorm"
|
|
||||||
|
|
||||||
// GetConfig fetches the application config from the database
|
|
||||||
func GetConfig(db *gorm.DB) (*AppConfig, error) {
|
|
||||||
var cfg AppConfig
|
|
||||||
err := db.First(&cfg, 1).Error
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return &cfg, nil
|
|
||||||
}
|
|
||||||
@@ -1,239 +0,0 @@
|
|||||||
package download
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"crypto/md5"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"rhemawavetech.com/rhemawave/internal/logger"
|
|
||||||
)
|
|
||||||
|
|
||||||
// DownloadStatus holds information related to downloads and is
|
|
||||||
// the container for information returned via channels within this package.
|
|
||||||
type DownloadStatus struct {
|
|
||||||
Max int64
|
|
||||||
Cur int64
|
|
||||||
Msg string
|
|
||||||
Err error
|
|
||||||
}
|
|
||||||
|
|
||||||
// manifest maps filename → expected MD5 hex string
|
|
||||||
type manifest map[string]string
|
|
||||||
|
|
||||||
// ─── Server script to generate checksums.md5 ─────────────────────────────────
|
|
||||||
//
|
|
||||||
// Run this on your file server whenever you add or replace audio files:
|
|
||||||
//
|
|
||||||
// cd /path/to/audio && md5sum *.mp3 > checksums.md5
|
|
||||||
//
|
|
||||||
// That's it. The app will pick up changes automatically on next run.
|
|
||||||
// ─────────────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const manifestFilename = "checksums.md5"
|
|
||||||
|
|
||||||
// DownloadAudioFiles downloads audio files, skipping any that already exist
|
|
||||||
// locally with a matching MD5. Files with a wrong checksum are deleted and
|
|
||||||
// re-downloaded. Progress is sent to progressChan, which is closed on return.
|
|
||||||
func DownloadAudioFiles(progressChan chan<- DownloadStatus, destDir string, urlBase string) {
|
|
||||||
defer close(progressChan)
|
|
||||||
|
|
||||||
send := func(cur, max int64, msg string, err error) {
|
|
||||||
progressChan <- DownloadStatus{Cur: cur, Max: max, Msg: msg, Err: err}
|
|
||||||
}
|
|
||||||
|
|
||||||
send(0, 0, "Fetching file manifest...", nil)
|
|
||||||
m, err := fetchManifest(urlBase + manifestFilename)
|
|
||||||
if err != nil {
|
|
||||||
send(0, 0, "Failed to fetch manifest", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
logger.Info("download: manifest loaded, %d files listed", len(m))
|
|
||||||
|
|
||||||
send(0, 0, "Comparing checksums...", nil)
|
|
||||||
|
|
||||||
type work struct {
|
|
||||||
filename string
|
|
||||||
expectedMD5 string
|
|
||||||
}
|
|
||||||
var queue []work
|
|
||||||
|
|
||||||
for filename, expectedMD5 := range m {
|
|
||||||
localPath := filepath.Join(destDir, filename)
|
|
||||||
localMD5, err := fileMD5(localPath)
|
|
||||||
if err != nil {
|
|
||||||
logger.Debug("download: %s not found locally, queuing", filename)
|
|
||||||
queue = append(queue, work{filename, expectedMD5})
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if localMD5 != expectedMD5 {
|
|
||||||
logger.Debug("download: %s checksum mismatch (got %s, want %s), re-downloading",
|
|
||||||
filename, localMD5, expectedMD5)
|
|
||||||
if err := os.Remove(localPath); err != nil {
|
|
||||||
logger.Warning("download: failed to remove corrupt file %s: %v", localPath, err)
|
|
||||||
}
|
|
||||||
queue = append(queue, work{filename, expectedMD5})
|
|
||||||
} else {
|
|
||||||
logger.Debug("download: %s OK", filename)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(queue) == 0 {
|
|
||||||
send(0, 0, "All files are up to date.", nil)
|
|
||||||
logger.Info("download: nothing to do")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
total := len(queue)
|
|
||||||
send(0, 0, fmt.Sprintf("0 / %d Files Downloaded", total), nil)
|
|
||||||
logger.Info("download: %d file(s) to download", total)
|
|
||||||
|
|
||||||
totalDownloaded := int64(0)
|
|
||||||
filesDownloaded := 0
|
|
||||||
|
|
||||||
for _, w := range queue {
|
|
||||||
url := urlBase + w.filename
|
|
||||||
destPath := filepath.Join(destDir, w.filename)
|
|
||||||
|
|
||||||
logger.Debug("download: starting %s", w.filename)
|
|
||||||
|
|
||||||
downloaded, err := downloadFile(url, destPath, func(n int64) {
|
|
||||||
totalDownloaded += n
|
|
||||||
send(totalDownloaded, 0, fmt.Sprintf("%d / %d Files Downloaded", filesDownloaded, total), nil)
|
|
||||||
})
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("download: failed to download %s: %v", w.filename, err)
|
|
||||||
send(totalDownloaded, 0, fmt.Sprintf("%d / %d Files Downloaded", filesDownloaded, total), err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
gotMD5, err := fileMD5(destPath)
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("download: could not hash %s after download: %v", w.filename, err)
|
|
||||||
os.Remove(destPath)
|
|
||||||
send(totalDownloaded, 0, "Checksum verification failed", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if gotMD5 != w.expectedMD5 {
|
|
||||||
err := fmt.Errorf("checksum mismatch after download: got %s, want %s", gotMD5, w.expectedMD5)
|
|
||||||
logger.Error("download: %s %v", w.filename, err)
|
|
||||||
os.Remove(destPath)
|
|
||||||
send(totalDownloaded, 0, "Checksum verification failed — file removed", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
filesDownloaded++
|
|
||||||
statusMsg := fmt.Sprintf("%d / %d Files Downloaded", filesDownloaded, total)
|
|
||||||
logger.Info("download: %s (%d bytes)", statusMsg, downloaded)
|
|
||||||
send(totalDownloaded, 0, statusMsg, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
send(totalDownloaded, totalDownloaded, "All downloads complete!", nil)
|
|
||||||
logger.Info("download: all files downloaded and verified")
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
// fetchManifest downloads and parses a checksums.md5 file.
|
|
||||||
// Format per line: "<md5hex> <filename>" (two spaces, same as md5sum output)
|
|
||||||
func fetchManifest(url string) (manifest, error) {
|
|
||||||
resp, err := http.Get(url)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("fetch manifest: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return nil, fmt.Errorf("fetch manifest: server returned %s", resp.Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
m := make(manifest)
|
|
||||||
scanner := bufio.NewScanner(resp.Body)
|
|
||||||
for scanner.Scan() {
|
|
||||||
line := strings.TrimSpace(scanner.Text())
|
|
||||||
if line == "" || strings.HasPrefix(line, "#") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// md5sum format: "hash filename" (two spaces)
|
|
||||||
parts := strings.SplitN(line, " ", 2)
|
|
||||||
if len(parts) != 2 {
|
|
||||||
logger.Warning("download: skipping malformed manifest line: %q", line)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
hash := strings.TrimSpace(parts[0])
|
|
||||||
name := strings.TrimSpace(parts[1])
|
|
||||||
m[name] = hash
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := scanner.Err(); err != nil {
|
|
||||||
return nil, fmt.Errorf("parse manifest: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return m, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// fileMD5 computes the MD5 checksum of a local file.
|
|
||||||
// Returns an error if the file doesn't exist or can't be read.
|
|
||||||
func fileMD5(path string) (string, error) {
|
|
||||||
f, err := os.Open(path)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
h := md5.New()
|
|
||||||
if _, err := io.Copy(h, f); err != nil {
|
|
||||||
return "", fmt.Errorf("hash %s: %w", path, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Sprintf("%x", h.Sum(nil)), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// downloadFile streams url to destPath, calling onChunk(n) after each write
|
|
||||||
// with the number of bytes just written. Returns total bytes downloaded.
|
|
||||||
func downloadFile(url, destPath string, onChunk func(int64)) (int64, error) {
|
|
||||||
resp, err := http.Get(url)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return 0, fmt.Errorf("GET %s: %s", url, resp.Status)
|
|
||||||
}
|
|
||||||
|
|
||||||
out, err := os.Create(destPath)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("create %s: %w", destPath, err)
|
|
||||||
}
|
|
||||||
defer out.Close()
|
|
||||||
|
|
||||||
var total int64
|
|
||||||
buf := make([]byte, 32*1024) // 32KB chunks
|
|
||||||
|
|
||||||
for {
|
|
||||||
n, err := resp.Body.Read(buf)
|
|
||||||
if n > 0 {
|
|
||||||
if _, werr := out.Write(buf[:n]); werr != nil {
|
|
||||||
os.Remove(destPath)
|
|
||||||
return total, fmt.Errorf("write %s: %w", destPath, werr)
|
|
||||||
}
|
|
||||||
total += int64(n)
|
|
||||||
onChunk(int64(n))
|
|
||||||
}
|
|
||||||
if err == io.EOF {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
os.Remove(destPath)
|
|
||||||
return total, fmt.Errorf("read %s: %w", url, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return total, nil
|
|
||||||
}
|
|
||||||
@@ -1,890 +0,0 @@
|
|||||||
package fontManager
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/go-gl/gl/v3.3-core/gl"
|
|
||||||
"github.com/go-gl/mathgl/mgl32"
|
|
||||||
"github.com/veandco/go-sdl2/sdl"
|
|
||||||
"github.com/veandco/go-sdl2/ttf"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/db"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/graphics"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
|
||||||
)
|
|
||||||
|
|
||||||
// FontManager handles the loading and management of fonts.
|
|
||||||
type FontManager struct {
|
|
||||||
Fonts map[string]*FontEntry
|
|
||||||
|
|
||||||
// Reusable GPU objects for string texture rendering
|
|
||||||
stringFBO uint32
|
|
||||||
stringVAO uint32
|
|
||||||
stringVBO uint32
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
fontManagerInstance *FontManager
|
|
||||||
fontManagerOnce sync.Once
|
|
||||||
initErr error // Package-level variable to capture error from within sync.Once
|
|
||||||
)
|
|
||||||
|
|
||||||
// InitFontManager initializes the FontManager singleton.
|
|
||||||
// It must be called once at startup after ShaderManager has been initialized.
|
|
||||||
func InitFontManager(cfg *db.AppConfig) error {
|
|
||||||
fontManagerOnce.Do(func() {
|
|
||||||
|
|
||||||
// Dependency check: Ensure ShaderManager is ready
|
|
||||||
sm := shaderManager.Get()
|
|
||||||
if sm == nil {
|
|
||||||
initErr = fmt.Errorf("cannot initialize FontManager because ShaderManager is not initialized")
|
|
||||||
return // Exit the anonymous function
|
|
||||||
}
|
|
||||||
|
|
||||||
fm := &FontManager{
|
|
||||||
Fonts: make(map[string]*FontEntry),
|
|
||||||
}
|
|
||||||
|
|
||||||
fontPath := filepath.Join(cfg.AssetsDir, "fonts")
|
|
||||||
if err := fm.loadFonts(fontPath, cfg); err != nil {
|
|
||||||
initErr = fmt.Errorf("could not load fonts from '%s': %w", fontPath, err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- NEW: Create the scrambled font after loading normal fonts ---
|
|
||||||
// We will use "DejaVuSans" as the base. If you use a different default font,
|
|
||||||
// change this string.
|
|
||||||
baseFontForScramble := "SpaceMonoNerdFont-Regular"
|
|
||||||
if _, ok := fm.Fonts[baseFontForScramble]; ok {
|
|
||||||
if err := fm.CreateScrambledFont(baseFontForScramble, "Scrambled"); err != nil {
|
|
||||||
// This is not a fatal error for the whole application, so we just log it.
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
}
|
|
||||||
// --- END NEW ---
|
|
||||||
|
|
||||||
// Create reusable GPU objects for string texture rendering
|
|
||||||
gl.GenFramebuffers(1, &fm.stringFBO)
|
|
||||||
gl.GenVertexArrays(1, &fm.stringVAO)
|
|
||||||
gl.GenBuffers(1, &fm.stringVBO)
|
|
||||||
|
|
||||||
// Set up VAO attribute layout once
|
|
||||||
gl.BindVertexArray(fm.stringVAO)
|
|
||||||
gl.BindBuffer(gl.ARRAY_BUFFER, fm.stringVBO)
|
|
||||||
gl.BufferData(gl.ARRAY_BUFFER, 6*4*4, nil, gl.DYNAMIC_DRAW) // 6 vertices * 4 floats * 4 bytes
|
|
||||||
gl.EnableVertexAttribArray(0)
|
|
||||||
gl.VertexAttribPointerWithOffset(0, 2, gl.FLOAT, false, 4*4, 0)
|
|
||||||
gl.EnableVertexAttribArray(1)
|
|
||||||
gl.VertexAttribPointerWithOffset(1, 2, gl.FLOAT, false, 4*4, 2*4)
|
|
||||||
gl.BindVertexArray(0)
|
|
||||||
gl.BindBuffer(gl.ARRAY_BUFFER, 0)
|
|
||||||
|
|
||||||
fontManagerInstance = fm
|
|
||||||
})
|
|
||||||
|
|
||||||
// Return the error that might have been captured inside the sync.Once block.
|
|
||||||
return initErr
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get returns the singleton instance of the FontManager.
|
|
||||||
// It will panic if InitFontManager has not been called successfully first.
|
|
||||||
func Get() *FontManager {
|
|
||||||
if fontManagerInstance == nil {
|
|
||||||
panic("FontManager has not been initialized or initialization failed. Call InitFontManager at application startup.")
|
|
||||||
}
|
|
||||||
return fontManagerInstance
|
|
||||||
}
|
|
||||||
|
|
||||||
// FontEntry combines related Font information together
|
|
||||||
type FontEntry struct {
|
|
||||||
Name string
|
|
||||||
Font *ttf.Font
|
|
||||||
Map *FontMap
|
|
||||||
}
|
|
||||||
|
|
||||||
// FontMap contains the character map for a specific font
|
|
||||||
type FontMap struct {
|
|
||||||
FontMapTextureID uint32 // Memory address of the FontMapTexture within the GPU
|
|
||||||
Width int32 // The width of the font map texture
|
|
||||||
Height int32 // The height of the font map FontMap Texture
|
|
||||||
Glyphs map[rune]Glyph // A map of runes and their locations within the Fontmap
|
|
||||||
padding int32
|
|
||||||
}
|
|
||||||
|
|
||||||
// TempGlyph is a temporary struct used only during font map creation.
|
|
||||||
type TempGlyph struct {
|
|
||||||
Rune rune
|
|
||||||
Advance int
|
|
||||||
surface *sdl.Surface
|
|
||||||
}
|
|
||||||
|
|
||||||
// Glyph contains the rendering information for a single character
|
|
||||||
type Glyph struct {
|
|
||||||
Rune rune // The character this glyph represents.
|
|
||||||
SrcRect sdl.Rect // The source rectangle in the texture atlas.
|
|
||||||
Advance int // How far to advance the cursor after drawing this glyph.
|
|
||||||
TextureID uint32 // The ID of the texture atlas this glyph belongs to.
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadFonts scans the fonts directory, loads all .ttf/.otf files, and creates a font map for each.
|
|
||||||
func (fm *FontManager) loadFonts(fontDir string, cfg *db.AppConfig) error {
|
|
||||||
files, err := os.ReadDir(fontDir)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("could not read font directory '%s': %w", fontDir, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, file := range files {
|
|
||||||
if file.IsDir() {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
fileName := file.Name()
|
|
||||||
if strings.HasSuffix(strings.ToLower(fileName), ".ttf") || strings.HasSuffix(strings.ToLower(fileName), ".otf") {
|
|
||||||
fontPath := filepath.Join(fontDir, fileName)
|
|
||||||
baseFontSize := cfg.StandardFontSize
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("could not convert init_font_size to int: %w", err)
|
|
||||||
}
|
|
||||||
font, err := ttf.OpenFont(fontPath, baseFontSize) // Using a higher resolution for better quality
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
fontName := strings.TrimSuffix(fileName, filepath.Ext(fileName))
|
|
||||||
fm.Fonts[fontName] = &FontEntry{
|
|
||||||
Name: fontName,
|
|
||||||
Font: font,
|
|
||||||
Map: nil,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, fontEntry := range fm.Fonts {
|
|
||||||
if err := fm.createFontMap(fontEntry, false, nil); err != nil {
|
|
||||||
return fmt.Errorf("unable to create Font Map for font: %s : %v", fontEntry.Name, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateScrambledFont generates a new font entry by scrambling the glyphs of a base font.
|
|
||||||
func (fm *FontManager) CreateScrambledFont(baseFontName string, newFontName string) error {
|
|
||||||
baseFont, ok := fm.Fonts[baseFontName]
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("base font '%s' not found", baseFontName)
|
|
||||||
}
|
|
||||||
|
|
||||||
scrambledFontEntry := &FontEntry{
|
|
||||||
Name: newFontName,
|
|
||||||
Font: baseFont.Font, // It uses the same underlying TTF font object for rendering
|
|
||||||
Map: nil,
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := fm.createFontMap(scrambledFontEntry, true, baseFont); err != nil {
|
|
||||||
return fmt.Errorf("unable to create scrambled font map: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fm.Fonts[newFontName] = scrambledFontEntry
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ATTENTION: TED
|
|
||||||
//
|
|
||||||
// # Deterministic Fibonacci-Based Visual Pattern Generation for Character Data
|
|
||||||
//
|
|
||||||
// This method transforms rendered text characters into structured visual patterns that produce
|
|
||||||
// character-specific spatial frequency signatures while maintaining perfect reversibility.
|
|
||||||
//
|
|
||||||
// The technology addresses a fundamental limitation in subliminal text display: conventional
|
|
||||||
// approaches either use unmodified text (consciously readable, defeating the subliminal purpose)
|
|
||||||
// or simple blurring/masking (destroys information content and character-specific structure).
|
|
||||||
// Our method preserves complete character information in a transformed visual state that remains
|
|
||||||
// illegible to conscious observation while generating structured spatial patterns.
|
|
||||||
//
|
|
||||||
// The approach treats each rendered character as a two-dimensional grid and applies deterministic
|
|
||||||
// geometric transformations parameterized by Fibonacci sequence values. The character's Unicode
|
|
||||||
// value seeds all transformation parameters, ensuring identical characters produce identical
|
|
||||||
// patterns while different characters generate distinct spatial signatures. Multiple transformation
|
|
||||||
// stages compound to create high-entropy visual output with underlying mathematical structure.
|
|
||||||
//
|
|
||||||
// Key differentiators from existing solutions:
|
|
||||||
// - Operates on rendered visual data rather than underlying text data or encrypted values
|
|
||||||
// - Generates character-specific spatial frequency patterns rather than uniform noise
|
|
||||||
// - Uses Fibonacci-sequence-derived parameters to create mathematically structured transformations
|
|
||||||
// - Maintains pixel-perfect reversibility despite high visual entropy
|
|
||||||
// - Produces deterministic, reproducible patterns for each character
|
|
||||||
//
|
|
||||||
// Within our Rhema Wave generation system, these transformed characters create spatial interference
|
|
||||||
// patterns designed for subconscious processing. Each character contributes a unique frequency
|
|
||||||
// signature to the overall visual field, with Fibonacci-based geometry intended to create harmonic
|
|
||||||
// relationships between character patterns. The result is a consciously illegible display that
|
|
||||||
// maintains structured information content in the spatial domain, forming a component of the
|
|
||||||
// broader Rhema Wave output.
|
|
||||||
func (fm *FontManager) scrambleSurface(surface *sdl.Surface, r rune) error {
|
|
||||||
if surface.W < 8 || surface.H < 8 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if err := surface.Lock(); err != nil {
|
|
||||||
return fmt.Errorf("could not lock surface for scrambling: %w", err)
|
|
||||||
}
|
|
||||||
defer surface.Unlock()
|
|
||||||
|
|
||||||
W := int(surface.W)
|
|
||||||
H := int(surface.H)
|
|
||||||
|
|
||||||
// Use much finer grid for complete illegibility
|
|
||||||
// Fibonacci number determines grid size: 5, 8, or 13
|
|
||||||
fib := []int{5, 8, 13}
|
|
||||||
fibIndex := int(r) % len(fib)
|
|
||||||
gridSize := fib[fibIndex]
|
|
||||||
|
|
||||||
// Ensure we have at least 8x8 grid for strong scrambling
|
|
||||||
if gridSize < 8 {
|
|
||||||
gridSize = 8
|
|
||||||
}
|
|
||||||
|
|
||||||
blockW := W / gridSize
|
|
||||||
blockH := H / gridSize
|
|
||||||
|
|
||||||
if blockW == 0 || blockH == 0 {
|
|
||||||
// Surface too small, fall back to 4x4
|
|
||||||
gridSize = 4
|
|
||||||
blockW = W / gridSize
|
|
||||||
blockH = H / gridSize
|
|
||||||
if blockW == 0 || blockH == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
numBlocks := gridSize * gridSize
|
|
||||||
|
|
||||||
// Create complete permutation using Fibonacci linear congruential generator
|
|
||||||
blockIndices := make([]int, numBlocks)
|
|
||||||
|
|
||||||
// Use Fibonacci numbers for the LCG parameters
|
|
||||||
// F(7)=13, F(8)=21, F(9)=34, F(10)=55, F(11)=89
|
|
||||||
fibSeq := []int{13, 21, 34, 55, 89, 144}
|
|
||||||
multiplier := fibSeq[int(r)%len(fibSeq)]
|
|
||||||
increment := fibSeq[(int(r)+1)%len(fibSeq)]
|
|
||||||
|
|
||||||
// Generate pseudo-random permutation
|
|
||||||
for i := 0; i < numBlocks; i++ {
|
|
||||||
blockIndices[i] = (multiplier*i + increment*int(r)) % numBlocks
|
|
||||||
}
|
|
||||||
|
|
||||||
// Additional scrambling: apply multiple Fibonacci-based transformations
|
|
||||||
numTransforms := 3 + (int(r) % 5) // 3-7 transformations
|
|
||||||
|
|
||||||
for t := 0; t < numTransforms; t++ {
|
|
||||||
transformType := (int(r) + t*fibSeq[t%len(fibSeq)]) % 5
|
|
||||||
|
|
||||||
switch transformType {
|
|
||||||
case 0: // Reverse sections
|
|
||||||
sectionSize := gridSize / 2
|
|
||||||
for i := 0; i < numBlocks; i += sectionSize {
|
|
||||||
end := i + sectionSize
|
|
||||||
if end > numBlocks {
|
|
||||||
end = numBlocks
|
|
||||||
}
|
|
||||||
reverseSlice(blockIndices[i:end])
|
|
||||||
}
|
|
||||||
|
|
||||||
case 1: // Spiral rotation
|
|
||||||
// Rotate blocks in a spiral pattern
|
|
||||||
for layer := 0; layer < gridSize/2; layer++ {
|
|
||||||
rotateLayer(blockIndices, gridSize, layer)
|
|
||||||
}
|
|
||||||
|
|
||||||
case 2: // Fibonacci walk swap
|
|
||||||
step := fib[t%len(fib)]
|
|
||||||
for i := 0; i < numBlocks-step; i += step {
|
|
||||||
blockIndices[i], blockIndices[i+step] = blockIndices[i+step], blockIndices[i]
|
|
||||||
}
|
|
||||||
|
|
||||||
case 3: // Checkerboard transpose
|
|
||||||
for i := 0; i < gridSize; i++ {
|
|
||||||
for j := 0; j < gridSize; j++ {
|
|
||||||
if (i+j)%2 == 0 {
|
|
||||||
idx1 := i*gridSize + j
|
|
||||||
idx2 := j*gridSize + i
|
|
||||||
if idx2 < numBlocks {
|
|
||||||
blockIndices[idx1], blockIndices[idx2] = blockIndices[idx2], blockIndices[idx1]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case 4: // Random-walk permutation
|
|
||||||
walkLen := fib[(t+1)%len(fib)]
|
|
||||||
pos := int(r+rune(t)) % numBlocks
|
|
||||||
for w := 0; w < walkLen && pos < numBlocks-1; w++ {
|
|
||||||
nextPos := (pos + fib[w%len(fib)]) % numBlocks
|
|
||||||
blockIndices[pos], blockIndices[nextPos] = blockIndices[nextPos], blockIndices[pos]
|
|
||||||
pos = nextPos
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy pixels to perform the scrambling
|
|
||||||
pixels := surface.Pixels()
|
|
||||||
bytesPerPixel := int(surface.Format.BytesPerPixel)
|
|
||||||
pitch := int(surface.Pitch)
|
|
||||||
originalPixels := make([]byte, len(pixels))
|
|
||||||
copy(originalPixels, pixels)
|
|
||||||
|
|
||||||
// Rearrange all blocks
|
|
||||||
for dstIdx := 0; dstIdx < numBlocks; dstIdx++ {
|
|
||||||
srcIdx := blockIndices[dstIdx]
|
|
||||||
|
|
||||||
srcCol := srcIdx % gridSize
|
|
||||||
srcRow := srcIdx / gridSize
|
|
||||||
dstCol := dstIdx % gridSize
|
|
||||||
dstRow := dstIdx / gridSize
|
|
||||||
|
|
||||||
srcX := srcCol * blockW
|
|
||||||
srcY := srcRow * blockH
|
|
||||||
dstX := dstCol * blockW
|
|
||||||
dstY := dstRow * blockH
|
|
||||||
|
|
||||||
// Copy block pixel by pixel
|
|
||||||
for y := 0; y < blockH; y++ {
|
|
||||||
if srcY+y >= H || dstY+y >= H {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
srcOffset := (srcY+y)*pitch + srcX*bytesPerPixel
|
|
||||||
dstOffset := (dstY+y)*pitch + dstX*bytesPerPixel
|
|
||||||
|
|
||||||
rowLen := blockW * bytesPerPixel
|
|
||||||
if srcX+blockW > W {
|
|
||||||
rowLen = (W - srcX) * bytesPerPixel
|
|
||||||
}
|
|
||||||
if dstX+blockW > W {
|
|
||||||
maxLen := (W - dstX) * bytesPerPixel
|
|
||||||
if maxLen < rowLen {
|
|
||||||
rowLen = maxLen
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if srcOffset+rowLen <= len(originalPixels) && dstOffset+rowLen <= len(pixels) {
|
|
||||||
copy(pixels[dstOffset:dstOffset+rowLen], originalPixels[srcOffset:srcOffset+rowLen])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to reverse a slice
|
|
||||||
func reverseSlice(arr []int) {
|
|
||||||
for i, j := 0, len(arr)-1; i < j; i, j = i+1, j-1 {
|
|
||||||
arr[i], arr[j] = arr[j], arr[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to rotate a layer in spiral pattern
|
|
||||||
func rotateLayer(arr []int, gridSize, layer int) {
|
|
||||||
if layer >= gridSize/2 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract the layer elements
|
|
||||||
var temp []int
|
|
||||||
|
|
||||||
// Top edge
|
|
||||||
for i := layer; i < gridSize-layer; i++ {
|
|
||||||
temp = append(temp, arr[layer*gridSize+i])
|
|
||||||
}
|
|
||||||
// Right edge
|
|
||||||
for i := layer + 1; i < gridSize-layer; i++ {
|
|
||||||
temp = append(temp, arr[i*gridSize+(gridSize-layer-1)])
|
|
||||||
}
|
|
||||||
// Bottom edge
|
|
||||||
if gridSize-layer-1 > layer {
|
|
||||||
for i := gridSize - layer - 2; i >= layer; i-- {
|
|
||||||
temp = append(temp, arr[(gridSize-layer-1)*gridSize+i])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Left edge
|
|
||||||
if gridSize-layer-1 > layer {
|
|
||||||
for i := gridSize - layer - 2; i > layer; i-- {
|
|
||||||
temp = append(temp, arr[i*gridSize+layer])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rotate the temp array by 1 position
|
|
||||||
if len(temp) > 0 {
|
|
||||||
last := temp[len(temp)-1]
|
|
||||||
copy(temp[1:], temp[0:len(temp)-1])
|
|
||||||
temp[0] = last
|
|
||||||
}
|
|
||||||
|
|
||||||
// Put back the rotated elements
|
|
||||||
idx := 0
|
|
||||||
// Top edge
|
|
||||||
for i := layer; i < gridSize-layer && idx < len(temp); i++ {
|
|
||||||
arr[layer*gridSize+i] = temp[idx]
|
|
||||||
idx++
|
|
||||||
}
|
|
||||||
// Right edge
|
|
||||||
for i := layer + 1; i < gridSize-layer && idx < len(temp); i++ {
|
|
||||||
arr[i*gridSize+(gridSize-layer-1)] = temp[idx]
|
|
||||||
idx++
|
|
||||||
}
|
|
||||||
// Bottom edge
|
|
||||||
if gridSize-layer-1 > layer {
|
|
||||||
for i := gridSize - layer - 2; i >= layer && idx < len(temp); i-- {
|
|
||||||
arr[(gridSize-layer-1)*gridSize+i] = temp[idx]
|
|
||||||
idx++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Left edge
|
|
||||||
if gridSize-layer-1 > layer {
|
|
||||||
for i := gridSize - layer - 2; i > layer && idx < len(temp); i-- {
|
|
||||||
arr[i*gridSize+layer] = temp[idx]
|
|
||||||
idx++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// createFontMap generates a texture atlas for a given font entry.
|
|
||||||
func (fm *FontManager) createFontMap(font *FontEntry, scramble bool, baseFont *FontEntry) error {
|
|
||||||
// var chars []rune
|
|
||||||
// for i := rune(32); i <= 126; i++ { // Common ASCII characters
|
|
||||||
// chars = append(chars, i)
|
|
||||||
// }
|
|
||||||
|
|
||||||
// Standard ASCII (A-Z, a-z, 0-9) + punctuation
|
|
||||||
chars := []rune{
|
|
||||||
// Numbers 0-9
|
|
||||||
48, 49, 50, 51, 52, 53, 54, 55, 56, 57, // 0-9
|
|
||||||
|
|
||||||
// Uppercase A-Z
|
|
||||||
65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, // A-O
|
|
||||||
80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, // P-Z
|
|
||||||
|
|
||||||
// Lowercase a-z
|
|
||||||
97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 107, 108, 109, // a-m
|
|
||||||
110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, // n-z
|
|
||||||
|
|
||||||
// Basic punctuation (ASCII 32-47, 58-64, 91-96, 123-126)
|
|
||||||
32, // Space
|
|
||||||
33, // !
|
|
||||||
34, // "
|
|
||||||
35, // #
|
|
||||||
36, // $
|
|
||||||
37, // %
|
|
||||||
38, // &
|
|
||||||
39, // '
|
|
||||||
40, // (
|
|
||||||
41, // )
|
|
||||||
42, // *
|
|
||||||
43, // +
|
|
||||||
44, // ,
|
|
||||||
45, // -
|
|
||||||
46, // .
|
|
||||||
47, // /
|
|
||||||
58, // :
|
|
||||||
59, // ;
|
|
||||||
60, // <
|
|
||||||
61, // =
|
|
||||||
62, // >
|
|
||||||
63, // ?
|
|
||||||
64, // @
|
|
||||||
91, // [
|
|
||||||
92, // \
|
|
||||||
93, // ]
|
|
||||||
94, // ^
|
|
||||||
95, // _
|
|
||||||
96, // `
|
|
||||||
123, // {
|
|
||||||
124, // |
|
|
||||||
125, // }
|
|
||||||
126, // ~
|
|
||||||
|
|
||||||
// Common Unicode punctuation
|
|
||||||
161, // ¡
|
|
||||||
191, // ¿
|
|
||||||
8211, // –
|
|
||||||
8212, // —
|
|
||||||
8216, // ‘
|
|
||||||
8217, // ’
|
|
||||||
8220, // “
|
|
||||||
8221, // ”
|
|
||||||
8226, // •
|
|
||||||
8230, // …
|
|
||||||
8240, // ‰
|
|
||||||
8364, // €
|
|
||||||
162, // ¢
|
|
||||||
163, // £
|
|
||||||
165, // ¥
|
|
||||||
169, // ©
|
|
||||||
174, // ®
|
|
||||||
8482, // ™
|
|
||||||
232, // è
|
|
||||||
233, // é
|
|
||||||
10003, // checkmark
|
|
||||||
}
|
|
||||||
tempGlyphs := make([]TempGlyph, 0, len(chars))
|
|
||||||
var totalWidth, maxHeight int32
|
|
||||||
padding := int32(2) // 2 pixels of padding between glyphs
|
|
||||||
|
|
||||||
// If scrambling, use the base font's metrics but our new entry's name
|
|
||||||
fontToRenderFrom := font
|
|
||||||
if scramble && baseFont != nil {
|
|
||||||
fontToRenderFrom = baseFont
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, char := range chars {
|
|
||||||
tempGlyph, err := fm.createTempGlyph(fontToRenderFrom, char)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- NEW: Scramble the surface if requested ---
|
|
||||||
if scramble {
|
|
||||||
if err := fm.scrambleSurface(tempGlyph.surface, char); err != nil {
|
|
||||||
// We can continue, it will just use the unscrambled glyph
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// --- END NEW ---
|
|
||||||
|
|
||||||
tempGlyphs = append(tempGlyphs, tempGlyph)
|
|
||||||
totalWidth += tempGlyph.surface.W + padding
|
|
||||||
if tempGlyph.surface.H > maxHeight {
|
|
||||||
maxHeight = tempGlyph.surface.H
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
atlasSurface, err := sdl.CreateRGBSurface(0, totalWidth, maxHeight, 32, 0x00ff0000, 0x0000ff00, 0x000000ff, 0xff000000)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("could not create atlas surface: %w", err)
|
|
||||||
}
|
|
||||||
defer atlasSurface.Free()
|
|
||||||
|
|
||||||
fontMap := &FontMap{
|
|
||||||
Glyphs: make(map[rune]Glyph),
|
|
||||||
padding: padding,
|
|
||||||
}
|
|
||||||
|
|
||||||
var currentX int32 = 0
|
|
||||||
for _, tempGlyph := range tempGlyphs {
|
|
||||||
dstRect := sdl.Rect{X: currentX, Y: 0, W: tempGlyph.surface.W, H: tempGlyph.surface.H}
|
|
||||||
if err := tempGlyph.surface.Blit(nil, atlasSurface, &dstRect); err != nil {
|
|
||||||
tempGlyph.surface.Free()
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
fontMap.Glyphs[tempGlyph.Rune] = Glyph{
|
|
||||||
Rune: tempGlyph.Rune,
|
|
||||||
Advance: tempGlyph.Advance,
|
|
||||||
SrcRect: dstRect,
|
|
||||||
}
|
|
||||||
|
|
||||||
currentX += tempGlyph.surface.W + padding
|
|
||||||
tempGlyph.surface.Free()
|
|
||||||
}
|
|
||||||
|
|
||||||
textureID, err := graphics.CreateTextureFromSurface(atlasSurface)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("could not create texture from atlas surface: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fontMap.FontMapTextureID = textureID
|
|
||||||
fontMap.Width = atlasSurface.W
|
|
||||||
fontMap.Height = atlasSurface.H
|
|
||||||
|
|
||||||
for r, g := range fontMap.Glyphs {
|
|
||||||
g.TextureID = textureID
|
|
||||||
fontMap.Glyphs[r] = g
|
|
||||||
}
|
|
||||||
|
|
||||||
font.Map = fontMap
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// createTempGlyph renders a single character to a temporary SDL surface.
|
|
||||||
func (fm *FontManager) createTempGlyph(font *FontEntry, char rune) (TempGlyph, error) {
|
|
||||||
runeSurface, err := font.Font.RenderGlyphBlended(char, sdl.Color{R: 255, G: 255, B: 255, A: 255})
|
|
||||||
if err != nil {
|
|
||||||
return TempGlyph{}, fmt.Errorf("unable to create glyph for char '%c': %w", char, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
metrics, err := font.Font.GlyphMetrics(char)
|
|
||||||
if err != nil {
|
|
||||||
runeSurface.Free()
|
|
||||||
return TempGlyph{}, fmt.Errorf("unable to fetch glyph metrics for char '%c': %w", char, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return TempGlyph{
|
|
||||||
Rune: char,
|
|
||||||
Advance: metrics.Advance,
|
|
||||||
surface: runeSurface,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetFont retrieves a loaded font by its name.
|
|
||||||
func (fm *FontManager) GetFont(name string) (*FontEntry, bool) {
|
|
||||||
fontEntry, ok := fm.Fonts[name]
|
|
||||||
return fontEntry, ok
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetGlyph retrieves the rendering data for a specific character from a font's map.
|
|
||||||
func (fm *FontManager) GetGlyph(fontName string, r rune) (Glyph, bool) {
|
|
||||||
fontEntry, ok := fm.Fonts[fontName]
|
|
||||||
if !ok || fontEntry.Map == nil {
|
|
||||||
return Glyph{}, false
|
|
||||||
}
|
|
||||||
glyph, ok := fontEntry.Map.Glyphs[r]
|
|
||||||
return glyph, ok
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetFontNames returns a slice of all loaded font names.
|
|
||||||
func (fm *FontManager) GetFontNames() []string {
|
|
||||||
names := make([]string, 0, len(fm.Fonts))
|
|
||||||
for name := range fm.Fonts {
|
|
||||||
names = append(names, name)
|
|
||||||
}
|
|
||||||
return names
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close frees all loaded font resources, including the generated textures.
|
|
||||||
func (fm *FontManager) Close() {
|
|
||||||
for _, fontEntry := range fm.Fonts {
|
|
||||||
if fontEntry.Map != nil && fontEntry.Map.FontMapTextureID != 0 {
|
|
||||||
gl.DeleteTextures(1, &fontEntry.Map.FontMapTextureID)
|
|
||||||
}
|
|
||||||
if fontEntry.Font != nil {
|
|
||||||
fontEntry.Font.Close()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if fm.stringFBO != 0 {
|
|
||||||
gl.DeleteFramebuffers(1, &fm.stringFBO)
|
|
||||||
}
|
|
||||||
if fm.stringVAO != 0 {
|
|
||||||
gl.DeleteVertexArrays(1, &fm.stringVAO)
|
|
||||||
}
|
|
||||||
if fm.stringVBO != 0 {
|
|
||||||
gl.DeleteBuffers(1, &fm.stringVBO)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (fm *FontManager) CreateStringTexture(fontName string, text string, shader *shaderManager.Shader) (textureID uint32, width int32, height int32, err error) {
|
|
||||||
shaderID := shader.ProgramID
|
|
||||||
if fontName == "" || text == "" {
|
|
||||||
return 0, 0, 0, fmt.Errorf("font name and text cannot be blank")
|
|
||||||
}
|
|
||||||
|
|
||||||
selectedFont, ok := fm.Fonts[fontName]
|
|
||||||
if !ok || selectedFont.Map == nil {
|
|
||||||
return 0, 0, 0, fmt.Errorf("font '%s' not found or its font map is not created", fontName)
|
|
||||||
}
|
|
||||||
|
|
||||||
var totalWidth, maxHeight int32
|
|
||||||
glyphsToRender := make([]Glyph, 0, len(text))
|
|
||||||
|
|
||||||
for _, r := range text {
|
|
||||||
g, ok := selectedFont.Map.Glyphs[r]
|
|
||||||
if !ok {
|
|
||||||
g = selectedFont.Map.Glyphs['?'] // Fallback
|
|
||||||
}
|
|
||||||
totalWidth += g.SrcRect.W + selectedFont.Map.padding
|
|
||||||
if g.SrcRect.H > maxHeight {
|
|
||||||
maxHeight = g.SrcRect.H
|
|
||||||
}
|
|
||||||
glyphsToRender = append(glyphsToRender, g)
|
|
||||||
}
|
|
||||||
|
|
||||||
if totalWidth <= 0 || maxHeight <= 0 {
|
|
||||||
return 0, 0, 0, fmt.Errorf("calculated texture dimensions are invalid: %dx%d", totalWidth, maxHeight)
|
|
||||||
}
|
|
||||||
|
|
||||||
var lastFBO int32
|
|
||||||
gl.GetIntegerv(gl.FRAMEBUFFER_BINDING, &lastFBO)
|
|
||||||
var lastViewport [4]int32
|
|
||||||
gl.GetIntegerv(gl.VIEWPORT, &lastViewport[0])
|
|
||||||
|
|
||||||
gl.BindFramebuffer(gl.FRAMEBUFFER, fm.stringFBO)
|
|
||||||
|
|
||||||
var newTextureID uint32
|
|
||||||
gl.GenTextures(1, &newTextureID)
|
|
||||||
gl.BindTexture(gl.TEXTURE_2D, newTextureID)
|
|
||||||
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
|
|
||||||
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
|
|
||||||
gl.PixelStorei(gl.UNPACK_ALIGNMENT, 1)
|
|
||||||
|
|
||||||
gl.TexImage2D(gl.TEXTURE_2D, 0, gl.RGBA, totalWidth, maxHeight, 0, gl.RGBA, gl.UNSIGNED_BYTE, nil)
|
|
||||||
gl.FramebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, newTextureID, 0)
|
|
||||||
|
|
||||||
if gl.CheckFramebufferStatus(gl.FRAMEBUFFER) != gl.FRAMEBUFFER_COMPLETE {
|
|
||||||
gl.BindFramebuffer(gl.FRAMEBUFFER, uint32(lastFBO))
|
|
||||||
gl.DeleteTextures(1, &newTextureID)
|
|
||||||
return 0, 0, 0, fmt.Errorf("framebuffer is not complete")
|
|
||||||
}
|
|
||||||
|
|
||||||
gl.Viewport(0, 0, totalWidth, maxHeight)
|
|
||||||
gl.ClearColor(0.0, 0.0, 0.0, 0.0)
|
|
||||||
gl.Clear(gl.COLOR_BUFFER_BIT)
|
|
||||||
gl.UseProgram(shaderID)
|
|
||||||
|
|
||||||
projection := mgl32.Ortho(0, float32(totalWidth), 0, float32(maxHeight), -1, 1)
|
|
||||||
gl.UniformMatrix4fv(shader.LocMVPMatrix, 1, false, &projection[0])
|
|
||||||
|
|
||||||
if shader.LocTextColor != -1 {
|
|
||||||
gl.Uniform4f(shader.LocTextColor, 1.0, 1.0, 1.0, 1.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
gl.ActiveTexture(gl.TEXTURE0)
|
|
||||||
gl.Uniform1i(shader.LocTexture, 0)
|
|
||||||
gl.Enable(gl.BLEND)
|
|
||||||
gl.BlendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
|
|
||||||
gl.BindTexture(gl.TEXTURE_2D, selectedFont.Map.FontMapTextureID)
|
|
||||||
|
|
||||||
gl.BindVertexArray(fm.stringVAO)
|
|
||||||
gl.BindBuffer(gl.ARRAY_BUFFER, fm.stringVBO)
|
|
||||||
|
|
||||||
var currentX float32
|
|
||||||
atlasW, atlasH := float32(selectedFont.Map.Width), float32(selectedFont.Map.Height)
|
|
||||||
|
|
||||||
for _, glyph := range glyphsToRender {
|
|
||||||
xPos := currentX
|
|
||||||
yPos := float32(0)
|
|
||||||
w, h := float32(glyph.SrcRect.W), float32(glyph.SrcRect.H)
|
|
||||||
u0 := float32(glyph.SrcRect.X) / atlasW
|
|
||||||
v0 := float32(glyph.SrcRect.Y) / atlasH
|
|
||||||
u1 := u0 + w/atlasW
|
|
||||||
v1 := v0 + h/atlasH
|
|
||||||
vertices := []float32{
|
|
||||||
xPos, yPos + h, u0, v0,
|
|
||||||
xPos, yPos, u0, v1,
|
|
||||||
xPos + w, yPos, u1, v1,
|
|
||||||
xPos, yPos + h, u0, v0,
|
|
||||||
xPos + w, yPos, u1, v1,
|
|
||||||
xPos + w, yPos + h, u1, v0,
|
|
||||||
}
|
|
||||||
gl.BufferSubData(gl.ARRAY_BUFFER, 0, len(vertices)*4, gl.Ptr(vertices))
|
|
||||||
gl.DrawArrays(gl.TRIANGLES, 0, 6)
|
|
||||||
currentX += float32(glyph.Advance)
|
|
||||||
}
|
|
||||||
|
|
||||||
gl.BindVertexArray(0)
|
|
||||||
gl.BindFramebuffer(gl.FRAMEBUFFER, uint32(lastFBO))
|
|
||||||
gl.Viewport(lastViewport[0], lastViewport[1], lastViewport[2], lastViewport[3])
|
|
||||||
gl.Disable(gl.BLEND)
|
|
||||||
|
|
||||||
return newTextureID, totalWidth, maxHeight, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewCreateStringTexture does the same thing as CreateStringTexture, except that it returns the width in pixels used by the glyphs
|
|
||||||
// in the texture, rather than the width of the texture itself. This can be used for trimming.
|
|
||||||
func (fm *FontManager) NewCreateStringTexture(fontName string, text string, shader *shaderManager.Shader) (textureID uint32, width float32, height int32, err error) {
|
|
||||||
shaderID := shader.ProgramID
|
|
||||||
if fontName == "" || text == "" {
|
|
||||||
return 0, 0, 0, fmt.Errorf("font name and text cannot be blank")
|
|
||||||
}
|
|
||||||
|
|
||||||
selectedFont, ok := fm.Fonts[fontName]
|
|
||||||
if !ok || selectedFont.Map == nil {
|
|
||||||
return 0, 0, 0, fmt.Errorf("font '%s' not found or its font map is not created", fontName)
|
|
||||||
}
|
|
||||||
|
|
||||||
var totalWidth, maxHeight int32
|
|
||||||
glyphsToRender := make([]Glyph, 0, len(text))
|
|
||||||
|
|
||||||
for _, r := range text {
|
|
||||||
g, ok := selectedFont.Map.Glyphs[r]
|
|
||||||
if !ok {
|
|
||||||
g = selectedFont.Map.Glyphs['?'] // Fallback
|
|
||||||
}
|
|
||||||
totalWidth += g.SrcRect.W + selectedFont.Map.padding
|
|
||||||
if g.SrcRect.H > maxHeight {
|
|
||||||
maxHeight = g.SrcRect.H
|
|
||||||
}
|
|
||||||
glyphsToRender = append(glyphsToRender, g)
|
|
||||||
}
|
|
||||||
|
|
||||||
if totalWidth <= 0 || maxHeight <= 0 {
|
|
||||||
return 0, 0, 0, fmt.Errorf("calculated texture dimensions are invalid: %dx%d", totalWidth, maxHeight)
|
|
||||||
}
|
|
||||||
|
|
||||||
var lastFBO int32
|
|
||||||
gl.GetIntegerv(gl.FRAMEBUFFER_BINDING, &lastFBO)
|
|
||||||
var lastViewport [4]int32
|
|
||||||
gl.GetIntegerv(gl.VIEWPORT, &lastViewport[0])
|
|
||||||
|
|
||||||
gl.BindFramebuffer(gl.FRAMEBUFFER, fm.stringFBO)
|
|
||||||
|
|
||||||
var newTextureID uint32
|
|
||||||
gl.GenTextures(1, &newTextureID)
|
|
||||||
gl.BindTexture(gl.TEXTURE_2D, newTextureID)
|
|
||||||
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
|
|
||||||
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
|
|
||||||
gl.PixelStorei(gl.UNPACK_ALIGNMENT, 1)
|
|
||||||
|
|
||||||
gl.TexImage2D(gl.TEXTURE_2D, 0, gl.RGBA, totalWidth, maxHeight, 0, gl.RGBA, gl.UNSIGNED_BYTE, nil)
|
|
||||||
gl.FramebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, newTextureID, 0)
|
|
||||||
|
|
||||||
if gl.CheckFramebufferStatus(gl.FRAMEBUFFER) != gl.FRAMEBUFFER_COMPLETE {
|
|
||||||
gl.BindFramebuffer(gl.FRAMEBUFFER, uint32(lastFBO))
|
|
||||||
gl.DeleteTextures(1, &newTextureID)
|
|
||||||
return 0, 0, 0, fmt.Errorf("framebuffer is not complete")
|
|
||||||
}
|
|
||||||
|
|
||||||
gl.Viewport(0, 0, totalWidth, maxHeight)
|
|
||||||
gl.ClearColor(0.0, 0.0, 0.0, 0.0)
|
|
||||||
gl.Clear(gl.COLOR_BUFFER_BIT)
|
|
||||||
gl.UseProgram(shaderID)
|
|
||||||
|
|
||||||
projection := mgl32.Ortho(0, float32(totalWidth), 0, float32(maxHeight), -1, 1)
|
|
||||||
gl.UniformMatrix4fv(shader.LocMVPMatrix, 1, false, &projection[0])
|
|
||||||
|
|
||||||
if shader.LocTextColor != -1 {
|
|
||||||
gl.Uniform4f(shader.LocTextColor, 1.0, 1.0, 1.0, 1.0)
|
|
||||||
}
|
|
||||||
|
|
||||||
gl.ActiveTexture(gl.TEXTURE0)
|
|
||||||
gl.Uniform1i(shader.LocTexture, 0)
|
|
||||||
gl.Enable(gl.BLEND)
|
|
||||||
gl.BlendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
|
|
||||||
gl.BindTexture(gl.TEXTURE_2D, selectedFont.Map.FontMapTextureID)
|
|
||||||
|
|
||||||
gl.BindVertexArray(fm.stringVAO)
|
|
||||||
gl.BindBuffer(gl.ARRAY_BUFFER, fm.stringVBO)
|
|
||||||
|
|
||||||
var currentX float32
|
|
||||||
atlasW, atlasH := float32(selectedFont.Map.Width), float32(selectedFont.Map.Height)
|
|
||||||
|
|
||||||
for _, glyph := range glyphsToRender {
|
|
||||||
xPos := currentX
|
|
||||||
yPos := float32(0)
|
|
||||||
w, h := float32(glyph.SrcRect.W), float32(glyph.SrcRect.H)
|
|
||||||
u0 := float32(glyph.SrcRect.X) / atlasW
|
|
||||||
v0 := float32(glyph.SrcRect.Y) / atlasH
|
|
||||||
u1 := u0 + w/atlasW
|
|
||||||
v1 := v0 + h/atlasH
|
|
||||||
vertices := []float32{
|
|
||||||
xPos, yPos + h, u0, v0,
|
|
||||||
xPos, yPos, u0, v1,
|
|
||||||
xPos + w, yPos, u1, v1,
|
|
||||||
xPos, yPos + h, u0, v0,
|
|
||||||
xPos + w, yPos, u1, v1,
|
|
||||||
xPos + w, yPos + h, u1, v0,
|
|
||||||
}
|
|
||||||
gl.BufferSubData(gl.ARRAY_BUFFER, 0, len(vertices)*4, gl.Ptr(vertices))
|
|
||||||
gl.DrawArrays(gl.TRIANGLES, 0, 6)
|
|
||||||
currentX += float32(glyph.Advance)
|
|
||||||
}
|
|
||||||
|
|
||||||
usedWidth := float32(currentX)
|
|
||||||
|
|
||||||
gl.BindVertexArray(0)
|
|
||||||
gl.BindFramebuffer(gl.FRAMEBUFFER, uint32(lastFBO))
|
|
||||||
gl.Viewport(lastViewport[0], lastViewport[1], lastViewport[2], lastViewport[3])
|
|
||||||
gl.Disable(gl.BLEND)
|
|
||||||
|
|
||||||
return newTextureID, usedWidth, maxHeight, nil
|
|
||||||
}
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
package graphics
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
|
|
||||||
"github.com/go-gl/gl/v3.3-core/gl"
|
|
||||||
"github.com/veandco/go-sdl2/sdl"
|
|
||||||
)
|
|
||||||
|
|
||||||
// CreateTextureFromSurface uploads an SDL surface to an OpenGL texture and returns the texture ID.
|
|
||||||
func CreateTextureFromSurface(surface *sdl.Surface) (uint32, error) {
|
|
||||||
if surface == nil {
|
|
||||||
return 0, fmt.Errorf("CreateTextureFromSurface: input surface is nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
var textureID uint32
|
|
||||||
var err error
|
|
||||||
|
|
||||||
surfaceToUpload := surface
|
|
||||||
var convertedSurface *sdl.Surface = nil
|
|
||||||
|
|
||||||
sdlFormatEnum := surface.Format.Format
|
|
||||||
var dataFormat uint32
|
|
||||||
var internalFormat int32 = gl.RGBA8
|
|
||||||
|
|
||||||
if sdlFormatEnum == sdl.PIXELFORMAT_INDEX8 {
|
|
||||||
convertedSurface, err = surface.ConvertFormat(sdl.PIXELFORMAT_ARGB8888, 0)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("failed to convert INDEX8 surface to ARGB8888: %w", err)
|
|
||||||
}
|
|
||||||
defer convertedSurface.Free()
|
|
||||||
|
|
||||||
surfaceToUpload = convertedSurface
|
|
||||||
sdlFormatEnum = surfaceToUpload.Format.Format
|
|
||||||
}
|
|
||||||
|
|
||||||
switch sdlFormatEnum {
|
|
||||||
case sdl.PIXELFORMAT_ARGB8888:
|
|
||||||
dataFormat = gl.BGRA
|
|
||||||
case sdl.PIXELFORMAT_ABGR8888:
|
|
||||||
dataFormat = gl.RGBA
|
|
||||||
case sdl.PIXELFORMAT_RGBA8888:
|
|
||||||
dataFormat = gl.RGBA
|
|
||||||
case sdl.PIXELFORMAT_BGRA8888:
|
|
||||||
dataFormat = gl.BGRA
|
|
||||||
default:
|
|
||||||
return 0, fmt.Errorf("unhandled SDL pixel format for OpenGL upload: %s", sdl.GetPixelFormatName(uint(sdlFormatEnum)))
|
|
||||||
}
|
|
||||||
|
|
||||||
for errCode := gl.GetError(); errCode != gl.NO_ERROR; errCode = gl.GetError() {
|
|
||||||
log.Printf("OpenGL error pending before GenTextures: 0x%X", errCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
gl.GenTextures(1, &textureID)
|
|
||||||
if errCode := gl.GetError(); errCode != gl.NO_ERROR {
|
|
||||||
return 0, fmt.Errorf("OpenGL error 0x%X after GenTextures", errCode)
|
|
||||||
}
|
|
||||||
if textureID == 0 {
|
|
||||||
return 0, fmt.Errorf("gl.GenTextures returned textureID 0")
|
|
||||||
}
|
|
||||||
|
|
||||||
gl.BindTexture(gl.TEXTURE_2D, textureID)
|
|
||||||
|
|
||||||
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST)
|
|
||||||
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST)
|
|
||||||
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
|
|
||||||
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
|
|
||||||
|
|
||||||
gl.PixelStorei(gl.UNPACK_ALIGNMENT, 1)
|
|
||||||
|
|
||||||
expectedPitch := surfaceToUpload.W * int32(surfaceToUpload.Format.BytesPerPixel)
|
|
||||||
if int32(surfaceToUpload.Pitch) != expectedPitch {
|
|
||||||
gl.PixelStorei(gl.UNPACK_ROW_LENGTH, surfaceToUpload.Pitch/int32(surfaceToUpload.Format.BytesPerPixel))
|
|
||||||
} else {
|
|
||||||
gl.PixelStorei(gl.UNPACK_ROW_LENGTH, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
gl.TexImage2D(
|
|
||||||
gl.TEXTURE_2D,
|
|
||||||
0,
|
|
||||||
internalFormat,
|
|
||||||
surfaceToUpload.W,
|
|
||||||
surfaceToUpload.H,
|
|
||||||
0,
|
|
||||||
dataFormat,
|
|
||||||
gl.UNSIGNED_BYTE,
|
|
||||||
gl.Ptr(surfaceToUpload.Pixels()),
|
|
||||||
)
|
|
||||||
|
|
||||||
gl.PixelStorei(gl.UNPACK_ALIGNMENT, 4)
|
|
||||||
gl.PixelStorei(gl.UNPACK_ROW_LENGTH, 0)
|
|
||||||
|
|
||||||
if glErr := gl.GetError(); glErr != gl.NO_ERROR {
|
|
||||||
gl.DeleteTextures(1, &textureID)
|
|
||||||
return 0, fmt.Errorf("OpenGL error 0x%X after TexImage2D", glErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
gl.BindTexture(gl.TEXTURE_2D, 0)
|
|
||||||
return textureID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// CheckGLError queries OpenGL for errors and returns the first one found.
|
|
||||||
func CheckGLError() error {
|
|
||||||
errCode := gl.GetError()
|
|
||||||
if errCode == gl.NO_ERROR {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
switch errCode {
|
|
||||||
case gl.INVALID_ENUM:
|
|
||||||
return fmt.Errorf("INVALID_ENUM")
|
|
||||||
case gl.INVALID_VALUE:
|
|
||||||
return fmt.Errorf("INVALID_VALUE")
|
|
||||||
case gl.INVALID_OPERATION:
|
|
||||||
return fmt.Errorf("INVALID_OPERATION")
|
|
||||||
case gl.STACK_OVERFLOW:
|
|
||||||
return fmt.Errorf("STACK_OVERFLOW")
|
|
||||||
case gl.STACK_UNDERFLOW:
|
|
||||||
return fmt.Errorf("STACK_UNDERFLOW")
|
|
||||||
case gl.OUT_OF_MEMORY:
|
|
||||||
return fmt.Errorf("OUT_OF_MEMORY")
|
|
||||||
case gl.INVALID_FRAMEBUFFER_OPERATION:
|
|
||||||
return fmt.Errorf("INVALID_FRAMEBUFFER_OPERATION")
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("Unknown GL error: 0x%X", errCode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,193 +0,0 @@
|
|||||||
package graphics
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/go-gl/gl/v3.3-core/gl"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
VirtualWidth int32 = 1920
|
|
||||||
VirtualHeight int32 = 1080
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
vfbo uint32
|
|
||||||
vfboTexture uint32
|
|
||||||
vfboDepthStencil uint32
|
|
||||||
blitVAO uint32
|
|
||||||
blitVBO uint32
|
|
||||||
blitShaderProg uint32
|
|
||||||
nativeW, nativeH int32
|
|
||||||
)
|
|
||||||
|
|
||||||
// InitVirtualFramebuffer creates the FBO at virtual resolution and the blit shader.
|
|
||||||
// Call after OpenGL context is created.
|
|
||||||
func InitVirtualFramebuffer(displayW, displayH int32) error {
|
|
||||||
nativeW = displayW
|
|
||||||
nativeH = displayH
|
|
||||||
|
|
||||||
// --- FBO setup ---
|
|
||||||
gl.GenFramebuffers(1, &vfbo)
|
|
||||||
gl.BindFramebuffer(gl.FRAMEBUFFER, vfbo)
|
|
||||||
|
|
||||||
gl.GenTextures(1, &vfboTexture)
|
|
||||||
gl.BindTexture(gl.TEXTURE_2D, vfboTexture)
|
|
||||||
gl.TexImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, VirtualWidth, VirtualHeight, 0, gl.RGBA, gl.UNSIGNED_BYTE, nil)
|
|
||||||
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
|
|
||||||
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
|
|
||||||
gl.FramebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, vfboTexture, 0)
|
|
||||||
|
|
||||||
// Depth/stencil renderbuffer
|
|
||||||
gl.GenRenderbuffers(1, &vfboDepthStencil)
|
|
||||||
gl.BindRenderbuffer(gl.RENDERBUFFER, vfboDepthStencil)
|
|
||||||
gl.RenderbufferStorage(gl.RENDERBUFFER, gl.DEPTH24_STENCIL8, VirtualWidth, VirtualHeight)
|
|
||||||
gl.FramebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_STENCIL_ATTACHMENT, gl.RENDERBUFFER, vfboDepthStencil)
|
|
||||||
|
|
||||||
if status := gl.CheckFramebufferStatus(gl.FRAMEBUFFER); status != gl.FRAMEBUFFER_COMPLETE {
|
|
||||||
return fmt.Errorf("virtual framebuffer incomplete: 0x%X", status)
|
|
||||||
}
|
|
||||||
|
|
||||||
gl.BindFramebuffer(gl.FRAMEBUFFER, 0)
|
|
||||||
|
|
||||||
// --- Fullscreen quad ---
|
|
||||||
vertices := []float32{
|
|
||||||
// pos (x,y) texcoord (u,v)
|
|
||||||
-1, -1, 0, 0,
|
|
||||||
1, -1, 1, 0,
|
|
||||||
-1, 1, 0, 1,
|
|
||||||
1, 1, 1, 1,
|
|
||||||
}
|
|
||||||
|
|
||||||
gl.GenVertexArrays(1, &blitVAO)
|
|
||||||
gl.GenBuffers(1, &blitVBO)
|
|
||||||
gl.BindVertexArray(blitVAO)
|
|
||||||
gl.BindBuffer(gl.ARRAY_BUFFER, blitVBO)
|
|
||||||
gl.BufferData(gl.ARRAY_BUFFER, len(vertices)*4, gl.Ptr(vertices), gl.STATIC_DRAW)
|
|
||||||
|
|
||||||
gl.EnableVertexAttribArray(0)
|
|
||||||
gl.VertexAttribPointerWithOffset(0, 2, gl.FLOAT, false, 4*4, 0)
|
|
||||||
gl.EnableVertexAttribArray(1)
|
|
||||||
gl.VertexAttribPointerWithOffset(1, 2, gl.FLOAT, false, 4*4, 2*4)
|
|
||||||
|
|
||||||
gl.BindVertexArray(0)
|
|
||||||
|
|
||||||
// --- Blit shader ---
|
|
||||||
var err error
|
|
||||||
blitShaderProg, err = compileBlitShader()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("blit shader compilation failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// BeginVirtualRender binds the FBO so all subsequent draws go to the virtual 1080p buffer.
|
|
||||||
func BeginVirtualRender() {
|
|
||||||
gl.BindFramebuffer(gl.FRAMEBUFFER, vfbo)
|
|
||||||
gl.Viewport(0, 0, VirtualWidth, VirtualHeight)
|
|
||||||
}
|
|
||||||
|
|
||||||
// BlitToScreen draws the virtual buffer to the actual screen at native resolution.
|
|
||||||
func BlitToScreen() {
|
|
||||||
gl.BindFramebuffer(gl.FRAMEBUFFER, 0)
|
|
||||||
gl.Viewport(0, 0, nativeW, nativeH)
|
|
||||||
gl.ClearColor(0, 0, 0, 1)
|
|
||||||
gl.Clear(gl.COLOR_BUFFER_BIT)
|
|
||||||
|
|
||||||
gl.UseProgram(blitShaderProg)
|
|
||||||
gl.ActiveTexture(gl.TEXTURE0)
|
|
||||||
gl.BindTexture(gl.TEXTURE_2D, vfboTexture)
|
|
||||||
|
|
||||||
gl.BindVertexArray(blitVAO)
|
|
||||||
gl.DrawArrays(gl.TRIANGLE_STRIP, 0, 4)
|
|
||||||
gl.BindVertexArray(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// DestroyVirtualFramebuffer cleans up all FBO resources.
|
|
||||||
func DestroyVirtualFramebuffer() {
|
|
||||||
if blitShaderProg != 0 {
|
|
||||||
gl.DeleteProgram(blitShaderProg)
|
|
||||||
}
|
|
||||||
if blitVBO != 0 {
|
|
||||||
gl.DeleteBuffers(1, &blitVBO)
|
|
||||||
}
|
|
||||||
if blitVAO != 0 {
|
|
||||||
gl.DeleteVertexArrays(1, &blitVAO)
|
|
||||||
}
|
|
||||||
if vfboDepthStencil != 0 {
|
|
||||||
gl.DeleteRenderbuffers(1, &vfboDepthStencil)
|
|
||||||
}
|
|
||||||
if vfboTexture != 0 {
|
|
||||||
gl.DeleteTextures(1, &vfboTexture)
|
|
||||||
}
|
|
||||||
if vfbo != 0 {
|
|
||||||
gl.DeleteFramebuffers(1, &vfbo)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func compileBlitShader() (uint32, error) {
|
|
||||||
vertSrc := `#version 330 core
|
|
||||||
layout(location = 0) in vec2 aPos;
|
|
||||||
layout(location = 1) in vec2 aTexCoord;
|
|
||||||
out vec2 vTexCoord;
|
|
||||||
void main() {
|
|
||||||
vTexCoord = aTexCoord;
|
|
||||||
gl_Position = vec4(aPos, 0.0, 1.0);
|
|
||||||
}` + "\x00"
|
|
||||||
|
|
||||||
fragSrc := `#version 330 core
|
|
||||||
in vec2 vTexCoord;
|
|
||||||
out vec4 FragColor;
|
|
||||||
uniform sampler2D screenTexture;
|
|
||||||
void main() {
|
|
||||||
FragColor = texture(screenTexture, vTexCoord);
|
|
||||||
}` + "\x00"
|
|
||||||
|
|
||||||
vertShader, err := compileShader(vertSrc, gl.VERTEX_SHADER)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
fragShader, err := compileShader(fragSrc, gl.FRAGMENT_SHADER)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
prog := gl.CreateProgram()
|
|
||||||
gl.AttachShader(prog, vertShader)
|
|
||||||
gl.AttachShader(prog, fragShader)
|
|
||||||
gl.LinkProgram(prog)
|
|
||||||
|
|
||||||
var status int32
|
|
||||||
gl.GetProgramiv(prog, gl.LINK_STATUS, &status)
|
|
||||||
if status == gl.FALSE {
|
|
||||||
var logLen int32
|
|
||||||
gl.GetProgramiv(prog, gl.INFO_LOG_LENGTH, &logLen)
|
|
||||||
logStr := make([]byte, logLen)
|
|
||||||
gl.GetProgramInfoLog(prog, logLen, nil, &logStr[0])
|
|
||||||
return 0, fmt.Errorf("shader link error: %s", string(logStr))
|
|
||||||
}
|
|
||||||
|
|
||||||
gl.DeleteShader(vertShader)
|
|
||||||
gl.DeleteShader(fragShader)
|
|
||||||
return prog, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func compileShader(src string, shaderType uint32) (uint32, error) {
|
|
||||||
shader := gl.CreateShader(shaderType)
|
|
||||||
csrc, free := gl.Strs(src)
|
|
||||||
gl.ShaderSource(shader, 1, csrc, nil)
|
|
||||||
free()
|
|
||||||
gl.CompileShader(shader)
|
|
||||||
|
|
||||||
var status int32
|
|
||||||
gl.GetShaderiv(shader, gl.COMPILE_STATUS, &status)
|
|
||||||
if status == gl.FALSE {
|
|
||||||
var logLen int32
|
|
||||||
gl.GetShaderiv(shader, gl.INFO_LOG_LENGTH, &logLen)
|
|
||||||
logStr := make([]byte, logLen)
|
|
||||||
gl.GetShaderInfoLog(shader, logLen, nil, &logStr[0])
|
|
||||||
return 0, fmt.Errorf("shader compile error: %s", string(logStr))
|
|
||||||
}
|
|
||||||
return shader, nil
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
// package keybinds provides keybind functionality for the entire application
|
|
||||||
package keybinds
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/veandco/go-sdl2/sdl"
|
|
||||||
)
|
|
||||||
|
|
||||||
// KeyCombo represents a combination of a key and it's active modifier.
|
|
||||||
type KeyCombo struct {
|
|
||||||
Key sdl.Keycode
|
|
||||||
Mod sdl.Keymod
|
|
||||||
}
|
|
||||||
|
|
||||||
// Binds contains all currently registered Keybinds.
|
|
||||||
var Binds = make(map[KeyCombo]func())
|
|
||||||
|
|
||||||
// Enabled controls whether PerformAction dispatches keybinds. Set to false
|
|
||||||
// to temporarily suppress all global keybinds (e.g. during a modal page).
|
|
||||||
var Enabled = true
|
|
||||||
|
|
||||||
// Regiser registers a new keybind in Binds
|
|
||||||
func Register(k sdl.Keycode, m sdl.Keymod, action func()) {
|
|
||||||
mod := NormalizeModifiers(m)
|
|
||||||
newCombo := KeyCombo{k, mod}
|
|
||||||
_, ok := Binds[newCombo]
|
|
||||||
if ok {
|
|
||||||
}
|
|
||||||
Binds[newCombo] = action
|
|
||||||
}
|
|
||||||
|
|
||||||
// BindKey adds a new KeyCombo and Function (the action to take) to the keybinds.Binds
|
|
||||||
func BindKey(k KeyCombo, f func()) {
|
|
||||||
k.Mod = NormalizeModifiers(k.Mod)
|
|
||||||
Binds[k] = f
|
|
||||||
}
|
|
||||||
|
|
||||||
// UnbindKey removes a KeyCombo from the available binds
|
|
||||||
func UnbindKey(k KeyCombo) {
|
|
||||||
k.Mod = NormalizeModifiers(k.Mod)
|
|
||||||
delete(Binds, k)
|
|
||||||
}
|
|
||||||
|
|
||||||
func PerformAction(event sdl.Event) {
|
|
||||||
if !Enabled {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
switch e := event.(type) {
|
|
||||||
case *sdl.KeyboardEvent:
|
|
||||||
if e.Type == sdl.KEYDOWN {
|
|
||||||
key := e.Keysym.Sym
|
|
||||||
rawMod := e.Keysym.Mod
|
|
||||||
mod := NormalizeModifiers(sdl.Keymod(rawMod))
|
|
||||||
combo := KeyCombo{key, mod}
|
|
||||||
action, ok := Binds[combo]
|
|
||||||
if ok {
|
|
||||||
action()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NormalizeModifiers converts a raw SDL modifier bitmask into a canonical form
|
|
||||||
// focusing on common modifiers (SHIFT, CTRL, ALT, GUI) and ignoring others like CapsLock.
|
|
||||||
func NormalizeModifiers(mod sdl.Keymod) sdl.Keymod {
|
|
||||||
var normalized sdl.Keymod = sdl.KMOD_NONE
|
|
||||||
if mod&sdl.KMOD_SHIFT != 0 {
|
|
||||||
normalized |= sdl.KMOD_SHIFT
|
|
||||||
}
|
|
||||||
if mod&sdl.KMOD_CTRL != 0 {
|
|
||||||
normalized |= sdl.KMOD_CTRL
|
|
||||||
}
|
|
||||||
if mod&sdl.KMOD_ALT != 0 {
|
|
||||||
normalized |= sdl.KMOD_ALT
|
|
||||||
}
|
|
||||||
if mod&sdl.KMOD_GUI != 0 {
|
|
||||||
normalized |= sdl.KMOD_GUI
|
|
||||||
}
|
|
||||||
return normalized
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
package license
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"net/url"
|
|
||||||
"path"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
var Level int
|
|
||||||
|
|
||||||
type Validator struct {
|
|
||||||
client *http.Client
|
|
||||||
apiURL string
|
|
||||||
apiPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
type Response struct {
|
|
||||||
Key string `json:"key"`
|
|
||||||
Email string `json:"email"`
|
|
||||||
Active bool `json:"active"`
|
|
||||||
Type string `json:"type"`
|
|
||||||
Level string `json:"level"`
|
|
||||||
LastCheck time.Time `json:"lastCheck"`
|
|
||||||
NewCheck time.Time `json:"newCheck"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewValidator(apiURL, apiPath string) *Validator {
|
|
||||||
return &Validator{
|
|
||||||
client: &http.Client{
|
|
||||||
Timeout: 10 * time.Second,
|
|
||||||
Transport: &http.Transport{
|
|
||||||
MaxIdleConns: 10,
|
|
||||||
IdleConnTimeout: 30 * time.Second,
|
|
||||||
DisableCompression: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
apiURL: apiURL,
|
|
||||||
apiPath: apiPath,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (v *Validator) Validate(key, email string) (*Response, error) {
|
|
||||||
u, err := url.Parse(v.apiURL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
u.Path = path.Join(u.Path, v.apiPath)
|
|
||||||
|
|
||||||
q := u.Query()
|
|
||||||
q.Set("licenseKey", key)
|
|
||||||
q.Set("emailAddress", email)
|
|
||||||
u.RawQuery = q.Encode()
|
|
||||||
|
|
||||||
resp, err := v.client.Get(u.String())
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("request failed: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return nil, fmt.Errorf("unexpected status: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var result Response
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
||||||
return nil, fmt.Errorf("failed ot decode response: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &result, nil
|
|
||||||
}
|
|
||||||
@@ -1,231 +0,0 @@
|
|||||||
package logger
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/db"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ─── Level ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
type Level int
|
|
||||||
|
|
||||||
const (
|
|
||||||
LevelDebug Level = iota
|
|
||||||
LevelInfo
|
|
||||||
LevelWarning
|
|
||||||
LevelError
|
|
||||||
LevelFatal
|
|
||||||
)
|
|
||||||
|
|
||||||
func (l Level) String() string {
|
|
||||||
return [...]string{"DEBUG", "INFO", "WARNING", "ERROR", "FATAL"}[l]
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Internal entry type ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
type entry struct {
|
|
||||||
level Level
|
|
||||||
message string
|
|
||||||
timestamp time.Time
|
|
||||||
fatal bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Package state ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
var (
|
|
||||||
SetLevel Level = LevelInfo
|
|
||||||
console = log.New(os.Stderr, "", 0)
|
|
||||||
|
|
||||||
ch chan entry // unbuffered send is a bug; always buffered
|
|
||||||
done chan struct{}
|
|
||||||
db_ *gorm.DB
|
|
||||||
file *os.File
|
|
||||||
)
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
ch = make(chan entry, 4096)
|
|
||||||
done = make(chan struct{})
|
|
||||||
go drain()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Configuration ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
// AddFile attaches or replaces the file backend. Safe to call at any time.
|
|
||||||
func AddFile(path string) error {
|
|
||||||
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("logger: open file: %w", err)
|
|
||||||
}
|
|
||||||
// Send a sentinel that carries the new file handle.
|
|
||||||
// We do this through the channel so the drain goroutine owns
|
|
||||||
// all file state — no mutex needed.
|
|
||||||
fileSwapCh <- f
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddDatabase attaches or replaces the database backend. Safe to call at any time.
|
|
||||||
func AddDatabase(d *gorm.DB) {
|
|
||||||
dbSwapCh <- d
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close drains remaining entries and shuts down the logger.
|
|
||||||
// Call this with defer in main().
|
|
||||||
func Close() {
|
|
||||||
close(done)
|
|
||||||
<-drainDone // wait for drain goroutine to finish flushing
|
|
||||||
if file != nil {
|
|
||||||
file.Close()
|
|
||||||
file = nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Swap channels (owned exclusively by drain goroutine) ────────────────────
|
|
||||||
|
|
||||||
var (
|
|
||||||
fileSwapCh = make(chan *os.File, 1)
|
|
||||||
dbSwapCh = make(chan *gorm.DB, 1)
|
|
||||||
drainDone = make(chan struct{})
|
|
||||||
)
|
|
||||||
|
|
||||||
// ─── Drain goroutine ──────────────────────────────────────────────────────────
|
|
||||||
//
|
|
||||||
// This is the ONLY goroutine that touches file, db_, or does DB writes.
|
|
||||||
// Everything else just sends to ch.
|
|
||||||
|
|
||||||
func drain() {
|
|
||||||
defer close(drainDone)
|
|
||||||
|
|
||||||
batch := make([]entry, 0, 64)
|
|
||||||
ticker := time.NewTicker(250 * time.Millisecond)
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
flush := func() {
|
|
||||||
if len(batch) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
writeBatchToDB(batch)
|
|
||||||
batch = batch[:0]
|
|
||||||
}
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
|
|
||||||
// New log entry
|
|
||||||
case e := <-ch:
|
|
||||||
writeToFile(e)
|
|
||||||
if db_ != nil {
|
|
||||||
batch = append(batch, e)
|
|
||||||
if len(batch) >= 64 {
|
|
||||||
flush()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Periodic DB flush so low-volume logs don't sit in the batch forever
|
|
||||||
case <-ticker.C:
|
|
||||||
flush()
|
|
||||||
|
|
||||||
// Hot-swap the file backend
|
|
||||||
case f := <-fileSwapCh:
|
|
||||||
if file != nil {
|
|
||||||
file.Close()
|
|
||||||
}
|
|
||||||
file = f
|
|
||||||
|
|
||||||
// Hot-swap the DB backend
|
|
||||||
case d := <-dbSwapCh:
|
|
||||||
flush() // flush any pending entries to the old db first
|
|
||||||
db_ = d
|
|
||||||
console.Println("logger: database backend attached")
|
|
||||||
|
|
||||||
// Shutdown: drain what's left, then exit
|
|
||||||
case <-done:
|
|
||||||
// Pull any entries still sitting in the channel
|
|
||||||
for len(ch) > 0 {
|
|
||||||
e := <-ch
|
|
||||||
writeToFile(e)
|
|
||||||
batch = append(batch, e)
|
|
||||||
}
|
|
||||||
flush()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Writers (called only from drain goroutine) ───────────────────────────────
|
|
||||||
|
|
||||||
func writeToFile(e entry) {
|
|
||||||
line := formatLine(e)
|
|
||||||
if file != nil {
|
|
||||||
fmt.Fprintln(file, line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeBatchToDB(batch []entry) {
|
|
||||||
if db_ == nil || len(batch) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
records := make([]db.Log, len(batch))
|
|
||||||
for i, e := range batch {
|
|
||||||
records[i] = db.Log{
|
|
||||||
Timestamp: e.timestamp.UnixMilli(),
|
|
||||||
Level: e.level.String(),
|
|
||||||
Message: e.message,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Single INSERT ... VALUES (...), (...), (...) instead of N individual saves.
|
|
||||||
if result := db_.CreateInBatches(records, 64); result.Error != nil {
|
|
||||||
console.Printf("logger: db batch write failed: %v\n", result.Error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatLine(e entry) string {
|
|
||||||
return fmt.Sprintf("%s [%s] %s",
|
|
||||||
e.timestamp.Format("2006-01-02 15:04:05.000"),
|
|
||||||
e.level,
|
|
||||||
e.message,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Public API ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func logMsg(level Level, fatal bool, format string, args ...any) {
|
|
||||||
if level == LevelDebug && SetLevel != LevelDebug {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
e := entry{
|
|
||||||
level: level,
|
|
||||||
message: fmt.Sprintf(format, args...),
|
|
||||||
timestamp: time.Now(), // captured here, in the caller's goroutine
|
|
||||||
fatal: fatal,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Console is synchronous — log.Logger is internally mutex-protected,
|
|
||||||
// so this is safe from any goroutine and gives you immediate output.
|
|
||||||
console.Println(formatLine(e))
|
|
||||||
|
|
||||||
// Non-blocking send: if the buffer is full, we drop rather than deadlock.
|
|
||||||
// 4096 entries of headroom means this should never happen in practice.
|
|
||||||
select {
|
|
||||||
case ch <- e:
|
|
||||||
default:
|
|
||||||
console.Println("logger: channel full, entry dropped")
|
|
||||||
}
|
|
||||||
|
|
||||||
if fatal {
|
|
||||||
// Give the drain goroutine a moment to flush before we exit.
|
|
||||||
// Close() is cleaner but Fatal has to exit immediately.
|
|
||||||
time.Sleep(150 * time.Millisecond)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func Debug(format string, args ...any) { logMsg(LevelDebug, false, format, args...) }
|
|
||||||
func Info(format string, args ...any) { logMsg(LevelInfo, false, format, args...) }
|
|
||||||
func Warning(format string, args ...any) { logMsg(LevelWarning, false, format, args...) }
|
|
||||||
func Error(format string, args ...any) { logMsg(LevelError, false, format, args...) }
|
|
||||||
func Fatal(format string, args ...any) { logMsg(LevelFatal, true, format, args...) }
|
|
||||||
@@ -1,496 +0,0 @@
|
|||||||
// internal/audio/mixer/mixer.go
|
|
||||||
package mixer
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"bytes"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"os/exec"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/veandco/go-sdl2/mix"
|
|
||||||
"github.com/veandco/go-sdl2/sdl"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/db"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/logger"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
mu sync.Mutex
|
|
||||||
inited bool
|
|
||||||
deviceOpen bool
|
|
||||||
deviceName string
|
|
||||||
|
|
||||||
currentMusic *mix.Music
|
|
||||||
currentPath string
|
|
||||||
currentLoops int // 0 = once, -1 = forever
|
|
||||||
|
|
||||||
// Config knobs (could be made public or read from your config)
|
|
||||||
defaultFreq = 48000
|
|
||||||
defaultFormat = uint16(sdl.AUDIO_F32)
|
|
||||||
defaultChannels = 2
|
|
||||||
defaultChunkSize = 1024
|
|
||||||
defaultAllow = sdl.AUDIO_ALLOW_ANY_CHANGE
|
|
||||||
|
|
||||||
refreshCacheCallback func()
|
|
||||||
)
|
|
||||||
|
|
||||||
type Sink struct {
|
|
||||||
ID int
|
|
||||||
Name string
|
|
||||||
Volume float64
|
|
||||||
IsDefault bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// Init initializes SDL audio + SDL_mixer. Safe to call multiple times.
|
|
||||||
func Init(cfg *db.AppConfig, onUpdate func()) error {
|
|
||||||
mu.Lock()
|
|
||||||
defer mu.Unlock()
|
|
||||||
if inited {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshCacheCallback = onUpdate
|
|
||||||
|
|
||||||
if err := sdl.InitSubSystem(sdl.INIT_AUDIO); err != nil {
|
|
||||||
return fmt.Errorf("SDL audio init failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// FIX 1: Initialize MP3 and OGG decoders
|
|
||||||
err := mix.Init(mix.INIT_MP3 | mix.INIT_OGG)
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("Failed to initialize MP3/OGG support: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
deviceName := cfg.SelectedAudioDevice
|
|
||||||
if deviceName == "default" {
|
|
||||||
deviceName = ""
|
|
||||||
}
|
|
||||||
if err := openDeviceLocked(deviceName); err != nil {
|
|
||||||
sdl.QuitSubSystem(sdl.INIT_AUDIO)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
SetAllSinkVolumes(cfg.SavedVolume)
|
|
||||||
|
|
||||||
inited = true
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// openDeviceLocked tries to open the named device; if it fails and the name wasn't empty, falls back to default.
|
|
||||||
func openDeviceLocked(name string) error {
|
|
||||||
if err := mix.OpenAudioDevice(defaultFreq, defaultFormat, defaultChannels, defaultChunkSize, name, defaultAllow); err != nil {
|
|
||||||
if name != "" {
|
|
||||||
// Log the failure of the requested device
|
|
||||||
|
|
||||||
// Fallback to default device
|
|
||||||
if err2 := mix.OpenAudioDevice(defaultFreq, defaultFormat, defaultChannels, defaultChunkSize, "", defaultAllow); err2 != nil {
|
|
||||||
return fmt.Errorf("failed to open device %q and default device: %w", name, err2)
|
|
||||||
}
|
|
||||||
deviceName = "" // actually opened default
|
|
||||||
} else {
|
|
||||||
return fmt.Errorf("failed to open default audio device: %w", err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if name != "" {
|
|
||||||
} else {
|
|
||||||
}
|
|
||||||
deviceName = name
|
|
||||||
}
|
|
||||||
deviceOpen = true
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Play loads a WAV (as music) and plays it. If loopForever is true, loops forever.
|
|
||||||
// If something is already playing, it stops and replaces it.
|
|
||||||
func Play(path string, loopForever bool, cfg *db.AppConfig) error {
|
|
||||||
// Check if audio is enabled or disabled
|
|
||||||
if !cfg.AudioEnabled {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lock the mutex
|
|
||||||
mu.Lock()
|
|
||||||
defer mu.Unlock()
|
|
||||||
|
|
||||||
// Check if mixer has been initialized and that a device is open
|
|
||||||
if !inited {
|
|
||||||
logger.Error("audio mixer not initialized")
|
|
||||||
return errors.New("audio mixer not initialized")
|
|
||||||
}
|
|
||||||
if !deviceOpen {
|
|
||||||
if err := openDeviceLocked(""); err != nil {
|
|
||||||
return fmt.Errorf("failed to open audio device: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if path == "" {
|
|
||||||
logger.Error("empty audio path")
|
|
||||||
return errors.New("empty audio path")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop and free any prior music
|
|
||||||
mix.HaltMusic()
|
|
||||||
if currentMusic != nil {
|
|
||||||
currentMusic.Free()
|
|
||||||
currentMusic = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load the music file
|
|
||||||
m, err := mix.LoadMUS(path)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("LoadMUS(%q): %w", path, err)
|
|
||||||
}
|
|
||||||
// Set current music to the loaded music file
|
|
||||||
currentMusic = m
|
|
||||||
currentPath = path
|
|
||||||
if loopForever {
|
|
||||||
currentLoops = -1
|
|
||||||
} else {
|
|
||||||
currentLoops = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
mix.VolumeMusic(mix.MAX_VOLUME)
|
|
||||||
volume := mix.VolumeMusic(-1)
|
|
||||||
logger.Info("Current volume is %d", volume)
|
|
||||||
|
|
||||||
if err := currentMusic.Play(currentLoops); err != nil {
|
|
||||||
currentMusic.Free()
|
|
||||||
currentMusic = nil
|
|
||||||
logger.Error("music.Play failed: %v", err)
|
|
||||||
return fmt.Errorf("music.Play: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop stops playback. If fadeMs > 0, fades out asynchronously; otherwise halts immediately.
|
|
||||||
// It also frees the currently loaded music (so a new Play will reload).
|
|
||||||
func Stop(fadeMs int, cfg *db.AppConfig) {
|
|
||||||
mu.Lock()
|
|
||||||
defer mu.Unlock()
|
|
||||||
if !inited {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if fadeMs > 0 && mix.PlayingMusic() {
|
|
||||||
_ = mix.FadeOutMusic(fadeMs) // async fade; we’ll still free below
|
|
||||||
}
|
|
||||||
|
|
||||||
mix.HaltMusic()
|
|
||||||
if currentMusic != nil {
|
|
||||||
currentMusic.Free()
|
|
||||||
currentMusic = nil
|
|
||||||
}
|
|
||||||
currentPath = ""
|
|
||||||
currentLoops = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsPlaying reports whether music is currently playing (or fading).
|
|
||||||
func IsPlaying() bool {
|
|
||||||
mu.Lock()
|
|
||||||
defer mu.Unlock()
|
|
||||||
if !inited {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return mix.PlayingMusic()
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetDefaultSinkVolume sets volume 0.0–1.0 on the default sink
|
|
||||||
func SetDefaultSinkVolume(level float64) error {
|
|
||||||
if level < 0 || level > 1 {
|
|
||||||
return fmt.Errorf("volume must be between 0.0 and 1.0, got %f", level)
|
|
||||||
}
|
|
||||||
pct := fmt.Sprintf("%.2f", level)
|
|
||||||
cmd := exec.Command("wpctl", "set-volume", "@DEFAULT_AUDIO_SINK@", pct)
|
|
||||||
if out, err := cmd.CombinedOutput(); err != nil {
|
|
||||||
return fmt.Errorf("wpctl failed: %w\noutput: %s", err, out)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// listSinkIDs returns just the numeric IDs of all audio sinks.
|
|
||||||
func listSinkIDs() ([]int, error) {
|
|
||||||
sinks, err := listSinks()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
ids := make([]int, len(sinks))
|
|
||||||
for i, s := range sinks {
|
|
||||||
ids[i] = s.ID
|
|
||||||
}
|
|
||||||
return ids, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// listSinks returns full Sink info for all audio sinks.
|
|
||||||
func listSinks() ([]Sink, error) {
|
|
||||||
out, err := exec.Command("wpctl", "status").Output()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("wpctl status: %w", err)
|
|
||||||
}
|
|
||||||
return parseSinks(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetAllSinkVolumes sets volume on every audio sink found via wpctl status
|
|
||||||
func SetAllSinkVolumes(level float64) error {
|
|
||||||
ids, err := listSinkIDs()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
pct := fmt.Sprintf("%.2f", level)
|
|
||||||
for _, id := range ids {
|
|
||||||
cmd := exec.Command("wpctl", "set-volume", strconv.Itoa(id), pct)
|
|
||||||
if out, err := cmd.CombinedOutput(); err != nil {
|
|
||||||
return fmt.Errorf("wpctl set-volume %d failed: %w\noutput: %s", id, err, out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ConvertVolume converts a desired volume percentage (0%, 25%, 33%, 90%, etc) to
|
|
||||||
// the float64 value needed to pass to SetAllSinkVolumes 0..1.0
|
|
||||||
// Percentages should be passed as integers. 25% -> 25
|
|
||||||
func ConvertVolume(volPerc int) float64 {
|
|
||||||
if volPerc == 0 {
|
|
||||||
return 0.0
|
|
||||||
}
|
|
||||||
return float64(volPerc) / 100.0
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseSinks parses the output of `wpctl status` and extracts audio sinks.
|
|
||||||
func parseSinks(data []byte) ([]Sink, error) {
|
|
||||||
var sinks []Sink
|
|
||||||
|
|
||||||
scanner := bufio.NewScanner(bytes.NewReader(data))
|
|
||||||
|
|
||||||
inAudioSection := false
|
|
||||||
inSinksSection := false
|
|
||||||
|
|
||||||
for scanner.Scan() {
|
|
||||||
line := scanner.Text()
|
|
||||||
stripped := strings.TrimLeft(line, " │├└─")
|
|
||||||
|
|
||||||
// Detect top-level sections: "Audio", "Video", "Settings", etc.
|
|
||||||
// These are lines with no leading tree characters.
|
|
||||||
if !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "│") {
|
|
||||||
inAudioSection = strings.TrimSpace(line) == "Audio"
|
|
||||||
inSinksSection = false
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if !inAudioSection {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect subsection headers within Audio
|
|
||||||
if strings.Contains(stripped, "Sinks:") {
|
|
||||||
inSinksSection = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// Any other subsection header resets sink parsing
|
|
||||||
if strings.HasSuffix(strings.TrimSpace(stripped), ":") {
|
|
||||||
inSinksSection = false
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if !inSinksSection {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
sink, ok := parseSinkLine(stripped)
|
|
||||||
if ok {
|
|
||||||
sinks = append(sinks, sink)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := scanner.Err(); err != nil {
|
|
||||||
return nil, fmt.Errorf("scanning wpctl output: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return sinks, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseSinkLine parses a single sink entry line like:
|
|
||||||
//
|
|
||||||
// "*51. USB Audio [vol: 1.00]"
|
|
||||||
// "33. Built-in Audio Analog Stereo [vol: 0.65]"
|
|
||||||
func parseSinkLine(s string) (Sink, bool) {
|
|
||||||
s = strings.TrimSpace(s)
|
|
||||||
if s == "" {
|
|
||||||
return Sink{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
var sink Sink
|
|
||||||
|
|
||||||
// Leading '*' marks the default sink
|
|
||||||
if strings.HasPrefix(s, "*") {
|
|
||||||
sink.IsDefault = true
|
|
||||||
s = strings.TrimPrefix(s, "*")
|
|
||||||
s = strings.TrimSpace(s)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expect "<id>. <name> ..."
|
|
||||||
dotIdx := strings.Index(s, ".")
|
|
||||||
if dotIdx < 0 {
|
|
||||||
return Sink{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
idStr := strings.TrimSpace(s[:dotIdx])
|
|
||||||
id, err := strconv.Atoi(idStr)
|
|
||||||
if err != nil {
|
|
||||||
return Sink{}, false
|
|
||||||
}
|
|
||||||
sink.ID = id
|
|
||||||
|
|
||||||
rest := strings.TrimSpace(s[dotIdx+1:])
|
|
||||||
|
|
||||||
// Extract [vol: X.XX] if present
|
|
||||||
volStart := strings.Index(rest, "[vol:")
|
|
||||||
if volStart >= 0 {
|
|
||||||
sink.Name = strings.TrimSpace(rest[:volStart])
|
|
||||||
volPart := rest[volStart:]
|
|
||||||
// "[vol: 0.65]" or "[vol: 0.65 MUTED]"
|
|
||||||
volPart = strings.TrimPrefix(volPart, "[vol:")
|
|
||||||
volPart = strings.TrimRight(volPart, "]")
|
|
||||||
volPart = strings.TrimSpace(volPart)
|
|
||||||
fields := strings.Fields(volPart)
|
|
||||||
if len(fields) > 0 {
|
|
||||||
v, err := strconv.ParseFloat(fields[0], 64)
|
|
||||||
if err == nil {
|
|
||||||
sink.Volume = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
sink.Name = strings.TrimSpace(rest)
|
|
||||||
}
|
|
||||||
|
|
||||||
return sink, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListDevices returns the list of available output device names (from core SDL).
|
|
||||||
func ListDevices() []string {
|
|
||||||
mu.Lock()
|
|
||||||
defer mu.Unlock()
|
|
||||||
n := sdl.GetNumAudioDevices(false)
|
|
||||||
if n <= 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
out := make([]string, 0, n)
|
|
||||||
for i := 0; i < n; i++ {
|
|
||||||
if name := sdl.GetAudioDeviceName(i, false); name != "" {
|
|
||||||
out = append(out, name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// CurrentDevice returns the last requested/opened device name ("" means default).
|
|
||||||
func CurrentDevice() string {
|
|
||||||
return deviceName
|
|
||||||
}
|
|
||||||
|
|
||||||
// SwitchDevice switches output devices, preserving volume and resuming playback of the current track/loop state if any.
|
|
||||||
func SwitchDevice(newName string, cfg *db.AppConfig) error {
|
|
||||||
mu.Lock()
|
|
||||||
defer mu.Unlock()
|
|
||||||
|
|
||||||
if !inited {
|
|
||||||
return errors.New("audio mixer not initialized")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log device switch attempt
|
|
||||||
// Automatically defaults to "system default"
|
|
||||||
oldDevice := CurrentDevice() // Which device is to be closed
|
|
||||||
// SDL expects "" for default device, not "system default"
|
|
||||||
if newName == "" { // Which device is being switched to?
|
|
||||||
newName = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// Capture current state
|
|
||||||
wasPlaying := mix.PlayingMusic()
|
|
||||||
savedPath := currentPath
|
|
||||||
savedLoops := currentLoops
|
|
||||||
|
|
||||||
// Stop and release current music before reopening device
|
|
||||||
mix.HaltMusic()
|
|
||||||
if currentMusic != nil {
|
|
||||||
currentMusic.Free()
|
|
||||||
currentMusic = nil
|
|
||||||
}
|
|
||||||
currentPath = ""
|
|
||||||
currentLoops = 0
|
|
||||||
|
|
||||||
// Close and reopen device
|
|
||||||
if deviceOpen {
|
|
||||||
mix.CloseAudio()
|
|
||||||
deviceOpen = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attempt to open the newName device
|
|
||||||
if err := openDeviceLocked(newName); err != nil {
|
|
||||||
// If error switching to new device, switch back to the old device
|
|
||||||
// oldDevice is already "" for default (which openDeviceLocked expects)
|
|
||||||
if restoreErr := openDeviceLocked(oldDevice); restoreErr != nil {
|
|
||||||
// We should not hit an error when switching back, but if we do...
|
|
||||||
logger.Error("Failed to switch to device: %s", oldDevice)
|
|
||||||
}
|
|
||||||
// Could not switch to the new device, return the error
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restore saved volume after reopening device
|
|
||||||
if cfg != nil {
|
|
||||||
SetAllSinkVolumes(cfg.SavedVolume)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optionally resume previous track if there was one
|
|
||||||
if wasPlaying && savedPath != "" {
|
|
||||||
m, err := mix.LoadMUS(savedPath)
|
|
||||||
if err != nil {
|
|
||||||
// Don't fail the device switch just because reload failed
|
|
||||||
return fmt.Errorf("device switched, but reloading %q failed: %w", savedPath, err)
|
|
||||||
}
|
|
||||||
currentMusic = m
|
|
||||||
currentPath = savedPath
|
|
||||||
currentLoops = savedLoops
|
|
||||||
if err := currentMusic.Play(currentLoops); err != nil {
|
|
||||||
currentMusic.Free()
|
|
||||||
currentMusic = nil
|
|
||||||
currentPath = ""
|
|
||||||
currentLoops = 0
|
|
||||||
return fmt.Errorf("device switched, but restarting playback failed: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Info("Audio device switched to %s", newName)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetVolume128() int {
|
|
||||||
mu.Lock()
|
|
||||||
defer mu.Unlock()
|
|
||||||
return mix.VolumeMusic(-1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shutdown fully tears everything down. Call at app exit.
|
|
||||||
func Shutdown() {
|
|
||||||
mu.Lock()
|
|
||||||
defer mu.Unlock()
|
|
||||||
|
|
||||||
mix.HaltMusic()
|
|
||||||
if currentMusic != nil {
|
|
||||||
currentMusic.Free()
|
|
||||||
currentMusic = nil
|
|
||||||
}
|
|
||||||
if deviceOpen {
|
|
||||||
mix.CloseAudio()
|
|
||||||
deviceOpen = false
|
|
||||||
}
|
|
||||||
|
|
||||||
if inited {
|
|
||||||
mix.Quit() // Add this to clean up the decoders
|
|
||||||
sdl.QuitSubSystem(sdl.INIT_AUDIO)
|
|
||||||
inited = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,312 +0,0 @@
|
|||||||
package mixer
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"rhemawavetech.com/rhemawave/internal/db"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Regression tests for critical audio bugs (v0.5)
|
|
||||||
|
|
||||||
// Regression: Stop() used to return early when AudioEnabled was false,
|
|
||||||
// leaving music playing after the user toggled audio off mid-playback.
|
|
||||||
func TestStopCleansUpWhenAudioDisabled(t *testing.T) {
|
|
||||||
mu.Lock()
|
|
||||||
wasInited := inited
|
|
||||||
inited = true
|
|
||||||
deviceOpen = false
|
|
||||||
currentPath = "/some/track.wav"
|
|
||||||
currentLoops = -1
|
|
||||||
mu.Unlock()
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
mu.Lock()
|
|
||||||
inited = wasInited
|
|
||||||
currentPath = ""
|
|
||||||
currentLoops = 0
|
|
||||||
mu.Unlock()
|
|
||||||
}()
|
|
||||||
|
|
||||||
cfg := &db.AppConfig{AudioEnabled: false}
|
|
||||||
Stop(0, cfg)
|
|
||||||
|
|
||||||
mu.Lock()
|
|
||||||
defer mu.Unlock()
|
|
||||||
if currentPath != "" {
|
|
||||||
t.Errorf("currentPath = %q, want empty after Stop with AudioEnabled=false", currentPath)
|
|
||||||
}
|
|
||||||
if currentLoops != 0 {
|
|
||||||
t.Errorf("currentLoops = %d, want 0 after Stop with AudioEnabled=false", currentLoops)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regression: SwitchDevice used to convert oldDevice="" to "system default"
|
|
||||||
// and pass that string to openDeviceLocked, which SDL does not recognize.
|
|
||||||
func TestSwitchDeviceReturnsErrorWhenUninitialized(t *testing.T) {
|
|
||||||
mu.Lock()
|
|
||||||
wasInited := inited
|
|
||||||
inited = false
|
|
||||||
deviceOpen = false
|
|
||||||
mu.Unlock()
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
mu.Lock()
|
|
||||||
inited = wasInited
|
|
||||||
mu.Unlock()
|
|
||||||
}()
|
|
||||||
|
|
||||||
cfg := &db.AppConfig{AudioEnabled: true, SavedVolume: 0.5}
|
|
||||||
err := SwitchDevice("Some Device", cfg)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("SwitchDevice on uninitialized mixer should return error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regression: SwitchDevice with nil config must not panic when attempting
|
|
||||||
// to restore volume after reopening the device.
|
|
||||||
func TestSwitchDeviceNilConfigDoesNotPanic(t *testing.T) {
|
|
||||||
mu.Lock()
|
|
||||||
wasInited := inited
|
|
||||||
inited = false
|
|
||||||
deviceOpen = false
|
|
||||||
mu.Unlock()
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
mu.Lock()
|
|
||||||
inited = wasInited
|
|
||||||
mu.Unlock()
|
|
||||||
}()
|
|
||||||
|
|
||||||
err := SwitchDevice("Some Device", nil)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("SwitchDevice on uninitialized mixer should return error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regression: SwitchDevice with empty device name (system default) should
|
|
||||||
// not be converted to "system default" before passing to openDeviceLocked.
|
|
||||||
func TestSwitchDeviceAcceptsEmptyDeviceName(t *testing.T) {
|
|
||||||
mu.Lock()
|
|
||||||
wasInited := inited
|
|
||||||
inited = false
|
|
||||||
deviceOpen = false
|
|
||||||
mu.Unlock()
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
mu.Lock()
|
|
||||||
inited = wasInited
|
|
||||||
mu.Unlock()
|
|
||||||
}()
|
|
||||||
|
|
||||||
cfg := &db.AppConfig{SavedVolume: 0.75}
|
|
||||||
err := SwitchDevice("", cfg)
|
|
||||||
// Returns error because mixer is not initialized, but should not panic
|
|
||||||
// and should handle the empty name gracefully.
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("SwitchDevice on uninitialized mixer should return error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestPlayDoesNotDeadlockWhenUninitialized verifies that Play() returns an error
|
|
||||||
// (not a deadlock) when the mixer has not been initialized.
|
|
||||||
// The old code would deadlock because Play() held mu then called SwitchDevice()
|
|
||||||
// which also tried to acquire mu.
|
|
||||||
func TestPlayDoesNotDeadlockWhenUninitialized(t *testing.T) {
|
|
||||||
// Ensure mixer is in uninitialized state
|
|
||||||
mu.Lock()
|
|
||||||
wasInited := inited
|
|
||||||
inited = false
|
|
||||||
deviceOpen = false
|
|
||||||
mu.Unlock()
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
mu.Lock()
|
|
||||||
inited = wasInited
|
|
||||||
mu.Unlock()
|
|
||||||
}()
|
|
||||||
|
|
||||||
cfg := &db.AppConfig{AudioEnabled: true}
|
|
||||||
done := make(chan error, 1)
|
|
||||||
go func() {
|
|
||||||
done <- Play("nonexistent.wav", false, cfg)
|
|
||||||
}()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case err := <-done:
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("Play() returned nil error on uninitialized mixer, want error")
|
|
||||||
}
|
|
||||||
case <-time.After(5 * time.Second):
|
|
||||||
t.Fatal("Play() deadlocked on uninitialized mixer (did not return within 5s)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseSinkLine(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
input string
|
|
||||||
wantOK bool
|
|
||||||
wantID int
|
|
||||||
wantName string
|
|
||||||
wantVol float64
|
|
||||||
wantDef bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "default sink with volume",
|
|
||||||
input: "*51. USB Audio [vol: 1.00]",
|
|
||||||
wantOK: true,
|
|
||||||
wantID: 51,
|
|
||||||
wantName: "USB Audio",
|
|
||||||
wantVol: 1.00,
|
|
||||||
wantDef: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "non-default sink with volume",
|
|
||||||
input: "33. Built-in Audio Analog Stereo [vol: 0.65]",
|
|
||||||
wantOK: true,
|
|
||||||
wantID: 33,
|
|
||||||
wantName: "Built-in Audio Analog Stereo",
|
|
||||||
wantVol: 0.65,
|
|
||||||
wantDef: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "default sink with muted tag",
|
|
||||||
input: "*51. USB Audio [vol: 0.50 MUTED]",
|
|
||||||
wantOK: true,
|
|
||||||
wantID: 51,
|
|
||||||
wantName: "USB Audio",
|
|
||||||
wantVol: 0.50,
|
|
||||||
wantDef: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "sink without volume field",
|
|
||||||
input: "33. Built-in Audio Analog Stereo",
|
|
||||||
wantOK: true,
|
|
||||||
wantID: 33,
|
|
||||||
wantName: "Built-in Audio Analog Stereo",
|
|
||||||
wantVol: 0.0,
|
|
||||||
wantDef: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty string",
|
|
||||||
input: "",
|
|
||||||
wantOK: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "no dot separator",
|
|
||||||
input: "no dot here",
|
|
||||||
wantOK: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "non-numeric ID",
|
|
||||||
input: "abc. Some Device [vol: 0.80]",
|
|
||||||
wantOK: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "zero volume",
|
|
||||||
input: "1. Muted Device [vol: 0.00]",
|
|
||||||
wantOK: true,
|
|
||||||
wantID: 1,
|
|
||||||
wantName: "Muted Device",
|
|
||||||
wantVol: 0.0,
|
|
||||||
wantDef: false,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
sink, ok := parseSinkLine(tt.input)
|
|
||||||
if ok != tt.wantOK {
|
|
||||||
t.Fatalf("parseSinkLine(%q) ok = %v, want %v", tt.input, ok, tt.wantOK)
|
|
||||||
}
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if sink.ID != tt.wantID {
|
|
||||||
t.Errorf("ID = %d, want %d", sink.ID, tt.wantID)
|
|
||||||
}
|
|
||||||
if sink.Name != tt.wantName {
|
|
||||||
t.Errorf("Name = %q, want %q", sink.Name, tt.wantName)
|
|
||||||
}
|
|
||||||
if sink.Volume != tt.wantVol {
|
|
||||||
t.Errorf("Volume = %f, want %f", sink.Volume, tt.wantVol)
|
|
||||||
}
|
|
||||||
if sink.IsDefault != tt.wantDef {
|
|
||||||
t.Errorf("IsDefault = %v, want %v", sink.IsDefault, tt.wantDef)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseSinks(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
input string
|
|
||||||
wantCount int
|
|
||||||
wantNames []string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "two sinks",
|
|
||||||
input: `Audio
|
|
||||||
Sinks:
|
|
||||||
*51. USB Audio [vol: 1.00]
|
|
||||||
33. Built-in Audio Analog Stereo [vol: 0.65]
|
|
||||||
Sources:
|
|
||||||
52. Microphone [vol: 0.80]`,
|
|
||||||
wantCount: 2,
|
|
||||||
wantNames: []string{"USB Audio", "Built-in Audio Analog Stereo"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "no sinks section",
|
|
||||||
input: `Audio
|
|
||||||
Sources:
|
|
||||||
52. Microphone [vol: 0.80]`,
|
|
||||||
wantCount: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "sinks with tree characters",
|
|
||||||
input: `Audio
|
|
||||||
├─Sinks:
|
|
||||||
│ ├─*51. USB Audio [vol: 1.00]
|
|
||||||
│ └─33. Built-in Audio Analog Stereo [vol: 0.65]
|
|
||||||
└─Sources:
|
|
||||||
└─52. Microphone [vol: 0.80]`,
|
|
||||||
wantCount: 2,
|
|
||||||
wantNames: []string{"USB Audio", "Built-in Audio Analog Stereo"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty input",
|
|
||||||
input: ``,
|
|
||||||
wantCount: 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "video section ignored",
|
|
||||||
input: `Video
|
|
||||||
Sinks:
|
|
||||||
100. HDMI Output [vol: 1.00]
|
|
||||||
Audio
|
|
||||||
Sinks:
|
|
||||||
51. USB Audio [vol: 1.00]`,
|
|
||||||
wantCount: 1,
|
|
||||||
wantNames: []string{"USB Audio"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
sinks, err := parseSinks([]byte(tt.input))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("parseSinks returned error: %v", err)
|
|
||||||
}
|
|
||||||
if len(sinks) != tt.wantCount {
|
|
||||||
t.Fatalf("got %d sinks, want %d", len(sinks), tt.wantCount)
|
|
||||||
}
|
|
||||||
for i, name := range tt.wantNames {
|
|
||||||
if sinks[i].Name != name {
|
|
||||||
t.Errorf("sink[%d].Name = %q, want %q", i, sinks[i].Name, name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
package mixer_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"math"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"rhemawavetech.com/rhemawave/internal/mixer"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestConvertVolume(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
input int
|
|
||||||
expected float64
|
|
||||||
}{
|
|
||||||
{"0 percent", 0, 0.0},
|
|
||||||
{"25 percent", 25, 0.25},
|
|
||||||
{"33 percent", 33, 0.33},
|
|
||||||
{"50 percent", 50, 0.50},
|
|
||||||
{"90 percent", 90, 0.90},
|
|
||||||
{"100 percent", 100, 1.0},
|
|
||||||
{"1 percent", 1, 0.01},
|
|
||||||
{"99 percent", 99, 0.99},
|
|
||||||
{"negative 10", -10, -0.10},
|
|
||||||
{"over 100", 150, 1.50},
|
|
||||||
}
|
|
||||||
|
|
||||||
const epsilon = 1e-9
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
got := mixer.ConvertVolume(tt.input)
|
|
||||||
if math.Abs(got-tt.expected) > epsilon {
|
|
||||||
t.Errorf("ConvertVolume(%d) = %v, want %v", tt.input, got, tt.expected)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
package netreq
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Makes a GET request to the specified URL and returns the JSON response
|
|
||||||
// as a byte slice. If the request fails, it returns an error.
|
|
||||||
func GetJSON[T any](url string) ([]T, error) {
|
|
||||||
fmt.Println("Fetching:", url)
|
|
||||||
res, err := http.Get(url)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer res.Body.Close()
|
|
||||||
|
|
||||||
body, err := io.ReadAll(res.Body)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if res.StatusCode > 299 {
|
|
||||||
return nil, fmt.Errorf("status %d: %s", res.StatusCode, string(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Trim whitespace to find the first actual JSON character
|
|
||||||
trimmed := bytes.TrimSpace(body)
|
|
||||||
if len(trimmed) == 0 {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var result []T
|
|
||||||
|
|
||||||
// 2. Determine if we are looking at an Array [ ] or an Object { }
|
|
||||||
if trimmed[0] == '[' {
|
|
||||||
// It's already an array, unmarshal directly
|
|
||||||
err = json.Unmarshal(body, &result)
|
|
||||||
} else {
|
|
||||||
// It's a single object, unmarshal into a single instance and wrap it
|
|
||||||
var single T
|
|
||||||
err = json.Unmarshal(body, &single)
|
|
||||||
result = []T{single}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse JSON: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Download downloads a specified object at a given url and places it in
|
|
||||||
// the provided dest directory.
|
|
||||||
func Download(name string, path string, url string) error {
|
|
||||||
fullFilePath := filepath.Join(path, name)
|
|
||||||
if err := os.MkdirAll(path, os.ModeDir); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
file, err := os.Create(fullFilePath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
resp, err := http.Get(url)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
_, err = io.Copy(file, resp.Body)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,577 +0,0 @@
|
|||||||
package network
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
gonm "github.com/Wifx/gonetworkmanager/v2"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/logger"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ─── Public types ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
type SecurityType string
|
|
||||||
|
|
||||||
const (
|
|
||||||
SecurityOpen SecurityType = "Open"
|
|
||||||
SecurityWEP SecurityType = "WEP"
|
|
||||||
SecurityWPA SecurityType = "WPA"
|
|
||||||
SecurityWPA2 SecurityType = "WPA2"
|
|
||||||
SecurityWPA3 SecurityType = "WPA3"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Network struct {
|
|
||||||
SSID string
|
|
||||||
SignalStrength uint8
|
|
||||||
Security SecurityType
|
|
||||||
Saved bool
|
|
||||||
Connected bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Package init ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
var nm gonm.NetworkManager
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
var err error
|
|
||||||
nm, err = gonm.NewNetworkManager()
|
|
||||||
if err != nil {
|
|
||||||
logger.Warning("network: NetworkManager unavailable, network features disabled: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── ScanForNetworks ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func ScanForNetworks(ctx context.Context) (<-chan []Network, error) {
|
|
||||||
dev, err := wifiDevice()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
ch := make(chan []Network, 1)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
defer close(ch)
|
|
||||||
|
|
||||||
ticker := time.NewTicker(15 * time.Second)
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
scan := func() {
|
|
||||||
if err := dev.RequestScan(); err != nil {
|
|
||||||
logger.Warning("network: scan request failed: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
time.Sleep(2 * time.Second)
|
|
||||||
|
|
||||||
nets, err := buildNetworkList(dev)
|
|
||||||
if err != nil {
|
|
||||||
logger.Warning("network: building network list failed: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
case ch <- nets:
|
|
||||||
case <-ctx.Done():
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
scan()
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ticker.C:
|
|
||||||
scan()
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
return ch, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── ConnectToNetwork ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func ConnectToNetwork(ssid, password string) error {
|
|
||||||
dev, err := wifiDevice()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
ifaceName, err := dev.GetPropertyInterface()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("network: cannot get interface name: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ap, err := findAccessPoint(dev, ssid)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("network: access point %q not found: %w", ssid, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete any existing profiles for this SSID before creating a new one.
|
|
||||||
// Stale/duplicate profiles cause NM to fail silently.
|
|
||||||
if err := deleteExistingConnections(ssid); err != nil {
|
|
||||||
logger.Warning("network: could not clean up old profiles for %q: %v", ssid, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
security := detectSecurity(ap)
|
|
||||||
settings, err := buildConnectionSettings(ssid, password, security, ifaceName)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Info("network: connecting to %q (%s)", ssid, security)
|
|
||||||
|
|
||||||
_, err = nm.AddAndActivateConnection(settings, dev)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("network: failed to connect to %q: %w", ssid, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return waitForConnection(dev, ssid, 20*time.Second)
|
|
||||||
}
|
|
||||||
|
|
||||||
// deleteExistingConnections removes all stored NM profiles for the given SSID.
|
|
||||||
func deleteExistingConnections(ssid string) error {
|
|
||||||
s, err := gonm.NewSettings()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
connections, err := s.ListConnections()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for _, conn := range connections {
|
|
||||||
cfg, err := conn.GetSettings()
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
wireless, ok := cfg["802-11-wireless"]
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
rawSSID, ok := wireless["ssid"]
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if string(rawSSID.([]byte)) != ssid {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
logger.Debug("network: removing existing profile for %q", ssid)
|
|
||||||
if err := conn.Delete(); err != nil {
|
|
||||||
logger.Warning("network: failed to delete profile for %q: %v", ssid, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── ForgetNetwork ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func ForgetNetwork(ssid string) error {
|
|
||||||
s, err := gonm.NewSettings()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("network: cannot access NM settings: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
connections, err := s.ListConnections()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("network: cannot list connections: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
found := false
|
|
||||||
for _, conn := range connections {
|
|
||||||
settings, err := conn.GetSettings()
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
wireless, ok := settings["802-11-wireless"]
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
rawSSID, ok := wireless["ssid"]
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if string(rawSSID.([]byte)) != ssid {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
found = true
|
|
||||||
logger.Info("network: forgetting %q", ssid)
|
|
||||||
if err := conn.Delete(); err != nil {
|
|
||||||
return fmt.Errorf("network: failed to delete connection %q: %w", ssid, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !found {
|
|
||||||
return fmt.Errorf("network: no saved connection found for %q", ssid)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── CurrentNetwork ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func CurrentNetwork() (string, error) {
|
|
||||||
dev, err := wifiDevice()
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
ap, err := dev.GetPropertyActiveAccessPoint()
|
|
||||||
if err != nil || ap == nil {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
ssid, err := ap.GetPropertySSID()
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
|
|
||||||
return ssid, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── ListSavedNetworks ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func ListSavedNetworks() ([]string, error) {
|
|
||||||
s, err := gonm.NewSettings()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("network: cannot access NM settings: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
connections, err := s.ListConnections()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("network: cannot list connections: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var ssids []string
|
|
||||||
for _, conn := range connections {
|
|
||||||
settings, err := conn.GetSettings()
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
wireless, ok := settings["802-11-wireless"]
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
rawSSID, ok := wireless["ssid"]
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
ssids = append(ssids, string(rawSSID.([]byte)))
|
|
||||||
}
|
|
||||||
|
|
||||||
return ssids, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── IsConnected ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func IsConnected() (bool, error) {
|
|
||||||
if nm == nil {
|
|
||||||
return false, fmt.Errorf("network: NetworkManager not available")
|
|
||||||
}
|
|
||||||
|
|
||||||
state, err := nm.GetPropertyState()
|
|
||||||
if err != nil {
|
|
||||||
return false, fmt.Errorf("network: cannot get NM state: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return state >= gonm.NmStateConnectedLocal, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── HasInternet ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func HasInternet() bool {
|
|
||||||
targets := []string{
|
|
||||||
"1.1.1.1:53",
|
|
||||||
"1.0.0.1:53",
|
|
||||||
"8.8.8.8:53",
|
|
||||||
"9.9.9.9:53",
|
|
||||||
"8.8.4.4:53",
|
|
||||||
"google.com:443",
|
|
||||||
"cloudflare.com:443",
|
|
||||||
}
|
|
||||||
|
|
||||||
results := make(chan bool, len(targets))
|
|
||||||
|
|
||||||
for _, target := range targets {
|
|
||||||
go func(addr string) {
|
|
||||||
conn, err := net.DialTimeout("tcp", addr, 2*time.Second)
|
|
||||||
if err == nil {
|
|
||||||
conn.Close()
|
|
||||||
results <- true
|
|
||||||
} else {
|
|
||||||
results <- false
|
|
||||||
}
|
|
||||||
}(target)
|
|
||||||
}
|
|
||||||
|
|
||||||
for range targets {
|
|
||||||
if <-results {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── WatchState ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func WatchState(ctx context.Context) (<-chan bool, error) {
|
|
||||||
if nm == nil {
|
|
||||||
return nil, fmt.Errorf("network: NetworkManager not available")
|
|
||||||
}
|
|
||||||
|
|
||||||
ch := make(chan bool, 1)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
defer close(ch)
|
|
||||||
|
|
||||||
ticker := time.NewTicker(3 * time.Second)
|
|
||||||
defer ticker.Stop()
|
|
||||||
|
|
||||||
var last *bool
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ticker.C:
|
|
||||||
connected, err := IsConnected()
|
|
||||||
if err != nil {
|
|
||||||
logger.Warning("network: WatchState poll failed: %v", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if last == nil || *last != connected {
|
|
||||||
last = &connected
|
|
||||||
select {
|
|
||||||
case ch <- connected:
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
return ch, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Internal helpers ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func wifiDevice() (gonm.DeviceWireless, error) {
|
|
||||||
if nm == nil {
|
|
||||||
return nil, fmt.Errorf("network: NetworkManager not available")
|
|
||||||
}
|
|
||||||
|
|
||||||
devices, err := nm.GetPropertyAllDevices()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("network: cannot list devices: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, dev := range devices {
|
|
||||||
devType, err := dev.GetPropertyDeviceType()
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if devType == gonm.NmDeviceTypeWifi {
|
|
||||||
return gonm.NewDeviceWireless(dev.GetPath())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("network: no WiFi device found")
|
|
||||||
}
|
|
||||||
|
|
||||||
func findAccessPoint(dev gonm.DeviceWireless, ssid string) (gonm.AccessPoint, error) {
|
|
||||||
aps, err := dev.GetPropertyAccessPoints()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, ap := range aps {
|
|
||||||
apSSID, err := ap.GetPropertySSID()
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if apSSID == ssid {
|
|
||||||
return ap, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("not found")
|
|
||||||
}
|
|
||||||
|
|
||||||
func detectSecurity(ap gonm.AccessPoint) SecurityType {
|
|
||||||
flags, _ := ap.GetPropertyFlags()
|
|
||||||
wpaFlags, _ := ap.GetPropertyWPAFlags()
|
|
||||||
rsnFlags, _ := ap.GetPropertyRSNFlags()
|
|
||||||
|
|
||||||
// WPA3 uses SAE (0x400)
|
|
||||||
if rsnFlags&0x400 != 0 {
|
|
||||||
return SecurityWPA3
|
|
||||||
}
|
|
||||||
// WPA2 uses PSK (0x100)
|
|
||||||
if rsnFlags&0x100 != 0 {
|
|
||||||
return SecurityWPA2
|
|
||||||
}
|
|
||||||
// WPA uses WPA flags (not RSN)
|
|
||||||
if wpaFlags != 0 {
|
|
||||||
return SecurityWPA
|
|
||||||
}
|
|
||||||
// WEP uses privacy flag
|
|
||||||
if flags&0x1 != 0 {
|
|
||||||
return SecurityWEP
|
|
||||||
}
|
|
||||||
|
|
||||||
return SecurityOpen
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildConnectionSettings(ssid, password string, security SecurityType, interfaceName string) (map[string]map[string]interface{}, error) {
|
|
||||||
settings := map[string]map[string]interface{}{
|
|
||||||
"connection": {
|
|
||||||
"id": ssid,
|
|
||||||
"type": "802-11-wireless",
|
|
||||||
"uuid": uuid.NewString(),
|
|
||||||
"interface-name": interfaceName,
|
|
||||||
"autoconnect": true,
|
|
||||||
"permissions": []string{},
|
|
||||||
},
|
|
||||||
"802-11-wireless": {
|
|
||||||
"ssid": []byte(ssid),
|
|
||||||
"mode": "infrastructure",
|
|
||||||
},
|
|
||||||
"ipv4": {"method": "auto"},
|
|
||||||
"ipv6": {"method": "auto"},
|
|
||||||
}
|
|
||||||
|
|
||||||
switch security {
|
|
||||||
case SecurityOpen:
|
|
||||||
// nothing
|
|
||||||
|
|
||||||
case SecurityWEP:
|
|
||||||
if password == "" {
|
|
||||||
return nil, fmt.Errorf("network: WEP network requires a password")
|
|
||||||
}
|
|
||||||
settings["802-11-wireless-security"] = map[string]interface{}{
|
|
||||||
"key-mgmt": "none",
|
|
||||||
"wep-key0": password,
|
|
||||||
"wep-key-type": uint32(1),
|
|
||||||
}
|
|
||||||
|
|
||||||
case SecurityWPA, SecurityWPA2:
|
|
||||||
if password == "" {
|
|
||||||
return nil, fmt.Errorf("network: WPA/WPA2 network requires a password")
|
|
||||||
}
|
|
||||||
settings["802-11-wireless-security"] = map[string]interface{}{
|
|
||||||
"key-mgmt": "wpa-psk",
|
|
||||||
"auth-alg": "open",
|
|
||||||
"psk": password,
|
|
||||||
"psk-flags": uint32(0),
|
|
||||||
}
|
|
||||||
|
|
||||||
case SecurityWPA3:
|
|
||||||
if password == "" {
|
|
||||||
return nil, fmt.Errorf("network: WPA3 network requires a password")
|
|
||||||
}
|
|
||||||
settings["802-11-wireless-security"] = map[string]interface{}{
|
|
||||||
"key-mgmt": "sae",
|
|
||||||
"psk": password,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return settings, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildNetworkList(dev gonm.DeviceWireless) ([]Network, error) {
|
|
||||||
aps, err := dev.GetPropertyAccessPoints()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
activeAP, _ := dev.GetPropertyActiveAccessPoint()
|
|
||||||
var activeSSID string
|
|
||||||
if activeAP != nil {
|
|
||||||
activeSSID, _ = activeAP.GetPropertySSID()
|
|
||||||
}
|
|
||||||
|
|
||||||
saved, _ := ListSavedNetworks()
|
|
||||||
savedSet := make(map[string]bool, len(saved))
|
|
||||||
for _, s := range saved {
|
|
||||||
savedSet[s] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
var networks []Network
|
|
||||||
seen := make(map[string]bool)
|
|
||||||
|
|
||||||
for _, ap := range aps {
|
|
||||||
ssid, err := ap.GetPropertySSID()
|
|
||||||
if err != nil || ssid == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if seen[ssid] {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
seen[ssid] = true
|
|
||||||
|
|
||||||
strength, _ := ap.GetPropertyStrength()
|
|
||||||
|
|
||||||
networks = append(networks, Network{
|
|
||||||
SSID: ssid,
|
|
||||||
SignalStrength: strength,
|
|
||||||
Security: detectSecurity(ap),
|
|
||||||
Saved: savedSet[ssid],
|
|
||||||
Connected: ssid == activeSSID,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return networks, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// waitForConnection waits until the wifi device reaches the Activated state,
|
|
||||||
// meaning DHCP has completed and the connection is fully up.
|
|
||||||
func waitForConnection(dev gonm.DeviceWireless, ssid string, timeout time.Duration) error {
|
|
||||||
deadline := time.Now().Add(timeout)
|
|
||||||
for time.Now().Before(deadline) {
|
|
||||||
state, err := dev.GetPropertyState()
|
|
||||||
if err != nil {
|
|
||||||
time.Sleep(500 * time.Millisecond)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Debug("network: device state %d, waiting...", state)
|
|
||||||
|
|
||||||
switch state {
|
|
||||||
case gonm.NmDeviceStateActivated:
|
|
||||||
current, err := CurrentNetwork()
|
|
||||||
if err == nil && current == ssid {
|
|
||||||
logger.Info("network: fully connected to %q (DHCP complete)", ssid)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
case gonm.NmDeviceStateFailed:
|
|
||||||
return fmt.Errorf("network: connection to %q failed (wrong password?)", ssid)
|
|
||||||
|
|
||||||
case gonm.NmDeviceStateDisconnected:
|
|
||||||
return fmt.Errorf("network: connection to %q was rejected by the network", ssid)
|
|
||||||
}
|
|
||||||
|
|
||||||
time.Sleep(500 * time.Millisecond)
|
|
||||||
}
|
|
||||||
return fmt.Errorf("network: timed out waiting for connection to %q", ssid)
|
|
||||||
}
|
|
||||||
|
|
||||||
var internetCheckURL = "https://clients3.google.com/generate_204"
|
|
||||||
|
|
||||||
func httpCheck() bool {
|
|
||||||
client := http.Client{Timeout: 3 * time.Second}
|
|
||||||
resp, err := client.Get(internetCheckURL)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
resp.Body.Close()
|
|
||||||
return resp.StatusCode == 204
|
|
||||||
}
|
|
||||||
|
|
||||||
var _ = httpCheck
|
|
||||||
@@ -1,626 +0,0 @@
|
|||||||
package page
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"math"
|
|
||||||
|
|
||||||
"github.com/go-gl/gl/v3.3-core/gl"
|
|
||||||
"github.com/go-gl/mathgl/mgl32"
|
|
||||||
"github.com/veandco/go-sdl2/sdl"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/colors"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/db"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/fontManager"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/logger"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/mixer"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
|
||||||
)
|
|
||||||
|
|
||||||
// focusToggle is the sentinel focusState value meaning the audio on/off toggle
|
|
||||||
// row has keyboard focus. Device indices (0..n-1) are used for everything below.
|
|
||||||
const focusToggle = -1
|
|
||||||
|
|
||||||
// AudioFileList is kept as the canonical seed data for the audio_files table.
|
|
||||||
// Track selection UI no longer lives on this page, but other parts of the
|
|
||||||
// app still read these rows from the database.
|
|
||||||
var AudioFileList = []db.AudioFile{
|
|
||||||
{ID: 14, Name: "Original Audio", Path: "OriginalAudio.wav", Selected: false, Order: 1},
|
|
||||||
{ID: 1, Name: "174 Hz", Path: "174Hz.wav", Selected: false, Order: 2},
|
|
||||||
{ID: 2, Name: "396 Hz", Path: "396Hz.wav", Selected: false, Order: 3},
|
|
||||||
{ID: 3, Name: "417 Hz", Path: "417Hz.wav", Selected: false, Order: 4},
|
|
||||||
{ID: 4, Name: "432 Hz", Path: "432Hz.wav", Selected: false, Order: 5},
|
|
||||||
{ID: 5, Name: "639 Hz", Path: "639Hz.wav", Selected: false, Order: 6},
|
|
||||||
{ID: 6, Name: "741 Hz", Path: "741Hz.wav", Selected: false, Order: 7},
|
|
||||||
{ID: 7, Name: "852 Hz", Path: "852Hz.wav", Selected: false, Order: 8},
|
|
||||||
{ID: 8, Name: "963 Hz", Path: "963Hz.wav", Selected: false, Order: 9},
|
|
||||||
{ID: 9, Name: "1111 Hz", Path: "1111Hz.wav", Selected: false, Order: 10},
|
|
||||||
{ID: 10, Name: "14 Hz Binaural Beat", Path: "14Hz_Binaural.wav", Selected: false, Order: 11},
|
|
||||||
{ID: 11, Name: "40 Hz Binaural Beat", Path: "40Hz_Binaural.wav", Selected: false, Order: 12},
|
|
||||||
{ID: 12, Name: "Alpha Waves", Path: "AlphaWaves.wav", Selected: false, Order: 13},
|
|
||||||
{ID: 13, Name: "Pineal Gland", Path: "PinealGland.wav", Selected: false, Order: 14},
|
|
||||||
}
|
|
||||||
|
|
||||||
const triangleSize = float32(16)
|
|
||||||
|
|
||||||
type AudioDevice struct {
|
|
||||||
Label string
|
|
||||||
ID uint32
|
|
||||||
index int
|
|
||||||
}
|
|
||||||
|
|
||||||
type AudioOptions struct {
|
|
||||||
// Page Attributes
|
|
||||||
Base
|
|
||||||
font *fontManager.FontEntry
|
|
||||||
textShader *shaderManager.Shader
|
|
||||||
solidShader *shaderManager.Shader
|
|
||||||
CfgPtr *db.AppConfig
|
|
||||||
|
|
||||||
// Page Textures
|
|
||||||
title StringItem
|
|
||||||
prompt StringItem
|
|
||||||
|
|
||||||
// Layout
|
|
||||||
verticalMargin int32
|
|
||||||
ItemScale float32
|
|
||||||
|
|
||||||
// Audio toggle
|
|
||||||
AudioStatusToggle StringItem
|
|
||||||
|
|
||||||
// Audio Devices
|
|
||||||
activeDeviceIndex int
|
|
||||||
NumAudioDevices int
|
|
||||||
AudioDevices []AudioDevice
|
|
||||||
AudioDeviceTextures []StringItem
|
|
||||||
|
|
||||||
// Volume row
|
|
||||||
VolumeLabel StringItem
|
|
||||||
volumePercent StringItem
|
|
||||||
volumeBarX int32
|
|
||||||
volumeBarY int32
|
|
||||||
volumeBarW int32
|
|
||||||
volumeBarH int32
|
|
||||||
|
|
||||||
// Triangle cursor
|
|
||||||
triVAO uint32
|
|
||||||
triVBO uint32
|
|
||||||
triX float32
|
|
||||||
triCenterY float32
|
|
||||||
spinElapsed float32
|
|
||||||
|
|
||||||
// Focus: focusToggle (-1) = toggle row, 0..n-1 = device at that index
|
|
||||||
focusState int
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Init ──────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (p *AudioOptions) Init(ctx *PageCtx) error {
|
|
||||||
var err error
|
|
||||||
p.verticalMargin = int32(20)
|
|
||||||
p.focusState = focusToggle
|
|
||||||
|
|
||||||
// Font
|
|
||||||
fm := fontManager.Get()
|
|
||||||
font, ok := fm.GetFont("SpaceMonoNerdFont-Regular")
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("failed to get font: %w", sdl.GetError())
|
|
||||||
}
|
|
||||||
p.font = font
|
|
||||||
|
|
||||||
// Shaders
|
|
||||||
sm := shaderManager.Get()
|
|
||||||
p.textShader, ok = sm.Get("text")
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("failed to get text shader: %w", sdl.GetError())
|
|
||||||
}
|
|
||||||
p.solidShader, ok = sm.Get("solid_color")
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("failed to get solid_color shader: %w", sdl.GetError())
|
|
||||||
}
|
|
||||||
|
|
||||||
if err = p.Base.Init(ctx); err != nil {
|
|
||||||
return fmt.Errorf("base init failed: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
config, err := db.GetConfig(p.Context.DbPtr)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
baseFontSize := config.StandardFontSize
|
|
||||||
titleScale := float32(64) / float32(baseFontSize)
|
|
||||||
p.ItemScale = float32(32) / float32(baseFontSize)
|
|
||||||
|
|
||||||
// Title
|
|
||||||
p.title, err = NewStringItem("Audio Options", p.font, colors.White)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("creating title: %w", err)
|
|
||||||
}
|
|
||||||
p.title.Scale(titleScale)
|
|
||||||
|
|
||||||
// Prompt
|
|
||||||
p.prompt, err = NewStringItem(
|
|
||||||
"Enter to select -- Up/Down to navigate -- Left/Right to change volume -- Backspace to go back",
|
|
||||||
p.font, colors.White,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("creating prompt: %w", err)
|
|
||||||
}
|
|
||||||
p.prompt.Scale(p.ItemScale)
|
|
||||||
|
|
||||||
// Volume row
|
|
||||||
p.VolumeLabel, err = NewStringItem("Volume", p.font, colors.White)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("creating volume label: %w", err)
|
|
||||||
}
|
|
||||||
p.VolumeLabel.Scale(p.ItemScale)
|
|
||||||
if err = p.CreateVolumePercentItem(); err != nil {
|
|
||||||
logger.Error("failed to create VolumePercentItem in AudioOptions.Init()")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Audio toggle
|
|
||||||
if err = p.CreateAudioStatusItem(); err != nil {
|
|
||||||
logger.Error("failed to create AudioStatusItem in AudioOptions.Init()")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Audio output devices
|
|
||||||
if err = p.GetAudioDevices(); err != nil {
|
|
||||||
logger.Error("failed to enumerate audio devices: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set active device index based on mixer's current device
|
|
||||||
currentName := mixer.CurrentDevice()
|
|
||||||
config, err = db.GetConfig(p.Context.DbPtr)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
p.activeDeviceIndex = -1 // default to not found
|
|
||||||
for i, dev := range p.AudioDevices {
|
|
||||||
if dev.Label == currentName {
|
|
||||||
p.activeDeviceIndex = i
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If not found and current device is default (""), check saved config
|
|
||||||
if p.activeDeviceIndex == -1 && currentName == "" && config.SelectedAudioDevice == "default" {
|
|
||||||
// Default device is selected - first device in SDL list is typically the default
|
|
||||||
if len(p.AudioDevices) > 0 {
|
|
||||||
p.activeDeviceIndex = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make sure the audio_files table is populated.
|
|
||||||
if err = p.seedAudioFiles(); err != nil {
|
|
||||||
logger.Error("failed to seed audio files: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Triangle cursor VAO/VBO — stride is vec2 only, no tex coords
|
|
||||||
triVerts := []float32{
|
|
||||||
0.5, 0.0,
|
|
||||||
-0.5, 0.5,
|
|
||||||
-0.5, -0.5,
|
|
||||||
}
|
|
||||||
gl.GenVertexArrays(1, &p.triVAO)
|
|
||||||
gl.GenBuffers(1, &p.triVBO)
|
|
||||||
gl.BindVertexArray(p.triVAO)
|
|
||||||
gl.BindBuffer(gl.ARRAY_BUFFER, p.triVBO)
|
|
||||||
gl.BufferData(gl.ARRAY_BUFFER, len(triVerts)*4, gl.Ptr(triVerts), gl.STATIC_DRAW)
|
|
||||||
gl.EnableVertexAttribArray(0)
|
|
||||||
gl.VertexAttribPointerWithOffset(0, 2, gl.FLOAT, false, 2*4, 0)
|
|
||||||
gl.BindBuffer(gl.ARRAY_BUFFER, 0)
|
|
||||||
gl.BindVertexArray(0)
|
|
||||||
|
|
||||||
p.layout()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// seedAudioFiles ensures the audio_files table has its default rows.
|
|
||||||
func (p *AudioOptions) seedAudioFiles() error {
|
|
||||||
var existing []db.AudioFile
|
|
||||||
if err := p.Context.DbPtr.Find(&existing).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if len(existing) == 0 {
|
|
||||||
return p.Context.DbPtr.Save(&AudioFileList).Error
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Audio status toggle ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (p *AudioOptions) CreateAudioStatusItem() error {
|
|
||||||
config, err := db.GetConfig(p.Context.DbPtr)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
on := config.AudioEnabled
|
|
||||||
statusString := "Turn Audio On"
|
|
||||||
color := colors.Green
|
|
||||||
if on {
|
|
||||||
statusString = "Turn Audio Off"
|
|
||||||
color = colors.Red
|
|
||||||
}
|
|
||||||
item, err := NewStringItem(statusString, p.font, color)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create '%s' string item: %v", statusString, err)
|
|
||||||
}
|
|
||||||
item.Scale(p.ItemScale)
|
|
||||||
p.AudioStatusToggle = item
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Audio Output Devices ──────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (p *AudioOptions) GetAudioDevices() error {
|
|
||||||
numAudioDevices := sdl.GetNumAudioDevices(false)
|
|
||||||
if numAudioDevices == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pre-allocate with zero length so append doesn't produce leading zero-value
|
|
||||||
// elements (the old code used make([]AudioDevice, n) then appended, which
|
|
||||||
// doubled the slice length with n zero-value entries at the front).
|
|
||||||
audioDevices := make([]AudioDevice, 0, numAudioDevices)
|
|
||||||
for i := range numAudioDevices {
|
|
||||||
label := sdl.GetAudioDeviceName(i, false)
|
|
||||||
audioDevices = append(audioDevices, AudioDevice{Label: label, ID: uint32(i)})
|
|
||||||
}
|
|
||||||
p.AudioDevices = audioDevices
|
|
||||||
|
|
||||||
for i := range audioDevices {
|
|
||||||
item, err := NewStringItem(p.AudioDevices[i].Label, p.font, colors.White)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
item.Scale(p.ItemScale)
|
|
||||||
p.AudioDeviceTextures = append(p.AudioDeviceTextures, item)
|
|
||||||
}
|
|
||||||
p.NumAudioDevices = numAudioDevices
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SelectAudioDevice is called when the user confirms a device from the list.
|
|
||||||
// The device-switching implementation goes here; for now we just log the choice.
|
|
||||||
func (p *AudioOptions) SelectAudioDevice(deviceIndex int) error {
|
|
||||||
if deviceIndex < 0 || deviceIndex >= len(p.AudioDevices) {
|
|
||||||
return fmt.Errorf("invalid device index: %d", deviceIndex)
|
|
||||||
}
|
|
||||||
device := p.AudioDevices[deviceIndex]
|
|
||||||
|
|
||||||
config, err := db.GetConfig(p.Context.DbPtr)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to get config for device switch: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := mixer.SwitchDevice(device.Label, config); err != nil {
|
|
||||||
return fmt.Errorf("failed to switch to device %s: %w", device.Label, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Persist the selected device to the database
|
|
||||||
config.SelectedAudioDevice = device.Label
|
|
||||||
if err := p.Context.DbPtr.Save(config).Error; err != nil {
|
|
||||||
logger.Error("failed to persist device selection: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
p.activeDeviceIndex = deviceIndex
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Volume ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (p *AudioOptions) CreateVolumePercentItem() error {
|
|
||||||
if p.volumePercent.ID != 0 {
|
|
||||||
gl.DeleteTextures(1, &p.volumePercent.ID)
|
|
||||||
}
|
|
||||||
config, err := db.GetConfig(p.Context.DbPtr)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
pct := int(config.SavedVolume * 100)
|
|
||||||
item, err := NewStringItem(fmt.Sprintf("%d%%", pct), p.font, colors.White)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create volume percent item: %v", err)
|
|
||||||
}
|
|
||||||
item.Scale(p.ItemScale)
|
|
||||||
p.volumePercent = item
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *AudioOptions) UpdateVolume(level int) {
|
|
||||||
config, err := db.GetConfig(p.Context.DbPtr)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
newVol := mixer.ConvertVolume(level) + config.SavedVolume
|
|
||||||
if newVol < 0.0 || newVol > 1.0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if newVol > .99 {
|
|
||||||
newVol = 1.0
|
|
||||||
}
|
|
||||||
config.SavedVolume = newVol
|
|
||||||
p.Context.DbPtr.Save(&config)
|
|
||||||
if err := mixer.SetAllSinkVolumes(newVol); err != nil {
|
|
||||||
logger.Error("Error setting allSinkVolumes: %v", err)
|
|
||||||
}
|
|
||||||
if err := p.CreateVolumePercentItem(); err != nil {
|
|
||||||
logger.Error("failed to refresh volume percent item: %v", err)
|
|
||||||
}
|
|
||||||
gap := int32(12)
|
|
||||||
p.volumePercent.X = p.volumeBarX + p.volumeBarW + gap
|
|
||||||
p.volumePercent.Y = p.volumeBarY + (p.volumeBarH-p.volumePercent.H)/2
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Layout ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (p *AudioOptions) layout() {
|
|
||||||
config, err := db.GetConfig(p.Context.DbPtr)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Title
|
|
||||||
p.title.X = p.ScreenCenterX - p.title.W/2
|
|
||||||
p.title.Y = p.ScreenHeight - p.title.H - p.verticalMargin*2
|
|
||||||
|
|
||||||
// Prompt
|
|
||||||
p.prompt.X = p.ScreenCenterX - p.prompt.W/2
|
|
||||||
p.prompt.Y = p.title.Y - p.verticalMargin - p.prompt.H
|
|
||||||
|
|
||||||
// Volume row
|
|
||||||
barH := int32(float32(config.StandardFontSize) * p.ItemScale)
|
|
||||||
barW := int32(float32(p.ScreenWidth) * 0.30)
|
|
||||||
barX := p.ScreenCenterX - barW/2
|
|
||||||
barY := p.prompt.Y - p.verticalMargin*2 - barH
|
|
||||||
|
|
||||||
p.volumeBarX = barX
|
|
||||||
p.volumeBarY = barY
|
|
||||||
p.volumeBarW = barW
|
|
||||||
p.volumeBarH = barH
|
|
||||||
|
|
||||||
p.VolumeLabel.X = barX - p.VolumeLabel.W - 20
|
|
||||||
p.VolumeLabel.Y = barY + (barH-p.VolumeLabel.H)/2
|
|
||||||
|
|
||||||
gap := int32(12)
|
|
||||||
p.volumePercent.X = barX + barW + gap
|
|
||||||
p.volumePercent.Y = barY + (barH-p.volumePercent.H)/2
|
|
||||||
|
|
||||||
// Audio toggle — centred below the volume row
|
|
||||||
p.AudioStatusToggle.X = p.ScreenCenterX - p.AudioStatusToggle.W/2
|
|
||||||
p.AudioStatusToggle.Y = barY - p.verticalMargin*3 - p.AudioStatusToggle.H
|
|
||||||
|
|
||||||
// Device list — each item centred and stacked below the toggle
|
|
||||||
nextY := p.AudioStatusToggle.Y - p.verticalMargin
|
|
||||||
for i := range p.AudioDeviceTextures {
|
|
||||||
item := &p.AudioDeviceTextures[i]
|
|
||||||
item.X = p.ScreenCenterX - item.W/2
|
|
||||||
item.Y = nextY - item.H
|
|
||||||
nextY = item.Y - p.verticalMargin
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reposition the triangle cursor for whatever row currently has focus
|
|
||||||
p.updateCursorPosition()
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateCursorPosition moves triX / triCenterY to sit just left of the
|
|
||||||
// focused row. Call this any time focusState changes or layout() reruns.
|
|
||||||
func (p *AudioOptions) updateCursorPosition() {
|
|
||||||
cursorGap := int32(12)
|
|
||||||
|
|
||||||
var item *StringItem
|
|
||||||
switch {
|
|
||||||
case p.focusState == focusToggle:
|
|
||||||
item = &p.AudioStatusToggle
|
|
||||||
case p.focusState >= 0 && p.focusState < len(p.AudioDeviceTextures):
|
|
||||||
item = &p.AudioDeviceTextures[p.focusState]
|
|
||||||
default:
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
p.triX = float32(item.X) - triangleSize - float32(cursorGap)
|
|
||||||
p.triCenterY = float32(item.Y) + float32(item.H)/2
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Toggle ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (p *AudioOptions) ToggleAudio() {
|
|
||||||
config, err := db.GetConfig(p.Context.DbPtr)
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("panic! Cannot query database for configuration")
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
config.AudioEnabled = !config.AudioEnabled
|
|
||||||
p.Context.DbPtr.Save(config)
|
|
||||||
p.CreateAudioStatusItem()
|
|
||||||
p.layout()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Events ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (p *AudioOptions) HandleEvent(event *sdl.Event) error {
|
|
||||||
e, ok := (*event).(*sdl.KeyboardEvent)
|
|
||||||
if !ok || e.Type != sdl.KEYDOWN {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
switch e.Keysym.Sym {
|
|
||||||
case sdl.K_RETURN:
|
|
||||||
if p.focusState == focusToggle {
|
|
||||||
p.ToggleAudio()
|
|
||||||
} else if p.focusState >= 0 && p.focusState < len(p.AudioDevices) {
|
|
||||||
if err := p.SelectAudioDevice(p.focusState); err != nil {
|
|
||||||
logger.Error("SelectAudioDevice: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case sdl.K_DOWN:
|
|
||||||
if p.focusState == focusToggle {
|
|
||||||
if len(p.AudioDeviceTextures) > 0 {
|
|
||||||
p.focusState = 0
|
|
||||||
p.updateCursorPosition()
|
|
||||||
}
|
|
||||||
} else if p.focusState < len(p.AudioDeviceTextures)-1 {
|
|
||||||
p.focusState++
|
|
||||||
p.updateCursorPosition()
|
|
||||||
}
|
|
||||||
|
|
||||||
case sdl.K_UP:
|
|
||||||
if p.focusState == 0 {
|
|
||||||
p.focusState = focusToggle
|
|
||||||
p.updateCursorPosition()
|
|
||||||
} else if p.focusState > 0 {
|
|
||||||
p.focusState--
|
|
||||||
p.updateCursorPosition()
|
|
||||||
}
|
|
||||||
|
|
||||||
case sdl.K_RIGHT:
|
|
||||||
p.UpdateVolume(10)
|
|
||||||
|
|
||||||
case sdl.K_LEFT:
|
|
||||||
p.UpdateVolume(-10)
|
|
||||||
|
|
||||||
case sdl.K_BACKSPACE:
|
|
||||||
p.Context.NavigateTo(SETTINGS)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Update ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (p *AudioOptions) Update(dt float32) error {
|
|
||||||
p.spinElapsed += dt
|
|
||||||
if p.spinElapsed >= 1.0 {
|
|
||||||
p.spinElapsed -= 1.0
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Render helpers ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (p *AudioOptions) renderVolumeBar() error {
|
|
||||||
config, err := db.GetConfig(p.Context.DbPtr)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
vol := config.SavedVolume
|
|
||||||
|
|
||||||
fillW := float32(p.volumeBarW) * float32(vol)
|
|
||||||
if fillW > 0 {
|
|
||||||
if err := p.RenderSolidColorQuad(
|
|
||||||
p.solidShader,
|
|
||||||
mgl32.Vec2{float32(p.volumeBarX), float32(p.volumeBarY)},
|
|
||||||
mgl32.Vec2{fillW, float32(p.volumeBarH)},
|
|
||||||
colors.White,
|
|
||||||
); err != nil {
|
|
||||||
return fmt.Errorf("rendering volume fill: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
thickness := float32(2)
|
|
||||||
bx := float32(p.volumeBarX)
|
|
||||||
by := float32(p.volumeBarY)
|
|
||||||
bw := float32(p.volumeBarW)
|
|
||||||
bh := float32(p.volumeBarH)
|
|
||||||
|
|
||||||
outlineRects := [4][2]mgl32.Vec2{
|
|
||||||
{{bx, by}, {bw, thickness}},
|
|
||||||
{{bx, by + bh - thickness}, {bw, thickness}},
|
|
||||||
{{bx, by}, {thickness, bh}},
|
|
||||||
{{bx + bw - thickness, by}, {thickness, bh}}, // fix: was missing `by` as Y component
|
|
||||||
}
|
|
||||||
for _, r := range outlineRects {
|
|
||||||
if err := p.RenderSolidColorQuad(p.solidShader, r[0], r[1], colors.Green); err != nil {
|
|
||||||
return fmt.Errorf("rendering volume outline: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// renderCursor draws the spinning triangle cursor next to the focused row.
|
|
||||||
func (p *AudioOptions) renderCursor() error {
|
|
||||||
scaleY := float32(math.Cos(2 * math.Pi * float64(p.spinElapsed)))
|
|
||||||
|
|
||||||
model := mgl32.Ident4()
|
|
||||||
model = model.Mul4(mgl32.Translate3D(p.triX+triangleSize/2, p.triCenterY, 0))
|
|
||||||
model = model.Mul4(mgl32.Scale3D(triangleSize, triangleSize*scaleY, 1))
|
|
||||||
mvp := p.Projection.Mul4(model)
|
|
||||||
|
|
||||||
gl.UseProgram(p.solidShader.ProgramID)
|
|
||||||
gl.UniformMatrix4fv(p.solidShader.LocMVPMatrix, 1, false, &mvp[0])
|
|
||||||
gl.Uniform4f(
|
|
||||||
p.solidShader.LocColor,
|
|
||||||
float32(colors.White.R)/255.0,
|
|
||||||
float32(colors.White.G)/255.0,
|
|
||||||
float32(colors.White.B)/255.0,
|
|
||||||
float32(colors.White.A)/255.0,
|
|
||||||
)
|
|
||||||
|
|
||||||
gl.BindVertexArray(p.triVAO)
|
|
||||||
gl.DrawArrays(gl.TRIANGLES, 0, 3)
|
|
||||||
gl.BindVertexArray(0)
|
|
||||||
gl.UseProgram(0)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Render ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (p *AudioOptions) Render() error {
|
|
||||||
if err := p.RenderTexture(p.textShader, p.title.ID, p.title.Position(), p.title.RawDimensions(), p.title.Color); err != nil {
|
|
||||||
return fmt.Errorf("render title: %w", err)
|
|
||||||
}
|
|
||||||
if err := p.RenderTexture(p.textShader, p.prompt.ID, p.prompt.Position(), p.prompt.RawDimensions(), p.prompt.Color); err != nil {
|
|
||||||
return fmt.Errorf("render prompt: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Volume row
|
|
||||||
if err := p.RenderTexture(p.textShader, p.VolumeLabel.ID, p.VolumeLabel.Position(), p.VolumeLabel.RawDimensions(), p.VolumeLabel.Color); err != nil {
|
|
||||||
return fmt.Errorf("render volume label: %w", err)
|
|
||||||
}
|
|
||||||
if err := p.renderVolumeBar(); err != nil {
|
|
||||||
return fmt.Errorf("render volume bar: %w", err)
|
|
||||||
}
|
|
||||||
if err := p.RenderTexture(p.textShader, p.volumePercent.ID, p.volumePercent.Position(), p.volumePercent.RawDimensions(), p.volumePercent.Color); err != nil {
|
|
||||||
return fmt.Errorf("render volume percent: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Toggle
|
|
||||||
if err := p.RenderTexture(p.textShader, p.AudioStatusToggle.ID, p.AudioStatusToggle.Position(), p.AudioStatusToggle.RawDimensions(), p.AudioStatusToggle.Color); err != nil {
|
|
||||||
return fmt.Errorf("render audio toggle: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Device list
|
|
||||||
for i := range p.AudioDeviceTextures {
|
|
||||||
item := &p.AudioDeviceTextures[i]
|
|
||||||
color := colors.White
|
|
||||||
if i == p.activeDeviceIndex {
|
|
||||||
color = colors.Green
|
|
||||||
}
|
|
||||||
if err := p.RenderTexture(p.textShader, item.ID, item.Position(), item.RawDimensions(), color); err != nil {
|
|
||||||
return fmt.Errorf("render audio device %d: %w", i, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Spinning triangle cursor — always on top
|
|
||||||
if err := p.renderCursor(); err != nil {
|
|
||||||
return fmt.Errorf("render cursor: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Destroy ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (p *AudioOptions) Destroy() error {
|
|
||||||
gl.DeleteTextures(1, &p.title.ID)
|
|
||||||
gl.DeleteTextures(1, &p.prompt.ID)
|
|
||||||
gl.DeleteTextures(1, &p.VolumeLabel.ID)
|
|
||||||
gl.DeleteTextures(1, &p.volumePercent.ID)
|
|
||||||
gl.DeleteTextures(1, &p.AudioStatusToggle.ID)
|
|
||||||
for i := range p.AudioDeviceTextures {
|
|
||||||
gl.DeleteTextures(1, &p.AudioDeviceTextures[i].ID)
|
|
||||||
}
|
|
||||||
gl.DeleteBuffers(1, &p.triVBO)
|
|
||||||
gl.DeleteVertexArrays(1, &p.triVAO)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
package page
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/veandco/go-sdl2/sdl"
|
|
||||||
)
|
|
||||||
|
|
||||||
type DownloadingAssets struct {
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *DownloadingAssets) Init(ctx *PageCtx) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *DownloadingAssets) HandleEvent(event *sdl.Event) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *DownloadingAssets) Update(dt float32) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *DownloadingAssets) Render() error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *DownloadingAssets) Destroy() error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
package page
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/veandco/go-sdl2/sdl"
|
|
||||||
)
|
|
||||||
|
|
||||||
type MessagePage struct {
|
|
||||||
Msg string
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *MessagePage) Init(ctx *PageCtx) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *MessagePage) HandleEvent(event *sdl.Event) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *MessagePage) Update(dt float32) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *MessagePage) Render() error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *MessagePage) Destroy() error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,407 +0,0 @@
|
|||||||
package page
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"regexp"
|
|
||||||
|
|
||||||
"github.com/go-gl/gl/v3.3-core/gl"
|
|
||||||
"github.com/go-gl/mathgl/mgl32"
|
|
||||||
"github.com/veandco/go-sdl2/sdl"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/colors"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/db"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/fontManager"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
|
||||||
)
|
|
||||||
|
|
||||||
// emailRegex validates email format
|
|
||||||
var emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
|
|
||||||
|
|
||||||
type EmailAddressPage struct {
|
|
||||||
Base
|
|
||||||
|
|
||||||
title string
|
|
||||||
titleTextureID uint32
|
|
||||||
titleTextureWidth int32
|
|
||||||
titleTextureHeight int32
|
|
||||||
|
|
||||||
currentEmailDisplayTextureID uint32
|
|
||||||
currentEmailDisplayTextureWidth int32
|
|
||||||
currentEmailDisplayTextureHeight int32
|
|
||||||
|
|
||||||
inputText string
|
|
||||||
inputTextTextureID uint32
|
|
||||||
inputTextTextureWidth int32
|
|
||||||
inputTextTextureHeight int32
|
|
||||||
|
|
||||||
// Error message handling
|
|
||||||
errorMessage string
|
|
||||||
errorTextureID uint32
|
|
||||||
errorTextureWidth int32
|
|
||||||
errorTextureHeight int32
|
|
||||||
|
|
||||||
// isFirstTimeSetup tracks whether page was loaded empty (first-time setup)
|
|
||||||
isFirstTimeSetup bool
|
|
||||||
|
|
||||||
// Text input deduplication
|
|
||||||
textInputSupported bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// isValidEmail checks if the given string is a valid email format
|
|
||||||
func isValidEmail(email string) bool {
|
|
||||||
return emailRegex.MatchString(email)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Init performs basic page initialization and creation of static assets.
|
|
||||||
func (p *EmailAddressPage) Init(ctx *PageCtx) error {
|
|
||||||
|
|
||||||
if err := p.Base.Init(ctx); err != nil {
|
|
||||||
return fmt.Errorf("failed base page initialization: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
p.title = "Your Email Address"
|
|
||||||
|
|
||||||
applicationFont := "SpaceMonoNerdFont-Regular"
|
|
||||||
|
|
||||||
fm := fontManager.Get()
|
|
||||||
textShader, ok := shaderManager.Get().Get("text")
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("unable to fetch text shader")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create title texture
|
|
||||||
titleTextureID, titleWidth, titleHeight, err := fm.CreateStringTexture(applicationFont, p.title, textShader)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create title texture: %w", err)
|
|
||||||
}
|
|
||||||
if titleTextureID == 0 {
|
|
||||||
return fmt.Errorf("CreateStringTexture returned a nil texture ID")
|
|
||||||
}
|
|
||||||
|
|
||||||
p.titleTextureID = titleTextureID
|
|
||||||
p.titleTextureWidth = titleWidth
|
|
||||||
p.titleTextureHeight = titleHeight
|
|
||||||
|
|
||||||
// Load existing email address from database
|
|
||||||
config, err := db.GetConfig(p.Context.DbPtr)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
emailAddress := config.EmailAddress
|
|
||||||
|
|
||||||
if emailAddress != "" {
|
|
||||||
p.inputText = emailAddress
|
|
||||||
p.isFirstTimeSetup = false
|
|
||||||
|
|
||||||
// Create current email display texture
|
|
||||||
currentEmailText := fmt.Sprintf("Current Email: %s", emailAddress)
|
|
||||||
currentEmailTextureID, currentEmailWidth, currentEmailHeight, err := fm.CreateStringTexture(applicationFont, currentEmailText, textShader)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create current email display texture: %w", err)
|
|
||||||
}
|
|
||||||
if currentEmailTextureID == 0 {
|
|
||||||
return fmt.Errorf("CreateStringTexture returned a nil texture ID for current email display")
|
|
||||||
}
|
|
||||||
|
|
||||||
p.currentEmailDisplayTextureID = currentEmailTextureID
|
|
||||||
p.currentEmailDisplayTextureWidth = currentEmailWidth
|
|
||||||
p.currentEmailDisplayTextureHeight = currentEmailHeight
|
|
||||||
} else {
|
|
||||||
p.inputText = ""
|
|
||||||
p.isFirstTimeSetup = true
|
|
||||||
}
|
|
||||||
|
|
||||||
sdl.StartTextInput()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleEvent processes SDL events and handles them appropriately for the EmailAddressPage.
|
|
||||||
func (p *EmailAddressPage) HandleEvent(event *sdl.Event) error {
|
|
||||||
actualEvent := *event
|
|
||||||
if !sdl.IsTextInputActive() {
|
|
||||||
sdl.StartTextInput()
|
|
||||||
}
|
|
||||||
|
|
||||||
switch e := actualEvent.(type) {
|
|
||||||
case *sdl.TextInputEvent:
|
|
||||||
if !p.textInputSupported {
|
|
||||||
// First TextInputEvent: the keyboard fallback already
|
|
||||||
// handled this character, so just record that text input
|
|
||||||
// is supported and skip the duplicate append.
|
|
||||||
p.textInputSupported = true
|
|
||||||
} else {
|
|
||||||
p.inputText += e.GetText()
|
|
||||||
// Clear error when user starts typing
|
|
||||||
p.errorMessage = ""
|
|
||||||
}
|
|
||||||
case *sdl.KeyboardEvent:
|
|
||||||
if e.Type == sdl.KEYDOWN {
|
|
||||||
switch e.Keysym.Sym {
|
|
||||||
case sdl.K_ESCAPE:
|
|
||||||
// Block escape during first-time setup
|
|
||||||
if p.isFirstTimeSetup {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// Allow escape back to settings if coming from settings
|
|
||||||
sdl.StopTextInput()
|
|
||||||
p.Context.NavigateTo(SETTINGS)
|
|
||||||
case sdl.K_RETURN:
|
|
||||||
// Validate email - check empty first, then format
|
|
||||||
if p.inputText == "" {
|
|
||||||
p.errorMessage = "Email address cannot be empty"
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if !isValidEmail(p.inputText) {
|
|
||||||
p.errorMessage = "Please enter a valid email address"
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
sdl.StopTextInput()
|
|
||||||
|
|
||||||
// Save email address to database
|
|
||||||
config, err := db.GetConfig(p.Context.DbPtr)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
config.EmailAddress = p.inputText
|
|
||||||
p.Context.DbPtr.Save(config)
|
|
||||||
|
|
||||||
if p.isFirstTimeSetup {
|
|
||||||
// First-time setup: navigate to License Key page
|
|
||||||
p.Context.NavigateTo(LICENSE_KEY)
|
|
||||||
} else {
|
|
||||||
p.Context.NavigateTo(SETTINGS)
|
|
||||||
}
|
|
||||||
case sdl.K_BACKSPACE:
|
|
||||||
if len(p.inputText) > 0 {
|
|
||||||
p.inputText = p.inputText[:len(p.inputText)-1]
|
|
||||||
// Clear error when user edits input
|
|
||||||
p.errorMessage = ""
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
// Keyboard fallback: if TextInputEvent is not supported,
|
|
||||||
// convert printable keycodes to characters directly.
|
|
||||||
// This handles kiosk systems without an input method framework.
|
|
||||||
if !p.textInputSupported {
|
|
||||||
if ch := keycodeToChar(e.Keysym.Sym, e.Keysym.Mod); ch != 0 {
|
|
||||||
p.inputText += string(ch)
|
|
||||||
p.errorMessage = ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update updates animations and changing textures and other changes using `dt` which represents a time delta in float32.
|
|
||||||
func (p *EmailAddressPage) Update(dt float32) error {
|
|
||||||
// Get application font from database
|
|
||||||
applicationFont := "SpaceMonoNerdFont-Regular"
|
|
||||||
|
|
||||||
fm := fontManager.Get()
|
|
||||||
sm := shaderManager.Get()
|
|
||||||
shader, ok := sm.Get("text")
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("unable to fetch text shader")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update input text texture
|
|
||||||
if len(p.inputText) > 0 {
|
|
||||||
// Create the new inputText texture
|
|
||||||
inputTextTextureID, inputTextTextureWidth, inputTextTextureHeight, err := fm.CreateStringTexture(
|
|
||||||
applicationFont,
|
|
||||||
p.inputText,
|
|
||||||
shader,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("unable to create new input text texture: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete old texture if it exists
|
|
||||||
if p.inputTextTextureID != 0 {
|
|
||||||
gl.DeleteTextures(1, &p.inputTextTextureID)
|
|
||||||
}
|
|
||||||
|
|
||||||
p.inputTextTextureID = inputTextTextureID
|
|
||||||
p.inputTextTextureWidth = inputTextTextureWidth
|
|
||||||
p.inputTextTextureHeight = inputTextTextureHeight
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update error message texture
|
|
||||||
if p.errorMessage != "" {
|
|
||||||
errorTextureID, errorWidth, errorHeight, err := fm.CreateStringTexture(
|
|
||||||
applicationFont,
|
|
||||||
p.errorMessage,
|
|
||||||
shader,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("unable to create error text texture: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete old error texture if it exists
|
|
||||||
if p.errorTextureID != 0 {
|
|
||||||
gl.DeleteTextures(1, &p.errorTextureID)
|
|
||||||
}
|
|
||||||
|
|
||||||
p.errorTextureID = errorTextureID
|
|
||||||
p.errorTextureWidth = errorWidth
|
|
||||||
p.errorTextureHeight = errorHeight
|
|
||||||
} else {
|
|
||||||
// Clear error texture if no error message
|
|
||||||
if p.errorTextureID != 0 {
|
|
||||||
gl.DeleteTextures(1, &p.errorTextureID)
|
|
||||||
p.errorTextureID = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render performs page drawing logic.
|
|
||||||
func (p *EmailAddressPage) Render() error {
|
|
||||||
config, err := db.GetConfig(p.Context.DbPtr)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
// Get base font size from database
|
|
||||||
baseFontSize := config.StandardFontSize
|
|
||||||
|
|
||||||
sm := shaderManager.Get()
|
|
||||||
|
|
||||||
// 1. Calculate Scale
|
|
||||||
titleFontSize := int32(64)
|
|
||||||
titleScale := float32(titleFontSize) / float32(baseFontSize)
|
|
||||||
currentEmailFontSize := int32(28)
|
|
||||||
currentEmailScale := float32(currentEmailFontSize) / float32(baseFontSize)
|
|
||||||
padding := int32(4)
|
|
||||||
verticalMargin := int32(20)
|
|
||||||
|
|
||||||
// 2. Define dimensions
|
|
||||||
targetFontSize := int32(36)
|
|
||||||
scale := float32(targetFontSize) / float32(baseFontSize)
|
|
||||||
border := int32(3)
|
|
||||||
boxH := targetFontSize + (padding * 2)
|
|
||||||
boxW := int32(600) + (padding * 2)
|
|
||||||
boxDimensions := mgl32.Vec2{float32(boxW), float32(boxH)}
|
|
||||||
borderBoxH := boxH + (border * 2)
|
|
||||||
borderBoxW := boxW + (border * 2)
|
|
||||||
borderBoxDimensions := mgl32.Vec2{float32(borderBoxW), float32(borderBoxH)}
|
|
||||||
titleH := float32(p.titleTextureHeight) * titleScale
|
|
||||||
titleW := float32(p.titleTextureWidth) * titleScale
|
|
||||||
titleDimensions := mgl32.Vec2{titleW, titleH}
|
|
||||||
inputTextH := float32(p.inputTextTextureHeight) * scale
|
|
||||||
inputTextW := float32(p.inputTextTextureWidth) * scale
|
|
||||||
inputTextDimensions := mgl32.Vec2{inputTextW, inputTextH}
|
|
||||||
|
|
||||||
var currentEmailDisplayH, currentEmailDisplayW float32
|
|
||||||
var currentEmailDisplayDimensions mgl32.Vec2
|
|
||||||
if p.currentEmailDisplayTextureID != 0 {
|
|
||||||
currentEmailDisplayH = float32(p.currentEmailDisplayTextureHeight) * currentEmailScale
|
|
||||||
currentEmailDisplayW = float32(p.currentEmailDisplayTextureWidth) * currentEmailScale
|
|
||||||
currentEmailDisplayDimensions = mgl32.Vec2{currentEmailDisplayW, currentEmailDisplayH}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Error text dimensions
|
|
||||||
errorFontSize := int32(20)
|
|
||||||
errorScale := float32(errorFontSize) / float32(baseFontSize)
|
|
||||||
var errorTextH, errorTextW float32
|
|
||||||
var errorTextDimensions mgl32.Vec2
|
|
||||||
if p.errorTextureID != 0 {
|
|
||||||
errorTextH = float32(p.errorTextureHeight) * errorScale
|
|
||||||
errorTextW = float32(p.errorTextureWidth) * errorScale
|
|
||||||
errorTextDimensions = mgl32.Vec2{errorTextW, errorTextH}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Define positions
|
|
||||||
borderBoxY := p.Base.ScreenCenterY - (borderBoxH / 2)
|
|
||||||
borderBoxX := p.Base.ScreenCenterX - (borderBoxW / 2)
|
|
||||||
borderBoxPosition := mgl32.Vec2{float32(borderBoxX), float32(borderBoxY)}
|
|
||||||
boxY := p.Base.ScreenCenterY - (boxH / 2)
|
|
||||||
boxX := p.Base.ScreenCenterX - (boxW / 2)
|
|
||||||
boxPosition := mgl32.Vec2{float32(boxX), float32(boxY)}
|
|
||||||
titleY := borderBoxY + borderBoxH + verticalMargin
|
|
||||||
titleX := p.Base.ScreenCenterX - int32(titleW/2)
|
|
||||||
titlePosition := mgl32.Vec2{float32(titleX), float32(titleY)}
|
|
||||||
inputTextY := boxY + padding
|
|
||||||
inputTextX := boxX + padding
|
|
||||||
inputTextPosition := mgl32.Vec2{float32(inputTextX), float32(inputTextY)}
|
|
||||||
|
|
||||||
var currentEmailDisplayPosition mgl32.Vec2
|
|
||||||
if p.currentEmailDisplayTextureID != 0 {
|
|
||||||
currentEmailDisplayY := borderBoxY - int32(currentEmailDisplayH) - verticalMargin
|
|
||||||
currentEmailDisplayX := p.Base.ScreenCenterX - int32(currentEmailDisplayW/2)
|
|
||||||
currentEmailDisplayPosition = mgl32.Vec2{float32(currentEmailDisplayX), float32(currentEmailDisplayY)}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Error text position (below current email display to avoid overlap)
|
|
||||||
var errorTextPosition mgl32.Vec2
|
|
||||||
if p.errorTextureID != 0 {
|
|
||||||
var errorTextY int32
|
|
||||||
if p.currentEmailDisplayTextureID != 0 {
|
|
||||||
// Position below the current email display
|
|
||||||
errorTextY = borderBoxY - int32(currentEmailDisplayH) - verticalMargin - int32(errorTextH) - (verticalMargin / 2)
|
|
||||||
} else {
|
|
||||||
// No current email display, position below the input box
|
|
||||||
errorTextY = borderBoxY - int32(errorTextH) - (verticalMargin / 2)
|
|
||||||
}
|
|
||||||
errorTextX := p.Base.ScreenCenterX - int32(errorTextW/2)
|
|
||||||
errorTextPosition = mgl32.Vec2{float32(errorTextX), float32(errorTextY)}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Define Colors
|
|
||||||
white := sdl.Color{R: 255, G: 255, B: 255, A: 255}
|
|
||||||
grey := sdl.Color{R: 120, G: 120, B: 120, A: 255}
|
|
||||||
darkGrey := sdl.Color{R: 30, G: 30, B: 30, A: 255}
|
|
||||||
|
|
||||||
// 5. Get Shaders
|
|
||||||
textShader, _ := sm.Get("text")
|
|
||||||
solidShader, _ := sm.Get("solid_color")
|
|
||||||
|
|
||||||
// 6. Draw everything
|
|
||||||
p.Base.RenderSolidColorQuad(solidShader, borderBoxPosition, borderBoxDimensions, grey)
|
|
||||||
p.Base.RenderSolidColorQuad(solidShader, boxPosition, boxDimensions, darkGrey)
|
|
||||||
p.Base.RenderTexture(textShader, p.titleTextureID, titlePosition, titleDimensions, white)
|
|
||||||
|
|
||||||
// Draw current email display if it exists
|
|
||||||
if p.currentEmailDisplayTextureID != 0 {
|
|
||||||
p.Base.RenderTexture(textShader, p.currentEmailDisplayTextureID, currentEmailDisplayPosition, currentEmailDisplayDimensions, white)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(p.inputText) > 0 {
|
|
||||||
p.Base.RenderTexture(textShader, p.inputTextTextureID, inputTextPosition, inputTextDimensions, white)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw error message if it exists
|
|
||||||
if p.errorTextureID != 0 {
|
|
||||||
p.Base.RenderTexture(textShader, p.errorTextureID, errorTextPosition, errorTextDimensions, colors.ErrorRed)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Destroy cleans up page-specific resources.
|
|
||||||
func (p *EmailAddressPage) Destroy() error {
|
|
||||||
|
|
||||||
gl.DeleteTextures(1, &p.titleTextureID)
|
|
||||||
|
|
||||||
// Delete current email display texture if it exists
|
|
||||||
if p.currentEmailDisplayTextureID != 0 {
|
|
||||||
gl.DeleteTextures(1, &p.currentEmailDisplayTextureID)
|
|
||||||
p.currentEmailDisplayTextureID = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete input text texture if it exists
|
|
||||||
if p.inputTextTextureID != 0 {
|
|
||||||
gl.DeleteTextures(1, &p.inputTextTextureID)
|
|
||||||
p.inputTextTextureID = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete error texture if it exists
|
|
||||||
if p.errorTextureID != 0 {
|
|
||||||
gl.DeleteTextures(1, &p.errorTextureID)
|
|
||||||
p.errorTextureID = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
sdl.StopTextInput()
|
|
||||||
return p.Base.Destroy()
|
|
||||||
}
|
|
||||||
@@ -1,419 +0,0 @@
|
|||||||
package page
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/go-gl/gl/v3.3-core/gl"
|
|
||||||
"github.com/go-gl/mathgl/mgl32"
|
|
||||||
"github.com/veandco/go-sdl2/sdl"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/colors"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/db"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/download"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/fontManager"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
|
||||||
)
|
|
||||||
|
|
||||||
var GitVersion = "07112026-1710"
|
|
||||||
|
|
||||||
type Home struct {
|
|
||||||
Base
|
|
||||||
fm *fontManager.FontManager
|
|
||||||
font *fontManager.FontEntry
|
|
||||||
title StringItem
|
|
||||||
version StringItem
|
|
||||||
prompt StringItem
|
|
||||||
note StringItem
|
|
||||||
message StringItem
|
|
||||||
downloadProgressChan chan download.DownloadStatus
|
|
||||||
licenseInfoHeading StringItem
|
|
||||||
emailDisplay StringItem
|
|
||||||
licenseKeyDisplay StringItem
|
|
||||||
hasLicenseInfo bool
|
|
||||||
baseFontSize int
|
|
||||||
isDownloadComplete bool
|
|
||||||
downloadFailed bool
|
|
||||||
downloadDone bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// Init performs basic page initialization and creation of static assets.
|
|
||||||
func (p *Home) Init(ctx *PageCtx) error {
|
|
||||||
title := "Radiant Wave"
|
|
||||||
version := GitVersion
|
|
||||||
prompt := "Press F2 to Start, or F3 for Settings"
|
|
||||||
note := "You can always press F1 to return here"
|
|
||||||
|
|
||||||
if err := p.Base.Init(ctx); err != nil {
|
|
||||||
return fmt.Errorf("failed base page initialization: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get font
|
|
||||||
p.fm = fontManager.Get()
|
|
||||||
font, ok := p.fm.GetFont("SpaceMonoNerdFont-Regular")
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("unable to fetch font")
|
|
||||||
}
|
|
||||||
p.font = font
|
|
||||||
|
|
||||||
// Create title texture
|
|
||||||
var err error
|
|
||||||
p.title, err = NewStringItem(title, font, colors.White)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("unable to create title string texture: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create version texture
|
|
||||||
versionString := "Version: " + version
|
|
||||||
p.version, err = NewStringItem(versionString, font, colors.White)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("unable to create version string texture: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create prompt texture
|
|
||||||
p.prompt, err = NewStringItem(prompt, font, colors.White)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("unable to create prompt string texture: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create note texture
|
|
||||||
p.note, err = NewStringItem(note, font, colors.White)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("unable to create note string texture: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create download channel and message
|
|
||||||
p.downloadProgressChan = make(chan download.DownloadStatus)
|
|
||||||
|
|
||||||
// Initialize message
|
|
||||||
p.message, err = NewStringItem("Status: Initializing...", font, colors.White)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("unable to create initial message texture: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
config, err := db.GetConfig(p.Context.DbPtr)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create license info textures if data exists
|
|
||||||
if config.EmailAddress != "" || config.LicenseKey != "" {
|
|
||||||
p.hasLicenseInfo = true
|
|
||||||
|
|
||||||
// Create heading
|
|
||||||
p.licenseInfoHeading, err = NewStringItem("License Info", font, colors.White)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("unable to create license info heading texture: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create email display if exists
|
|
||||||
if config.EmailAddress != "" {
|
|
||||||
emailText := fmt.Sprintf("Email: %s", config.EmailAddress)
|
|
||||||
p.emailDisplay, err = NewStringItem(emailText, font, colors.White)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("unable to create email display texture: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create license key display if exists
|
|
||||||
if config.LicenseKey != "" {
|
|
||||||
licenseKeyText := fmt.Sprintf("License Key: %s", config.LicenseKey)
|
|
||||||
p.licenseKeyDisplay, err = NewStringItem(licenseKeyText, font, colors.White)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("unable to create license key display texture: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get base font size from database
|
|
||||||
baseFontSize := config.StandardFontSize
|
|
||||||
p.baseFontSize = baseFontSize
|
|
||||||
|
|
||||||
// Scale the note text
|
|
||||||
fontSize := int32(32)
|
|
||||||
scale := float32(fontSize) / float32(baseFontSize)
|
|
||||||
p.note.W = int32(float32(p.note.W) * scale * 0.75)
|
|
||||||
p.note.H = int32(float32(p.note.H) * scale * 0.75)
|
|
||||||
|
|
||||||
// Scale license info texts
|
|
||||||
if p.hasLicenseInfo {
|
|
||||||
licenseInfoScale := float32(28) / float32(baseFontSize)
|
|
||||||
p.licenseInfoHeading.W = int32(float32(p.licenseInfoHeading.W) * licenseInfoScale)
|
|
||||||
p.licenseInfoHeading.H = int32(float32(p.licenseInfoHeading.H) * licenseInfoScale)
|
|
||||||
if p.emailDisplay.ID != 0 {
|
|
||||||
p.emailDisplay.W = int32(float32(p.emailDisplay.W) * licenseInfoScale * 0.85)
|
|
||||||
p.emailDisplay.H = int32(float32(p.emailDisplay.H) * licenseInfoScale * 0.85)
|
|
||||||
}
|
|
||||||
if p.licenseKeyDisplay.ID != 0 {
|
|
||||||
p.licenseKeyDisplay.W = int32(float32(p.licenseKeyDisplay.W) * licenseInfoScale * 0.85)
|
|
||||||
p.licenseKeyDisplay.H = int32(float32(p.licenseKeyDisplay.H) * licenseInfoScale * 0.85)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scale message text
|
|
||||||
messageScale := float32(28) / float32(baseFontSize)
|
|
||||||
p.message.W = int32(float32(p.message.W) * messageScale)
|
|
||||||
p.message.H = int32(float32(p.message.H) * messageScale)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleEvent processes SDL events and handles them appropriately for the Home page.
|
|
||||||
func (p *Home) HandleEvent(event *sdl.Event) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Home) Update(dt float32) error {
|
|
||||||
var (
|
|
||||||
progressUpdate download.DownloadStatus
|
|
||||||
ok bool
|
|
||||||
gotUpdate bool
|
|
||||||
)
|
|
||||||
|
|
||||||
// Drain the channel, keeping only the newest update.
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case progressUpdate, ok = <-p.Context.MessageChannel:
|
|
||||||
gotUpdate = true
|
|
||||||
if !ok {
|
|
||||||
if p.downloadFailed {
|
|
||||||
p.setMessage("Download failed — restart to retry", colors.Red)
|
|
||||||
} else {
|
|
||||||
p.setMessage("Status: READY", colors.Green)
|
|
||||||
p.isDownloadComplete = true
|
|
||||||
if !p.downloadDone {
|
|
||||||
p.downloadDone = true
|
|
||||||
close(p.Context.DownloadDone)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
goto drained
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
drained:
|
|
||||||
if !gotUpdate {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if progressUpdate.Err != nil {
|
|
||||||
p.downloadFailed = true
|
|
||||||
p.setMessage(fmt.Sprintf("Error: %s", progressUpdate.Err.Error()), colors.Red)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the display string based on what the download package tells us.
|
|
||||||
var msg string
|
|
||||||
if progressUpdate.Max <= 0 {
|
|
||||||
// No total known yet — show the descriptive message from the package.
|
|
||||||
// This covers: "Fetching file manifest", "Checking local files",
|
|
||||||
// "Comparing checksums", "Building download list", etc.
|
|
||||||
msg = fmt.Sprintf("Status: %s", progressUpdate.Msg)
|
|
||||||
} else {
|
|
||||||
// We have a known total — show percentage.
|
|
||||||
pct := float64(progressUpdate.Cur) / float64(progressUpdate.Max) * 100
|
|
||||||
msg = fmt.Sprintf("Status: Downloading Assets — %.1f%%", pct)
|
|
||||||
}
|
|
||||||
|
|
||||||
p.setMessage(msg, colors.White)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// setMessage deletes the old message texture and builds a new one.
|
|
||||||
func (p *Home) setMessage(text string, color sdl.Color) {
|
|
||||||
if p.message.ID != 0 {
|
|
||||||
gl.DeleteTextures(1, &p.message.ID)
|
|
||||||
p.message.ID = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
newMsg, err := NewStringItem(text, p.font, color)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
messageScale := float32(28) / float32(p.baseFontSize)
|
|
||||||
newMsg.W = int32(float32(newMsg.W) * messageScale)
|
|
||||||
newMsg.H = int32(float32(newMsg.H) * messageScale)
|
|
||||||
p.message = newMsg
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Home) Render() error {
|
|
||||||
baseFontSize := p.baseFontSize
|
|
||||||
sm := shaderManager.Get()
|
|
||||||
verticalMargin := int32(20)
|
|
||||||
licenseInfoMargin := int32(10)
|
|
||||||
|
|
||||||
// 1) Calculate scales
|
|
||||||
titleFontSize := int32(64)
|
|
||||||
bodyFontSize := int32(32)
|
|
||||||
|
|
||||||
titleScale := float32(titleFontSize) / float32(baseFontSize)
|
|
||||||
bodyScale := float32(bodyFontSize) / float32(baseFontSize)
|
|
||||||
|
|
||||||
// 2) Calculate dimensions (scaled)
|
|
||||||
titleW := float32(p.title.W) * titleScale
|
|
||||||
titleH := float32(p.title.H) * titleScale
|
|
||||||
titleDim := mgl32.Vec2{titleW, titleH}
|
|
||||||
|
|
||||||
versionW := float32(p.version.W) * bodyScale
|
|
||||||
versionH := float32(p.version.H) * bodyScale
|
|
||||||
versionDim := mgl32.Vec2{versionW, versionH}
|
|
||||||
|
|
||||||
promptW := float32(p.prompt.W) * bodyScale
|
|
||||||
promptH := float32(p.prompt.H) * bodyScale
|
|
||||||
promptDim := mgl32.Vec2{promptW, promptH}
|
|
||||||
|
|
||||||
// Note dimensions (already scaled in Init)
|
|
||||||
noteW := float32(p.note.W)
|
|
||||||
noteH := float32(p.note.H)
|
|
||||||
|
|
||||||
// Message dimensions (already scaled in Init/Update)
|
|
||||||
messageW := float32(p.message.W)
|
|
||||||
messageH := float32(p.message.H)
|
|
||||||
|
|
||||||
// License info dimensions (already scaled in Init)
|
|
||||||
licenseHeadingW := float32(p.licenseInfoHeading.W)
|
|
||||||
licenseHeadingH := float32(p.licenseInfoHeading.H)
|
|
||||||
var emailDisplayW, emailDisplayH, licenseKeyDisplayW, licenseKeyDisplayH float32
|
|
||||||
if p.emailDisplay.ID != 0 {
|
|
||||||
emailDisplayW = float32(p.emailDisplay.W)
|
|
||||||
emailDisplayH = float32(p.emailDisplay.H)
|
|
||||||
}
|
|
||||||
if p.licenseKeyDisplay.ID != 0 {
|
|
||||||
licenseKeyDisplayW = float32(p.licenseKeyDisplay.W)
|
|
||||||
licenseKeyDisplayH = float32(p.licenseKeyDisplay.H)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3) Calculate total height of the vertical stack
|
|
||||||
totalH := int32(titleH) + verticalMargin +
|
|
||||||
int32(versionH) + verticalMargin +
|
|
||||||
int32(promptH) + verticalMargin +
|
|
||||||
int32(noteH)
|
|
||||||
|
|
||||||
// 4) Y-up layout: start from the TOP of the centered stack and step DOWN
|
|
||||||
top := p.Base.ScreenCenterY + totalH/2
|
|
||||||
|
|
||||||
titleY := top - int32(titleH)
|
|
||||||
versionY := titleY - verticalMargin - int32(versionH)
|
|
||||||
p.prompt.Y = versionY - verticalMargin - int32(promptH)
|
|
||||||
p.note.Y = p.prompt.Y - verticalMargin - int32(noteH)
|
|
||||||
|
|
||||||
// 5) Center X for each item
|
|
||||||
titleX := p.Base.ScreenCenterX - int32(titleW/2)
|
|
||||||
versionX := p.Base.ScreenCenterX - int32(versionW/2)
|
|
||||||
p.prompt.X = p.Base.ScreenCenterX - int32(promptW/2)
|
|
||||||
p.note.X = p.Base.ScreenCenterX - int32(noteW/2)
|
|
||||||
|
|
||||||
titlePos := mgl32.Vec2{float32(titleX), float32(titleY)}
|
|
||||||
versionPos := mgl32.Vec2{float32(versionX), float32(versionY)}
|
|
||||||
|
|
||||||
// 6) Position license info at bottom center
|
|
||||||
if p.hasLicenseInfo {
|
|
||||||
bottomMargin := int32(20)
|
|
||||||
|
|
||||||
// Calculate total height of license info block
|
|
||||||
licenseInfoTotalH := int32(licenseHeadingH)
|
|
||||||
if p.emailDisplay.ID != 0 {
|
|
||||||
licenseInfoTotalH += int32(emailDisplayH) + licenseInfoMargin
|
|
||||||
}
|
|
||||||
if p.licenseKeyDisplay.ID != 0 {
|
|
||||||
licenseInfoTotalH += int32(licenseKeyDisplayH) + licenseInfoMargin
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start from bottom and work up
|
|
||||||
currentY := bottomMargin
|
|
||||||
|
|
||||||
// License key (bottom-most if it exists)
|
|
||||||
if p.licenseKeyDisplay.ID != 0 {
|
|
||||||
p.licenseKeyDisplay.X = p.Base.ScreenCenterX - int32(licenseKeyDisplayW/2)
|
|
||||||
p.licenseKeyDisplay.Y = currentY
|
|
||||||
currentY += int32(licenseKeyDisplayH) + licenseInfoMargin
|
|
||||||
}
|
|
||||||
|
|
||||||
// Email
|
|
||||||
if p.emailDisplay.ID != 0 {
|
|
||||||
p.emailDisplay.X = p.Base.ScreenCenterX - int32(emailDisplayW/2)
|
|
||||||
p.emailDisplay.Y = currentY
|
|
||||||
currentY += int32(emailDisplayH) + licenseInfoMargin
|
|
||||||
}
|
|
||||||
|
|
||||||
// Heading (top-most)
|
|
||||||
p.licenseInfoHeading.X = p.Base.ScreenCenterX - int32(licenseHeadingW/2)
|
|
||||||
p.licenseInfoHeading.Y = currentY
|
|
||||||
}
|
|
||||||
|
|
||||||
// 7) Position message between license info and note/prompt
|
|
||||||
if p.hasLicenseInfo {
|
|
||||||
// Calculate the top of the license info block
|
|
||||||
licenseInfoTopY := p.licenseInfoHeading.Y + int32(licenseHeadingH)
|
|
||||||
if p.emailDisplay.ID != 0 {
|
|
||||||
licenseInfoTopY += int32(emailDisplayH) + licenseInfoMargin
|
|
||||||
}
|
|
||||||
if p.licenseKeyDisplay.ID != 0 {
|
|
||||||
licenseInfoTopY += int32(licenseKeyDisplayH) + licenseInfoMargin
|
|
||||||
}
|
|
||||||
|
|
||||||
// Center the message vertically between license info and note
|
|
||||||
availableSpace := p.note.Y - licenseInfoTopY - verticalMargin
|
|
||||||
p.message.Y = licenseInfoTopY + availableSpace/2 - int32(messageH/2)
|
|
||||||
} else {
|
|
||||||
// If no license info, place message below note
|
|
||||||
p.message.Y = p.note.Y - verticalMargin - int32(messageH)
|
|
||||||
}
|
|
||||||
p.message.X = p.Base.ScreenCenterX - int32(messageW/2)
|
|
||||||
|
|
||||||
// 8) Get shader and render
|
|
||||||
textShader, ok := sm.Get("text")
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("failed to fetch text shader")
|
|
||||||
}
|
|
||||||
color := colors.White
|
|
||||||
|
|
||||||
p.Base.RenderTexture(textShader, p.title.ID, titlePos, titleDim, color)
|
|
||||||
p.Base.RenderTexture(textShader, p.version.ID, versionPos, versionDim, color)
|
|
||||||
p.Base.RenderTexture(textShader, p.prompt.ID, mgl32.Vec2{float32(p.prompt.X), float32(p.prompt.Y)}, promptDim, color)
|
|
||||||
p.Base.RenderTexture(textShader, p.note.ID, mgl32.Vec2{float32(p.note.X), float32(p.note.Y)}, mgl32.Vec2{noteW, noteH}, color)
|
|
||||||
|
|
||||||
// Render message
|
|
||||||
if p.message.ID != 0 {
|
|
||||||
msgColor := colors.White
|
|
||||||
if p.isDownloadComplete {
|
|
||||||
msgColor = colors.Green
|
|
||||||
}
|
|
||||||
p.Base.RenderTexture(textShader, p.message.ID, mgl32.Vec2{float32(p.message.X), float32(p.message.Y)}, mgl32.Vec2{messageW, messageH}, msgColor)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render license info if it exists
|
|
||||||
if p.hasLicenseInfo {
|
|
||||||
p.Base.RenderTexture(textShader, p.licenseInfoHeading.ID, mgl32.Vec2{float32(p.licenseInfoHeading.X), float32(p.licenseInfoHeading.Y)}, mgl32.Vec2{licenseHeadingW, licenseHeadingH}, color)
|
|
||||||
if p.emailDisplay.ID != 0 {
|
|
||||||
p.Base.RenderTexture(textShader, p.emailDisplay.ID, mgl32.Vec2{float32(p.emailDisplay.X), float32(p.emailDisplay.Y)}, mgl32.Vec2{emailDisplayW, emailDisplayH}, color)
|
|
||||||
}
|
|
||||||
if p.licenseKeyDisplay.ID != 0 {
|
|
||||||
p.Base.RenderTexture(textShader, p.licenseKeyDisplay.ID, mgl32.Vec2{float32(p.licenseKeyDisplay.X), float32(p.licenseKeyDisplay.Y)}, mgl32.Vec2{licenseKeyDisplayW, licenseKeyDisplayH}, color)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Destroy cleans up page-specific resources.
|
|
||||||
func (p *Home) Destroy() error {
|
|
||||||
gl.DeleteTextures(1, &p.title.ID)
|
|
||||||
gl.DeleteTextures(1, &p.version.ID)
|
|
||||||
gl.DeleteTextures(1, &p.prompt.ID)
|
|
||||||
gl.DeleteTextures(1, &p.note.ID)
|
|
||||||
gl.DeleteTextures(1, &p.message.ID)
|
|
||||||
|
|
||||||
if p.hasLicenseInfo {
|
|
||||||
gl.DeleteTextures(1, &p.licenseInfoHeading.ID)
|
|
||||||
if p.emailDisplay.ID != 0 {
|
|
||||||
gl.DeleteTextures(1, &p.emailDisplay.ID)
|
|
||||||
}
|
|
||||||
if p.licenseKeyDisplay.ID != 0 {
|
|
||||||
gl.DeleteTextures(1, &p.licenseKeyDisplay.ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return p.Base.Destroy()
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
package page
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"rhemawavetech.com/rhemawave/internal/download"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Regression: DownloadDone was closed twice when checkState() re-navigated
|
|
||||||
// to HOME on the DOWNLOADING_ASSETS -> READY transition. The new Home page
|
|
||||||
// saw the already-closed MessageChannel and tried to close DownloadDone
|
|
||||||
// again, causing panic: close of closed channel.
|
|
||||||
//
|
|
||||||
// Primary fix: checkState() now skips navigation when transitioning between
|
|
||||||
// states that both map to the HOME page. The downloadDone flag on Home is
|
|
||||||
// defense-in-depth.
|
|
||||||
|
|
||||||
// TestHomeDownloadDoneGuardPreventsDoubleClose verifies that the per-instance
|
|
||||||
// downloadDone flag prevents closing DownloadDone more than once, even if a
|
|
||||||
// new Home page somehow reaches the close path.
|
|
||||||
func TestHomeDownloadDoneGuardPreventsDoubleClose(t *testing.T) {
|
|
||||||
downloadDone := make(chan struct{})
|
|
||||||
|
|
||||||
// First Home closes DownloadDone.
|
|
||||||
h1 := &Home{downloadDone: false}
|
|
||||||
h1.Context = &PageCtx{DownloadDone: downloadDone}
|
|
||||||
if !h1.downloadDone {
|
|
||||||
h1.downloadDone = true
|
|
||||||
close(h1.Context.DownloadDone)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Second Home with guard already set (simulates defense-in-depth).
|
|
||||||
h2 := &Home{downloadDone: true}
|
|
||||||
h2.Context = &PageCtx{DownloadDone: downloadDone}
|
|
||||||
if !h2.downloadDone {
|
|
||||||
h2.downloadDone = true
|
|
||||||
close(h2.Context.DownloadDone) // would panic without guard
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHomeDownloadDoneGuardUnsetWouldPanic proves that without the
|
|
||||||
// downloadDone guard, closing DownloadDone a second time panics.
|
|
||||||
func TestHomeDownloadDoneGuardUnsetWouldPanic(t *testing.T) {
|
|
||||||
downloadDone := make(chan struct{})
|
|
||||||
close(downloadDone) // pre-close
|
|
||||||
|
|
||||||
defer func() {
|
|
||||||
if r := recover(); r == nil {
|
|
||||||
t.Error("expected panic when closing already-closed channel")
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// New Home with downloadDone=false tries to close an already-closed channel.
|
|
||||||
h := &Home{downloadDone: false}
|
|
||||||
h.Context = &PageCtx{DownloadDone: downloadDone}
|
|
||||||
if !h.downloadDone {
|
|
||||||
h.downloadDone = true
|
|
||||||
close(h.Context.DownloadDone) // panics
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHomeDownloadFailedDoesNotClose verifies that when a download fails,
|
|
||||||
// DownloadDone is NOT closed (the error path should not signal success).
|
|
||||||
func TestHomeDownloadFailedDoesNotClose(t *testing.T) {
|
|
||||||
downloadDone := make(chan struct{})
|
|
||||||
|
|
||||||
h := &Home{
|
|
||||||
downloadFailed: true,
|
|
||||||
downloadDone: false,
|
|
||||||
}
|
|
||||||
h.Context = &PageCtx{
|
|
||||||
MessageChannel: make(chan download.DownloadStatus),
|
|
||||||
DownloadDone: downloadDone,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Simulate the Update logic: on failure, downloadDone should not be set
|
|
||||||
// and DownloadDone should not be closed.
|
|
||||||
if h.downloadFailed {
|
|
||||||
// Error path: do not close DownloadDone.
|
|
||||||
} else if !h.downloadDone {
|
|
||||||
h.downloadDone = true
|
|
||||||
close(h.Context.DownloadDone)
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-downloadDone:
|
|
||||||
t.Error("DownloadDone should not be closed on download failure")
|
|
||||||
default:
|
|
||||||
// Correct: channel is still open.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestHomeDownloadDoneAlreadyClosed verifies that a Home page with
|
|
||||||
// downloadDone=true never attempts to close the channel.
|
|
||||||
func TestHomeDownloadDoneAlreadyClosed(t *testing.T) {
|
|
||||||
downloadDone := make(chan struct{})
|
|
||||||
close(downloadDone)
|
|
||||||
|
|
||||||
h := &Home{downloadDone: true}
|
|
||||||
h.Context = &PageCtx{DownloadDone: downloadDone}
|
|
||||||
|
|
||||||
// Guard should prevent any close attempt.
|
|
||||||
if !h.downloadDone {
|
|
||||||
h.downloadDone = true
|
|
||||||
close(h.Context.DownloadDone)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,459 +0,0 @@
|
|||||||
package page
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/go-gl/gl/v3.3-core/gl"
|
|
||||||
"github.com/go-gl/mathgl/mgl32"
|
|
||||||
"github.com/veandco/go-sdl2/sdl"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/colors"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/db"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/fontManager"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/logger"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
|
||||||
)
|
|
||||||
|
|
||||||
type LicenseKeyPage struct {
|
|
||||||
Base
|
|
||||||
|
|
||||||
title string
|
|
||||||
titleTextureID uint32
|
|
||||||
titleTextureWidth int32
|
|
||||||
titleTextureHeight int32
|
|
||||||
|
|
||||||
currentLicenseDisplayTextureID uint32
|
|
||||||
currentLicenseDisplayTextureWidth int32
|
|
||||||
currentLicenseDisplayTextureHeight int32
|
|
||||||
|
|
||||||
inputText string
|
|
||||||
inputTextTextureID uint32
|
|
||||||
inputTextTextureWidth int32
|
|
||||||
inputTextTextureHeight int32
|
|
||||||
|
|
||||||
// Error message handling
|
|
||||||
errorMessage string
|
|
||||||
errorTextureID uint32
|
|
||||||
errorTextureWidth int32
|
|
||||||
errorTextureHeight int32
|
|
||||||
|
|
||||||
// isFirstTimeSetup tracks whether page was loaded empty (first-time setup)
|
|
||||||
isFirstTimeSetup bool
|
|
||||||
|
|
||||||
// Text input deduplication
|
|
||||||
textInputSupported bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// Init performs basic page initialization and creation of static assets.
|
|
||||||
func (p *LicenseKeyPage) Init(ctx *PageCtx) error {
|
|
||||||
|
|
||||||
if err := p.Base.Init(ctx); err != nil {
|
|
||||||
return fmt.Errorf("failed base page initialization: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
p.title = "License Key"
|
|
||||||
|
|
||||||
// Get application font from database
|
|
||||||
applicationFont := "SpaceMonoNerdFont-Regular"
|
|
||||||
|
|
||||||
fm := fontManager.Get()
|
|
||||||
textShader, ok := shaderManager.Get().Get("text")
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("unable to fetch text shader")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create title texture
|
|
||||||
titleTextureID, titleWidth, titleHeight, err := fm.CreateStringTexture(applicationFont, p.title, textShader)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create title texture: %w", err)
|
|
||||||
}
|
|
||||||
if titleTextureID == 0 {
|
|
||||||
return fmt.Errorf("CreateStringTexture returned a nil texture ID")
|
|
||||||
}
|
|
||||||
|
|
||||||
p.titleTextureID = titleTextureID
|
|
||||||
p.titleTextureWidth = titleWidth
|
|
||||||
p.titleTextureHeight = titleHeight
|
|
||||||
|
|
||||||
// Load existing license key from database
|
|
||||||
config, err := db.GetConfig(p.Context.DbPtr)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
licenseKey := config.LicenseKey
|
|
||||||
|
|
||||||
if licenseKey != "" {
|
|
||||||
p.inputText = licenseKey
|
|
||||||
p.isFirstTimeSetup = false
|
|
||||||
|
|
||||||
// Create current license key display texture
|
|
||||||
currentLicenseText := fmt.Sprintf("Current License Key: %s", licenseKey)
|
|
||||||
currentLicenseTextureID, currentLicenseWidth, currentLicenseHeight, err := fm.CreateStringTexture(applicationFont, currentLicenseText, textShader)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create current license display texture: %w", err)
|
|
||||||
}
|
|
||||||
if currentLicenseTextureID == 0 {
|
|
||||||
return fmt.Errorf("CreateStringTexture returned a nil texture ID for current license display")
|
|
||||||
}
|
|
||||||
|
|
||||||
p.currentLicenseDisplayTextureID = currentLicenseTextureID
|
|
||||||
p.currentLicenseDisplayTextureWidth = currentLicenseWidth
|
|
||||||
p.currentLicenseDisplayTextureHeight = currentLicenseHeight
|
|
||||||
} else {
|
|
||||||
p.inputText = ""
|
|
||||||
p.isFirstTimeSetup = true
|
|
||||||
}
|
|
||||||
|
|
||||||
sdl.StartTextInput()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatLicenseKey formats the input string with dashes every 4 characters
|
|
||||||
// and limits to 16 alphanumeric characters (not counting dashes)
|
|
||||||
func formatLicenseKey(input string) string {
|
|
||||||
// Remove all dashes and non-alphanumeric characters
|
|
||||||
cleaned := strings.Map(func(r rune) rune {
|
|
||||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
return -1
|
|
||||||
}, input)
|
|
||||||
|
|
||||||
// Limit to 16 characters
|
|
||||||
if len(cleaned) > 16 {
|
|
||||||
cleaned = cleaned[:16]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert dashes every 4 characters
|
|
||||||
var result strings.Builder
|
|
||||||
for i, char := range cleaned {
|
|
||||||
if i > 0 && i%4 == 0 {
|
|
||||||
result.WriteRune('-')
|
|
||||||
}
|
|
||||||
result.WriteRune(char)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.String()
|
|
||||||
}
|
|
||||||
|
|
||||||
// HandleEvent processes SDL events and handles them appropriately for the LicenseKeyPage.
|
|
||||||
func (p *LicenseKeyPage) HandleEvent(event *sdl.Event) error {
|
|
||||||
actualEvent := *event
|
|
||||||
if !sdl.IsTextInputActive() {
|
|
||||||
sdl.StartTextInput()
|
|
||||||
}
|
|
||||||
|
|
||||||
switch e := actualEvent.(type) {
|
|
||||||
case *sdl.TextInputEvent:
|
|
||||||
if !p.textInputSupported {
|
|
||||||
// First TextInputEvent: the keyboard fallback already
|
|
||||||
// handled this character, so just record that text input
|
|
||||||
// is supported and skip the duplicate append.
|
|
||||||
p.textInputSupported = true
|
|
||||||
} else {
|
|
||||||
// Add the new character and reformat
|
|
||||||
p.inputText += e.GetText()
|
|
||||||
p.inputText = formatLicenseKey(p.inputText)
|
|
||||||
// Clear error when user starts typing
|
|
||||||
p.errorMessage = ""
|
|
||||||
}
|
|
||||||
case *sdl.KeyboardEvent:
|
|
||||||
if e.Type == sdl.KEYDOWN {
|
|
||||||
switch e.Keysym.Sym {
|
|
||||||
case sdl.K_ESCAPE:
|
|
||||||
// Block escape during first-time setup
|
|
||||||
if p.isFirstTimeSetup {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// Allow escape back to settings if coming from settings
|
|
||||||
sdl.StopTextInput()
|
|
||||||
p.Context.NavigateTo(SETTINGS)
|
|
||||||
case sdl.K_RETURN:
|
|
||||||
// Validate license key - check empty first, then length, then characters
|
|
||||||
cleaned := strings.ReplaceAll(p.inputText, "-", "")
|
|
||||||
if cleaned == "" {
|
|
||||||
p.errorMessage = "License key cannot be empty"
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if len(cleaned) != 16 {
|
|
||||||
p.errorMessage = fmt.Sprintf("License key must be 16 characters (currently %d)", len(cleaned))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// Check for invalid characters
|
|
||||||
for _, r := range cleaned {
|
|
||||||
if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')) {
|
|
||||||
p.errorMessage = "License key must contain only letters and numbers"
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sdl.StopTextInput()
|
|
||||||
|
|
||||||
// Save license key to database
|
|
||||||
config, err := db.GetConfig(p.Context.DbPtr)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
config.LicenseKey = p.inputText
|
|
||||||
p.Context.DbPtr.Save(config)
|
|
||||||
|
|
||||||
// Write license key to plain text file for the updater
|
|
||||||
if homeDir, err := os.UserHomeDir(); err == nil {
|
|
||||||
keyPath := filepath.Join(homeDir, ".local", "share", "rhemawave", "license_key")
|
|
||||||
if err := os.WriteFile(keyPath, []byte(p.inputText), 0644); err != nil {
|
|
||||||
logger.Error("Failed to write license key file: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if p.isFirstTimeSetup {
|
|
||||||
// First-time setup: rewind to Welcome page
|
|
||||||
p.Context.NavigateTo(HOME)
|
|
||||||
} else {
|
|
||||||
// Coming from settings: rewind back to settings
|
|
||||||
p.Context.NavigateTo(SETTINGS)
|
|
||||||
}
|
|
||||||
case sdl.K_BACKSPACE:
|
|
||||||
if len(p.inputText) > 0 {
|
|
||||||
// Remove last character and reformat
|
|
||||||
// Strip all dashes first to get raw characters
|
|
||||||
cleaned := strings.ReplaceAll(p.inputText, "-", "")
|
|
||||||
if len(cleaned) > 0 {
|
|
||||||
cleaned = cleaned[:len(cleaned)-1]
|
|
||||||
}
|
|
||||||
p.inputText = formatLicenseKey(cleaned)
|
|
||||||
// Clear error when user edits input
|
|
||||||
p.errorMessage = ""
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
// Keyboard fallback: if TextInputEvent is not supported,
|
|
||||||
// convert printable keycodes to characters directly.
|
|
||||||
// This handles kiosk systems without an input method framework.
|
|
||||||
if !p.textInputSupported {
|
|
||||||
if ch := keycodeToChar(e.Keysym.Sym, e.Keysym.Mod); ch != 0 {
|
|
||||||
p.inputText += string(ch)
|
|
||||||
p.inputText = formatLicenseKey(p.inputText)
|
|
||||||
p.errorMessage = ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update updates animations and changing textures and other changes using `dt` which represents a time delta in float32.
|
|
||||||
func (p *LicenseKeyPage) Update(dt float32) error {
|
|
||||||
// Get application font from database
|
|
||||||
applicationFont := "SpaceMonoNerdFont-Regular"
|
|
||||||
|
|
||||||
fm := fontManager.Get()
|
|
||||||
sm := shaderManager.Get()
|
|
||||||
shader, ok := sm.Get("text")
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("unable to fetch text shader")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update input text texture
|
|
||||||
if len(p.inputText) > 0 {
|
|
||||||
// Create the new inputText texture
|
|
||||||
inputTextTextureID, inputTextTextureWidth, inputTextTextureHeight, err := fm.CreateStringTexture(
|
|
||||||
applicationFont,
|
|
||||||
p.inputText,
|
|
||||||
shader,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("unable to create new input text texture: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete old texture if it exists
|
|
||||||
if p.inputTextTextureID != 0 {
|
|
||||||
gl.DeleteTextures(1, &p.inputTextTextureID)
|
|
||||||
}
|
|
||||||
|
|
||||||
p.inputTextTextureID = inputTextTextureID
|
|
||||||
p.inputTextTextureWidth = inputTextTextureWidth
|
|
||||||
p.inputTextTextureHeight = inputTextTextureHeight
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update error message texture
|
|
||||||
if p.errorMessage != "" {
|
|
||||||
errorTextureID, errorWidth, errorHeight, err := fm.CreateStringTexture(
|
|
||||||
applicationFont,
|
|
||||||
p.errorMessage,
|
|
||||||
shader,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("unable to create error text texture: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete old error texture if it exists
|
|
||||||
if p.errorTextureID != 0 {
|
|
||||||
gl.DeleteTextures(1, &p.errorTextureID)
|
|
||||||
}
|
|
||||||
|
|
||||||
p.errorTextureID = errorTextureID
|
|
||||||
p.errorTextureWidth = errorWidth
|
|
||||||
p.errorTextureHeight = errorHeight
|
|
||||||
} else {
|
|
||||||
// Clear error texture if no error message
|
|
||||||
if p.errorTextureID != 0 {
|
|
||||||
gl.DeleteTextures(1, &p.errorTextureID)
|
|
||||||
p.errorTextureID = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render performs page drawing logic.
|
|
||||||
func (p *LicenseKeyPage) Render() error {
|
|
||||||
config, err := db.GetConfig(p.Context.DbPtr)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
// Get base font size from database
|
|
||||||
baseFontSize := config.StandardFontSize
|
|
||||||
|
|
||||||
sm := shaderManager.Get()
|
|
||||||
|
|
||||||
// 1. Calculate Scale
|
|
||||||
titleFontSize := int32(64)
|
|
||||||
titleScale := float32(titleFontSize) / float32(baseFontSize)
|
|
||||||
currentLicenseFontSize := int32(28)
|
|
||||||
currentLicenseScale := float32(currentLicenseFontSize) / float32(baseFontSize)
|
|
||||||
padding := int32(4)
|
|
||||||
verticalMargin := int32(20)
|
|
||||||
|
|
||||||
// 2. Define dimensions
|
|
||||||
targetFontSize := int32(36)
|
|
||||||
scale := float32(targetFontSize) / float32(baseFontSize)
|
|
||||||
border := int32(3)
|
|
||||||
|
|
||||||
boxH := targetFontSize + (padding * 2)
|
|
||||||
boxW := int32(600) + (padding * 2)
|
|
||||||
boxDimensions := mgl32.Vec2{float32(boxW), float32(boxH)}
|
|
||||||
|
|
||||||
borderBoxH := boxH + (border * 2)
|
|
||||||
borderBoxW := boxW + (border * 2)
|
|
||||||
borderBoxDimensions := mgl32.Vec2{float32(borderBoxW), float32(borderBoxH)}
|
|
||||||
|
|
||||||
titleH := float32(p.titleTextureHeight) * titleScale
|
|
||||||
titleW := float32(p.titleTextureWidth) * titleScale
|
|
||||||
titleDimensions := mgl32.Vec2{titleW, titleH}
|
|
||||||
|
|
||||||
inputTextH := float32(p.inputTextTextureHeight) * scale
|
|
||||||
inputTextW := float32(p.inputTextTextureWidth) * scale
|
|
||||||
inputTextDimensions := mgl32.Vec2{inputTextW, inputTextH}
|
|
||||||
|
|
||||||
var currentLicenseDisplayH, currentLicenseDisplayW float32
|
|
||||||
var currentLicenseDisplayDimensions mgl32.Vec2
|
|
||||||
if p.currentLicenseDisplayTextureID != 0 {
|
|
||||||
currentLicenseDisplayH = float32(p.currentLicenseDisplayTextureHeight) * currentLicenseScale
|
|
||||||
currentLicenseDisplayW = float32(p.currentLicenseDisplayTextureWidth) * currentLicenseScale
|
|
||||||
currentLicenseDisplayDimensions = mgl32.Vec2{currentLicenseDisplayW, currentLicenseDisplayH}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Error text dimensions
|
|
||||||
errorFontSize := int32(20)
|
|
||||||
errorScale := float32(errorFontSize) / float32(baseFontSize)
|
|
||||||
var errorTextH, errorTextW float32
|
|
||||||
var errorTextDimensions mgl32.Vec2
|
|
||||||
if p.errorTextureID != 0 {
|
|
||||||
errorTextH = float32(p.errorTextureHeight) * errorScale
|
|
||||||
errorTextW = float32(p.errorTextureWidth) * errorScale
|
|
||||||
errorTextDimensions = mgl32.Vec2{errorTextW, errorTextH}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Define positions
|
|
||||||
borderBoxY := p.Base.ScreenCenterY - (borderBoxH / 2)
|
|
||||||
borderBoxX := p.Base.ScreenCenterX - (borderBoxW / 2)
|
|
||||||
borderBoxPosition := mgl32.Vec2{float32(borderBoxX), float32(borderBoxY)}
|
|
||||||
|
|
||||||
boxY := p.Base.ScreenCenterY - (boxH / 2)
|
|
||||||
boxX := p.Base.ScreenCenterX - (boxW / 2)
|
|
||||||
boxPosition := mgl32.Vec2{float32(boxX), float32(boxY)}
|
|
||||||
|
|
||||||
titleY := borderBoxY + borderBoxH + verticalMargin
|
|
||||||
titleX := p.Base.ScreenCenterX - int32(titleW/2)
|
|
||||||
titlePosition := mgl32.Vec2{float32(titleX), float32(titleY)}
|
|
||||||
|
|
||||||
inputTextY := boxY + padding
|
|
||||||
inputTextX := boxX + padding
|
|
||||||
inputTextPosition := mgl32.Vec2{float32(inputTextX), float32(inputTextY)}
|
|
||||||
|
|
||||||
var currentLicenseDisplayPosition mgl32.Vec2
|
|
||||||
if p.currentLicenseDisplayTextureID != 0 {
|
|
||||||
currentLicenseDisplayY := borderBoxY - int32(currentLicenseDisplayH) - verticalMargin
|
|
||||||
currentLicenseDisplayX := p.Base.ScreenCenterX - int32(currentLicenseDisplayW/2)
|
|
||||||
currentLicenseDisplayPosition = mgl32.Vec2{float32(currentLicenseDisplayX), float32(currentLicenseDisplayY)}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Error text position (below current license display to avoid overlap)
|
|
||||||
var errorTextPosition mgl32.Vec2
|
|
||||||
if p.errorTextureID != 0 {
|
|
||||||
var errorTextY int32
|
|
||||||
if p.currentLicenseDisplayTextureID != 0 {
|
|
||||||
// Position below the current license display
|
|
||||||
errorTextY = borderBoxY - int32(currentLicenseDisplayH) - verticalMargin - int32(errorTextH) - (verticalMargin / 2)
|
|
||||||
} else {
|
|
||||||
// No current license display, position below the input box
|
|
||||||
errorTextY = borderBoxY - int32(errorTextH) - (verticalMargin / 2)
|
|
||||||
}
|
|
||||||
errorTextX := p.Base.ScreenCenterX - int32(errorTextW/2)
|
|
||||||
errorTextPosition = mgl32.Vec2{float32(errorTextX), float32(errorTextY)}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Get Shaders
|
|
||||||
textShader, _ := sm.Get("text")
|
|
||||||
solidShader, _ := sm.Get("solid_color")
|
|
||||||
|
|
||||||
// 5. Draw everything
|
|
||||||
p.Base.RenderSolidColorQuad(solidShader, borderBoxPosition, borderBoxDimensions, colors.Gray)
|
|
||||||
p.Base.RenderSolidColorQuad(solidShader, boxPosition, boxDimensions, colors.DarkGray)
|
|
||||||
p.Base.RenderTexture(textShader, p.titleTextureID, titlePosition, titleDimensions, colors.White)
|
|
||||||
|
|
||||||
// Draw current license display if it exists
|
|
||||||
if p.currentLicenseDisplayTextureID != 0 {
|
|
||||||
p.Base.RenderTexture(textShader, p.currentLicenseDisplayTextureID, currentLicenseDisplayPosition, currentLicenseDisplayDimensions, colors.White)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(p.inputText) > 0 {
|
|
||||||
p.Base.RenderTexture(textShader, p.inputTextTextureID, inputTextPosition, inputTextDimensions, colors.White)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw error message if it exists
|
|
||||||
if p.errorTextureID != 0 {
|
|
||||||
p.Base.RenderTexture(textShader, p.errorTextureID, errorTextPosition, errorTextDimensions, colors.ErrorRed)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Destroy cleans up page-specific resources.
|
|
||||||
func (p *LicenseKeyPage) Destroy() error {
|
|
||||||
|
|
||||||
gl.DeleteTextures(1, &p.titleTextureID)
|
|
||||||
|
|
||||||
// Delete current license display texture if it exists
|
|
||||||
if p.currentLicenseDisplayTextureID != 0 {
|
|
||||||
gl.DeleteTextures(1, &p.currentLicenseDisplayTextureID)
|
|
||||||
p.currentLicenseDisplayTextureID = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete input text texture if it exists
|
|
||||||
if p.inputTextTextureID != 0 {
|
|
||||||
gl.DeleteTextures(1, &p.inputTextTextureID)
|
|
||||||
p.inputTextTextureID = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete error texture if it exists
|
|
||||||
if p.errorTextureID != 0 {
|
|
||||||
gl.DeleteTextures(1, &p.errorTextureID)
|
|
||||||
p.errorTextureID = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
sdl.StopTextInput()
|
|
||||||
return p.Base.Destroy()
|
|
||||||
}
|
|
||||||
@@ -1,273 +0,0 @@
|
|||||||
package page
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/go-gl/gl/v3.3-core/gl"
|
|
||||||
"github.com/go-gl/mathgl/mgl32"
|
|
||||||
"github.com/veandco/go-sdl2/sdl"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/colors"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/db"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/fontManager"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/logger"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
|
||||||
)
|
|
||||||
|
|
||||||
type LogViewerPage struct {
|
|
||||||
Base
|
|
||||||
|
|
||||||
titleTextureID uint32
|
|
||||||
titleTextureWidth int32
|
|
||||||
titleTextureHeight int32
|
|
||||||
|
|
||||||
lines []string
|
|
||||||
lineTextureIDs []uint32
|
|
||||||
lineTextureWidths []int32
|
|
||||||
lineTextureHeights []int32
|
|
||||||
lineColors []sdl.Color
|
|
||||||
|
|
||||||
hintTextureID uint32
|
|
||||||
hintTextureWidth int32
|
|
||||||
hintTextureHeight int32
|
|
||||||
|
|
||||||
scrollOffset int32
|
|
||||||
maxScrollOffset int32
|
|
||||||
lineHeight int32
|
|
||||||
visibleHeight int32
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *LogViewerPage) Init(ctx *PageCtx) error {
|
|
||||||
var logs []db.Log
|
|
||||||
if err := p.Base.Init(ctx); err != nil {
|
|
||||||
return fmt.Errorf("failed base page initialization: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if result := p.Context.DbPtr.Order("timestamp desc").Limit(250).Find(&logs); result.Error != nil {
|
|
||||||
logger.Error("error querying database for logs: %s", result.Error)
|
|
||||||
return fmt.Errorf("%s", result.Error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// // Format and word-wrap each log entry
|
|
||||||
// for _, entry := range logs {
|
|
||||||
// formatted := fmt.Sprintf("[%s] [%s] %s", entry.Timestamp, entry.Level, entry.Message)
|
|
||||||
// wrapped := wrapText(formatted, 100)
|
|
||||||
// p.lines = append(p.lines, strings.Split(wrapped, "\n")...)
|
|
||||||
// }
|
|
||||||
|
|
||||||
levelColors := map[string]sdl.Color{
|
|
||||||
"DEBUG": colors.SkyBlue,
|
|
||||||
"INFO": colors.White,
|
|
||||||
"WARNING": colors.Yellow,
|
|
||||||
"ERROR": colors.RedOrange,
|
|
||||||
}
|
|
||||||
|
|
||||||
fallbackColor := colors.White
|
|
||||||
|
|
||||||
p.lineColors = make([]sdl.Color, len(p.lines))
|
|
||||||
|
|
||||||
for _, entry := range logs {
|
|
||||||
color, ok := levelColors[entry.Level]
|
|
||||||
if !ok {
|
|
||||||
color = fallbackColor
|
|
||||||
}
|
|
||||||
logTime := time.UnixMilli(entry.Timestamp)
|
|
||||||
timeStr := logTime.Format("2006-01-02 15:04")
|
|
||||||
line := fmt.Sprintf("[%s] [%s] %s", timeStr, entry.Level, entry.Message)
|
|
||||||
p.lines = append(p.lines, line)
|
|
||||||
p.lineColors = append(p.lineColors, color)
|
|
||||||
}
|
|
||||||
|
|
||||||
applicationFont := "SpaceMonoNerdFont-Regular"
|
|
||||||
|
|
||||||
fm := fontManager.Get()
|
|
||||||
textShader, ok := shaderManager.Get().Get("text")
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("unable to fetch text shader")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Title texture
|
|
||||||
titleID, titleW, titleH, err := fm.CreateStringTexture(applicationFont, "Application Logs", textShader)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create title texture: %w", err)
|
|
||||||
}
|
|
||||||
p.titleTextureID = titleID
|
|
||||||
p.titleTextureWidth = titleW
|
|
||||||
p.titleTextureHeight = titleH
|
|
||||||
|
|
||||||
// Line textures
|
|
||||||
p.lineTextureIDs = make([]uint32, len(p.lines))
|
|
||||||
p.lineTextureWidths = make([]int32, len(p.lines))
|
|
||||||
p.lineTextureHeights = make([]int32, len(p.lines))
|
|
||||||
|
|
||||||
for i, line := range p.lines {
|
|
||||||
if strings.TrimSpace(line) == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
texID, w, h, err := fm.CreateStringTexture(applicationFont, line, textShader)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create line texture: %w", err)
|
|
||||||
}
|
|
||||||
p.lineTextureIDs[i] = texID
|
|
||||||
p.lineTextureWidths[i] = w
|
|
||||||
p.lineTextureHeights[i] = h
|
|
||||||
}
|
|
||||||
|
|
||||||
// Hint texture
|
|
||||||
hintText := "Arrow Keys / PgUp/PgDn to scroll | F1|2|3 to Exit"
|
|
||||||
hintID, hintW, hintH, err := fm.CreateStringTexture(applicationFont, hintText, textShader)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create hint texture: %w", err)
|
|
||||||
}
|
|
||||||
p.hintTextureID = hintID
|
|
||||||
p.hintTextureWidth = hintW
|
|
||||||
p.hintTextureHeight = hintH
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *LogViewerPage) HandleEvent(event *sdl.Event) error {
|
|
||||||
switch e := (*event).(type) {
|
|
||||||
case *sdl.KeyboardEvent:
|
|
||||||
if e.Type == sdl.KEYDOWN {
|
|
||||||
switch e.Keysym.Sym {
|
|
||||||
case sdl.K_BACKSPACE:
|
|
||||||
p.Context.NavigateTo(SETTINGS)
|
|
||||||
case sdl.K_UP:
|
|
||||||
p.scrollOffset -= p.lineHeight
|
|
||||||
if p.scrollOffset < 0 {
|
|
||||||
p.scrollOffset = 0
|
|
||||||
}
|
|
||||||
case sdl.K_DOWN:
|
|
||||||
p.scrollOffset += p.lineHeight
|
|
||||||
if p.scrollOffset > p.maxScrollOffset {
|
|
||||||
p.scrollOffset = p.maxScrollOffset
|
|
||||||
}
|
|
||||||
case sdl.K_PAGEUP:
|
|
||||||
p.scrollOffset -= p.visibleHeight
|
|
||||||
if p.scrollOffset < 0 {
|
|
||||||
p.scrollOffset = 0
|
|
||||||
}
|
|
||||||
case sdl.K_PAGEDOWN:
|
|
||||||
p.scrollOffset += p.visibleHeight
|
|
||||||
if p.scrollOffset > p.maxScrollOffset {
|
|
||||||
p.scrollOffset = p.maxScrollOffset
|
|
||||||
}
|
|
||||||
case sdl.K_HOME:
|
|
||||||
p.scrollOffset = 0
|
|
||||||
case sdl.K_END:
|
|
||||||
p.scrollOffset = p.maxScrollOffset
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case *sdl.MouseWheelEvent:
|
|
||||||
p.scrollOffset -= e.Y * p.lineHeight * 3
|
|
||||||
if p.scrollOffset < 0 {
|
|
||||||
p.scrollOffset = 0
|
|
||||||
}
|
|
||||||
if p.scrollOffset > p.maxScrollOffset {
|
|
||||||
p.scrollOffset = p.maxScrollOffset
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *LogViewerPage) Update(dt float32) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *LogViewerPage) Render() error {
|
|
||||||
config, err := db.GetConfig(p.Context.DbPtr)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
baseFontSize := config.StandardFontSize
|
|
||||||
|
|
||||||
sm := shaderManager.Get()
|
|
||||||
textShader, _ := sm.Get("text")
|
|
||||||
|
|
||||||
titleFontSize := int32(48)
|
|
||||||
titleScale := float32(titleFontSize) / float32(baseFontSize)
|
|
||||||
contentFontSize := int32(24)
|
|
||||||
contentScale := float32(contentFontSize) / float32(baseFontSize)
|
|
||||||
hintFontSize := int32(20)
|
|
||||||
hintScale := float32(hintFontSize) / float32(baseFontSize)
|
|
||||||
|
|
||||||
margin := int32(40)
|
|
||||||
lineSpacing := int32(4)
|
|
||||||
p.lineHeight = contentFontSize + lineSpacing
|
|
||||||
|
|
||||||
contentLeftX := 25
|
|
||||||
|
|
||||||
// Title
|
|
||||||
titleH := float32(p.titleTextureHeight) * titleScale
|
|
||||||
titleW := float32(p.titleTextureWidth) * titleScale
|
|
||||||
titleX := p.Base.ScreenCenterX - int32(titleW/2)
|
|
||||||
titleY := p.Base.ScreenHeight - margin - int32(titleH)
|
|
||||||
p.Base.RenderTexture(textShader, p.titleTextureID, mgl32.Vec2{float32(titleX), float32(titleY)}, mgl32.Vec2{titleW, titleH}, colors.White)
|
|
||||||
|
|
||||||
// Hint at bottom
|
|
||||||
hintH := float32(p.hintTextureHeight) * hintScale
|
|
||||||
hintW := float32(p.hintTextureWidth) * hintScale
|
|
||||||
hintX := p.Base.ScreenCenterX - int32(hintW/2)
|
|
||||||
hintY := margin
|
|
||||||
p.Base.RenderTexture(textShader, p.hintTextureID, mgl32.Vec2{float32(hintX), float32(hintY)}, mgl32.Vec2{hintW, hintH}, colors.Gray)
|
|
||||||
|
|
||||||
// Content area bounds
|
|
||||||
contentTop := titleY - margin
|
|
||||||
contentBottom := hintY + int32(hintH) + margin
|
|
||||||
p.visibleHeight = contentTop - contentBottom
|
|
||||||
|
|
||||||
// Total content height
|
|
||||||
totalContentHeight := int32(len(p.lines)) * p.lineHeight
|
|
||||||
p.maxScrollOffset = totalContentHeight - p.visibleHeight
|
|
||||||
if p.maxScrollOffset < 0 {
|
|
||||||
p.maxScrollOffset = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render visible lines
|
|
||||||
currentY := contentTop - p.lineHeight + p.scrollOffset
|
|
||||||
|
|
||||||
for i, line := range p.lines {
|
|
||||||
if strings.TrimSpace(line) == "" {
|
|
||||||
currentY -= p.lineHeight
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if p.lineTextureIDs[i] == 0 {
|
|
||||||
currentY -= p.lineHeight
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if currentY > contentTop || currentY+p.lineHeight < contentBottom {
|
|
||||||
currentY -= p.lineHeight
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
lineW := float32(p.lineTextureWidths[i]) * contentScale
|
|
||||||
lineH := float32(p.lineTextureHeights[i]) * contentScale
|
|
||||||
|
|
||||||
color := colors.White
|
|
||||||
if i < len(p.lineColors) {
|
|
||||||
color = p.lineColors[i]
|
|
||||||
}
|
|
||||||
|
|
||||||
p.Base.RenderTexture(textShader, p.lineTextureIDs[i], mgl32.Vec2{float32(contentLeftX), float32(currentY)}, mgl32.Vec2{lineW, lineH}, color)
|
|
||||||
currentY -= p.lineHeight
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *LogViewerPage) Destroy() error {
|
|
||||||
gl.DeleteTextures(1, &p.titleTextureID)
|
|
||||||
gl.DeleteTextures(1, &p.hintTextureID)
|
|
||||||
|
|
||||||
for _, texID := range p.lineTextureIDs {
|
|
||||||
if texID != 0 {
|
|
||||||
gl.DeleteTextures(1, &texID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return p.Base.Destroy()
|
|
||||||
}
|
|
||||||
@@ -1,236 +0,0 @@
|
|||||||
// Package page provides the interface for pages
|
|
||||||
package page
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/go-gl/gl/v3.3-core/gl"
|
|
||||||
"github.com/go-gl/mathgl/mgl32"
|
|
||||||
"github.com/veandco/go-sdl2/sdl"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Page defines the contract for all application screens.
|
|
||||||
type Page interface {
|
|
||||||
Init(ctx *PageCtx) error
|
|
||||||
HandleEvent(event *sdl.Event) error
|
|
||||||
Update(dt float32) error
|
|
||||||
Render() error
|
|
||||||
Destroy() error
|
|
||||||
}
|
|
||||||
|
|
||||||
// Base is a helper struct that pages can embed to get common functionality.
|
|
||||||
type Base struct {
|
|
||||||
Context *PageCtx
|
|
||||||
QuadVAO uint32
|
|
||||||
QuadVBO uint32
|
|
||||||
Projection mgl32.Mat4
|
|
||||||
ScreenWidth int32
|
|
||||||
ScreenHeight int32
|
|
||||||
ScreenCenterX int32
|
|
||||||
ScreenCenterY int32
|
|
||||||
}
|
|
||||||
|
|
||||||
// Init stores the application context and sets up common resources.
|
|
||||||
func (p *Base) Init(ctx *PageCtx) error {
|
|
||||||
p.Context = ctx
|
|
||||||
vertices := []float32{
|
|
||||||
// pos(x,y) tex(u,v)
|
|
||||||
0.0, 1.0, 0.0, 1.0,
|
|
||||||
1.0, 0.0, 1.0, 0.0,
|
|
||||||
0.0, 0.0, 0.0, 0.0,
|
|
||||||
|
|
||||||
0.0, 1.0, 0.0, 1.0,
|
|
||||||
1.0, 1.0, 1.0, 1.0,
|
|
||||||
1.0, 0.0, 1.0, 0.0,
|
|
||||||
}
|
|
||||||
|
|
||||||
W := p.Context.W
|
|
||||||
H := p.Context.H
|
|
||||||
|
|
||||||
p.Projection = mgl32.Ortho(0, float32(W), 0, float32(H), -1, 1)
|
|
||||||
p.ScreenHeight = H
|
|
||||||
p.ScreenWidth = W
|
|
||||||
p.ScreenCenterY = H / 2
|
|
||||||
p.ScreenCenterX = W / 2
|
|
||||||
|
|
||||||
gl.GenVertexArrays(1, &p.QuadVAO)
|
|
||||||
gl.GenBuffers(1, &p.QuadVBO)
|
|
||||||
gl.BindVertexArray(p.QuadVAO)
|
|
||||||
gl.BindBuffer(gl.ARRAY_BUFFER, p.QuadVBO)
|
|
||||||
gl.BufferData(gl.ARRAY_BUFFER, len(vertices)*4, gl.Ptr(vertices), gl.STATIC_DRAW)
|
|
||||||
|
|
||||||
stride := int32(4 * 4) // Each vertex has 4 floats (pos.x, pos.y, tex.u, tex.v)
|
|
||||||
|
|
||||||
// Attribute 0: Vertex Position (vec2)
|
|
||||||
gl.EnableVertexAttribArray(0)
|
|
||||||
gl.VertexAttribPointerWithOffset(0, 2, gl.FLOAT, false, stride, 0)
|
|
||||||
|
|
||||||
// Attribute 1: Texture Coordinate (vec2)
|
|
||||||
gl.EnableVertexAttribArray(1)
|
|
||||||
gl.VertexAttribPointerWithOffset(1, 2, gl.FLOAT, false, stride, 2*4) // Offset by 2 floats
|
|
||||||
|
|
||||||
gl.BindBuffer(gl.ARRAY_BUFFER, 0)
|
|
||||||
gl.BindVertexArray(0)
|
|
||||||
gl.Disable(gl.BLEND)
|
|
||||||
gl.UseProgram(0)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Destroy cleans up the common OpenGL resources.
|
|
||||||
func (p *Base) Destroy() error {
|
|
||||||
gl.DeleteBuffers(1, &p.QuadVBO)
|
|
||||||
gl.DeleteVertexArrays(1, &p.QuadVAO)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// BeginTextureRendering sets up shared GL state for a batch of RenderTexture calls.
|
|
||||||
// Call this once before a series of RenderTexture calls, then EndTextureRendering after.
|
|
||||||
func (p *Base) BeginTextureRendering(shader *shaderManager.Shader) {
|
|
||||||
gl.UseProgram(shader.ProgramID)
|
|
||||||
gl.Uniform1i(shader.LocTexture, 0)
|
|
||||||
gl.ActiveTexture(gl.TEXTURE0)
|
|
||||||
gl.Enable(gl.BLEND)
|
|
||||||
gl.BlendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
|
|
||||||
gl.BindVertexArray(p.QuadVAO)
|
|
||||||
}
|
|
||||||
|
|
||||||
// EndTextureRendering restores GL state after a batch of RenderTexture calls.
|
|
||||||
func (p *Base) EndTextureRendering() {
|
|
||||||
gl.BindVertexArray(0)
|
|
||||||
gl.Disable(gl.BLEND)
|
|
||||||
gl.UseProgram(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RenderTexture draws a given texture to the screen at a specified position and size.
|
|
||||||
// For best performance, wrap multiple calls with BeginTextureRendering/EndTextureRendering.
|
|
||||||
// This method also works standalone for one-off renders.
|
|
||||||
func (p *Base) RenderTexture(shader *shaderManager.Shader, textureID uint32, position, size mgl32.Vec2, color sdl.Color) error {
|
|
||||||
gl.UseProgram(shader.ProgramID)
|
|
||||||
gl.Uniform1i(shader.LocTexture, 0)
|
|
||||||
|
|
||||||
if shader.LocTextColor == -1 {
|
|
||||||
return fmt.Errorf("could not find 'u_textColor' uniform in shader program %d", shader.ProgramID)
|
|
||||||
}
|
|
||||||
gl.Uniform4f(shader.LocTextColor, float32(color.R)/255.0, float32(color.G)/255.0, float32(color.B)/255.0, float32(color.A)/255.0)
|
|
||||||
|
|
||||||
model := mgl32.Ident4()
|
|
||||||
model = model.Mul4(mgl32.Translate3D(position.X(), position.Y(), 0))
|
|
||||||
model = model.Mul4(mgl32.Scale3D(size.X(), size.Y(), 1))
|
|
||||||
mvp := p.Projection.Mul4(model)
|
|
||||||
gl.UniformMatrix4fv(shader.LocMVPMatrix, 1, false, &mvp[0])
|
|
||||||
|
|
||||||
gl.ActiveTexture(gl.TEXTURE0)
|
|
||||||
gl.BindTexture(gl.TEXTURE_2D, textureID)
|
|
||||||
gl.Enable(gl.BLEND)
|
|
||||||
gl.BlendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
|
|
||||||
|
|
||||||
gl.BindVertexArray(p.QuadVAO)
|
|
||||||
gl.DrawArrays(gl.TRIANGLES, 0, 6)
|
|
||||||
|
|
||||||
gl.BindVertexArray(0)
|
|
||||||
gl.Disable(gl.BLEND)
|
|
||||||
gl.UseProgram(0)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RenderTextureBatched is a lightweight version of RenderTexture for use inside
|
|
||||||
// a BeginTextureRendering/EndTextureRendering block. It skips redundant state setup.
|
|
||||||
func (p *Base) RenderTextureBatched(shader *shaderManager.Shader, textureID uint32, position, size mgl32.Vec2, color sdl.Color) {
|
|
||||||
gl.Uniform4f(shader.LocTextColor, float32(color.R)/255.0, float32(color.G)/255.0, float32(color.B)/255.0, float32(color.A)/255.0)
|
|
||||||
|
|
||||||
model := mgl32.Ident4()
|
|
||||||
model = model.Mul4(mgl32.Translate3D(position.X(), position.Y(), 0))
|
|
||||||
model = model.Mul4(mgl32.Scale3D(size.X(), size.Y(), 1))
|
|
||||||
mvp := p.Projection.Mul4(model)
|
|
||||||
gl.UniformMatrix4fv(shader.LocMVPMatrix, 1, false, &mvp[0])
|
|
||||||
|
|
||||||
gl.BindTexture(gl.TEXTURE_2D, textureID)
|
|
||||||
gl.DrawArrays(gl.TRIANGLES, 0, 6)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RenderSolidColorQuad draws a rectangle of a solid color to the screen.
|
|
||||||
func (p *Base) RenderSolidColorQuad(shader *shaderManager.Shader, position, size mgl32.Vec2, color sdl.Color) error {
|
|
||||||
gl.UseProgram(shader.ProgramID)
|
|
||||||
|
|
||||||
model := mgl32.Ident4()
|
|
||||||
model = model.Mul4(mgl32.Translate3D(position.X(), position.Y(), 0))
|
|
||||||
model = model.Mul4(mgl32.Scale3D(size.X(), size.Y(), 1))
|
|
||||||
mvp := p.Projection.Mul4(model)
|
|
||||||
|
|
||||||
if shader.LocMVPMatrix == -1 {
|
|
||||||
return fmt.Errorf("RenderSolidColorQuad: could not find 'u_mvpMatrix' uniform")
|
|
||||||
}
|
|
||||||
gl.UniformMatrix4fv(shader.LocMVPMatrix, 1, false, &mvp[0])
|
|
||||||
|
|
||||||
if shader.LocColor == -1 {
|
|
||||||
return fmt.Errorf("RenderSolidColorQuad: could not find 'u_color' uniform")
|
|
||||||
}
|
|
||||||
gl.Uniform4f(shader.LocColor, float32(color.R)/255.0, float32(color.G)/255.0, float32(color.B)/255.0, float32(color.A)/255.0)
|
|
||||||
|
|
||||||
gl.Enable(gl.BLEND)
|
|
||||||
gl.BlendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA)
|
|
||||||
|
|
||||||
gl.BindVertexArray(p.QuadVAO)
|
|
||||||
gl.DrawArrays(gl.TRIANGLES, 0, 6)
|
|
||||||
|
|
||||||
gl.BindVertexArray(0)
|
|
||||||
gl.Disable(gl.BLEND)
|
|
||||||
gl.UseProgram(0)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Provide default implementations for the Page interface.
|
|
||||||
func (p *Base) HandleEvent(event sdl.Event) error { return nil }
|
|
||||||
func (p *Base) Update(dt float32) error { return nil }
|
|
||||||
func (p *Base) Render() error { return nil }
|
|
||||||
|
|
||||||
// --- Helper Functions ---
|
|
||||||
// keycodeToChar converts an SDL keycode to a rune, respecting SHIFT state.
|
|
||||||
// Returns 0 for non-printable keys.
|
|
||||||
func keycodeToChar(sym sdl.Keycode, mod uint16) rune {
|
|
||||||
shifted := mod&sdl.KMOD_SHIFT != 0
|
|
||||||
|
|
||||||
// Letters
|
|
||||||
if sym >= sdl.K_a && sym <= sdl.K_z {
|
|
||||||
ch := rune(sym)
|
|
||||||
if shifted {
|
|
||||||
ch -= 'a' - 'A'
|
|
||||||
}
|
|
||||||
return ch
|
|
||||||
}
|
|
||||||
|
|
||||||
// Digits and their shifted symbols
|
|
||||||
if sym >= sdl.K_0 && sym <= sdl.K_9 {
|
|
||||||
if shifted {
|
|
||||||
shiftDigits := []rune{')', '!', '@', '#', '$', '%', '^', '&', '*', '('}
|
|
||||||
return shiftDigits[sym-sdl.K_0]
|
|
||||||
}
|
|
||||||
return rune(sym)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Common punctuation / symbols
|
|
||||||
type charPair struct{ normal, shifted rune }
|
|
||||||
punctuation := map[sdl.Keycode]charPair{
|
|
||||||
sdl.K_SPACE: {' ', ' '},
|
|
||||||
sdl.K_MINUS: {'-', '_'},
|
|
||||||
sdl.K_EQUALS: {'=', '+'},
|
|
||||||
sdl.K_LEFTBRACKET: {'[', '{'},
|
|
||||||
sdl.K_RIGHTBRACKET: {']', '}'},
|
|
||||||
sdl.K_BACKSLASH: {'\\', '|'},
|
|
||||||
sdl.K_SEMICOLON: {';', ':'},
|
|
||||||
sdl.K_QUOTE: {'\'', '"'},
|
|
||||||
sdl.K_BACKQUOTE: {'`', '~'},
|
|
||||||
sdl.K_COMMA: {',', '<'},
|
|
||||||
sdl.K_PERIOD: {'.', '>'},
|
|
||||||
sdl.K_SLASH: {'/', '?'},
|
|
||||||
}
|
|
||||||
if pair, ok := punctuation[sym]; ok {
|
|
||||||
if shifted {
|
|
||||||
return pair.shifted
|
|
||||||
}
|
|
||||||
return pair.normal
|
|
||||||
}
|
|
||||||
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
package page
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/go-gl/mathgl/mgl32"
|
|
||||||
"github.com/veandco/go-sdl2/sdl"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/fontManager"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
|
||||||
)
|
|
||||||
|
|
||||||
// PageItem defines the basic features of any item on any page
|
|
||||||
// This coupling makes creating, updating and managing pages easier
|
|
||||||
type PageItem struct {
|
|
||||||
ID uint32
|
|
||||||
W int32
|
|
||||||
H int32
|
|
||||||
X int32 // Horizontal position
|
|
||||||
Y int32 // Vertical position
|
|
||||||
Color sdl.Color
|
|
||||||
// index int
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scale updates the W and H of an item based on a scale factor (usually determined by base font size / desired font size)
|
|
||||||
func (i *PageItem) Scale(scale float32) {
|
|
||||||
i.W = int32(float32(i.W) * scale)
|
|
||||||
i.H = int32(float32(i.H) * scale)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dimensions returns an mgl32.Vec2 containing the W and H of an item, respectively
|
|
||||||
func (i *PageItem) Dimensions(scale float32) mgl32.Vec2 {
|
|
||||||
return mgl32.Vec2{float32(float32(i.W) * scale), float32(float32(i.H) * scale)}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RawDimensions returns a mgl32.Vec2 containing the W and H of an item, without scaling
|
|
||||||
// This method will replace Dimensions in the future. Future PageItems should first call Scale(), then RawDimensions()
|
|
||||||
func (i *PageItem) RawDimensions() mgl32.Vec2 {
|
|
||||||
return mgl32.Vec2{float32(i.W), float32(i.H)}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Position returns an mgl32.Vec2 contianing the X and Y positions of an item, respectively
|
|
||||||
func (i *PageItem) Position() mgl32.Vec2 {
|
|
||||||
return mgl32.Vec2{float32(i.X), float32(i.Y)}
|
|
||||||
}
|
|
||||||
|
|
||||||
// StringItem is an extended PageItem that contains a string content and string specific methods
|
|
||||||
type StringItem struct {
|
|
||||||
PageItem
|
|
||||||
content string
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewStringItem creates a new StringItem with the given content, font, and color.
|
|
||||||
// It initializes the texture ID, width, and height by creating a string texture using the font
|
|
||||||
// and shader manager.
|
|
||||||
func NewStringItem(content string, font *fontManager.FontEntry, color sdl.Color) (StringItem, error) {
|
|
||||||
var err error
|
|
||||||
|
|
||||||
fm := fontManager.Get()
|
|
||||||
sm := shaderManager.Get()
|
|
||||||
|
|
||||||
shader, ok := sm.Get("text")
|
|
||||||
if !ok {
|
|
||||||
return StringItem{}, fmt.Errorf("failed to fetch shader")
|
|
||||||
}
|
|
||||||
|
|
||||||
id, width, height, err := fm.CreateStringTexture(font.Name, content, shader)
|
|
||||||
if err != nil {
|
|
||||||
return StringItem{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
newStringItem := StringItem{
|
|
||||||
PageItem: PageItem{
|
|
||||||
ID: id,
|
|
||||||
W: width,
|
|
||||||
H: height,
|
|
||||||
Color: color,
|
|
||||||
},
|
|
||||||
content: content,
|
|
||||||
}
|
|
||||||
|
|
||||||
return newStringItem, err
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,793 +0,0 @@
|
|||||||
package page
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"math"
|
|
||||||
"sort"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"unicode"
|
|
||||||
|
|
||||||
"github.com/go-gl/gl/v3.3-core/gl"
|
|
||||||
"github.com/go-gl/mathgl/mgl32"
|
|
||||||
"github.com/veandco/go-sdl2/sdl"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/colors"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/db"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/fontManager"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/logger"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/sync"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Scrollbar Visuals
|
|
||||||
var (
|
|
||||||
colorScrollTrack = sdl.Color{R: 255, G: 255, B: 255, A: 30}
|
|
||||||
colorScrollThumb = sdl.Color{R: 255, G: 255, B: 255, A: 100}
|
|
||||||
)
|
|
||||||
|
|
||||||
type FlatItem struct {
|
|
||||||
Label StringItem
|
|
||||||
ID string
|
|
||||||
IsCategory bool
|
|
||||||
IsSubCategory bool
|
|
||||||
Depth int
|
|
||||||
Expanded bool
|
|
||||||
SearchText string // lowercase name + content, pre-built at load time
|
|
||||||
}
|
|
||||||
|
|
||||||
type ProclamationOptions struct {
|
|
||||||
Base
|
|
||||||
font *fontManager.FontEntry
|
|
||||||
textShader *shaderManager.Shader
|
|
||||||
solidShader *shaderManager.Shader
|
|
||||||
title StringItem
|
|
||||||
prompt StringItem
|
|
||||||
|
|
||||||
// Browse Tree State
|
|
||||||
allItems []*FlatItem
|
|
||||||
activeMap map[string]bool
|
|
||||||
highlightIndex int
|
|
||||||
|
|
||||||
// Browse Scrolling State
|
|
||||||
scrollOffset int
|
|
||||||
maxVisible int
|
|
||||||
|
|
||||||
// Scrollbar Dimens
|
|
||||||
scrollTrackWidth int32
|
|
||||||
scrollTrackX int32
|
|
||||||
scrollTrackHeight int32
|
|
||||||
scrollTrackY int32
|
|
||||||
|
|
||||||
indentSize float32
|
|
||||||
verticalMargin int32
|
|
||||||
|
|
||||||
// Search State
|
|
||||||
isSearchActive bool
|
|
||||||
searchQuery string
|
|
||||||
searchInput StringItem
|
|
||||||
searchItems []*FlatItem // filtered view — pointers into allProclamations
|
|
||||||
searchHighlight int
|
|
||||||
searchScrollOff int
|
|
||||||
|
|
||||||
// Full flat proclamation pool (pointers shared with allItems leaves)
|
|
||||||
allProclamations []*FlatItem
|
|
||||||
|
|
||||||
// Spinning triangle cursor — same shape/animation as AudioOptions —
|
|
||||||
// that points at whichever row is currently highlighted.
|
|
||||||
triVAO uint32
|
|
||||||
triVBO uint32
|
|
||||||
triX float32
|
|
||||||
triCenterY float32
|
|
||||||
triVisible bool
|
|
||||||
spinElapsed float32
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ProclamationOptions) Init(ctx *PageCtx) error {
|
|
||||||
p.verticalMargin = 25
|
|
||||||
p.indentSize = 40.0
|
|
||||||
p.activeMap = make(map[string]bool)
|
|
||||||
p.allItems = []*FlatItem{}
|
|
||||||
p.scrollOffset = 0
|
|
||||||
p.isSearchActive = false
|
|
||||||
p.scrollTrackWidth = 30
|
|
||||||
p.scrollTrackX = p.ScreenWidth - p.scrollTrackWidth
|
|
||||||
var err error
|
|
||||||
|
|
||||||
// Syncronize proclamations first
|
|
||||||
var allProcs []db.Proclamation
|
|
||||||
allProcs, err = sync.SyncProclamations(ctx.DbPtr)
|
|
||||||
if err != nil {
|
|
||||||
// sync failed, fallback to database
|
|
||||||
ctx.DbPtr.Find(&allProcs)
|
|
||||||
}
|
|
||||||
|
|
||||||
sync.SyncFrequencies(ctx.DbPtr)
|
|
||||||
|
|
||||||
fm := fontManager.Get()
|
|
||||||
font, _ := fm.GetFont("SpaceMonoNerdFont-Regular")
|
|
||||||
p.font = font
|
|
||||||
|
|
||||||
sm := shaderManager.Get()
|
|
||||||
p.textShader, _ = sm.Get("text")
|
|
||||||
p.solidShader, _ = sm.Get("solid_color")
|
|
||||||
|
|
||||||
p.Base.Init(ctx)
|
|
||||||
|
|
||||||
config, err := db.GetConfig(p.Context.DbPtr)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
itemScale := float32(32) / float32(config.StandardFontSize)
|
|
||||||
p.title, _ = NewStringItem("Proclamation Browser", p.font, colors.White)
|
|
||||||
p.title.Scale(float32(64) / float32(config.StandardFontSize))
|
|
||||||
|
|
||||||
p.prompt, _ = NewStringItem("Type to Search Space to Select Enter to Save Escape to Exit", p.font, colors.White)
|
|
||||||
p.prompt.Scale(itemScale * 0.8)
|
|
||||||
|
|
||||||
// Calculation for visible area
|
|
||||||
areaHeight := float32(p.ScreenHeight) - 250
|
|
||||||
p.maxVisible = int(areaHeight / 47)
|
|
||||||
|
|
||||||
// Load current selections
|
|
||||||
var selected []db.Proclamation
|
|
||||||
ctx.DbPtr.Where("selected = ?", true).Where("id != ?", defaultProclamationID).Find(&selected)
|
|
||||||
for _, s := range selected {
|
|
||||||
if s.ID == defaultProclamationID {
|
|
||||||
logger.Debug("Found default proclamation in selected list: %s", s.ID)
|
|
||||||
}
|
|
||||||
p.activeMap[s.ID] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
procContentMap := make(map[string]string, len(allProcs))
|
|
||||||
for _, pr := range allProcs {
|
|
||||||
if pr.ID == defaultProclamationID {
|
|
||||||
logger.Debug("Found default proclamation in allProcs list: %s", pr.ID)
|
|
||||||
}
|
|
||||||
procContentMap[pr.ID] = pr.Content
|
|
||||||
}
|
|
||||||
p.allProclamations = []*FlatItem{}
|
|
||||||
|
|
||||||
for _, pr := range allProcs {
|
|
||||||
fi := p.createFlatItem(pr.Name, pr.ID, false, 0)
|
|
||||||
fi.SearchText = strings.ToLower(pr.Name)
|
|
||||||
p.allProclamations = append(p.allProclamations, fi)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load Root Categories
|
|
||||||
var parentCategories []db.ProclamationCategory
|
|
||||||
p.Context.DbPtr.Where("parent_id IS NULL OR parent_id = 0").Find(&parentCategories)
|
|
||||||
for _, c := range parentCategories {
|
|
||||||
p.allItems = append(p.allItems, p.createFlatItem(c.Name, fmt.Sprint(c.ID), true, 0))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Triangle cursor VAO/VBO — stride is vec2 only, no tex coords.
|
|
||||||
// Mirrors the spinning cursor used in AudioOptions.
|
|
||||||
triVerts := []float32{
|
|
||||||
0.5, 0.0,
|
|
||||||
-0.5, 0.5,
|
|
||||||
-0.5, -0.5,
|
|
||||||
}
|
|
||||||
gl.GenVertexArrays(1, &p.triVAO)
|
|
||||||
gl.GenBuffers(1, &p.triVBO)
|
|
||||||
gl.BindVertexArray(p.triVAO)
|
|
||||||
gl.BindBuffer(gl.ARRAY_BUFFER, p.triVBO)
|
|
||||||
gl.BufferData(gl.ARRAY_BUFFER, len(triVerts)*4, gl.Ptr(triVerts), gl.STATIC_DRAW)
|
|
||||||
gl.EnableVertexAttribArray(0)
|
|
||||||
gl.VertexAttribPointerWithOffset(0, 2, gl.FLOAT, false, 2*4, 0)
|
|
||||||
gl.BindBuffer(gl.ARRAY_BUFFER, 0)
|
|
||||||
gl.BindVertexArray(0)
|
|
||||||
|
|
||||||
p.layout()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ProclamationOptions) createFlatItem(name, id string, isCat bool, depth int) *FlatItem {
|
|
||||||
text := name
|
|
||||||
if isCat && depth == 0 {
|
|
||||||
text = "[+] " + name
|
|
||||||
}
|
|
||||||
config, err := db.GetConfig(p.Context.DbPtr)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
itemScale := float32(32) / float32(config.StandardFontSize)
|
|
||||||
lbl, _ := NewStringItem(text, p.font, colors.White)
|
|
||||||
lbl.Scale(itemScale)
|
|
||||||
return &FlatItem{
|
|
||||||
Label: lbl,
|
|
||||||
ID: id,
|
|
||||||
IsCategory: isCat,
|
|
||||||
IsSubCategory: isCat && depth > 0,
|
|
||||||
Depth: depth,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ProclamationOptions) layout() {
|
|
||||||
p.title.X = p.ScreenCenterX - p.title.W/2
|
|
||||||
p.title.Y = 40
|
|
||||||
p.prompt.X = p.ScreenCenterX - p.prompt.W/2
|
|
||||||
p.prompt.Y = p.ScreenHeight - 60
|
|
||||||
|
|
||||||
startY := float32(p.ScreenHeight - 150)
|
|
||||||
p.scrollTrackY = 130
|
|
||||||
p.scrollTrackHeight = int32(startY) - 130 + 50
|
|
||||||
|
|
||||||
// Reserve room on the left of the list for the spinning cursor
|
|
||||||
// (triangleSize wide, cursorGap away from the text it points at),
|
|
||||||
// ahead of the original left margin used for list rows.
|
|
||||||
cursorGap := int32(12)
|
|
||||||
listBaseX := int32(60) + int32(triangleSize) + cursorGap
|
|
||||||
|
|
||||||
if p.isSearchActive {
|
|
||||||
// Position search bar label
|
|
||||||
p.searchInput.X = 60
|
|
||||||
p.searchInput.Y = 100
|
|
||||||
|
|
||||||
// Hide all browse items
|
|
||||||
for _, item := range p.allItems {
|
|
||||||
item.Label.X = -5000
|
|
||||||
}
|
|
||||||
|
|
||||||
// Position filtered search results
|
|
||||||
for i, item := range p.searchItems {
|
|
||||||
item.Label.X = -5000
|
|
||||||
if i >= p.searchScrollOff && i < p.searchScrollOff+p.maxVisible {
|
|
||||||
visibleIdx := i - p.searchScrollOff
|
|
||||||
item.Label.X = listBaseX
|
|
||||||
item.Label.Y = int32(startY - float32(visibleIdx)*47.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Point the cursor at the highlighted search result, if it's
|
|
||||||
// currently scrolled into view.
|
|
||||||
p.triVisible = false
|
|
||||||
if len(p.searchItems) > 0 && p.searchHighlight >= 0 && p.searchHighlight < len(p.searchItems) &&
|
|
||||||
p.searchHighlight >= p.searchScrollOff && p.searchHighlight < p.searchScrollOff+p.maxVisible {
|
|
||||||
target := p.searchItems[p.searchHighlight].Label
|
|
||||||
p.triX = float32(target.X) - triangleSize - float32(cursorGap)
|
|
||||||
p.triCenterY = float32(target.Y) + float32(target.H)/2
|
|
||||||
p.triVisible = true
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Browse mode
|
|
||||||
for i, item := range p.allItems {
|
|
||||||
item.Label.X = -5000
|
|
||||||
if i >= p.scrollOffset && i < p.scrollOffset+p.maxVisible {
|
|
||||||
visibleIdx := i - p.scrollOffset
|
|
||||||
item.Label.X = listBaseX + int32(float32(item.Depth)*p.indentSize)
|
|
||||||
item.Label.Y = int32(startY - float32(visibleIdx)*47.0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Point the cursor at the highlighted browse row, if it's currently
|
|
||||||
// scrolled into view. Since target.X already includes the row's depth
|
|
||||||
// indentation, the cursor shifts in and out with it automatically —
|
|
||||||
// the gap between cursor and text stays constant regardless of depth.
|
|
||||||
p.triVisible = false
|
|
||||||
if len(p.allItems) > 0 && p.highlightIndex >= 0 && p.highlightIndex < len(p.allItems) &&
|
|
||||||
p.highlightIndex >= p.scrollOffset && p.highlightIndex < p.scrollOffset+p.maxVisible {
|
|
||||||
target := p.allItems[p.highlightIndex].Label
|
|
||||||
p.triX = float32(target.X) - triangleSize - float32(cursorGap)
|
|
||||||
p.triCenterY = float32(target.Y) + float32(target.H)/2
|
|
||||||
p.triVisible = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ProclamationOptions) toggleExpansion(index int) {
|
|
||||||
item := p.allItems[index]
|
|
||||||
if !item.IsCategory || item.IsSubCategory {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
pureName := item.Label.content[4:]
|
|
||||||
gl.DeleteTextures(1, &item.Label.ID)
|
|
||||||
|
|
||||||
config, err := db.GetConfig(p.Context.DbPtr)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
itemScale := float32(32) / float32(config.StandardFontSize)
|
|
||||||
if item.Expanded {
|
|
||||||
p.collapse(index)
|
|
||||||
item.Label, _ = NewStringItem("[+] "+pureName, p.font, colors.White)
|
|
||||||
} else {
|
|
||||||
p.expand(index)
|
|
||||||
item.Label, _ = NewStringItem("[-] "+pureName, p.font, colors.White)
|
|
||||||
}
|
|
||||||
item.Label.Scale(itemScale)
|
|
||||||
item.Expanded = !item.Expanded
|
|
||||||
p.layout()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ProclamationOptions) expand(index int) {
|
|
||||||
parent := p.allItems[index]
|
|
||||||
parentID, _ := strconv.Atoi(parent.ID)
|
|
||||||
var newItems []*FlatItem
|
|
||||||
|
|
||||||
// Load subcategories
|
|
||||||
var cats []db.ProclamationCategory
|
|
||||||
p.Context.DbPtr.Where("parent_id = ?", parentID).Find(&cats)
|
|
||||||
for _, c := range cats {
|
|
||||||
fi := p.createFlatItem(c.Name, fmt.Sprint(c.ID), true, parent.Depth+1)
|
|
||||||
fi.Expanded = true
|
|
||||||
newItems = append(newItems, fi)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load proclamations for this category
|
|
||||||
var procs []db.Proclamation
|
|
||||||
p.Context.DbPtr.Where("category_id = ?", parentID).Where("ID != ?", defaultProclamationID).Find(&procs)
|
|
||||||
|
|
||||||
// Sort proclamations alphabetically by name
|
|
||||||
sort.Slice(procs, func(i, j int) bool {
|
|
||||||
return strings.ToLower(procs[i].Name) < strings.ToLower(procs[j].Name)
|
|
||||||
})
|
|
||||||
|
|
||||||
for _, pr := range procs {
|
|
||||||
var fi *FlatItem
|
|
||||||
// Reuse existing proclamation from allProclamations if available
|
|
||||||
for _, existing := range p.allProclamations {
|
|
||||||
if existing.ID == pr.ID {
|
|
||||||
fi = existing
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if fi == nil {
|
|
||||||
// Safety fallback
|
|
||||||
fi = p.createFlatItem(pr.Name, pr.ID, false, parent.Depth+1)
|
|
||||||
fi.SearchText = strings.ToLower(pr.Name + " " + pr.Content)
|
|
||||||
p.allProclamations = append(p.allProclamations, fi)
|
|
||||||
}
|
|
||||||
fi.Depth = parent.Depth + 1
|
|
||||||
newItems = append(newItems, fi)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insert new items after parent
|
|
||||||
tail := append([]*FlatItem{}, p.allItems[index+1:]...)
|
|
||||||
p.allItems = append(p.allItems[:index+1], append(newItems, tail...)...)
|
|
||||||
|
|
||||||
// Recurse into subcategories
|
|
||||||
i := index + 1
|
|
||||||
for i < len(p.allItems) && p.allItems[i].Depth > parent.Depth {
|
|
||||||
if p.allItems[i].IsCategory && p.allItems[i].Depth > 0 {
|
|
||||||
p.expand(i)
|
|
||||||
}
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ProclamationOptions) collapse(index int) {
|
|
||||||
depth := p.allItems[index].Depth
|
|
||||||
i := index + 1
|
|
||||||
for i < len(p.allItems) && p.allItems[i].Depth > depth {
|
|
||||||
// Do NOT delete textures here — leaves are shared with allProclamations.
|
|
||||||
// Only delete textures for category items that aren't in the proclamation pool.
|
|
||||||
if p.allItems[i].IsCategory {
|
|
||||||
gl.DeleteTextures(1, &p.allItems[i].Label.ID)
|
|
||||||
}
|
|
||||||
i++
|
|
||||||
}
|
|
||||||
p.allItems = append(p.allItems[:index+1], p.allItems[i:]...)
|
|
||||||
}
|
|
||||||
|
|
||||||
// matchesWordPrefix returns true if any word in text starts with query (case-insensitive).
|
|
||||||
func matchesWordPrefix(text, query string) bool {
|
|
||||||
query = strings.ToLower(query)
|
|
||||||
text = strings.ToLower(text)
|
|
||||||
|
|
||||||
// Split text into words (non-alphanumeric boundaries)
|
|
||||||
words := strings.FieldsFunc(text, func(r rune) bool {
|
|
||||||
return !unicode.IsLetter(r) && !unicode.IsNumber(r)
|
|
||||||
})
|
|
||||||
|
|
||||||
for _, word := range words {
|
|
||||||
if strings.HasPrefix(word, query) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ProclamationOptions) buildSearchList() {
|
|
||||||
p.searchItems = nil
|
|
||||||
if p.searchQuery == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, item := range p.allProclamations {
|
|
||||||
if matchesWordPrefix(item.SearchText, p.searchQuery) {
|
|
||||||
p.searchItems = append(p.searchItems, item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if p.searchHighlight >= len(p.searchItems) {
|
|
||||||
p.searchHighlight = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ProclamationOptions) rebuildSearchLabel() {
|
|
||||||
if p.searchInput.ID != 0 {
|
|
||||||
gl.DeleteTextures(1, &p.searchInput.ID)
|
|
||||||
p.searchInput.ID = 0
|
|
||||||
}
|
|
||||||
if p.searchQuery == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
config, err := db.GetConfig(p.Context.DbPtr)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
itemScale := float32(32) / float32(config.StandardFontSize)
|
|
||||||
p.searchInput, _ = NewStringItem("Search: "+p.searchQuery, p.font, colors.Yellow)
|
|
||||||
p.searchInput.Scale(itemScale * 1.1)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ProclamationOptions) enterSearchMode(firstRune rune) {
|
|
||||||
p.searchQuery = string(firstRune)
|
|
||||||
p.isSearchActive = true
|
|
||||||
p.searchScrollOff = 0
|
|
||||||
p.searchHighlight = 0
|
|
||||||
p.buildSearchList()
|
|
||||||
p.rebuildSearchLabel()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ProclamationOptions) exitSearchMode() {
|
|
||||||
p.searchQuery = ""
|
|
||||||
p.isSearchActive = false
|
|
||||||
p.searchItems = nil
|
|
||||||
p.searchHighlight = 0
|
|
||||||
p.searchScrollOff = 0
|
|
||||||
if p.searchInput.ID != 0 {
|
|
||||||
gl.DeleteTextures(1, &p.searchInput.ID)
|
|
||||||
p.searchInput.ID = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ProclamationOptions) HandleEvent(event *sdl.Event) error {
|
|
||||||
switch e := (*event).(type) {
|
|
||||||
case *sdl.KeyboardEvent:
|
|
||||||
if e.Type != sdl.KEYDOWN {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
sym := e.Keysym.Sym
|
|
||||||
|
|
||||||
// --- Backspace: always handled specially ---
|
|
||||||
if sym == sdl.K_BACKSPACE {
|
|
||||||
if p.isSearchActive {
|
|
||||||
if len(p.searchQuery) > 0 {
|
|
||||||
p.searchQuery = p.searchQuery[:len(p.searchQuery)-1]
|
|
||||||
}
|
|
||||||
if p.searchQuery == "" {
|
|
||||||
p.exitSearchMode()
|
|
||||||
} else {
|
|
||||||
p.buildSearchList()
|
|
||||||
p.rebuildSearchLabel()
|
|
||||||
}
|
|
||||||
p.layout()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// Browse mode backspace — navigate back
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Escape ---
|
|
||||||
if sym == sdl.K_ESCAPE {
|
|
||||||
if p.isSearchActive {
|
|
||||||
p.exitSearchMode()
|
|
||||||
p.layout()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
p.Context.NavigateTo(SETTINGS)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Search mode input handling ---
|
|
||||||
if p.isSearchActive {
|
|
||||||
switch sym {
|
|
||||||
case sdl.K_UP:
|
|
||||||
if p.searchHighlight > 0 {
|
|
||||||
p.searchHighlight--
|
|
||||||
if p.searchHighlight < p.searchScrollOff {
|
|
||||||
p.searchScrollOff--
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case sdl.K_DOWN:
|
|
||||||
if p.searchHighlight < len(p.searchItems)-1 {
|
|
||||||
p.searchHighlight++
|
|
||||||
if p.searchHighlight >= p.searchScrollOff+p.maxVisible {
|
|
||||||
p.searchScrollOff++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case sdl.K_PAGEUP:
|
|
||||||
p.searchHighlight = p.searchScrollOff
|
|
||||||
p.searchScrollOff -= p.maxVisible - 1
|
|
||||||
if p.searchScrollOff < 0 {
|
|
||||||
p.searchScrollOff = 0
|
|
||||||
p.searchHighlight = 0
|
|
||||||
}
|
|
||||||
case sdl.K_PAGEDOWN:
|
|
||||||
bottom := p.searchScrollOff + p.maxVisible - 1
|
|
||||||
if bottom >= len(p.searchItems) {
|
|
||||||
bottom = len(p.searchItems) - 1
|
|
||||||
}
|
|
||||||
p.searchScrollOff = bottom
|
|
||||||
p.searchHighlight = bottom
|
|
||||||
case sdl.K_SPACE:
|
|
||||||
if len(p.searchItems) > 0 {
|
|
||||||
item := p.searchItems[p.searchHighlight]
|
|
||||||
p.activeMap[item.ID] = !p.activeMap[item.ID]
|
|
||||||
}
|
|
||||||
p.acceptChanges()
|
|
||||||
case sdl.K_RETURN:
|
|
||||||
if len(p.searchItems) > 0 {
|
|
||||||
item := p.searchItems[p.searchHighlight]
|
|
||||||
p.activeMap[item.ID] = !p.activeMap[item.ID]
|
|
||||||
}
|
|
||||||
p.acceptChanges()
|
|
||||||
default:
|
|
||||||
// Append printable characters to search query
|
|
||||||
r := rune(sym)
|
|
||||||
if r >= 33 && r <= 126 {
|
|
||||||
if e.Keysym.Mod&sdl.KMOD_SHIFT != 0 {
|
|
||||||
r = unicode.ToUpper(r)
|
|
||||||
}
|
|
||||||
p.searchQuery += string(r)
|
|
||||||
p.buildSearchList()
|
|
||||||
p.rebuildSearchLabel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
p.layout()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Browse mode ---
|
|
||||||
switch sym {
|
|
||||||
case sdl.K_UP:
|
|
||||||
if p.highlightIndex > 0 {
|
|
||||||
p.highlightIndex--
|
|
||||||
if p.highlightIndex < p.scrollOffset {
|
|
||||||
p.scrollOffset--
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case sdl.K_DOWN:
|
|
||||||
if p.highlightIndex < len(p.allItems)-1 {
|
|
||||||
p.highlightIndex++
|
|
||||||
if p.highlightIndex >= p.scrollOffset+p.maxVisible {
|
|
||||||
p.scrollOffset++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case sdl.K_PAGEUP:
|
|
||||||
p.highlightIndex = p.scrollOffset
|
|
||||||
p.scrollOffset -= p.maxVisible - 1
|
|
||||||
if p.scrollOffset < 0 {
|
|
||||||
p.scrollOffset = 0
|
|
||||||
p.highlightIndex = 0
|
|
||||||
}
|
|
||||||
case sdl.K_PAGEDOWN:
|
|
||||||
bottomIdx := p.scrollOffset + p.maxVisible - 1
|
|
||||||
if bottomIdx >= len(p.allItems) {
|
|
||||||
bottomIdx = len(p.allItems) - 1
|
|
||||||
}
|
|
||||||
p.scrollOffset = bottomIdx
|
|
||||||
p.highlightIndex = bottomIdx
|
|
||||||
case sdl.K_RETURN:
|
|
||||||
item := p.allItems[p.highlightIndex]
|
|
||||||
if !item.IsCategory { // If the item is NOT a Category
|
|
||||||
p.activeMap[item.ID] = !p.activeMap[item.ID] // Toggle it from the 'selected' list
|
|
||||||
p.acceptChanges()
|
|
||||||
} else if !item.IsSubCategory {
|
|
||||||
p.toggleExpansion(p.highlightIndex)
|
|
||||||
}
|
|
||||||
case sdl.K_SPACE:
|
|
||||||
item := p.allItems[p.highlightIndex]
|
|
||||||
if !item.IsCategory { // If the item is NOT a Category
|
|
||||||
p.activeMap[item.ID] = !p.activeMap[item.ID] // Toggle it from the 'selected' list
|
|
||||||
p.acceptChanges()
|
|
||||||
} else if !item.IsSubCategory {
|
|
||||||
p.toggleExpansion(p.highlightIndex)
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
// Any printable character enters search mode
|
|
||||||
r := rune(sym)
|
|
||||||
if r >= 33 && r <= 126 {
|
|
||||||
if e.Keysym.Mod&sdl.KMOD_SHIFT != 0 {
|
|
||||||
r = unicode.ToUpper(r)
|
|
||||||
}
|
|
||||||
p.enterSearchMode(r)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
p.layout()
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ProclamationOptions) acceptChanges() {
|
|
||||||
p.Context.DbPtr.Transaction(func(tx *gorm.DB) error {
|
|
||||||
tx.Model(&db.Proclamation{}).Where("1 = 1").Update("selected", false)
|
|
||||||
var ids []string
|
|
||||||
for id, active := range p.activeMap {
|
|
||||||
if active {
|
|
||||||
ids = append(ids, id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(ids) > 0 {
|
|
||||||
tx.Model(&db.Proclamation{}).Where("id IN ?", ids).Update("selected", true)
|
|
||||||
logger.Debug("Saving selected state for %s\n", ids)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update advances the spinning-cursor animation. The cursor's *position* is
|
|
||||||
// recomputed in layout() whenever the highlighted row changes; this only
|
|
||||||
// drives the spin itself, frame to frame.
|
|
||||||
func (p *ProclamationOptions) Update(dt float32) error {
|
|
||||||
p.spinElapsed += dt
|
|
||||||
if p.spinElapsed >= 1.0 {
|
|
||||||
p.spinElapsed -= 1.0
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ProclamationOptions) Render() error {
|
|
||||||
p.RenderTexture(p.textShader, p.title.ID, p.title.Position(), p.title.RawDimensions(), p.title.Color)
|
|
||||||
p.RenderTexture(p.textShader, p.prompt.ID, p.prompt.Position(), p.prompt.RawDimensions(), p.prompt.Color)
|
|
||||||
|
|
||||||
if p.isSearchActive {
|
|
||||||
// Search bar label
|
|
||||||
if p.searchInput.ID != 0 {
|
|
||||||
p.RenderTexture(p.textShader, p.searchInput.ID, p.searchInput.Position(), p.searchInput.RawDimensions(), p.searchInput.Color)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(p.searchItems) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := p.searchScrollOff; i < p.searchScrollOff+p.maxVisible && i < len(p.searchItems); i++ {
|
|
||||||
item := p.searchItems[i]
|
|
||||||
if p.activeMap[item.ID] {
|
|
||||||
item.Label.Color = colors.Orange
|
|
||||||
} else {
|
|
||||||
item.Label.Color = colors.White
|
|
||||||
}
|
|
||||||
p.RenderTexture(p.textShader, item.Label.ID, item.Label.Position(), item.Label.RawDimensions(), item.Label.Color)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Spinning triangle cursor — always on top, points at the highlighted result
|
|
||||||
p.renderCursor()
|
|
||||||
|
|
||||||
p.renderSearchScrollbar()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Browse mode
|
|
||||||
if len(p.allItems) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := p.scrollOffset; i < p.scrollOffset+p.maxVisible && i < len(p.allItems); i++ {
|
|
||||||
item := p.allItems[i]
|
|
||||||
if item.IsCategory {
|
|
||||||
item.Label.Color = colors.DodgerBlue
|
|
||||||
} else if p.activeMap[item.ID] {
|
|
||||||
item.Label.Color = colors.Orange
|
|
||||||
} else {
|
|
||||||
item.Label.Color = colors.White
|
|
||||||
}
|
|
||||||
p.RenderTexture(p.textShader, item.Label.ID, item.Label.Position(), item.Label.RawDimensions(), item.Label.Color)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Spinning triangle cursor — always on top, points at the highlighted row
|
|
||||||
p.renderCursor()
|
|
||||||
|
|
||||||
p.renderScrollbar()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// renderCursor draws the spinning triangle cursor pointing at whichever row
|
|
||||||
// is currently highlighted, in either browse or search mode. Identical
|
|
||||||
// shape/animation to the cursor in AudioOptions.
|
|
||||||
func (p *ProclamationOptions) renderCursor() error {
|
|
||||||
if !p.triVisible {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
scaleY := float32(math.Cos(2 * math.Pi * float64(p.spinElapsed)))
|
|
||||||
|
|
||||||
model := mgl32.Ident4()
|
|
||||||
model = model.Mul4(mgl32.Translate3D(p.triX+triangleSize/2, p.triCenterY, 0))
|
|
||||||
model = model.Mul4(mgl32.Scale3D(triangleSize, triangleSize*scaleY, 1))
|
|
||||||
mvp := p.Projection.Mul4(model)
|
|
||||||
|
|
||||||
gl.UseProgram(p.solidShader.ProgramID)
|
|
||||||
gl.UniformMatrix4fv(p.solidShader.LocMVPMatrix, 1, false, &mvp[0])
|
|
||||||
gl.Uniform4f(
|
|
||||||
p.solidShader.LocColor,
|
|
||||||
float32(colors.White.R)/255.0,
|
|
||||||
float32(colors.White.G)/255.0,
|
|
||||||
float32(colors.White.B)/255.0,
|
|
||||||
float32(colors.White.A)/255.0,
|
|
||||||
)
|
|
||||||
|
|
||||||
gl.BindVertexArray(p.triVAO)
|
|
||||||
gl.DrawArrays(gl.TRIANGLES, 0, 3)
|
|
||||||
gl.BindVertexArray(0)
|
|
||||||
gl.UseProgram(0)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ProclamationOptions) renderScrollbar() {
|
|
||||||
total := len(p.allItems)
|
|
||||||
if total <= p.maxVisible {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
p.RenderSolidColorQuad(p.solidShader,
|
|
||||||
mgl32.Vec2{float32(p.scrollTrackX), float32(p.scrollTrackY)},
|
|
||||||
mgl32.Vec2{float32(p.scrollTrackWidth), float32(p.scrollTrackHeight)},
|
|
||||||
colorScrollTrack)
|
|
||||||
|
|
||||||
visibleRatio := float32(p.maxVisible) / float32(total)
|
|
||||||
scrollRatio := float32(p.scrollOffset) / float32(total)
|
|
||||||
thumbHeight := float32(p.scrollTrackHeight) * visibleRatio
|
|
||||||
if thumbHeight < 20 {
|
|
||||||
thumbHeight = 20
|
|
||||||
}
|
|
||||||
thumbY := float32(p.scrollTrackY) + float32(p.scrollTrackHeight)*scrollRatio
|
|
||||||
|
|
||||||
p.RenderSolidColorQuad(p.solidShader,
|
|
||||||
mgl32.Vec2{float32(p.scrollTrackX + 2), thumbY},
|
|
||||||
mgl32.Vec2{float32(p.scrollTrackWidth - 4), thumbHeight},
|
|
||||||
colorScrollThumb)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ProclamationOptions) renderSearchScrollbar() {
|
|
||||||
total := len(p.searchItems)
|
|
||||||
if total <= p.maxVisible {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
p.RenderSolidColorQuad(p.solidShader,
|
|
||||||
mgl32.Vec2{float32(p.scrollTrackX), float32(p.scrollTrackY)},
|
|
||||||
mgl32.Vec2{float32(p.scrollTrackWidth), float32(p.scrollTrackHeight)},
|
|
||||||
colorScrollTrack)
|
|
||||||
|
|
||||||
visibleRatio := float32(p.maxVisible) / float32(total)
|
|
||||||
scrollRatio := float32(p.searchScrollOff) / float32(total)
|
|
||||||
thumbHeight := float32(p.scrollTrackHeight) * visibleRatio
|
|
||||||
if thumbHeight < 20 {
|
|
||||||
thumbHeight = 20
|
|
||||||
}
|
|
||||||
thumbY := float32(p.scrollTrackY) + float32(p.scrollTrackHeight)*scrollRatio
|
|
||||||
|
|
||||||
p.RenderSolidColorQuad(p.solidShader,
|
|
||||||
mgl32.Vec2{float32(p.scrollTrackX + 2), thumbY},
|
|
||||||
mgl32.Vec2{float32(p.scrollTrackWidth - 4), thumbHeight},
|
|
||||||
colorScrollThumb)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ProclamationOptions) Destroy() error {
|
|
||||||
gl.DeleteTextures(1, &p.title.ID)
|
|
||||||
gl.DeleteTextures(1, &p.prompt.ID)
|
|
||||||
if p.searchInput.ID != 0 {
|
|
||||||
gl.DeleteTextures(1, &p.searchInput.ID)
|
|
||||||
}
|
|
||||||
// Delete browse category textures
|
|
||||||
for _, item := range p.allItems {
|
|
||||||
if item.IsCategory {
|
|
||||||
gl.DeleteTextures(1, &item.Label.ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Delete proclamation textures via the pool (single source of truth)
|
|
||||||
for _, item := range p.allProclamations {
|
|
||||||
gl.DeleteTextures(1, &item.Label.ID)
|
|
||||||
}
|
|
||||||
gl.DeleteBuffers(1, &p.triVBO)
|
|
||||||
gl.DeleteVertexArrays(1, &p.triVAO)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
package page
|
|
||||||
|
|
||||||
import (
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/download"
|
|
||||||
)
|
|
||||||
|
|
||||||
type pageType int
|
|
||||||
|
|
||||||
const (
|
|
||||||
HOME pageType = iota
|
|
||||||
SCROLLER
|
|
||||||
SETTINGS
|
|
||||||
WIFI
|
|
||||||
AUDIO
|
|
||||||
PROCLAMATIONS
|
|
||||||
LICENSE_KEY
|
|
||||||
EMAIL_ADDRESS
|
|
||||||
LOGS
|
|
||||||
MESSAGE
|
|
||||||
DOWNLOADING_ASSETS
|
|
||||||
)
|
|
||||||
|
|
||||||
type PageCtx struct {
|
|
||||||
DbPtr *gorm.DB
|
|
||||||
W, H int32
|
|
||||||
MessageChannel chan download.DownloadStatus
|
|
||||||
DownloadDone chan struct{} // closed when downloads finish (success or failure)
|
|
||||||
NavigateTo func(pageType)
|
|
||||||
Msg string // Message to be displayed by MessagePage
|
|
||||||
Inst string // Instructions to be displayed on MessagePage
|
|
||||||
}
|
|
||||||
|
|
||||||
type Router struct {
|
|
||||||
ActivePage Page
|
|
||||||
Context PageCtx
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewRouter(pgCtx PageCtx) *Router {
|
|
||||||
r := &Router{
|
|
||||||
Context: pgCtx,
|
|
||||||
}
|
|
||||||
r.Context.NavigateTo = r.NavigateTo
|
|
||||||
r.NavigateTo(HOME)
|
|
||||||
return r
|
|
||||||
}
|
|
||||||
|
|
||||||
func (r *Router) NavigateTo(p pageType) {
|
|
||||||
if r.ActivePage != nil {
|
|
||||||
r.ActivePage.Destroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
switch p {
|
|
||||||
case HOME:
|
|
||||||
r.ActivePage = &Home{}
|
|
||||||
case SCROLLER:
|
|
||||||
r.ActivePage = &ScrollerPage{}
|
|
||||||
case SETTINGS:
|
|
||||||
r.ActivePage = &Settings{}
|
|
||||||
case WIFI:
|
|
||||||
r.ActivePage = &WiFiSetupPage{}
|
|
||||||
case AUDIO:
|
|
||||||
r.ActivePage = &AudioOptions{}
|
|
||||||
case PROCLAMATIONS:
|
|
||||||
r.ActivePage = &ProclamationOptions{}
|
|
||||||
case LICENSE_KEY:
|
|
||||||
r.ActivePage = &LicenseKeyPage{}
|
|
||||||
case EMAIL_ADDRESS:
|
|
||||||
r.ActivePage = &EmailAddressPage{}
|
|
||||||
case LOGS:
|
|
||||||
r.ActivePage = &LogViewerPage{}
|
|
||||||
case MESSAGE:
|
|
||||||
r.ActivePage = &MessagePage{}
|
|
||||||
case DOWNLOADING_ASSETS:
|
|
||||||
r.ActivePage = &DownloadingAssets{}
|
|
||||||
}
|
|
||||||
|
|
||||||
r.ActivePage.Init(&r.Context)
|
|
||||||
}
|
|
||||||
@@ -1,829 +0,0 @@
|
|||||||
package page
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"math"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/go-gl/gl/v3.3-core/gl"
|
|
||||||
"github.com/veandco/go-sdl2/sdl"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/colors"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/db"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/fontManager"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/logger"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/mixer"
|
|
||||||
colorPattern "rhemawavetech.com/rhemawave/internal/pattern/color"
|
|
||||||
linePattern "rhemawavetech.com/rhemawave/internal/pattern/line"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
defaultProclamationID string = "f3d42354-3197-45e4-9b08-be447f14386d"
|
|
||||||
proclamationPlayCount int = 20
|
|
||||||
// defaultProclamationPlayCount int = 1
|
|
||||||
)
|
|
||||||
|
|
||||||
type ScrollerPage struct {
|
|
||||||
Base
|
|
||||||
|
|
||||||
streams []proclamationStream
|
|
||||||
streamIndex int
|
|
||||||
|
|
||||||
// currentAudioFreq is the solfeggio Hz value of whatever track is
|
|
||||||
// currently playing. 0 means "no specific frequency" (either the
|
|
||||||
// legacy Selected-AudioFile track, or nothing yet).
|
|
||||||
currentAudioFreq int
|
|
||||||
|
|
||||||
IsDownloadingAudio bool
|
|
||||||
|
|
||||||
lines *LineDeque
|
|
||||||
lineHeight int32
|
|
||||||
shader *shaderManager.Shader
|
|
||||||
activePattern linePattern.LinePattern
|
|
||||||
font *fontManager.FontEntry
|
|
||||||
spaceWidth int32
|
|
||||||
lineCtx *linePattern.LineRenderContext
|
|
||||||
colorPattern *colorPattern.ColorPattern
|
|
||||||
scrollOffset float32
|
|
||||||
scrollY float32
|
|
||||||
fontScale float32
|
|
||||||
|
|
||||||
// Reuse one GL texture per unique word.
|
|
||||||
wordTextureCache map[string]StringItem
|
|
||||||
|
|
||||||
increment float32
|
|
||||||
velocity float32
|
|
||||||
|
|
||||||
// Time-based velocity control
|
|
||||||
velocityFloor float32
|
|
||||||
velocityCeiling float32
|
|
||||||
velocityStep float32
|
|
||||||
velocityDelay float32
|
|
||||||
velocityDirection int
|
|
||||||
velocityTimer float32
|
|
||||||
velocityState int
|
|
||||||
autoVelocity bool
|
|
||||||
hold float32
|
|
||||||
|
|
||||||
// Color palette settings
|
|
||||||
useColorPalettes bool
|
|
||||||
}
|
|
||||||
|
|
||||||
type ScrollerProclamation struct {
|
|
||||||
db.Proclamation
|
|
||||||
colorPattern.ColorPattern
|
|
||||||
ColorPalette []sdl.Color
|
|
||||||
}
|
|
||||||
|
|
||||||
type Line struct {
|
|
||||||
words []StringItem
|
|
||||||
width int32
|
|
||||||
color sdl.Color // Color for the entire line
|
|
||||||
}
|
|
||||||
|
|
||||||
// proclamationStream tracks one proclamation and how many full loops it should play.
|
|
||||||
type proclamationStream struct {
|
|
||||||
name string
|
|
||||||
words []string
|
|
||||||
repeat int
|
|
||||||
completed int
|
|
||||||
wordIndex int
|
|
||||||
palette []sdl.Color // Color palette for this proclamation
|
|
||||||
colorIndex int // Track which color to use next
|
|
||||||
|
|
||||||
// frequencyValue is the solfeggio Hz value that should be audible while
|
|
||||||
// this stream plays. 0 means "no specific frequency" (the default
|
|
||||||
// proclamation) — entering this stream never changes the audio.
|
|
||||||
frequencyValue int
|
|
||||||
}
|
|
||||||
|
|
||||||
// LineDeque is a double-ended queue for Line values backed by a circular buffer.
|
|
||||||
// It avoids O(n) prepend and prevents capacity leaks from head-of-slice removal.
|
|
||||||
type LineDeque struct {
|
|
||||||
buf []Line
|
|
||||||
head, tail int
|
|
||||||
count int
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewLineDeque(cap int) *LineDeque {
|
|
||||||
if cap < 4 {
|
|
||||||
cap = 4
|
|
||||||
}
|
|
||||||
return &LineDeque{buf: make([]Line, cap)}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *LineDeque) Len() int { return d.count }
|
|
||||||
|
|
||||||
func (d *LineDeque) grow() {
|
|
||||||
newCap := len(d.buf) * 2
|
|
||||||
newBuf := make([]Line, newCap)
|
|
||||||
for i := 0; i < d.count; i++ {
|
|
||||||
newBuf[i] = d.buf[(d.head+i)%len(d.buf)]
|
|
||||||
}
|
|
||||||
d.buf = newBuf
|
|
||||||
d.head = 0
|
|
||||||
d.tail = d.count
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *LineDeque) PushBack(l Line) {
|
|
||||||
if d.count == len(d.buf) {
|
|
||||||
d.grow()
|
|
||||||
}
|
|
||||||
d.buf[d.tail] = l
|
|
||||||
d.tail = (d.tail + 1) % len(d.buf)
|
|
||||||
d.count++
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *LineDeque) PushFront(l Line) {
|
|
||||||
if d.count == len(d.buf) {
|
|
||||||
d.grow()
|
|
||||||
}
|
|
||||||
d.head = (d.head - 1 + len(d.buf)) % len(d.buf)
|
|
||||||
d.buf[d.head] = l
|
|
||||||
d.count++
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *LineDeque) PopFront() Line {
|
|
||||||
l := d.buf[d.head]
|
|
||||||
d.buf[d.head] = Line{} // clear for GC
|
|
||||||
d.head = (d.head + 1) % len(d.buf)
|
|
||||||
d.count--
|
|
||||||
return l
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *LineDeque) PopBack() Line {
|
|
||||||
d.tail = (d.tail - 1 + len(d.buf)) % len(d.buf)
|
|
||||||
l := d.buf[d.tail]
|
|
||||||
d.buf[d.tail] = Line{} // clear for GC
|
|
||||||
d.count--
|
|
||||||
return l
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *LineDeque) Front() Line { return d.buf[d.head] }
|
|
||||||
|
|
||||||
func (d *LineDeque) Back() Line {
|
|
||||||
return d.buf[(d.tail-1+len(d.buf))%len(d.buf)]
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *LineDeque) Get(i int) *Line {
|
|
||||||
return &d.buf[(d.head+i)%len(d.buf)]
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *LineDeque) Set(i int, l Line) {
|
|
||||||
d.buf[(d.head+i)%len(d.buf)] = l
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *LineDeque) Clear() {
|
|
||||||
for i := 0; i < d.count; i++ {
|
|
||||||
d.buf[(d.head+i)%len(d.buf)] = Line{}
|
|
||||||
}
|
|
||||||
d.head, d.tail, d.count = 0, 0, 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ScrollerPage) Init(ctx *PageCtx) error {
|
|
||||||
p.increment = 100.0
|
|
||||||
p.velocity = 800.0
|
|
||||||
p.SetAutoVelocity(true, -4000.0, 4000.0, 100.0, 2.5, 6.0)
|
|
||||||
p.useColorPalettes = true // Default to enabled
|
|
||||||
|
|
||||||
sm := shaderManager.Get()
|
|
||||||
fm := fontManager.Get()
|
|
||||||
|
|
||||||
// Get font
|
|
||||||
font, ok := fm.GetFont("Scrambled")
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("unable to fetch font 'Scrambled'")
|
|
||||||
}
|
|
||||||
p.font = font
|
|
||||||
logger.Debug("Font name: %s", font.Name)
|
|
||||||
|
|
||||||
p.Base.Init(ctx)
|
|
||||||
|
|
||||||
// Get text shader
|
|
||||||
textShader, ok := sm.Get("text")
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("text shader not found")
|
|
||||||
}
|
|
||||||
p.shader = textShader
|
|
||||||
logger.Debug("Shader Program ID: %d", textShader.ProgramID)
|
|
||||||
logger.Debug("p.shader Program ID: %d", p.shader.ProgramID)
|
|
||||||
|
|
||||||
p.wordTextureCache = make(map[string]StringItem, 256)
|
|
||||||
|
|
||||||
// Load proclamation streams from database
|
|
||||||
if err := p.loadProclamations(); err != nil {
|
|
||||||
logger.Error("Error loading proclamations: %v", err)
|
|
||||||
p.Context.NavigateTo(PROCLAMATIONS)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get line pattern from database
|
|
||||||
config, err := db.GetConfig(p.Context.DbPtr)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
linePatternName := config.LinePattern
|
|
||||||
|
|
||||||
p.activePattern, err = linePattern.Get(linePatternName)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to load line pattern '%s': %w", linePatternName, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize color pattern
|
|
||||||
baseColors := []sdl.Color{
|
|
||||||
colors.Tan,
|
|
||||||
colors.PeachPuff,
|
|
||||||
colors.LightCyan,
|
|
||||||
colors.LightSeaGreen,
|
|
||||||
colors.MediumTurquoise,
|
|
||||||
colors.DarkCyan,
|
|
||||||
colors.MediumPurple,
|
|
||||||
}
|
|
||||||
p.colorPattern = colorPattern.NewColorPattern(baseColors)
|
|
||||||
|
|
||||||
// Font setup
|
|
||||||
p.fontScale = float32(32) / float32(config.StandardFontSize)
|
|
||||||
nLines := int(float32(p.ScreenHeight) / 32.0)
|
|
||||||
p.lines = NewLineDeque(nLines * 2)
|
|
||||||
|
|
||||||
// Calculate space width using the scale ratio.
|
|
||||||
spaceGlyph, ok := fm.GetGlyph(font.Name, ' ')
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("failed to get space glyph")
|
|
||||||
}
|
|
||||||
p.spaceWidth = int32(float32(spaceGlyph.Advance) * p.fontScale)
|
|
||||||
|
|
||||||
// Get line height using the same scale ratio.
|
|
||||||
tmpStrID, _, H, err := fm.NewCreateStringTexture(font.Name, "Mg", p.shader)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create sample texture: %w", err)
|
|
||||||
}
|
|
||||||
p.lineHeight = int32(float32(H) * p.fontScale)
|
|
||||||
gl.DeleteTextures(1, &tmpStrID)
|
|
||||||
|
|
||||||
p.lineCtx = &linePattern.LineRenderContext{
|
|
||||||
ScreenWidth: p.ScreenWidth,
|
|
||||||
Font: font,
|
|
||||||
FontScale: p.fontScale,
|
|
||||||
SpaceWidth: p.spaceWidth,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create initial lines
|
|
||||||
for i := range nLines {
|
|
||||||
if err := p.createLine(i, true); err != nil {
|
|
||||||
return fmt.Errorf("failed to create initial line %d: %w", i, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start audio for the first proclamation in the stream. Falls back to
|
|
||||||
// whatever track is marked Selected in the audio_files table if the
|
|
||||||
// first stream has no frequency (e.g. only the default proclamation is
|
|
||||||
// queued) or its matching file doesn't exist on disk yet.
|
|
||||||
started := false
|
|
||||||
if len(p.streams) > 0 {
|
|
||||||
started = p.switchAudioForFrequency(p.streams[0].frequencyValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !started {
|
|
||||||
var selectedAudio db.AudioFile
|
|
||||||
if err := p.Context.DbPtr.Where("Selected = ?", true).First(&selectedAudio).Error; err != nil {
|
|
||||||
selectedAudio = db.AudioFile{
|
|
||||||
Name: "OriginalAudio",
|
|
||||||
Path: filepath.Join(audioDir(), "OriginalAudio.wav"),
|
|
||||||
Selected: true,
|
|
||||||
Order: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Info("Playing %s", selectedAudio.Path)
|
|
||||||
if err := mixer.Play(selectedAudio.Path, true, config); err != nil {
|
|
||||||
logger.Error("Mixer returned an error: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
currentDevice := mixer.CurrentDevice()
|
|
||||||
if currentDevice == "" {
|
|
||||||
currentDevice = "default"
|
|
||||||
}
|
|
||||||
_ = currentDevice
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadProclamations loads selected proclamations as stream units (not one giant word list).
|
|
||||||
func (p *ScrollerPage) loadProclamations() error {
|
|
||||||
p.streams = p.streams[:0]
|
|
||||||
|
|
||||||
// Load all solfeggio frequencies
|
|
||||||
var frequencies []db.SolfeggioFrequency
|
|
||||||
if err := p.Context.DbPtr.Find(&frequencies).Error; err != nil {
|
|
||||||
return fmt.Errorf("failed to load solfeggio frequencies: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a map of frequency ID to its value
|
|
||||||
frequencyMap := make(map[int]int)
|
|
||||||
for _, freq := range frequencies {
|
|
||||||
frequencyMap[freq.ID] = freq.Value
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map frequency values to their color palettes
|
|
||||||
frequencyPalettes := map[int][]sdl.Color{
|
|
||||||
396: colors.Freq_396_Colors[:],
|
|
||||||
417: colors.Freq_417_Colors[:],
|
|
||||||
528: colors.Freq_528_Colors[:], // Default proclamation
|
|
||||||
639: colors.Freq_639_Colors[:],
|
|
||||||
741: colors.Freq_741_Colors[:],
|
|
||||||
852: colors.Freq_852_Colors[:],
|
|
||||||
963: colors.Freq_963_Colors[:],
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load selected proclamations
|
|
||||||
var selected []db.Proclamation
|
|
||||||
if err := p.Context.DbPtr.Where("selected = ?", true).Find(&selected).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load default proclamation
|
|
||||||
var defaultProclamation db.Proclamation
|
|
||||||
if err := p.Context.DbPtr.Where("ID = ?", defaultProclamationID).First(&defaultProclamation).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defaultWords := strings.Fields(defaultProclamation.Content)
|
|
||||||
|
|
||||||
// Build interleaved stream:
|
|
||||||
// each selected proclamation repeats N times, then default repeats once.
|
|
||||||
for _, proclamation := range selected {
|
|
||||||
// Skip default proclamation if it's in the selected list
|
|
||||||
if proclamation.ID == defaultProclamationID {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
words := strings.Fields(proclamation.Content)
|
|
||||||
if len(words) == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the frequency value for this proclamation
|
|
||||||
freqValue, ok := frequencyMap[proclamation.SolfeggioFrequencyID]
|
|
||||||
if !ok {
|
|
||||||
logger.Warning("No frequency found for proclamation %s, using default palette", proclamation.Name)
|
|
||||||
freqValue = 528 // Default to 528 if frequency not found
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the palette for this frequency
|
|
||||||
palette, ok := frequencyPalettes[freqValue]
|
|
||||||
if !ok {
|
|
||||||
logger.Warning("No palette found for frequency %d, using default palette", freqValue)
|
|
||||||
palette = colors.Freq_528_Colors[:] // Fallback to default palette
|
|
||||||
}
|
|
||||||
|
|
||||||
p.streams = append(p.streams, proclamationStream{
|
|
||||||
name: proclamation.Name,
|
|
||||||
words: words,
|
|
||||||
repeat: proclamationPlayCount,
|
|
||||||
palette: palette,
|
|
||||||
colorIndex: 0,
|
|
||||||
frequencyValue: freqValue,
|
|
||||||
})
|
|
||||||
|
|
||||||
if len(defaultWords) > 0 {
|
|
||||||
p.streams = append(p.streams, proclamationStream{
|
|
||||||
name: defaultProclamation.Name,
|
|
||||||
words: defaultWords,
|
|
||||||
repeat: 1,
|
|
||||||
palette: colors.Freq_528_Colors[:], // Default palette for default proclamation
|
|
||||||
colorIndex: 0,
|
|
||||||
frequencyValue: 0, // default proclamation never changes audio
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: if no non-default selected proclamations, at least play default.
|
|
||||||
if len(p.streams) == 0 && len(defaultWords) > 0 {
|
|
||||||
p.streams = append(p.streams, proclamationStream{
|
|
||||||
name: defaultProclamation.Name,
|
|
||||||
words: defaultWords,
|
|
||||||
repeat: 1,
|
|
||||||
palette: colors.Freq_528_Colors[:],
|
|
||||||
colorIndex: 0,
|
|
||||||
frequencyValue: 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(p.streams) == 0 {
|
|
||||||
return fmt.Errorf("failed to load proclamations: no proclamation words available")
|
|
||||||
}
|
|
||||||
|
|
||||||
p.streamIndex = 0
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// audioDir returns the path to the audio files directory (~/.radiantwave/audio).
|
|
||||||
func audioDir() string {
|
|
||||||
home, err := os.UserHomeDir()
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
return filepath.Join(home, ".radiantwave", "audio")
|
|
||||||
}
|
|
||||||
|
|
||||||
// audioPathForFrequency builds the on-disk path for a solfeggio frequency's
|
|
||||||
// audio file, e.g. 396 -> "~/.radiantwave/audio/396Hz.wav".
|
|
||||||
func (p *ScrollerPage) audioPathForFrequency(freq int) string {
|
|
||||||
return filepath.Join(audioDir(), fmt.Sprintf("%dHz.wav", freq))
|
|
||||||
}
|
|
||||||
|
|
||||||
// switchAudioForFrequency swaps the currently playing track to match freq.
|
|
||||||
//
|
|
||||||
// - freq == 0 ("no specific frequency", i.e. the default proclamation) is
|
|
||||||
// always a no-op — whatever is playing keeps playing.
|
|
||||||
// - If freq already matches what's playing, nothing happens.
|
|
||||||
// - If the matching file doesn't exist on disk yet, the current track
|
|
||||||
// keeps playing untouched (so missing solfeggio audio never causes
|
|
||||||
// silence — and starts working automatically once the file is added).
|
|
||||||
// - Otherwise the current track is stopped immediately and freq's track
|
|
||||||
// starts right away (hard cut, no fade).
|
|
||||||
//
|
|
||||||
// Returns true if freq's track is now playing (either just switched, or
|
|
||||||
// already was).
|
|
||||||
func (p *ScrollerPage) switchAudioForFrequency(freq int) bool {
|
|
||||||
if freq == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if freq == p.currentAudioFreq {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
path := p.audioPathForFrequency(freq)
|
|
||||||
if _, err := os.Stat(path); err != nil {
|
|
||||||
logger.Warning("No audio file for %dHz (%s), keeping current track", freq, path)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
config, err := db.GetConfig(p.Context.DbPtr)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
mixer.Stop(3000, config)
|
|
||||||
if err := mixer.Play(path, true, config); err != nil {
|
|
||||||
logger.Error("Mixer returned an error: %v", err)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
p.currentAudioFreq = freq
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// nextLineWords gets words for the current line and advances proclamation stream when needed.
|
|
||||||
func (p *ScrollerPage) nextLineWords() []string {
|
|
||||||
if len(p.streams) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
s := &p.streams[p.streamIndex]
|
|
||||||
if len(s.words) == 0 {
|
|
||||||
p.streamIndex = (p.streamIndex + 1) % len(p.streams)
|
|
||||||
return p.nextLineWords()
|
|
||||||
}
|
|
||||||
|
|
||||||
prevIdx := s.wordIndex
|
|
||||||
words := p.activePattern.GetNextLine(s.words, &s.wordIndex, p.lineCtx)
|
|
||||||
|
|
||||||
// If word index wraps, one full proclamation pass completed.
|
|
||||||
if s.wordIndex < prevIdx {
|
|
||||||
s.completed++
|
|
||||||
targetRepeats := s.repeat
|
|
||||||
if targetRepeats <= 0 {
|
|
||||||
targetRepeats = 1
|
|
||||||
}
|
|
||||||
if s.completed >= targetRepeats {
|
|
||||||
s.completed = 0
|
|
||||||
s.wordIndex = 0
|
|
||||||
s.colorIndex = 0 // Reset color index when proclamation restarts
|
|
||||||
p.streamIndex = (p.streamIndex + 1) % len(p.streams)
|
|
||||||
next := &p.streams[p.streamIndex]
|
|
||||||
p.switchAudioForFrequency(next.frequencyValue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return words
|
|
||||||
}
|
|
||||||
|
|
||||||
// getCachedWord returns a reusable textured StringItem for a word.
|
|
||||||
func (p *ScrollerPage) getCachedWord(word string) (StringItem, error) {
|
|
||||||
if cached, ok := p.wordTextureCache[word]; ok {
|
|
||||||
return cached, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
base, err := NewStringItem(word, p.font, sdl.Color{R: 255, G: 255, B: 255, A: 255})
|
|
||||||
if err != nil {
|
|
||||||
return StringItem{}, fmt.Errorf("unable to create cached string item for word '%s': %w", word, err)
|
|
||||||
}
|
|
||||||
base.Scale(p.fontScale)
|
|
||||||
|
|
||||||
p.wordTextureCache[word] = base
|
|
||||||
return base, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildLineFromWords builds a line using cached texture IDs and per-instance color/position.
|
|
||||||
func (p *ScrollerPage) buildLineFromWords(words []string) (Line, error) {
|
|
||||||
line := Line{words: []StringItem{}}
|
|
||||||
|
|
||||||
// Get the current proclamation's color palette
|
|
||||||
var lineColor sdl.Color
|
|
||||||
if p.useColorPalettes && len(p.streams) > 0 {
|
|
||||||
currentStream := &p.streams[p.streamIndex]
|
|
||||||
if len(currentStream.palette) > 0 {
|
|
||||||
// Use the current color from the palette
|
|
||||||
lineColor = currentStream.palette[currentStream.colorIndex]
|
|
||||||
// Increment color index for the next line
|
|
||||||
currentStream.colorIndex = (currentStream.colorIndex + 1) % len(currentStream.palette)
|
|
||||||
} else {
|
|
||||||
// Fallback to default color if no palette is found
|
|
||||||
lineColor = p.colorPattern.Color()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Use the default color pattern if useColorPalettes is false
|
|
||||||
lineColor = p.colorPattern.Color()
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, word := range words {
|
|
||||||
if word == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
cached, err := p.getCachedWord(word)
|
|
||||||
if err != nil {
|
|
||||||
return Line{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
item := cached
|
|
||||||
item.Color = lineColor // Assign the line color to all words in the line
|
|
||||||
|
|
||||||
if len(line.words) > 0 {
|
|
||||||
line.width += p.spaceWidth
|
|
||||||
}
|
|
||||||
line.words = append(line.words, item)
|
|
||||||
line.width += item.W
|
|
||||||
}
|
|
||||||
|
|
||||||
line.color = lineColor // Store the line color
|
|
||||||
return line, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// positionLine sets X/Y for all words in a line.
|
|
||||||
func (p *ScrollerPage) positionLine(line *Line, baseY int32) {
|
|
||||||
startX := p.ScreenCenterX - (line.width / 2)
|
|
||||||
for j := range line.words {
|
|
||||||
if j == 0 {
|
|
||||||
line.words[j].X = startX
|
|
||||||
} else {
|
|
||||||
line.words[j].X = line.words[j-1].X + line.words[j-1].W + p.spaceWidth
|
|
||||||
}
|
|
||||||
line.words[j].Y = baseY
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// createLine is a helper to generate a new line at a specific index.
|
|
||||||
// isInitial determines if this is during initialization (positions from top)
|
|
||||||
// or dynamic creation (positions relative to existing lines).
|
|
||||||
func (p *ScrollerPage) createLine(index int, isInitial bool) error {
|
|
||||||
words := p.nextLineWords()
|
|
||||||
line, err := p.buildLineFromWords(words)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
var baseY int32
|
|
||||||
if isInitial {
|
|
||||||
baseY = int32(p.scrollY) - int32(p.lineHeight)*int32(index)
|
|
||||||
} else {
|
|
||||||
baseY = 0
|
|
||||||
}
|
|
||||||
p.positionLine(&line, baseY)
|
|
||||||
|
|
||||||
if isInitial {
|
|
||||||
p.lines.PushBack(line)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ScrollerPage) HandleEvent(event *sdl.Event) error {
|
|
||||||
// switch e := (*event).(type) {
|
|
||||||
// case *sdl.KeyboardEvent:
|
|
||||||
// if e.Type != sdl.KEYDOWN {
|
|
||||||
// return nil
|
|
||||||
// }
|
|
||||||
// switch e.Keysym.Sym {
|
|
||||||
// case sdl.K_UP:
|
|
||||||
// if !p.autoVelocity {
|
|
||||||
// p.velocity += p.increment
|
|
||||||
// }
|
|
||||||
// case sdl.K_DOWN:
|
|
||||||
// if !p.autoVelocity {
|
|
||||||
// p.velocity -= p.increment
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
// return nil
|
|
||||||
// }
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ScrollerPage) Update(dt float32) error {
|
|
||||||
p.updateAutoVelocity(dt)
|
|
||||||
|
|
||||||
p.scrollOffset += dt * p.velocity
|
|
||||||
move := int32(p.scrollOffset)
|
|
||||||
if move == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
p.scrollOffset -= float32(move)
|
|
||||||
|
|
||||||
// Move all lines vertically
|
|
||||||
for i := 0; i < p.lines.Len(); i++ {
|
|
||||||
line := p.lines.Get(i)
|
|
||||||
for j := range line.words {
|
|
||||||
line.words[j].Y += move
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle line replacement based on scroll direction
|
|
||||||
if p.velocity > 0 {
|
|
||||||
// Moving up: replace lines that go off top
|
|
||||||
for p.lines.Len() > 0 && len(p.lines.Front().words) > 0 && p.lines.Front().words[0].Y > p.ScreenHeight {
|
|
||||||
_ = p.lines.PopFront() // no texture delete; textures are shared via cache
|
|
||||||
|
|
||||||
if err := p.addLineAtBottom(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if p.velocity < 0 {
|
|
||||||
// Moving down: replace lines that go off bottom
|
|
||||||
for p.lines.Len() > 0 {
|
|
||||||
back := p.lines.Back()
|
|
||||||
if len(back.words) == 0 || back.words[0].Y+back.words[0].H >= 0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = p.lines.PopBack() // no texture delete; textures are shared via cache
|
|
||||||
|
|
||||||
if err := p.addLineAtTop(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// addLineAtBottom creates and adds a new line at the bottom of the display.
|
|
||||||
func (p *ScrollerPage) addLineAtBottom() error {
|
|
||||||
newWords := p.nextLineWords()
|
|
||||||
line, err := p.buildLineFromWords(newWords)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
var baseY int32
|
|
||||||
if p.lines.Len() > 0 && len(p.lines.Back().words) > 0 {
|
|
||||||
baseY = p.lines.Back().words[0].Y - int32(p.lineHeight)
|
|
||||||
} else {
|
|
||||||
baseY = -int32(p.lineHeight)
|
|
||||||
}
|
|
||||||
|
|
||||||
p.positionLine(&line, baseY)
|
|
||||||
p.lines.PushBack(line)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// addLineAtTop creates and adds a new line at the top of the display.
|
|
||||||
func (p *ScrollerPage) addLineAtTop() error {
|
|
||||||
newWords := p.nextLineWords()
|
|
||||||
line, err := p.buildLineFromWords(newWords)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
var baseY int32
|
|
||||||
if p.lines.Len() > 0 && len(p.lines.Front().words) > 0 {
|
|
||||||
baseY = p.lines.Front().words[0].Y + int32(p.lineHeight)
|
|
||||||
} else {
|
|
||||||
baseY = p.ScreenHeight
|
|
||||||
}
|
|
||||||
|
|
||||||
p.positionLine(&line, baseY)
|
|
||||||
p.lines.PushFront(line)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ScrollerPage) Render() error {
|
|
||||||
p.BeginTextureRendering(p.shader)
|
|
||||||
for i := 0; i < p.lines.Len(); i++ {
|
|
||||||
line := p.lines.Get(i)
|
|
||||||
for _, word := range line.words {
|
|
||||||
if word.ID == 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
p.RenderTextureBatched(p.shader, word.ID, word.Position(), word.RawDimensions(), word.Color)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
p.EndTextureRendering()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *ScrollerPage) Destroy() error {
|
|
||||||
if p == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
config, err := db.GetConfig(p.Context.DbPtr)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
mixer.Stop(3000, config)
|
|
||||||
|
|
||||||
// Delete each cached word texture exactly once.
|
|
||||||
for k, item := range p.wordTextureCache {
|
|
||||||
if item.ID != 0 {
|
|
||||||
id := item.ID
|
|
||||||
gl.DeleteTextures(1, &id)
|
|
||||||
}
|
|
||||||
delete(p.wordTextureCache, k)
|
|
||||||
}
|
|
||||||
|
|
||||||
gl.Flush()
|
|
||||||
|
|
||||||
// Drop references for GC safely
|
|
||||||
p.lines = nil
|
|
||||||
p.streams = nil
|
|
||||||
p.lineCtx = nil
|
|
||||||
p.font = nil
|
|
||||||
p.shader = nil
|
|
||||||
p.colorPattern = nil
|
|
||||||
p.wordTextureCache = nil
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetAutoVelocity configures the automatic velocity system for the ScrollerPage.
|
|
||||||
func (p *ScrollerPage) SetAutoVelocity(enabled bool, floor, ceiling, step, delay, hold float32) {
|
|
||||||
p.autoVelocity = enabled
|
|
||||||
p.velocityFloor = -float32(math.Abs(float64(floor)))
|
|
||||||
p.velocityCeiling = float32(math.Abs(float64(ceiling)))
|
|
||||||
p.velocityStep = float32(math.Abs(float64(step)))
|
|
||||||
p.velocityDelay = delay
|
|
||||||
p.hold = hold
|
|
||||||
|
|
||||||
// Initialize state
|
|
||||||
p.velocityDirection = 1
|
|
||||||
p.velocityState = 0
|
|
||||||
p.velocityTimer = 0.0
|
|
||||||
p.velocity = p.velocityFloor
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetUseColorPalettes enables or disables the use of color palettes for lines.
|
|
||||||
func (p *ScrollerPage) SetUseColorPalettes(enabled bool) {
|
|
||||||
p.useColorPalettes = enabled
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateAutoVelocity handles automatic velocity changes.
|
|
||||||
func (p *ScrollerPage) updateAutoVelocity(dt float32) {
|
|
||||||
if !p.autoVelocity {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
switch p.velocityState {
|
|
||||||
case 0: // Changing velocity
|
|
||||||
if p.velocityDirection == 1 {
|
|
||||||
p.velocity += p.velocityStep * dt
|
|
||||||
if p.velocity >= p.velocityCeiling {
|
|
||||||
p.velocity = p.velocityCeiling
|
|
||||||
p.velocityState = 1
|
|
||||||
p.velocityTimer = 0.0
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
p.velocity -= p.velocityStep * dt
|
|
||||||
if p.velocity <= p.velocityFloor {
|
|
||||||
p.velocity = p.velocityFloor
|
|
||||||
p.velocityState = 2
|
|
||||||
p.velocityTimer = 0.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case 1: // Waiting at ceiling
|
|
||||||
p.velocityTimer += dt
|
|
||||||
if p.velocityTimer >= p.velocityDelay {
|
|
||||||
p.velocityDirection = -1
|
|
||||||
p.velocityState = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
case 2: // Waiting at floor
|
|
||||||
p.velocityTimer += dt
|
|
||||||
if p.velocityTimer >= p.velocityDelay {
|
|
||||||
p.velocityDirection = 1
|
|
||||||
p.velocityState = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,255 +0,0 @@
|
|||||||
package page
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/go-gl/gl/v3.3-core/gl"
|
|
||||||
"github.com/veandco/go-sdl2/sdl"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/colors"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/db"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/fontManager"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Settings struct {
|
|
||||||
Base
|
|
||||||
font *fontManager.FontEntry
|
|
||||||
textShader *shaderManager.Shader
|
|
||||||
solidShader *shaderManager.Shader
|
|
||||||
title StringItem
|
|
||||||
prompt StringItem
|
|
||||||
options []Option
|
|
||||||
verticalMargin int32
|
|
||||||
bgHighlight PageItem
|
|
||||||
highlightIndex int
|
|
||||||
}
|
|
||||||
|
|
||||||
type Option struct {
|
|
||||||
target pageType
|
|
||||||
label StringItem
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Settings) Init(ctx *PageCtx) error {
|
|
||||||
var err error
|
|
||||||
p.verticalMargin = int32(20)
|
|
||||||
|
|
||||||
// Get font
|
|
||||||
fm := fontManager.Get()
|
|
||||||
font, ok := fm.GetFont("SpaceMonoNerdFont-Regular")
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("failed to get font: %w", sdl.GetError())
|
|
||||||
}
|
|
||||||
p.font = font
|
|
||||||
|
|
||||||
// Get shaders
|
|
||||||
sm := shaderManager.Get()
|
|
||||||
p.textShader, ok = sm.Get("text")
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("failed to get text shader: %w", sdl.GetError())
|
|
||||||
}
|
|
||||||
p.solidShader, ok = sm.Get("solid_color")
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("failed to get solid_color shader: %w", sdl.GetError())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize base
|
|
||||||
if err := p.Base.Init(ctx); err != nil {
|
|
||||||
return fmt.Errorf("failed base page initialization: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create title
|
|
||||||
p.title, err = NewStringItem("Settings", font, colors.White)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("creating title: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create prompt
|
|
||||||
p.prompt, err = NewStringItem("Use Arrow Keys to Navigate, Enter to Select, or F1 to return to the Main Menu", font, colors.White)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("creating prompt: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create options
|
|
||||||
proclamationsLabel, err := NewStringItem("Proclamations", font, colors.White)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("creating proclamations label: %w", err)
|
|
||||||
}
|
|
||||||
p.options = append(p.options, Option{
|
|
||||||
target: PROCLAMATIONS,
|
|
||||||
label: proclamationsLabel,
|
|
||||||
})
|
|
||||||
|
|
||||||
audioLabel, err := NewStringItem("Audio", font, colors.White)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("creating audio label: %w", err)
|
|
||||||
}
|
|
||||||
p.options = append(p.options, Option{
|
|
||||||
target: AUDIO,
|
|
||||||
label: audioLabel,
|
|
||||||
})
|
|
||||||
|
|
||||||
emailLabel, err := NewStringItem("Email Address", font, colors.White)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("creating email label: %w", err)
|
|
||||||
}
|
|
||||||
p.options = append(p.options, Option{
|
|
||||||
target: EMAIL_ADDRESS,
|
|
||||||
label: emailLabel,
|
|
||||||
})
|
|
||||||
|
|
||||||
licenseKeyLabel, err := NewStringItem("License Key", font, colors.White)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("creating license key label: %w", err)
|
|
||||||
}
|
|
||||||
p.options = append(p.options, Option{
|
|
||||||
target: LICENSE_KEY,
|
|
||||||
label: licenseKeyLabel,
|
|
||||||
})
|
|
||||||
|
|
||||||
wifiLabel, err := NewStringItem("WiFi Setup", font, colors.White)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("creating wifi label: %w", err)
|
|
||||||
}
|
|
||||||
p.options = append(p.options, Option{
|
|
||||||
target: WIFI,
|
|
||||||
label: wifiLabel,
|
|
||||||
})
|
|
||||||
|
|
||||||
logsLabel, err := NewStringItem("Logs", font, colors.White)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("creating logs label: %w", err)
|
|
||||||
}
|
|
||||||
p.options = append(p.options, Option{
|
|
||||||
target: LOGS,
|
|
||||||
label: logsLabel,
|
|
||||||
})
|
|
||||||
|
|
||||||
// TODO: Add entry for "Tutorial"
|
|
||||||
// TODO: Add entry for "About"
|
|
||||||
|
|
||||||
// Get base font size from database
|
|
||||||
config, err := db.GetConfig(p.Context.DbPtr)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
baseFontSize := config.StandardFontSize
|
|
||||||
|
|
||||||
// Calculate scales
|
|
||||||
titleScale := float32(64) / float32(baseFontSize)
|
|
||||||
scale := float32(32) / float32(baseFontSize)
|
|
||||||
|
|
||||||
// Apply scales
|
|
||||||
p.title.Scale(titleScale)
|
|
||||||
p.prompt.Scale(scale)
|
|
||||||
|
|
||||||
for i := range p.options {
|
|
||||||
p.options[i].label.Scale(scale)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create background highlight using first option's dimensions
|
|
||||||
p.bgHighlight = PageItem{
|
|
||||||
ID: 0,
|
|
||||||
W: p.options[0].label.W,
|
|
||||||
H: p.options[0].label.H,
|
|
||||||
Color: colors.White,
|
|
||||||
}
|
|
||||||
p.highlightIndex = 0
|
|
||||||
|
|
||||||
// Position title at bottom
|
|
||||||
p.title.X = p.Base.ScreenCenterX - p.title.W/2
|
|
||||||
p.title.Y = p.ScreenHeight - p.title.H - p.verticalMargin*2
|
|
||||||
|
|
||||||
// Position prompt above title
|
|
||||||
p.prompt.X = p.ScreenCenterX - int32(float32(p.prompt.W)/2)
|
|
||||||
p.prompt.Y = p.title.Y - p.verticalMargin*2 - p.prompt.H
|
|
||||||
|
|
||||||
// If this value starts at 0, nothing is further left than that...
|
|
||||||
var furthestLeft int32 = p.ScreenWidth // < more to the left, > more to the right, = same
|
|
||||||
|
|
||||||
// Position options stacked upward from prompt
|
|
||||||
for i, option := range p.options {
|
|
||||||
p.options[i].label.X = p.ScreenCenterX - option.label.W/2 // Calculate horizontal center posision
|
|
||||||
if p.options[i].label.X < furthestLeft { // If the left most pixel is further left than furthestLeft, indicating a longer line:
|
|
||||||
// Then update furthestLeft
|
|
||||||
furthestLeft = p.options[i].label.X
|
|
||||||
}
|
|
||||||
if i == 0 {
|
|
||||||
p.options[i].label.Y = int32(float32(p.prompt.Y)-(float32(p.verticalMargin)*4)) - option.label.H
|
|
||||||
} else {
|
|
||||||
p.options[i].label.Y = p.options[i-1].label.Y - option.label.H - 10
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now, re-iterate over the options and justify everything to furthestLeft
|
|
||||||
for i := range p.options {
|
|
||||||
p.options[i].label.X = furthestLeft
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Settings) Update(dt float32) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Settings) HandleEvent(event *sdl.Event) error {
|
|
||||||
switch e := (*event).(type) {
|
|
||||||
case *sdl.KeyboardEvent:
|
|
||||||
if e.Type != sdl.KEYDOWN {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
switch e.Keysym.Sym {
|
|
||||||
case sdl.K_UP:
|
|
||||||
if p.highlightIndex > 0 {
|
|
||||||
p.highlightIndex--
|
|
||||||
}
|
|
||||||
case sdl.K_DOWN:
|
|
||||||
if p.highlightIndex < len(p.options)-1 {
|
|
||||||
p.highlightIndex++
|
|
||||||
}
|
|
||||||
case sdl.K_RETURN:
|
|
||||||
if p.highlightIndex >= 0 && p.highlightIndex < len(p.options) {
|
|
||||||
selectedOption := p.options[p.highlightIndex]
|
|
||||||
p.Context.NavigateTo(selectedOption.target)
|
|
||||||
}
|
|
||||||
case sdl.K_BACKSPACE:
|
|
||||||
p.Context.NavigateTo(HOME)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Settings) Render() error {
|
|
||||||
// Render title and prompt
|
|
||||||
p.RenderTexture(p.textShader, p.title.ID, p.title.Position(), p.title.RawDimensions(), p.title.Color)
|
|
||||||
p.RenderTexture(p.textShader, p.prompt.ID, p.prompt.Position(), p.prompt.RawDimensions(), p.prompt.Color)
|
|
||||||
|
|
||||||
// Render highlight behind selected option
|
|
||||||
p.RenderSolidColorQuad(
|
|
||||||
p.solidShader,
|
|
||||||
p.options[p.highlightIndex].label.Position(),
|
|
||||||
p.options[p.highlightIndex].label.RawDimensions(),
|
|
||||||
p.bgHighlight.Color,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Render all options with appropriate colors
|
|
||||||
for i, option := range p.options {
|
|
||||||
if p.highlightIndex == i {
|
|
||||||
option.label.Color = colors.Black
|
|
||||||
} else {
|
|
||||||
option.label.Color = colors.White
|
|
||||||
}
|
|
||||||
p.RenderTexture(p.textShader, option.label.ID, option.label.Position(), option.label.RawDimensions(), option.label.Color)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *Settings) Destroy() error {
|
|
||||||
gl.DeleteTextures(1, &p.title.ID)
|
|
||||||
gl.DeleteTextures(1, &p.prompt.ID)
|
|
||||||
for i := range p.options {
|
|
||||||
gl.DeleteTextures(1, &p.options[i].label.ID)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,656 +0,0 @@
|
|||||||
package page
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/go-gl/gl/v3.3-core/gl"
|
|
||||||
"github.com/go-gl/mathgl/mgl32"
|
|
||||||
"github.com/veandco/go-sdl2/sdl"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/colors"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/db"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/fontManager"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/keybinds"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/logger"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/network"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
type networkUIItem struct {
|
|
||||||
Net network.Network
|
|
||||||
Item StringItem
|
|
||||||
}
|
|
||||||
|
|
||||||
type wifiStatus int
|
|
||||||
|
|
||||||
const (
|
|
||||||
wifiStatusIdle wifiStatus = iota
|
|
||||||
wifiStatusScanning
|
|
||||||
wifiStatusConnecting
|
|
||||||
wifiStatusConnected
|
|
||||||
wifiStatusError
|
|
||||||
)
|
|
||||||
|
|
||||||
// ─── Page ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
type WiFiSetupPage struct {
|
|
||||||
Base
|
|
||||||
applicationFont *fontManager.FontEntry
|
|
||||||
textInputSupported bool
|
|
||||||
|
|
||||||
// UI elements
|
|
||||||
BaseFontSize int
|
|
||||||
titleItem StringItem
|
|
||||||
scanningStatusItem StringItem
|
|
||||||
connectionStatusItem StringItem
|
|
||||||
passwordPromptItem StringItem
|
|
||||||
passwordInputItem StringItem
|
|
||||||
passwordErrorItem StringItem
|
|
||||||
|
|
||||||
// Network list
|
|
||||||
networks []networkUIItem
|
|
||||||
selectedIndex int
|
|
||||||
|
|
||||||
// State
|
|
||||||
status wifiStatus
|
|
||||||
statusMessage string
|
|
||||||
connectError string
|
|
||||||
|
|
||||||
// Password entry
|
|
||||||
isEnteringPassword bool
|
|
||||||
passwordInput string
|
|
||||||
selectedNet *network.Network
|
|
||||||
|
|
||||||
// Async
|
|
||||||
ctx context.Context
|
|
||||||
cancel context.CancelFunc
|
|
||||||
networksCh <-chan []network.Network
|
|
||||||
connectResultCh chan error
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Init ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (p *WiFiSetupPage) Init(ctx *PageCtx) error {
|
|
||||||
if err := p.Base.Init(ctx); err != nil {
|
|
||||||
return fmt.Errorf("failed base page initialization: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fm := fontManager.Get()
|
|
||||||
font, ok := fm.GetFont("SpaceMonoNerdFont-Regular")
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("could not find application font: SpaceMonoNerdFont-Regular")
|
|
||||||
}
|
|
||||||
p.applicationFont = font
|
|
||||||
|
|
||||||
var err error
|
|
||||||
|
|
||||||
p.titleItem, err = NewStringItem("Wi-Fi Setup", p.applicationFont, colors.White)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create title item: %w", err)
|
|
||||||
}
|
|
||||||
p.scanningStatusItem, err = NewStringItem("Scanning for networks...", p.applicationFont, colors.White)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create scanning status item: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
config, err := db.GetConfig(p.Context.DbPtr)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
p.BaseFontSize = config.StandardFontSize
|
|
||||||
|
|
||||||
p.connectResultCh = make(chan error, 1)
|
|
||||||
p.ctx, p.cancel = context.WithCancel(context.Background())
|
|
||||||
|
|
||||||
p.startScan()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Scan ─────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (p *WiFiSetupPage) startScan() {
|
|
||||||
p.status = wifiStatusScanning
|
|
||||||
p.statusMessage = "Scanning for networks..."
|
|
||||||
p.selectedIndex = 0
|
|
||||||
p.networks = nil
|
|
||||||
|
|
||||||
var err error
|
|
||||||
p.networksCh, err = network.ScanForNetworks(p.ctx)
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("wifi: failed to start scan: %v", err)
|
|
||||||
p.status = wifiStatusError
|
|
||||||
p.statusMessage = "Failed to start network scan. No Wi-Fi adapter found."
|
|
||||||
p.rebuildStatusTexture()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Input ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (p *WiFiSetupPage) HandleEvent(event *sdl.Event) error {
|
|
||||||
switch e := (*event).(type) {
|
|
||||||
case *sdl.KeyboardEvent:
|
|
||||||
if e.Type != sdl.KEYDOWN {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if p.isEnteringPassword {
|
|
||||||
p.handlePasswordKey(e.Keysym.Sym, e.Keysym.Mod)
|
|
||||||
} else {
|
|
||||||
p.handleNavKey(e.Keysym.Sym)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *WiFiSetupPage) handlePasswordKey(sym sdl.Keycode, mod uint16) {
|
|
||||||
switch sym {
|
|
||||||
case sdl.K_RETURN:
|
|
||||||
p.submitPassword()
|
|
||||||
case sdl.K_BACKSPACE:
|
|
||||||
if len(p.passwordInput) > 0 {
|
|
||||||
runes := []rune(p.passwordInput)
|
|
||||||
p.passwordInput = string(runes[:len(runes)-1])
|
|
||||||
p.rebuildPasswordTexture()
|
|
||||||
}
|
|
||||||
case sdl.K_ESCAPE:
|
|
||||||
logger.Info("wifi: password entry cancelled")
|
|
||||||
p.exitPasswordMode()
|
|
||||||
p.connectError = ""
|
|
||||||
p.selectedNet = nil
|
|
||||||
default:
|
|
||||||
if ch := keycodeToChar(sym, mod); ch != 0 {
|
|
||||||
p.passwordInput += string(ch)
|
|
||||||
p.rebuildPasswordTexture()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *WiFiSetupPage) handleNavKey(sym sdl.Keycode) {
|
|
||||||
switch sym {
|
|
||||||
case sdl.K_UP:
|
|
||||||
if p.selectedIndex > 0 {
|
|
||||||
p.selectedIndex--
|
|
||||||
}
|
|
||||||
|
|
||||||
case sdl.K_DOWN:
|
|
||||||
if p.selectedIndex < len(p.networks)-1 {
|
|
||||||
p.selectedIndex++
|
|
||||||
}
|
|
||||||
|
|
||||||
case sdl.K_RETURN:
|
|
||||||
if len(p.networks) == 0 || p.selectedIndex >= len(p.networks) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
selected := p.networks[p.selectedIndex].Net
|
|
||||||
p.selectedNet = &p.networks[p.selectedIndex].Net
|
|
||||||
p.connectError = ""
|
|
||||||
|
|
||||||
if selected.Security != network.SecurityOpen {
|
|
||||||
p.passwordInput = ""
|
|
||||||
p.enterPasswordMode()
|
|
||||||
} else {
|
|
||||||
p.connect("")
|
|
||||||
}
|
|
||||||
|
|
||||||
case sdl.K_r:
|
|
||||||
if p.status != wifiStatusScanning && p.status != wifiStatusConnecting {
|
|
||||||
p.startScan()
|
|
||||||
}
|
|
||||||
|
|
||||||
case sdl.K_ESCAPE:
|
|
||||||
p.selectedNet = nil
|
|
||||||
p.connectError = ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *WiFiSetupPage) enterPasswordMode() {
|
|
||||||
keybinds.Enabled = false
|
|
||||||
p.isEnteringPassword = true
|
|
||||||
p.textInputSupported = false
|
|
||||||
p.rebuildPasswordPromptTexture()
|
|
||||||
p.rebuildPasswordTexture()
|
|
||||||
sdl.StartTextInput()
|
|
||||||
logger.Info("wifi: password entry started for %q", p.selectedNet.SSID)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *WiFiSetupPage) exitPasswordMode() {
|
|
||||||
keybinds.Enabled = true
|
|
||||||
p.isEnteringPassword = false
|
|
||||||
sdl.StopTextInput()
|
|
||||||
logger.Info("wifi: password entry finished")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *WiFiSetupPage) submitPassword() {
|
|
||||||
p.exitPasswordMode()
|
|
||||||
p.connect(p.passwordInput)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Connect ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (p *WiFiSetupPage) connect(password string) {
|
|
||||||
if p.selectedNet == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ssid := p.selectedNet.SSID
|
|
||||||
p.status = wifiStatusConnecting
|
|
||||||
p.statusMessage = fmt.Sprintf("Connecting to \"%s\"...", ssid)
|
|
||||||
logger.Info("wifi: connecting to %q", ssid)
|
|
||||||
|
|
||||||
// ConnectToNetwork blocks (up to 15s), so run it off the main goroutine.
|
|
||||||
go func() {
|
|
||||||
err := network.ConnectToNetwork(ssid, password)
|
|
||||||
p.connectResultCh <- err
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Update ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (p *WiFiSetupPage) Update(dt float32) error {
|
|
||||||
select {
|
|
||||||
|
|
||||||
case nets, ok := <-p.networksCh:
|
|
||||||
if ok {
|
|
||||||
p.rebuildNetworkList(nets)
|
|
||||||
if p.status == wifiStatusScanning {
|
|
||||||
p.status = wifiStatusIdle
|
|
||||||
p.statusMessage = ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case err := <-p.connectResultCh:
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("wifi: connection failed: %v", err)
|
|
||||||
p.status = wifiStatusError
|
|
||||||
p.connectError = fmt.Sprintf("Could not connect: %s", err.Error())
|
|
||||||
p.statusMessage = ""
|
|
||||||
|
|
||||||
// Drop back into password entry so the user can try again.
|
|
||||||
if p.selectedNet != nil && p.selectedNet.Security != network.SecurityOpen {
|
|
||||||
p.passwordInput = ""
|
|
||||||
p.rebuildPasswordErrorTexture()
|
|
||||||
p.enterPasswordMode()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ssid := ""
|
|
||||||
if p.selectedNet != nil {
|
|
||||||
ssid = p.selectedNet.SSID
|
|
||||||
}
|
|
||||||
logger.Info("wifi: connected to %q", ssid)
|
|
||||||
p.status = wifiStatusConnected
|
|
||||||
p.connectError = ""
|
|
||||||
p.statusMessage = fmt.Sprintf("Connected to \"%s\" — press F1 to continue", ssid)
|
|
||||||
// Trigger a fresh scan so the list shows the connected state.
|
|
||||||
p.startScan()
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
p.rebuildStatusTexture()
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Texture builders ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (p *WiFiSetupPage) rebuildNetworkList(nets []network.Network) {
|
|
||||||
for _, item := range p.networks {
|
|
||||||
if item.Item.ID != 0 {
|
|
||||||
gl.DeleteTextures(1, &item.Item.ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
p.networks = make([]networkUIItem, 0, len(nets))
|
|
||||||
|
|
||||||
for _, n := range nets {
|
|
||||||
label := n.SSID
|
|
||||||
switch {
|
|
||||||
case n.Connected:
|
|
||||||
label += " ✓"
|
|
||||||
case n.Saved:
|
|
||||||
label += " ★"
|
|
||||||
}
|
|
||||||
if n.Security != network.SecurityOpen {
|
|
||||||
var secStr string
|
|
||||||
switch n.Security {
|
|
||||||
case network.SecurityWEP:
|
|
||||||
secStr = "WEP"
|
|
||||||
case network.SecurityWPA:
|
|
||||||
secStr = "WPA"
|
|
||||||
case network.SecurityWPA2:
|
|
||||||
secStr = "WPA2"
|
|
||||||
case network.SecurityWPA3:
|
|
||||||
secStr = "WPA3"
|
|
||||||
default:
|
|
||||||
secStr = "Secured"
|
|
||||||
}
|
|
||||||
label += " [" + secStr + "]"
|
|
||||||
}
|
|
||||||
|
|
||||||
c := colors.White
|
|
||||||
if n.Connected {
|
|
||||||
c = colors.LawnGreen
|
|
||||||
}
|
|
||||||
|
|
||||||
item, err := NewStringItem(label, p.applicationFont, c)
|
|
||||||
if err != nil {
|
|
||||||
logger.Warning("wifi: failed to create item for %q: %v", n.SSID, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
p.networks = append(p.networks, networkUIItem{Net: n, Item: item})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *WiFiSetupPage) rebuildStatusTexture() {
|
|
||||||
if p.connectionStatusItem.ID != 0 {
|
|
||||||
gl.DeleteTextures(1, &p.connectionStatusItem.ID)
|
|
||||||
p.connectionStatusItem.ID = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
var msg string
|
|
||||||
var c sdl.Color
|
|
||||||
|
|
||||||
switch p.status {
|
|
||||||
case wifiStatusScanning:
|
|
||||||
msg = "Scanning for nearby networks, please wait..."
|
|
||||||
c = colors.White
|
|
||||||
case wifiStatusConnecting:
|
|
||||||
msg = p.statusMessage
|
|
||||||
c = colors.Yellow
|
|
||||||
case wifiStatusConnected:
|
|
||||||
msg = p.statusMessage
|
|
||||||
c = colors.LawnGreen
|
|
||||||
case wifiStatusError:
|
|
||||||
msg = p.connectError
|
|
||||||
if msg == "" {
|
|
||||||
msg = "Something went wrong. Press R to scan again."
|
|
||||||
}
|
|
||||||
c = colors.Red
|
|
||||||
default:
|
|
||||||
if len(p.networks) == 0 {
|
|
||||||
msg = "No networks found. Press R to scan again."
|
|
||||||
c = colors.Yellow
|
|
||||||
} else {
|
|
||||||
msg = "Select a network to connect — press R to rescan"
|
|
||||||
c = colors.White
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if msg == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
item, err := NewStringItem(msg, p.applicationFont, c)
|
|
||||||
if err == nil {
|
|
||||||
p.connectionStatusItem = item
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *WiFiSetupPage) rebuildPasswordTexture() {
|
|
||||||
if p.passwordInputItem.ID != 0 {
|
|
||||||
gl.DeleteTextures(1, &p.passwordInputItem.ID)
|
|
||||||
}
|
|
||||||
text := p.passwordInput
|
|
||||||
if text == "" {
|
|
||||||
text = " " // prevent empty texture
|
|
||||||
}
|
|
||||||
item, err := NewStringItem(text, p.applicationFont, colors.White)
|
|
||||||
if err == nil {
|
|
||||||
p.passwordInputItem = item
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *WiFiSetupPage) rebuildPasswordPromptTexture() {
|
|
||||||
if p.passwordPromptItem.ID != 0 {
|
|
||||||
gl.DeleteTextures(1, &p.passwordPromptItem.ID)
|
|
||||||
}
|
|
||||||
if p.selectedNet == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
prompt := fmt.Sprintf("Enter password for \"%s\":", p.selectedNet.SSID)
|
|
||||||
item, err := NewStringItem(prompt, p.applicationFont, colors.White)
|
|
||||||
if err == nil {
|
|
||||||
p.passwordPromptItem = item
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *WiFiSetupPage) rebuildPasswordErrorTexture() {
|
|
||||||
if p.passwordErrorItem.ID != 0 {
|
|
||||||
gl.DeleteTextures(1, &p.passwordErrorItem.ID)
|
|
||||||
p.passwordErrorItem.ID = 0
|
|
||||||
}
|
|
||||||
if p.connectError == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
item, err := NewStringItem(p.connectError, p.applicationFont, colors.Red)
|
|
||||||
if err == nil {
|
|
||||||
p.passwordErrorItem = item
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Render ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (p *WiFiSetupPage) Render() error {
|
|
||||||
solidShader, _ := shaderManager.Get().Get("solid_color")
|
|
||||||
textShader, _ := shaderManager.Get().Get("text")
|
|
||||||
|
|
||||||
bgColor := sdl.Color{R: 0, G: 0, B: 0, A: 255}
|
|
||||||
selColor := sdl.Color{R: 68, G: 71, B: 90, A: 255}
|
|
||||||
white := colors.White
|
|
||||||
vertMargin := float32(20)
|
|
||||||
|
|
||||||
// Background
|
|
||||||
p.Base.RenderSolidColorQuad(solidShader,
|
|
||||||
mgl32.Vec2{0, 0},
|
|
||||||
mgl32.Vec2{float32(p.ScreenWidth), float32(p.ScreenHeight)},
|
|
||||||
bgColor,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Title — 48pt
|
|
||||||
titleScale := float32(48) / float32(p.BaseFontSize)
|
|
||||||
titleW := float32(p.titleItem.W) * titleScale
|
|
||||||
titleH := float32(p.titleItem.H) * titleScale
|
|
||||||
titleX := float32(p.ScreenCenterX) - titleW/2
|
|
||||||
titleY := float32(p.ScreenHeight) - titleH - 40
|
|
||||||
p.Base.RenderTexture(textShader, p.titleItem.ID,
|
|
||||||
mgl32.Vec2{titleX, titleY},
|
|
||||||
mgl32.Vec2{titleW, titleH},
|
|
||||||
white,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Status message — 30pt (was 24pt, now 1.25x)
|
|
||||||
statusY := titleY - titleH - vertMargin
|
|
||||||
if p.status == wifiStatusScanning {
|
|
||||||
scale := float32(30) / float32(p.BaseFontSize)
|
|
||||||
w := float32(p.scanningStatusItem.W) * scale
|
|
||||||
h := float32(p.scanningStatusItem.H) * scale
|
|
||||||
x := float32(p.ScreenCenterX) - w/2
|
|
||||||
p.Base.RenderTexture(textShader, p.scanningStatusItem.ID,
|
|
||||||
mgl32.Vec2{x, statusY},
|
|
||||||
mgl32.Vec2{w, h},
|
|
||||||
white,
|
|
||||||
)
|
|
||||||
} else if p.connectionStatusItem.ID != 0 {
|
|
||||||
scale := float32(30) / float32(p.BaseFontSize)
|
|
||||||
w := float32(p.connectionStatusItem.W) * scale
|
|
||||||
h := float32(p.connectionStatusItem.H) * scale
|
|
||||||
x := float32(p.ScreenCenterX) - w/2
|
|
||||||
p.Base.RenderTexture(textShader, p.connectionStatusItem.ID,
|
|
||||||
mgl32.Vec2{x, statusY},
|
|
||||||
mgl32.Vec2{w, h},
|
|
||||||
p.connectionStatusItem.Color,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Network list
|
|
||||||
listY := statusY - vertMargin
|
|
||||||
itemH := int32(40)
|
|
||||||
listPad := int32(10)
|
|
||||||
listW := float32(700)
|
|
||||||
listX := float32(p.ScreenCenterX) - listW/2
|
|
||||||
|
|
||||||
for i, item := range p.networks {
|
|
||||||
rowY := listY - float32(int32(i+1)*itemH)
|
|
||||||
|
|
||||||
if i == p.selectedIndex {
|
|
||||||
p.Base.RenderSolidColorQuad(solidShader,
|
|
||||||
mgl32.Vec2{listX, rowY},
|
|
||||||
mgl32.Vec2{listW, float32(itemH)},
|
|
||||||
selColor,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
scale := float32(24) / float32(p.BaseFontSize)
|
|
||||||
tw := float32(item.Item.W) * scale
|
|
||||||
th := float32(item.Item.H) * scale
|
|
||||||
tx := listX + float32(listPad)
|
|
||||||
ty := rowY + (float32(itemH)/2 - th/2)
|
|
||||||
p.Base.RenderTexture(textShader, item.Item.ID,
|
|
||||||
mgl32.Vec2{tx, ty},
|
|
||||||
mgl32.Vec2{tw, th},
|
|
||||||
item.Item.Color,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Password dialog
|
|
||||||
if p.isEnteringPassword {
|
|
||||||
if err := p.renderPasswordDialog(solidShader, textShader, float32(p.BaseFontSize)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (p *WiFiSetupPage) renderPasswordDialog(solidShader, textShader *shaderManager.Shader, baseFontSize float32) error {
|
|
||||||
overlayColor := sdl.Color{R: 0, G: 0, B: 0, A: 180}
|
|
||||||
dialogBgColor := sdl.Color{R: 40, G: 42, B: 54, A: 255}
|
|
||||||
boxBorderColor := sdl.Color{R: 120, G: 120, B: 120, A: 255}
|
|
||||||
boxBgColor := sdl.Color{R: 30, G: 30, B: 30, A: 255}
|
|
||||||
white := colors.White
|
|
||||||
|
|
||||||
dialogW := int32(600)
|
|
||||||
dialogH := int32(250)
|
|
||||||
if p.connectError != "" {
|
|
||||||
dialogH = 310
|
|
||||||
}
|
|
||||||
dialogX := p.ScreenCenterX - dialogW/2
|
|
||||||
dialogY := p.ScreenCenterY - dialogH/2
|
|
||||||
|
|
||||||
// Overlay + dialog box
|
|
||||||
p.Base.RenderSolidColorQuad(solidShader,
|
|
||||||
mgl32.Vec2{0, 0},
|
|
||||||
mgl32.Vec2{float32(p.ScreenWidth), float32(p.ScreenHeight)},
|
|
||||||
overlayColor,
|
|
||||||
)
|
|
||||||
p.Base.RenderSolidColorQuad(solidShader,
|
|
||||||
mgl32.Vec2{float32(dialogX), float32(dialogY)},
|
|
||||||
mgl32.Vec2{float32(dialogW), float32(dialogH)},
|
|
||||||
dialogBgColor,
|
|
||||||
)
|
|
||||||
|
|
||||||
currentY := float32(dialogY+dialogH) - 30
|
|
||||||
|
|
||||||
// Error message
|
|
||||||
if p.passwordErrorItem.ID != 0 {
|
|
||||||
scale := float32(20) / baseFontSize
|
|
||||||
w := float32(p.passwordErrorItem.W) * scale
|
|
||||||
h := float32(p.passwordErrorItem.H) * scale
|
|
||||||
x := float32(p.ScreenCenterX) - w/2
|
|
||||||
currentY -= h
|
|
||||||
p.Base.RenderTexture(textShader, p.passwordErrorItem.ID,
|
|
||||||
mgl32.Vec2{x, currentY},
|
|
||||||
mgl32.Vec2{w, h},
|
|
||||||
colors.Red,
|
|
||||||
)
|
|
||||||
currentY -= 20
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prompt
|
|
||||||
if p.passwordPromptItem.ID != 0 {
|
|
||||||
scale := float32(24) / baseFontSize
|
|
||||||
w := float32(p.passwordPromptItem.W) * scale
|
|
||||||
h := float32(p.passwordPromptItem.H) * scale
|
|
||||||
x := float32(p.ScreenCenterX) - w/2
|
|
||||||
currentY -= h
|
|
||||||
p.Base.RenderTexture(textShader, p.passwordPromptItem.ID,
|
|
||||||
mgl32.Vec2{x, currentY},
|
|
||||||
mgl32.Vec2{w, h},
|
|
||||||
white,
|
|
||||||
)
|
|
||||||
currentY -= 30
|
|
||||||
}
|
|
||||||
|
|
||||||
// Input box
|
|
||||||
padding := int32(4)
|
|
||||||
border := int32(3)
|
|
||||||
boxW := int32(500)
|
|
||||||
fontSize := int32(36)
|
|
||||||
boxH := fontSize + padding*2
|
|
||||||
bboxW := boxW + border*2
|
|
||||||
bboxH := boxH + border*2
|
|
||||||
bboxX := p.ScreenCenterX - bboxW/2
|
|
||||||
bboxY := int32(currentY) - boxH - padding*2
|
|
||||||
boxX := p.ScreenCenterX - boxW/2
|
|
||||||
boxY := int32(currentY) - boxH - padding
|
|
||||||
|
|
||||||
p.Base.RenderSolidColorQuad(solidShader,
|
|
||||||
mgl32.Vec2{float32(bboxX), float32(bboxY)},
|
|
||||||
mgl32.Vec2{float32(bboxW), float32(bboxH)},
|
|
||||||
boxBorderColor,
|
|
||||||
)
|
|
||||||
p.Base.RenderSolidColorQuad(solidShader,
|
|
||||||
mgl32.Vec2{float32(boxX), float32(boxY)},
|
|
||||||
mgl32.Vec2{float32(boxW), float32(boxH)},
|
|
||||||
boxBgColor,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Password text (plain, unmasked)
|
|
||||||
if p.passwordInputItem.ID != 0 {
|
|
||||||
scale := float32(fontSize) / baseFontSize
|
|
||||||
w := float32(p.passwordInputItem.W) * scale
|
|
||||||
h := float32(p.passwordInputItem.H) * scale
|
|
||||||
p.Base.RenderTexture(textShader, p.passwordInputItem.ID,
|
|
||||||
mgl32.Vec2{float32(boxX + padding), float32(boxY + padding)},
|
|
||||||
mgl32.Vec2{w, h},
|
|
||||||
white,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Destroy ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func (p *WiFiSetupPage) Destroy() error {
|
|
||||||
keybinds.Enabled = true
|
|
||||||
sdl.StopTextInput()
|
|
||||||
|
|
||||||
// Cancel the scan context — stops ScanForNetworks goroutine.
|
|
||||||
if p.cancel != nil {
|
|
||||||
p.cancel()
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteTexture := func(id *uint32) {
|
|
||||||
if *id != 0 {
|
|
||||||
gl.DeleteTextures(1, id)
|
|
||||||
*id = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteTexture(&p.titleItem.ID)
|
|
||||||
deleteTexture(&p.scanningStatusItem.ID)
|
|
||||||
deleteTexture(&p.connectionStatusItem.ID)
|
|
||||||
deleteTexture(&p.passwordPromptItem.ID)
|
|
||||||
deleteTexture(&p.passwordInputItem.ID)
|
|
||||||
deleteTexture(&p.passwordErrorItem.ID)
|
|
||||||
|
|
||||||
for _, item := range p.networks {
|
|
||||||
deleteTexture(&item.Item.ID)
|
|
||||||
}
|
|
||||||
|
|
||||||
return p.Base.Destroy()
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
package pattern
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/veandco/go-sdl2/sdl"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/colors"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ColorPattern struct {
|
|
||||||
Colors []sdl.Color
|
|
||||||
colorIndex int
|
|
||||||
repeatsLeft int
|
|
||||||
a, b int
|
|
||||||
increasing bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewColorPattern(colors []sdl.Color) *ColorPattern {
|
|
||||||
if len(colors) < 2 {
|
|
||||||
panic("ColorPattern requires at least 2 colors")
|
|
||||||
}
|
|
||||||
return &ColorPattern{
|
|
||||||
Colors: colors,
|
|
||||||
a: 0,
|
|
||||||
b: 1,
|
|
||||||
increasing: true,
|
|
||||||
repeatsLeft: 1, // Start with b (1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ATTENTION: TED
|
|
||||||
//
|
|
||||||
// # Fibonacci-Oscillating Temporal Color Distribution for Sequential Text Elements
|
|
||||||
//
|
|
||||||
// This method distributes intent-based colors across sequential text elements (words) using an
|
|
||||||
// oscillating Fibonacci sequence to control temporal color duration, creating rhythmic color
|
|
||||||
// patterns that maintain visual balance while varying exposure times.
|
|
||||||
//
|
|
||||||
// The technology addresses the challenge of applying multiple psychologically-selected colors
|
|
||||||
// to scrolling text in a way that maintains even distribution over time while creating natural
|
|
||||||
// rhythm. Conventional approaches either apply colors randomly (creating unbalanced temporal
|
|
||||||
// distribution) or use fixed intervals (producing mechanical, unnatural patterns that lack
|
|
||||||
// harmonic variation). Our method ensures each color in the palette appears throughout the
|
|
||||||
// entire display duration while varying its temporal presence according to natural mathematical
|
|
||||||
// progression.
|
|
||||||
//
|
|
||||||
// The approach uses a bidirectional Fibonacci sequence to determine how many consecutive words
|
|
||||||
// receive each color. The sequence increases (1, 1, 2, 3, 5, 8, 13, 21, 34, 55 words) then
|
|
||||||
// decreases back down (55, 34, 21, 13, 8, 5, 3, 2, 1, 1 words), creating an oscillating pattern.
|
|
||||||
// Colors from the intent-based palette are applied in sequence, with each color's duration
|
|
||||||
// determined by the current Fibonacci value. This creates a "breathing" rhythm where colors
|
|
||||||
// appear briefly, expand to longer durations, then contract back to brief appearances before
|
|
||||||
// cycling to the next color.
|
|
||||||
//
|
|
||||||
// Key differentiators from existing solutions:
|
|
||||||
// - Uses bidirectional Fibonacci sequence (ascending then descending) rather than fixed intervals
|
|
||||||
// - Creates natural rhythmic variation in color exposure duration
|
|
||||||
// - Ensures all palette colors appear throughout entire display timeline, not in isolated sections
|
|
||||||
// - Combines intent-based color selection with mathematical temporal distribution
|
|
||||||
// - Produces harmonic color rhythm synchronized with natural numerical progression
|
|
||||||
//
|
|
||||||
// Within our Rhema Wave generation system, this color distribution works in conjunction with
|
|
||||||
// the spatially-scrambled character patterns. While the scrambling creates spatial frequency
|
|
||||||
// signatures, the Fibonacci-oscillating color pattern creates temporal frequency modulation.
|
|
||||||
// The colors themselves are selected according to color theory principles based on the proclamation's
|
|
||||||
// psychological intent, and the oscillating Fibonacci distribution ensures these therapeutic colors
|
|
||||||
// maintain presence throughout the viewing session while creating harmonic temporal rhythm that
|
|
||||||
// reinforces the overall Rhema Wave effect.
|
|
||||||
func (c *ColorPattern) Color() sdl.Color {
|
|
||||||
// 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, 987, 1597, 2584, 4181, 6765, 10946, 17711, 28657, 46368, 75025, 121393, 196418
|
|
||||||
// This is a failsafe in case this pattern is used but colors are not supplied. Without this check, no text will display due to not having a color
|
|
||||||
// and becoming translucent.
|
|
||||||
if len(c.Colors) == 0 {
|
|
||||||
return colors.White // fallback to white
|
|
||||||
}
|
|
||||||
|
|
||||||
color := c.Colors[c.colorIndex%len(c.Colors)]
|
|
||||||
c.repeatsLeft--
|
|
||||||
|
|
||||||
if c.repeatsLeft <= 0 {
|
|
||||||
// Move to next color in the palette
|
|
||||||
c.colorIndex = (c.colorIndex + 1) % len(c.Colors)
|
|
||||||
|
|
||||||
// Update Fibonacci sequence for repeat count
|
|
||||||
if c.increasing {
|
|
||||||
c.a, c.b = c.b, c.a+c.b
|
|
||||||
if c.b > 55 { // Update this threahold to adjust the number of repeats
|
|
||||||
c.increasing = false
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
c.a, c.b = c.b-c.a, c.a
|
|
||||||
if c.a <= 0 {
|
|
||||||
c.a, c.b = 0, 1
|
|
||||||
c.increasing = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
c.repeatsLeft = c.b
|
|
||||||
}
|
|
||||||
|
|
||||||
return color
|
|
||||||
}
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
package pattern
|
|
||||||
|
|
||||||
type FibonacciPattern struct {
|
|
||||||
masterSequence []int
|
|
||||||
sequence []int
|
|
||||||
increasing bool
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
Register("fibonacci", func() LinePattern {
|
|
||||||
return &FibonacciPattern{
|
|
||||||
masterSequence: []int{1, 2, 3, 5, 8, 13, 21},
|
|
||||||
sequence: []int{1},
|
|
||||||
increasing: true,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (f *FibonacciPattern) Name() string { return "Fibonacci" }
|
|
||||||
|
|
||||||
// ATTENTION: TED
|
|
||||||
//
|
|
||||||
// # Fibonacci-Oscillating Spatial Distribution for Sequential Text Display
|
|
||||||
//
|
|
||||||
// This method arranges sequential text elements (words from proclamations) into display lines
|
|
||||||
// where the word count per line follows an oscillating Fibonacci sequence, creating both a
|
|
||||||
// visual wave pattern in two-dimensional space and ensuring even temporal distribution of
|
|
||||||
// content segments.
|
|
||||||
//
|
|
||||||
// The technology addresses the challenge of displaying repeated proclamation content in a way
|
|
||||||
// that maintains balanced exposure of all content portions while creating visually dynamic
|
|
||||||
// spatial patterns. Conventional scrolling text approaches either use fixed line lengths
|
|
||||||
// (creating uniform, non-dynamic displays) or random word counts (producing unbalanced content
|
|
||||||
// distribution where some portions appear more frequently than others). Our method ensures
|
|
||||||
// proclamation content is consumed in progressively sized segments that balance over time while
|
|
||||||
// creating a natural wave-like visual rhythm.
|
|
||||||
//
|
|
||||||
// The approach uses a bidirectional Fibonacci sequence to determine words per line. Starting
|
|
||||||
// with minimal word count (1, 1 words), the sequence expands (2, 3, 5, 8, 13, 21... words per
|
|
||||||
// line) until reaching a threshold, then contracts back down (21, 13, 8, 5, 3, 2, 1, 1 words),
|
|
||||||
// creating an oscillating wave. As lines scroll vertically, this varying horizontal word count
|
|
||||||
// creates a lateral wave pattern in the two-dimensional display space. Words are drawn sequentially
|
|
||||||
// from the proclamation text, and the varying segment sizes (1 word, then 2, then 3, then 5, etc.)
|
|
||||||
// ensure that over multiple oscillation cycles, the entire proclamation content receives balanced
|
|
||||||
// temporal exposure.
|
|
||||||
//
|
|
||||||
// Key differentiators from existing solutions:
|
|
||||||
// - Uses oscillating Fibonacci sequence for line word counts rather than fixed or random lengths
|
|
||||||
// - Creates two-dimensional wave pattern from one-dimensional text sequence
|
|
||||||
// - Ensures mathematical content distribution balance over time through progressive segmentation
|
|
||||||
// - Produces natural expansion-contraction rhythm synchronized with Fibonacci progression
|
|
||||||
// - Combines spatial wave formation with temporal content distribution in single mechanism
|
|
||||||
//
|
|
||||||
// Within our Rhema Wave generation system, this spatial distribution pattern works in concert
|
|
||||||
// with the character-level scrambling and color distribution methods. While character scrambling
|
|
||||||
// creates local spatial frequency patterns and color oscillation creates temporal color rhythm,
|
|
||||||
// the Fibonacci line length pattern generates a macro-level spatial wave that modulates the
|
|
||||||
// overall display geometry. The mathematical relationship between line lengths creates harmonic
|
|
||||||
// spatial progression, and the oscillating nature ensures proclamation content cycles through
|
|
||||||
// varying exposure durations, contributing to the layered frequency composition of the complete
|
|
||||||
// Rhema Wave output.
|
|
||||||
func (f *FibonacciPattern) GetNextLine(words []string, wordIndex *int, ctx *LineRenderContext) []string {
|
|
||||||
if ctx == nil || len(words) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
numWords := f.sequence[len(f.sequence)-1]
|
|
||||||
targetWidth := int32(ctx.ScreenWidth)
|
|
||||||
|
|
||||||
var line []string
|
|
||||||
var currentWidth int32
|
|
||||||
wordsAdded := 0
|
|
||||||
|
|
||||||
for wordsAdded < numWords && len(words) > 0 {
|
|
||||||
if int(*wordIndex) > len(words) {
|
|
||||||
*wordIndex = 0
|
|
||||||
}
|
|
||||||
word := words[*wordIndex%len(words)]
|
|
||||||
*wordIndex++
|
|
||||||
|
|
||||||
if word == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
wordWidth := estimateWordWidth(word, ctx.Font, ctx.FontScale)
|
|
||||||
if len(line) > 0 {
|
|
||||||
wordWidth += ctx.SpaceWidth
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(line) == 0 || currentWidth+wordWidth <= int32(float32(targetWidth)*float32(.9)) {
|
|
||||||
line = append(line, word)
|
|
||||||
currentWidth += wordWidth
|
|
||||||
wordsAdded++
|
|
||||||
} else {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: What should we do here instead?
|
|
||||||
// A line being longer than the display still needs to be handled, though, it should never happen
|
|
||||||
// if currentWidth >= ctx.ScreenWidth {
|
|
||||||
// syscall.Kill(syscall.Getpid(), syscall.SIGTRAP)
|
|
||||||
// }
|
|
||||||
|
|
||||||
// Update the sequence based on direction
|
|
||||||
if f.increasing {
|
|
||||||
if len(f.sequence) < len(f.masterSequence) {
|
|
||||||
// Grow sequence by appending next Fibonacci number
|
|
||||||
f.sequence = append(f.sequence, f.masterSequence[len(f.sequence)])
|
|
||||||
} else {
|
|
||||||
// Reached the top, now start shrinking
|
|
||||||
f.increasing = false
|
|
||||||
f.sequence = f.sequence[:len(f.sequence)-1]
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if len(f.sequence) > 1 {
|
|
||||||
// Shrink the sequence
|
|
||||||
f.sequence = f.sequence[:len(f.sequence)-1]
|
|
||||||
} else {
|
|
||||||
// Hit bottom, start growing again
|
|
||||||
f.increasing = true
|
|
||||||
f.sequence = append(f.sequence, f.masterSequence[1]) // index 1 = second element = 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return line
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
package pattern
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"rhemawavetech.com/rhemawave/internal/fontManager"
|
|
||||||
)
|
|
||||||
|
|
||||||
type LinePattern interface {
|
|
||||||
GetNextLine(words []string, wordIndex *int, ctx *LineRenderContext) []string // Generates the next line in the pattern
|
|
||||||
Name() string // Used to display the name of a LinePattern
|
|
||||||
}
|
|
||||||
|
|
||||||
// PatternInfo holds the public information about a registered pattern.
|
|
||||||
type PatternInfo struct {
|
|
||||||
Key string // The machine-readable key (e.g., "sine")
|
|
||||||
DisplayName string // The human-readable name for the UI (e.g., "Sine Wave")
|
|
||||||
}
|
|
||||||
|
|
||||||
type LineRenderContext struct {
|
|
||||||
ScreenWidth int32
|
|
||||||
Font *fontManager.FontEntry
|
|
||||||
FontScale float32
|
|
||||||
SpaceWidth int32
|
|
||||||
}
|
|
||||||
|
|
||||||
var Registry = make(map[string]func() LinePattern)
|
|
||||||
|
|
||||||
func Register(name string, constructor func() LinePattern) {
|
|
||||||
Registry[name] = constructor
|
|
||||||
}
|
|
||||||
|
|
||||||
func Get(name string) (LinePattern, error) {
|
|
||||||
constructor, ok := Registry[name]
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("failed to find line pattern %s in Registry", name)
|
|
||||||
}
|
|
||||||
return constructor(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListAvailable returns a slice of info for all registered patterns.
|
|
||||||
// This is used to build a settings menu.
|
|
||||||
func ListAvailable() []PatternInfo {
|
|
||||||
var list []PatternInfo
|
|
||||||
for key, constructor := range Registry {
|
|
||||||
pattern := constructor() // Create a temporary instance to get its name
|
|
||||||
list = append(list, PatternInfo{
|
|
||||||
Key: key,
|
|
||||||
DisplayName: pattern.Name(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return list
|
|
||||||
}
|
|
||||||
|
|
||||||
// estimateWordWidth provides a basic estimate of the width of a word based on the font and scale.
|
|
||||||
func estimateWordWidth(word string, font *fontManager.FontEntry, scale float32) int32 {
|
|
||||||
if font == nil {
|
|
||||||
// Basic fallback estimate
|
|
||||||
return int32(float32(len(word)) * 10 * scale)
|
|
||||||
}
|
|
||||||
|
|
||||||
total := int32(0)
|
|
||||||
for _, ch := range word {
|
|
||||||
glyph, ok := fontManager.Get().GetGlyph(font.Name, ch)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
total += int32(float32(glyph.Advance) * scale)
|
|
||||||
}
|
|
||||||
return total
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
package pattern
|
|
||||||
|
|
||||||
import (
|
|
||||||
"math"
|
|
||||||
|
|
||||||
"rhemawavetech.com/rhemawave/internal/fontManager"
|
|
||||||
)
|
|
||||||
|
|
||||||
type SineWavePattern struct {
|
|
||||||
waveStepCounter int
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
|
||||||
Register("sine", func() LinePattern { return &SineWavePattern{} })
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *SineWavePattern) Name() string { return "Sine Wave" }
|
|
||||||
func (s *SineWavePattern) GetNextLine(words []string, wordIndex *int, ctx *LineRenderContext) []string {
|
|
||||||
const (
|
|
||||||
totalLines = 67
|
|
||||||
frequency = 5.0 // cycles across all lines
|
|
||||||
amplitude = 0.85 // how much wave can scale
|
|
||||||
baseRatio = 0.03 // minimum line width as % of screen
|
|
||||||
sharpness = 3.0 // sharpness of the curve
|
|
||||||
)
|
|
||||||
|
|
||||||
screenWidth := int32(800)
|
|
||||||
spaceWidth := int32(10)
|
|
||||||
font := (*fontManager.FontEntry)(nil)
|
|
||||||
fontScale := float32(1.0)
|
|
||||||
|
|
||||||
if ctx != nil {
|
|
||||||
screenWidth = ctx.ScreenWidth
|
|
||||||
spaceWidth = ctx.SpaceWidth
|
|
||||||
font = ctx.Font
|
|
||||||
fontScale = ctx.FontScale
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sharpen the wave (skinnier troughs)
|
|
||||||
// raw := (math.Sin(2*math.Pi*frequency*float64(s.waveStepCounter)/totalLines) + 1.0) / 2.0
|
|
||||||
// waveVal := math.Pow(raw, sharpness)
|
|
||||||
angle := 2 * math.Pi * frequency * float64(s.waveStepCounter) / totalLines
|
|
||||||
waveVal := (math.Sin(angle) + 1.0) / 2.0
|
|
||||||
|
|
||||||
// Target width in pixels
|
|
||||||
targetWidth := int32(float64(screenWidth) * (baseRatio + amplitude*waveVal))
|
|
||||||
|
|
||||||
var line []string
|
|
||||||
var currentWidth int32
|
|
||||||
|
|
||||||
for len(words) > 0 {
|
|
||||||
word := words[*wordIndex%len(words)]
|
|
||||||
*wordIndex++
|
|
||||||
|
|
||||||
if word == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
wordWidth := estimateWordWidth(word, font, fontScale)
|
|
||||||
if len(line) > 0 {
|
|
||||||
wordWidth += spaceWidth
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(line) == 0 {
|
|
||||||
// Always include the first word
|
|
||||||
line = append(line, word)
|
|
||||||
currentWidth += wordWidth
|
|
||||||
} else {
|
|
||||||
// Only add next word if it fits
|
|
||||||
if currentWidth+wordWidth > targetWidth {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
line = append(line, word)
|
|
||||||
currentWidth += wordWidth
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
s.waveStepCounter++
|
|
||||||
return line
|
|
||||||
}
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
package shaderManager
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/go-gl/gl/v3.3-core/gl"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Shader represents a compiled GLSL program and its associated data.
|
|
||||||
type Shader struct {
|
|
||||||
Name string
|
|
||||||
ProgramID uint32
|
|
||||||
Render func() // This will be used in the future.
|
|
||||||
|
|
||||||
// Cached uniform locations (looked up once after linking)
|
|
||||||
LocMVPMatrix int32
|
|
||||||
LocTexture int32
|
|
||||||
LocTextColor int32
|
|
||||||
LocColor int32
|
|
||||||
}
|
|
||||||
|
|
||||||
// ShaderManager handles the loading, compiling, and management of GLSL shader programs.
|
|
||||||
type ShaderManager struct {
|
|
||||||
shaders map[string]*Shader
|
|
||||||
}
|
|
||||||
|
|
||||||
var (
|
|
||||||
instance *ShaderManager
|
|
||||||
once sync.Once
|
|
||||||
)
|
|
||||||
|
|
||||||
// InitShaderManager initializes the ShaderManager singleton.
|
|
||||||
func InitShaderManager(shaderDir string) error {
|
|
||||||
var err error
|
|
||||||
once.Do(func() {
|
|
||||||
sm := &ShaderManager{
|
|
||||||
shaders: make(map[string]*Shader),
|
|
||||||
}
|
|
||||||
if e := sm.LoadShaders(shaderDir); e != nil {
|
|
||||||
err = fmt.Errorf("could not load shaders from %s: %w", shaderDir, e)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
instance = sm
|
|
||||||
})
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get returns the singleton ShaderManager instance.
|
|
||||||
func Get() *ShaderManager {
|
|
||||||
if instance == nil {
|
|
||||||
panic("ShaderManager has not been initialized. Call InitShaderManager at application startup.")
|
|
||||||
}
|
|
||||||
return instance
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get retrieves a compiled Shader object by its name.
|
|
||||||
func (sm *ShaderManager) Get(name string) (*Shader, bool) {
|
|
||||||
shader, ok := sm.shaders[name]
|
|
||||||
return shader, ok
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListShaders returns all shaders.
|
|
||||||
func (sm *ShaderManager) ListShaders() map[string]*Shader {
|
|
||||||
return sm.shaders
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close deletes all loaded shader programs from the GPU to free up resources.
|
|
||||||
func (sm *ShaderManager) Close() {
|
|
||||||
for name, shader := range sm.shaders {
|
|
||||||
gl.DeleteProgram(shader.ProgramID)
|
|
||||||
delete(sm.shaders, name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoadShaders scans a directory for .vert and .frag files and creates a shader program for each pair.
|
|
||||||
func (sm *ShaderManager) LoadShaders(shaderDir string) error {
|
|
||||||
// Get assets directory from database
|
|
||||||
shaderFiles := make(map[string]string)
|
|
||||||
|
|
||||||
err := filepath.Walk(shaderDir, func(path string, info os.FileInfo, err error) error {
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !info.IsDir() && strings.HasSuffix(info.Name(), ".vert") {
|
|
||||||
name := strings.TrimSuffix(info.Name(), ".vert")
|
|
||||||
shaderFiles[name] = path
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("error walking shader directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for name, vertPath := range shaderFiles {
|
|
||||||
fragPath := strings.Replace(vertPath, ".vert", ".frag", 1)
|
|
||||||
if _, err := os.Stat(fragPath); os.IsNotExist(err) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
programID, err := createShaderProgram(vertPath, fragPath)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
newShader := &Shader{
|
|
||||||
Name: name,
|
|
||||||
ProgramID: programID,
|
|
||||||
Render: nil, // Render function is not yet assigned.
|
|
||||||
}
|
|
||||||
newShader.cacheUniformLocations()
|
|
||||||
sm.shaders[name] = newShader
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// cacheUniformLocations looks up common uniform locations once and stores them on the Shader.
|
|
||||||
func (s *Shader) cacheUniformLocations() {
|
|
||||||
s.LocMVPMatrix = gl.GetUniformLocation(s.ProgramID, gl.Str("u_mvpMatrix\x00"))
|
|
||||||
s.LocTexture = gl.GetUniformLocation(s.ProgramID, gl.Str("u_texture\x00"))
|
|
||||||
s.LocTextColor = gl.GetUniformLocation(s.ProgramID, gl.Str("u_textColor\x00"))
|
|
||||||
s.LocColor = gl.GetUniformLocation(s.ProgramID, gl.Str("u_color\x00"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// createShaderProgram reads, compiles, and links a vertex and fragment shader from file paths.
|
|
||||||
func createShaderProgram(vertexPath, fragmentPath string) (uint32, error) {
|
|
||||||
vertexSource, err := os.ReadFile(vertexPath)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("reading vertex shader %s: %w", vertexPath, err)
|
|
||||||
}
|
|
||||||
fragmentSource, err := os.ReadFile(fragmentPath)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("reading fragment shader %s: %w", fragmentPath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
vertexShader, err := compileShader(string(vertexSource)+"\x00", gl.VERTEX_SHADER)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
defer gl.DeleteShader(vertexShader)
|
|
||||||
|
|
||||||
fragmentShader, err := compileShader(string(fragmentSource)+"\x00", gl.FRAGMENT_SHADER)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
defer gl.DeleteShader(fragmentShader)
|
|
||||||
|
|
||||||
program := gl.CreateProgram()
|
|
||||||
gl.AttachShader(program, vertexShader)
|
|
||||||
gl.AttachShader(program, fragmentShader)
|
|
||||||
gl.LinkProgram(program)
|
|
||||||
|
|
||||||
var status int32
|
|
||||||
gl.GetProgramiv(program, gl.LINK_STATUS, &status)
|
|
||||||
if status == gl.FALSE {
|
|
||||||
var logLength int32
|
|
||||||
gl.GetProgramiv(program, gl.INFO_LOG_LENGTH, &logLength)
|
|
||||||
log := strings.Repeat("\x00", int(logLength+1))
|
|
||||||
gl.GetProgramInfoLog(program, logLength, nil, gl.Str(log))
|
|
||||||
return 0, fmt.Errorf("failed to link program: %v", log)
|
|
||||||
}
|
|
||||||
|
|
||||||
return program, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// compileShader is a helper function that compiles a single shader source string.
|
|
||||||
func compileShader(source string, shaderType uint32) (uint32, error) {
|
|
||||||
shader := gl.CreateShader(shaderType)
|
|
||||||
csources, free := gl.Strs(source)
|
|
||||||
gl.ShaderSource(shader, 1, csources, nil)
|
|
||||||
free()
|
|
||||||
gl.CompileShader(shader)
|
|
||||||
|
|
||||||
var status int32
|
|
||||||
gl.GetShaderiv(shader, gl.COMPILE_STATUS, &status)
|
|
||||||
if status == gl.FALSE {
|
|
||||||
var logLength int32
|
|
||||||
gl.GetShaderiv(shader, gl.INFO_LOG_LENGTH, &logLength)
|
|
||||||
log := strings.Repeat("\x00", int(logLength+1))
|
|
||||||
gl.GetShaderInfoLog(shader, logLength, nil, gl.Str(log))
|
|
||||||
|
|
||||||
var shaderTypeName string
|
|
||||||
switch shaderType {
|
|
||||||
case gl.VERTEX_SHADER:
|
|
||||||
shaderTypeName = "Vertex"
|
|
||||||
case gl.FRAGMENT_SHADER:
|
|
||||||
shaderTypeName = "Fragment"
|
|
||||||
default:
|
|
||||||
shaderTypeName = "Unknown"
|
|
||||||
}
|
|
||||||
return 0, fmt.Errorf("failed to compile %s shader: %v", shaderTypeName, log)
|
|
||||||
}
|
|
||||||
|
|
||||||
return shader, nil
|
|
||||||
}
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
package sync
|
|
||||||
|
|
||||||
import (
|
|
||||||
"regexp"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"gorm.io/gorm/clause"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/db"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/logger"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/netreq"
|
|
||||||
)
|
|
||||||
|
|
||||||
var htmlRegex = regexp.MustCompile(`<[^>]*>`)
|
|
||||||
|
|
||||||
// SyncProclamations fetches data from the API, cleans it, and updates the local DB.
|
|
||||||
// It returns the slice of proclamations so the UI can update immediately.
|
|
||||||
func SyncProclamations(database *gorm.DB) ([]db.Proclamation, error) {
|
|
||||||
fullURL := "https://portal.radiantwavetech.com/api/v1/proclamations"
|
|
||||||
logger.Debug("Syncronizing proclamations. URL: %s", fullURL)
|
|
||||||
|
|
||||||
proclamations, err := netreq.GetJSON[db.Proclamation](fullURL)
|
|
||||||
if err != nil {
|
|
||||||
// If we're offline or API is down, we log it but don't crash.
|
|
||||||
// The UI can still use whatever is currently in the DB.
|
|
||||||
logger.Error("Proclamation sync failed, skipping update: %v", err)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
logger.Debug("Request Granted")
|
|
||||||
|
|
||||||
for i := range proclamations {
|
|
||||||
proclamations[i].Content = htmlRegex.ReplaceAllString(proclamations[i].Content, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
var storedProclamationSettings []db.Proclamation
|
|
||||||
database.Select("ID", "Selected").Find(&storedProclamationSettings)
|
|
||||||
|
|
||||||
// [ID]Selected
|
|
||||||
stash := make(map[string]bool)
|
|
||||||
for _, row := range storedProclamationSettings {
|
|
||||||
stash[row.ID] = row.Selected
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := range proclamations {
|
|
||||||
proclamations[i].Selected = stash[proclamations[i].ID]
|
|
||||||
}
|
|
||||||
|
|
||||||
err = database.Transaction(func(tx *gorm.DB) error {
|
|
||||||
ids := make([]string, len(proclamations))
|
|
||||||
for i, p := range proclamations {
|
|
||||||
ids[i] = p.ID
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(ids) == 0 {
|
|
||||||
// API returned nothing → DB should be empty
|
|
||||||
if err := tx.Where("1 = 1").Delete(&db.Proclamation{}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Remove anything not in API
|
|
||||||
if err := tx.Where("id NOT IN ?", ids).Delete(&db.Proclamation{}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upsert (preserve Selected automatically)
|
|
||||||
if err := tx.Clauses(clause.OnConflict{
|
|
||||||
Columns: []clause.Column{{Name: "id"}},
|
|
||||||
DoUpdates: clause.AssignmentColumns([]string{
|
|
||||||
"name",
|
|
||||||
"content",
|
|
||||||
"solfeggio_frequency_id",
|
|
||||||
"category_id",
|
|
||||||
"available",
|
|
||||||
"price",
|
|
||||||
}),
|
|
||||||
}).Create(&proclamations).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return nil as err for this statement, does not exit the function
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
logger.Debug("Proclamations stored to database")
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("Failed to save synced proclamations: %v", err)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Info("Successfully synced proclamations: %d", len(proclamations))
|
|
||||||
return proclamations, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func SyncCategories(database *gorm.DB) ([]db.ProclamationCategory, error) {
|
|
||||||
fullURL := "https://portal.radiantwavetech.com/api/v1/categories"
|
|
||||||
categories, err := netreq.GetJSON[db.ProclamationCategory](fullURL)
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("Category sync failed, skipping update: %v", err)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = database.Transaction(func(tx *gorm.DB) error {
|
|
||||||
if err := tx.Where("1 = 1").Delete(&db.ProclamationCategory{}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := tx.Save(&categories).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("failed to sync proclamation categories: %v", err)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Info("Successfully synced proclamation categories: %d", len(categories))
|
|
||||||
return categories, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func SyncFrequencies(database *gorm.DB) ([]db.SolfeggioFrequency, error) {
|
|
||||||
fullURL := "https://portal.radiantwavetech.com/api/v1/frequencies"
|
|
||||||
frequencies, err := netreq.GetJSON[db.SolfeggioFrequency](fullURL)
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("Frequency sync failed, skipping update: %v", err)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
err = database.Transaction(func(tx *gorm.DB) error {
|
|
||||||
if err := tx.Where("1 = 1").Delete(&db.SolfeggioFrequency{}).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := tx.Save(&frequencies).Error; err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
logger.Error("failed to sync solfeggio frequencies: %v", err)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
logger.Info("Succesfully synced solfeggio frequencies: %d", len(frequencies))
|
|
||||||
return frequencies, nil
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
self:
|
|
||||||
|
|
||||||
{ config, lib, pkgs, ... }:
|
|
||||||
|
|
||||||
let
|
|
||||||
cfg = config.services.rhemawave;
|
|
||||||
in
|
|
||||||
{
|
|
||||||
options.services.rhemawave = {
|
|
||||||
enable = lib.mkEnableOption "RhemaWave kiosk application";
|
|
||||||
|
|
||||||
package = lib.mkOption {
|
|
||||||
type = lib.types.package;
|
|
||||||
# Points to the default package in your flake
|
|
||||||
default = self.packages.${pkgs.system}.default;
|
|
||||||
description = "The RhemaWave package to use.";
|
|
||||||
};
|
|
||||||
|
|
||||||
user = lib.mkOption {
|
|
||||||
type = lib.types.str;
|
|
||||||
default = "kiosk";
|
|
||||||
description = "The user account name.";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
config = lib.mkIf cfg.enable {
|
|
||||||
# Just put the package in the system path.
|
|
||||||
# The machine (configuration.nix) handles the service and the files.
|
|
||||||
environment.systemPackages = [ cfg.package ];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
# NixOS test VM for RadiantWave — mirrors production kiosk setup.
|
|
||||||
#
|
|
||||||
# Build & run:
|
|
||||||
# nixos-rebuild build-vm --flake .#test-vm
|
|
||||||
# ./result/bin/run-test-vm-vm
|
|
||||||
#
|
|
||||||
# This replicates the production kiosk config from KioskConfig:
|
|
||||||
# - cage as Wayland compositor (fullscreen kiosk)
|
|
||||||
# - PipeWire audio (with a virtual sink for device-switch testing)
|
|
||||||
# - NetworkManager
|
|
||||||
# - Auto-login as kiosk user
|
|
||||||
# - RadiantWave started via loginShellInit (same as production)
|
|
||||||
|
|
||||||
{ config, lib, pkgs, ... }:
|
|
||||||
|
|
||||||
let
|
|
||||||
rhemaApp = config.services.rhemawave.package;
|
|
||||||
in
|
|
||||||
{
|
|
||||||
# ── Hostname ──────────────────────────────────────────────────────────────
|
|
||||||
networking.hostName = "radiantwave-test";
|
|
||||||
|
|
||||||
# ── RadiantWave module ────────────────────────────────────────────────────
|
|
||||||
services.rhemawave.enable = true;
|
|
||||||
|
|
||||||
# ── Boot ──────────────────────────────────────────────────────────────────
|
|
||||||
boot.loader.systemd-boot.enable = lib.mkForce false;
|
|
||||||
boot.loader.efi.canTouchEfiVariables = lib.mkForce false;
|
|
||||||
|
|
||||||
# ── Packages (matches production) ─────────────────────────────────────────
|
|
||||||
environment.systemPackages = [
|
|
||||||
pkgs.cage pkgs.rsync pkgs.dbus pkgs.mesa pkgs.git pkgs.tailscale pkgs.sqlite
|
|
||||||
];
|
|
||||||
|
|
||||||
# ── Audio (PipeWire + virtual sink for device-switch testing) ─────────────
|
|
||||||
services.pipewire = {
|
|
||||||
enable = true;
|
|
||||||
alsa.enable = true;
|
|
||||||
pulse.enable = true;
|
|
||||||
extraConfig.pipewire."30-virtual-sink" = {
|
|
||||||
"context.modules" = [
|
|
||||||
{
|
|
||||||
name = "libpipewire-module-loopback";
|
|
||||||
args = {
|
|
||||||
"node.name" = "virtual-speaker";
|
|
||||||
"node.description" = "Virtual Speaker";
|
|
||||||
"audio.position" = [ "FL" "FR" ];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
# ── Seat (matches production) ─────────────────────────────────────────────
|
|
||||||
services.seatd.enable = true;
|
|
||||||
hardware.graphics.enable = true;
|
|
||||||
|
|
||||||
# ── Networking ────────────────────────────────────────────────────────────
|
|
||||||
services.resolved.enable = false;
|
|
||||||
networking.networkmanager.enable = true;
|
|
||||||
systemd.services.NetworkManager-wait-online.enable = false;
|
|
||||||
systemd.network.wait-online.enable = false;
|
|
||||||
|
|
||||||
# ── SSH ───────────────────────────────────────────────────────────────────
|
|
||||||
services.openssh = {
|
|
||||||
enable = true;
|
|
||||||
settings = {
|
|
||||||
PermitRootLogin = "prohibit-password";
|
|
||||||
PasswordAuthentication = true;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
# ── User (matches production) ─────────────────────────────────────────────
|
|
||||||
users.users.kiosk = {
|
|
||||||
isNormalUser = true;
|
|
||||||
uid = 1000;
|
|
||||||
extraGroups = [ "audio" "video" "input" "networkmanager" "wheel" ];
|
|
||||||
password = "kiosk";
|
|
||||||
};
|
|
||||||
services.getty.autologinUser = "kiosk";
|
|
||||||
|
|
||||||
# ── Data directory ────────────────────────────────────────────────────────
|
|
||||||
systemd.tmpfiles.rules = [
|
|
||||||
"d /home/kiosk/.local/share/rhemawave 0770 kiosk kiosk -"
|
|
||||||
"d /home/kiosk/.radiantwave/audio 0755 kiosk kiosk -"
|
|
||||||
];
|
|
||||||
|
|
||||||
# ── RadiantWave service (via loginShellInit, same as production) ──────────
|
|
||||||
environment.loginShellInit = ''
|
|
||||||
if [ "$(tty)" = "/dev/tty1" ]; then
|
|
||||||
export GODEBUG=netdns=cgo
|
|
||||||
export SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
|
|
||||||
|
|
||||||
# Smart Network Wait
|
|
||||||
for i in {1..10}; do
|
|
||||||
if ${pkgs.iputils}/bin/ping -c 1 8.8.8.8 &> /dev/null; then
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
sleep 1
|
|
||||||
done
|
|
||||||
|
|
||||||
# Graphics fixes (matches production)
|
|
||||||
export SDL_VIDEODRIVER=wayland
|
|
||||||
export QT_QPA_PLATFORM=wayland
|
|
||||||
export XDG_SESSION_TYPE=wayland
|
|
||||||
export WLR_RENDERER=pixman
|
|
||||||
export WLR_NO_HARDWARE_CURSORS=1
|
|
||||||
|
|
||||||
# Asset sync + start app (same as production)
|
|
||||||
mkdir -p /home/kiosk/.local/share/rhemawave
|
|
||||||
${pkgs.rsync}/bin/rsync -ru ${rhemaApp}/share/rhemawave/ /home/kiosk/.local/share/rhemawave/
|
|
||||||
mkdir -p /home/kiosk/.radiantwave/audio
|
|
||||||
${pkgs.rsync}/bin/rsync -ru ${rhemaApp}/share/rhemawave/audio/ /home/kiosk/.radiantwave/audio/
|
|
||||||
|
|
||||||
cage -- ${rhemaApp}/bin/rhemawave -d -s > /tmp/kiosk.log 2>&1
|
|
||||||
fi
|
|
||||||
'';
|
|
||||||
|
|
||||||
# ── VM settings ───────────────────────────────────────────────────────────
|
|
||||||
virtualisation = {
|
|
||||||
memorySize = 4096;
|
|
||||||
cores = 4;
|
|
||||||
graphics = true;
|
|
||||||
forwardPorts = [
|
|
||||||
{ from = "host"; host.port = 2222; guest.port = 22; }
|
|
||||||
];
|
|
||||||
qemu.options = [
|
|
||||||
"-device intel-hda"
|
|
||||||
"-device hda-duplex"
|
|
||||||
"-vga virtio"
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
services.logrotate.enable = false;
|
|
||||||
|
|
||||||
# ── Nix settings ──────────────────────────────────────────────────────────
|
|
||||||
nix.settings.experimental-features = [ "nix-command" "flakes" ];
|
|
||||||
nix.settings.accept-flake-config = true;
|
|
||||||
|
|
||||||
# ── Firewall (open for local dev) ─────────────────────────────────────────
|
|
||||||
networking.firewall.enable = false;
|
|
||||||
|
|
||||||
system.stateVersion = "24.11";
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
* Networking Issues
|
|
||||||
- The networking package needs a LOT more logging... Nothing gets output when things crash.
|
|
||||||
-
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
// Allow kiosk user to run apt update, apt install for rhemawave packages,
|
|
||||||
// and restart getty for application reloading
|
|
||||||
polkit.addRule(function(action, subject) {
|
|
||||||
if (action.id == "org.freedesktop.policykit.exec" &&
|
|
||||||
subject.user == "kiosk") {
|
|
||||||
|
|
||||||
var program = action.lookup("program");
|
|
||||||
var cmdline = action.lookup("command_line");
|
|
||||||
|
|
||||||
// Allow dpkg --configure -a
|
|
||||||
if (program == "/usr/bin/dpkg") {
|
|
||||||
if (cmdline && cmdline.indexOf("--configure") >= 0) {
|
|
||||||
return polkit.Result.YES;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Allow apt update
|
|
||||||
if (program == "/usr/bin/apt") {
|
|
||||||
if (cmdline && cmdline.indexOf("update") >= 0) {
|
|
||||||
return polkit.Result.YES;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Allow: apt install --only-upgrade rhemawave[-dev]
|
|
||||||
if (cmdline &&
|
|
||||||
cmdline.indexOf("install") >= 0 &&
|
|
||||||
cmdline.indexOf("--only-upgrade") >= 0 &&
|
|
||||||
cmdline.indexOf("rhemawave") >= 0) {
|
|
||||||
return polkit.Result.YES;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Allow systemctl restart getty@tty1.service
|
|
||||||
if (program == "/usr/bin/systemctl") {
|
|
||||||
if (cmdline &&
|
|
||||||
cmdline.indexOf("restart") >= 0 &&
|
|
||||||
cmdline.indexOf("getty@tty1.service") >= 0) {
|
|
||||||
return polkit.Result.YES;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Allow sh for Tailscale install script
|
|
||||||
if (program == "/usr/bin/sh" || program == "/bin/sh") {
|
|
||||||
if (cmdline && cmdline.indexOf("tailscale.com/install.sh") >= 0) {
|
|
||||||
return polkit.Result.YES;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Allow tailscale set --operator
|
|
||||||
if (program == "/usr/bin/tailscale") {
|
|
||||||
if (cmdline && cmdline.indexOf("set") >= 0 && cmdline.indexOf("--operator") >= 0) {
|
|
||||||
return polkit.Result.YES;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
[Service]
|
|
||||||
ExecStart=
|
|
||||||
ExecStart=-/sbin/agetty --autologin kiosk --noclear %I $TERM
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Post-installation script for RhemaWave
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Create kiosk group if it doesn't exist
|
|
||||||
if ! getent group kiosk > /dev/null 2>&1; then
|
|
||||||
echo "Creating kiosk group..."
|
|
||||||
groupadd --system kiosk
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Create kiosk user if it doesn't exist
|
|
||||||
if ! getent passwd kiosk > /dev/null 2>&1; then
|
|
||||||
echo "Creating kiosk user..."
|
|
||||||
useradd --system --gid kiosk --create-home --home-dir /home/kiosk \
|
|
||||||
--shell /bin/bash --comment "RhemaWave Kiosk User" kiosk
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Set proper group ownership for writable directories
|
|
||||||
chgrp -R kiosk /usr/local/share/rhemawave
|
|
||||||
|
|
||||||
# Ensure kiosk user can write to the data directory
|
|
||||||
chmod g+w /usr/local/share/rhemawave
|
|
||||||
|
|
||||||
echo "✓ RhemaWave post-install complete"
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
# RhemaWave kiosk auto-launch on tty1
|
|
||||||
if [ -z "$WAYLAND_DISPLAY" ] && [ "$(tty)" = "/dev/tty1" ]; then
|
|
||||||
export XDG_RUNTIME_DIR=/run/user/$(id -u)
|
|
||||||
export SDL_VIDEODRIVER=wayland
|
|
||||||
|
|
||||||
# Check for updates before launching
|
|
||||||
/usr/bin/python3 /usr/local/bin/rhemawave-updater.py || true
|
|
||||||
|
|
||||||
# Set all sinks to full volume
|
|
||||||
for sink in $(wpctl status | grep -oP '^\s+\K\d+(?=\.)' | head -10); do
|
|
||||||
wpctl set-volume "$sink" 1.0 2>/dev/null
|
|
||||||
done
|
|
||||||
|
|
||||||
exec cage -- /usr/local/bin/rhemawave
|
|
||||||
fi
|
|
||||||
|
|
||||||
@@ -1,564 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
rhemawave-updater.py
|
|
||||||
|
|
||||||
Auto-updater for RhemaWave kiosk application.
|
|
||||||
Uses apt to check for and install updates from the configured repository.
|
|
||||||
|
|
||||||
USAGE:
|
|
||||||
Run as kiosk user:
|
|
||||||
python3 /usr/local/bin/rhemawave-updater.py
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import subprocess
|
|
||||||
import json
|
|
||||||
import socket
|
|
||||||
import urllib.request
|
|
||||||
from pathlib import Path
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
# Configuration - templated by build script
|
|
||||||
PACKAGE_NAME = "__CHANNEL__"
|
|
||||||
HEADSCALE_URL = "https://headscale.rhemawavetech.com"
|
|
||||||
HEADSCALE_AUTHKEY = "803130d5b2e38bffa9fa5b6921a3d6403100e2e30fea1bd5"
|
|
||||||
|
|
||||||
# Paths
|
|
||||||
LOG_FILE = Path("/home/kiosk/rhemawave-updater.log")
|
|
||||||
LICENSE_KEY_PATH = Path.home() / ".local/share/rhemawave/license_key"
|
|
||||||
|
|
||||||
|
|
||||||
class UpdaterLogger:
|
|
||||||
"""Simple logger that writes to both stdout and file"""
|
|
||||||
|
|
||||||
def __init__(self, log_file):
|
|
||||||
self.log_file = log_file
|
|
||||||
log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
log_file.touch(exist_ok=True)
|
|
||||||
|
|
||||||
def log(self, message):
|
|
||||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
||||||
formatted = f"[{timestamp}] {message}"
|
|
||||||
print(formatted)
|
|
||||||
try:
|
|
||||||
with open(self.log_file, 'a') as f:
|
|
||||||
f.write(formatted + '\n')
|
|
||||||
except IOError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def error(self, message):
|
|
||||||
self.log(f"ERROR: {message}")
|
|
||||||
|
|
||||||
def debug(self, message):
|
|
||||||
self.log(f"DEBUG: {message}")
|
|
||||||
|
|
||||||
def success(self, message):
|
|
||||||
self.log(f"✓ {message}")
|
|
||||||
|
|
||||||
|
|
||||||
logger = UpdaterLogger(LOG_FILE)
|
|
||||||
|
|
||||||
|
|
||||||
def check_network(timeout=2):
|
|
||||||
"""Quick check for internet connectivity."""
|
|
||||||
logger.log("Checking network connectivity...")
|
|
||||||
try:
|
|
||||||
socket.create_connection(("1.1.1.1", 53), timeout=timeout)
|
|
||||||
logger.success("Network check passed (1.1.1.1:53 reachable)")
|
|
||||||
return True
|
|
||||||
except OSError as e:
|
|
||||||
logger.error(f"Network check failed: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def run_command(cmd, description, use_pkexec=False):
|
|
||||||
"""Run a command and return success status"""
|
|
||||||
if use_pkexec:
|
|
||||||
cmd = ["pkexec"] + cmd
|
|
||||||
|
|
||||||
logger.log(f"{description}...")
|
|
||||||
logger.debug(f"Command: {' '.join(cmd)}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
cmd,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=300
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.debug(f"Return code: {result.returncode}")
|
|
||||||
|
|
||||||
if result.stdout:
|
|
||||||
for line in result.stdout.splitlines():
|
|
||||||
logger.log(f" {line}")
|
|
||||||
|
|
||||||
if result.returncode != 0:
|
|
||||||
logger.error(f"{description} failed with return code {result.returncode}")
|
|
||||||
if result.stderr:
|
|
||||||
for line in result.stderr.splitlines():
|
|
||||||
logger.error(f" {line}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
logger.success(f"{description} completed successfully")
|
|
||||||
return True
|
|
||||||
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
logger.error(f"{description} timed out")
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"{description} failed: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def configure_dpkg():
|
|
||||||
logger.log("Checking for dpkg errors")
|
|
||||||
if not run_command(
|
|
||||||
["dpkg", "--configure", "-a"],
|
|
||||||
'Check for dpkg configuration errors and resolve them before performing updates',
|
|
||||||
use_pkexec=True):
|
|
||||||
logger.error("Unable to reconfigure system, please reach out to support!")
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
def is_tailscale_installed():
|
|
||||||
"""Check if Tailscale is installed."""
|
|
||||||
logger.log("Checking if Tailscale is installed...")
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
["tailscale", "version"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=True
|
|
||||||
)
|
|
||||||
version = result.stdout.strip()
|
|
||||||
logger.success(f"Tailscale is installed: {version}")
|
|
||||||
return True
|
|
||||||
except FileNotFoundError:
|
|
||||||
logger.log("Tailscale binary not found")
|
|
||||||
return False
|
|
||||||
except subprocess.CalledProcessError as e:
|
|
||||||
logger.error(f"Tailscale version check failed: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def install_tailscale():
|
|
||||||
"""Install Tailscale."""
|
|
||||||
logger.log("Installing Tailscale...")
|
|
||||||
try:
|
|
||||||
logger.debug("Running: pkexec sh -c 'curl -fsSL https://tailscale.com/install.sh | sh'")
|
|
||||||
result = subprocess.run(
|
|
||||||
["pkexec", "sh", "-c", "curl -fsSL https://tailscale.com/install.sh | sh"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=120
|
|
||||||
)
|
|
||||||
logger.debug(f"Install return code: {result.returncode}")
|
|
||||||
if result.stdout:
|
|
||||||
for line in result.stdout.splitlines():
|
|
||||||
logger.log(f" {line}")
|
|
||||||
if result.stderr:
|
|
||||||
for line in result.stderr.splitlines():
|
|
||||||
logger.log(f" [stderr] {line}")
|
|
||||||
|
|
||||||
if result.returncode != 0:
|
|
||||||
logger.error("Tailscale installation failed")
|
|
||||||
return False
|
|
||||||
|
|
||||||
logger.success("Tailscale installed successfully")
|
|
||||||
return True
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
logger.error("Tailscale installation timed out")
|
|
||||||
return False
|
|
||||||
except subprocess.CalledProcessError as e:
|
|
||||||
logger.error(f"Tailscale installation failed: {e}")
|
|
||||||
if e.stderr:
|
|
||||||
logger.error(f"stderr: {e.stderr}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def set_tailscale_operator():
|
|
||||||
"""Set Tailscale operator to kiosk."""
|
|
||||||
logger.log("Setting Tailscale operator to 'kiosk'...")
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
["pkexec", "tailscale", "set", "--operator=kiosk"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=30
|
|
||||||
)
|
|
||||||
logger.debug(f"Return code: {result.returncode}")
|
|
||||||
if result.stdout:
|
|
||||||
logger.debug(f"stdout: {result.stdout}")
|
|
||||||
if result.stderr:
|
|
||||||
logger.debug(f"stderr: {result.stderr}")
|
|
||||||
|
|
||||||
if result.returncode != 0:
|
|
||||||
logger.error(f"Failed to set operator: {result.stderr}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
logger.success("Tailscale operator set to 'kiosk'")
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to set Tailscale operator: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def get_tailscale_status():
|
|
||||||
"""Get full Tailscale status as dict."""
|
|
||||||
logger.log("Getting Tailscale status...")
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
["tailscale", "status", "--json"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=10
|
|
||||||
)
|
|
||||||
logger.debug(f"Return code: {result.returncode}")
|
|
||||||
|
|
||||||
if result.returncode != 0:
|
|
||||||
logger.error(f"tailscale status failed: {result.stderr}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
status = json.loads(result.stdout)
|
|
||||||
|
|
||||||
# Log key status info
|
|
||||||
backend_state = status.get("BackendState", "unknown")
|
|
||||||
logger.log(f" BackendState: {backend_state}")
|
|
||||||
|
|
||||||
self_info = status.get("Self", {})
|
|
||||||
if self_info:
|
|
||||||
logger.log(f" HostName: {self_info.get('HostName', 'unknown')}")
|
|
||||||
logger.log(f" DNSName: {self_info.get('DNSName', 'unknown')}")
|
|
||||||
logger.log(f" Online: {self_info.get('Online', 'unknown')}")
|
|
||||||
tailscale_ips = self_info.get("TailscaleIPs", [])
|
|
||||||
logger.log(f" TailscaleIPs: {tailscale_ips}")
|
|
||||||
|
|
||||||
# Check for health issues
|
|
||||||
health = status.get("Health", [])
|
|
||||||
if health:
|
|
||||||
logger.log(" Health issues:")
|
|
||||||
for issue in health:
|
|
||||||
logger.log(f" - {issue}")
|
|
||||||
|
|
||||||
return status
|
|
||||||
except json.JSONDecodeError as e:
|
|
||||||
logger.error(f"Failed to parse Tailscale status JSON: {e}")
|
|
||||||
return None
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
logger.error("Tailscale status timed out")
|
|
||||||
return None
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to get Tailscale status: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def is_tailscale_logged_in(status=None):
|
|
||||||
"""Check if Tailscale is logged in."""
|
|
||||||
if status is None:
|
|
||||||
status = get_tailscale_status()
|
|
||||||
|
|
||||||
if status is None:
|
|
||||||
logger.error("Cannot determine login status - no status available")
|
|
||||||
return False
|
|
||||||
|
|
||||||
backend_state = status.get("BackendState", "")
|
|
||||||
logged_in = backend_state == "Running"
|
|
||||||
|
|
||||||
if logged_in:
|
|
||||||
logger.success("Tailscale is logged in (BackendState=Running)")
|
|
||||||
else:
|
|
||||||
logger.log(f"Tailscale is NOT logged in (BackendState={backend_state})")
|
|
||||||
|
|
||||||
return logged_in
|
|
||||||
|
|
||||||
|
|
||||||
def tailscale_login(hostname=None):
|
|
||||||
"""Log in to Tailscale/Headscale."""
|
|
||||||
logger.log("Logging in to Headscale...")
|
|
||||||
logger.debug(f" Server: {HEADSCALE_URL}")
|
|
||||||
logger.debug(f" AuthKey: {HEADSCALE_AUTHKEY[:8]}...{HEADSCALE_AUTHKEY[-4:]}")
|
|
||||||
if hostname:
|
|
||||||
logger.debug(f" Hostname: {hostname}")
|
|
||||||
|
|
||||||
cmd = [
|
|
||||||
"tailscale", "up",
|
|
||||||
"--login-server", HEADSCALE_URL,
|
|
||||||
"--authkey", HEADSCALE_AUTHKEY,
|
|
||||||
"--force-reauth"
|
|
||||||
]
|
|
||||||
if hostname:
|
|
||||||
cmd.extend(["--hostname", hostname])
|
|
||||||
|
|
||||||
logger.debug(f"Command: {' '.join(cmd)}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
cmd,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=30
|
|
||||||
)
|
|
||||||
logger.debug(f"Return code: {result.returncode}")
|
|
||||||
if result.stdout:
|
|
||||||
logger.log(f" stdout: {result.stdout}")
|
|
||||||
if result.stderr:
|
|
||||||
logger.log(f" stderr: {result.stderr}")
|
|
||||||
|
|
||||||
if result.returncode != 0:
|
|
||||||
logger.error(f"Tailscale login failed with code {result.returncode}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
logger.success("Tailscale login successful")
|
|
||||||
return True
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
logger.error("Tailscale login timed out")
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Tailscale login failed: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def get_current_tailscale_hostname(status=None):
|
|
||||||
"""Get the hostname we're currently advertising."""
|
|
||||||
if status is None:
|
|
||||||
status = get_tailscale_status()
|
|
||||||
|
|
||||||
if status is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
hostname = status.get("Self", {}).get("HostName")
|
|
||||||
logger.debug(f"Current Tailscale hostname: {hostname}")
|
|
||||||
return hostname
|
|
||||||
|
|
||||||
|
|
||||||
def get_license_key():
|
|
||||||
"""Read license key from plain text file."""
|
|
||||||
logger.log("Fetching license key...")
|
|
||||||
logger.debug(f" Path: {LICENSE_KEY_PATH}")
|
|
||||||
|
|
||||||
if not LICENSE_KEY_PATH.exists():
|
|
||||||
logger.log(" License key file does not exist")
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
license_key = LICENSE_KEY_PATH.read_text().strip()
|
|
||||||
if not license_key:
|
|
||||||
logger.log(" License key file is empty")
|
|
||||||
return None
|
|
||||||
|
|
||||||
logger.success(f"License key found: {license_key}")
|
|
||||||
return license_key
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to read license key: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_tailscale_installed():
|
|
||||||
"""Ensure Tailscale is installed, install if needed."""
|
|
||||||
logger.log("=== Ensuring Tailscale is installed ===")
|
|
||||||
|
|
||||||
if is_tailscale_installed():
|
|
||||||
return True
|
|
||||||
|
|
||||||
logger.log("Tailscale not installed, attempting installation...")
|
|
||||||
|
|
||||||
if not install_tailscale():
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Verify installation
|
|
||||||
if not is_tailscale_installed():
|
|
||||||
logger.error("Tailscale installation verification failed")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Set operator
|
|
||||||
if not set_tailscale_operator():
|
|
||||||
logger.error("Failed to set Tailscale operator, continuing anyway...")
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_tailscale_connected():
|
|
||||||
"""Ensure Tailscale is logged in and connected."""
|
|
||||||
logger.log("=== Ensuring Tailscale is connected ===")
|
|
||||||
|
|
||||||
status = get_tailscale_status()
|
|
||||||
|
|
||||||
if not is_tailscale_logged_in(status):
|
|
||||||
logger.log("Tailscale not logged in, attempting login...")
|
|
||||||
|
|
||||||
# Get license key to use as hostname if available
|
|
||||||
license_key = get_license_key()
|
|
||||||
|
|
||||||
if not tailscale_login(hostname=license_key):
|
|
||||||
logger.error("Failed to log in to Tailscale")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Re-check status after login
|
|
||||||
status = get_tailscale_status()
|
|
||||||
if not is_tailscale_logged_in(status):
|
|
||||||
logger.error("Still not logged in after login attempt")
|
|
||||||
return False
|
|
||||||
|
|
||||||
logger.success("Tailscale is connected")
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def sync_tailscale_hostname():
|
|
||||||
"""Update tailscale hostname to match license key if needed."""
|
|
||||||
logger.log("=== Syncing Tailscale hostname ===")
|
|
||||||
|
|
||||||
license_key = get_license_key()
|
|
||||||
|
|
||||||
if not license_key:
|
|
||||||
logger.log("No license key found, skipping hostname sync")
|
|
||||||
return
|
|
||||||
|
|
||||||
status = get_tailscale_status()
|
|
||||||
current = get_current_tailscale_hostname(status)
|
|
||||||
|
|
||||||
logger.log(f" Current hostname: {current}")
|
|
||||||
logger.log(f" Desired hostname: {license_key}")
|
|
||||||
|
|
||||||
if current == license_key:
|
|
||||||
logger.success(f"Hostname already correct: {license_key}")
|
|
||||||
return
|
|
||||||
|
|
||||||
logger.log(f"Hostname mismatch, updating: {current} -> {license_key}")
|
|
||||||
|
|
||||||
if tailscale_login(hostname=license_key):
|
|
||||||
logger.success("Hostname updated successfully")
|
|
||||||
# Verify the change
|
|
||||||
new_status = get_tailscale_status()
|
|
||||||
new_hostname = get_current_tailscale_hostname(new_status)
|
|
||||||
logger.log(f" Verified new hostname: {new_hostname}")
|
|
||||||
else:
|
|
||||||
logger.error("Failed to update hostname")
|
|
||||||
|
|
||||||
|
|
||||||
def sync_ssh_keys():
|
|
||||||
"""Fetch SSH public keys from GitHub and write to authorized_keys."""
|
|
||||||
logger.log("=== Syncing SSH keys from GitHub ===")
|
|
||||||
github_user = "nclack"
|
|
||||||
url = f"https://github.com/{github_user}.keys"
|
|
||||||
|
|
||||||
try:
|
|
||||||
logger.debug(f"Fetching keys from {url}")
|
|
||||||
req = urllib.request.Request(url, headers={"User-Agent": "rhemawave-updater"})
|
|
||||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
||||||
keys = resp.read().decode().strip()
|
|
||||||
|
|
||||||
if not keys:
|
|
||||||
logger.error("No SSH keys returned from GitHub")
|
|
||||||
return
|
|
||||||
|
|
||||||
ssh_dir = Path.home() / ".ssh"
|
|
||||||
ssh_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
||||||
auth_keys = ssh_dir / "authorized_keys"
|
|
||||||
auth_keys.write_text(keys + "\n")
|
|
||||||
auth_keys.chmod(0o600)
|
|
||||||
|
|
||||||
key_count = len(keys.splitlines())
|
|
||||||
logger.success(f"Wrote {key_count} SSH key(s) to {auth_keys}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to sync SSH keys: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
def check_for_upgrade():
|
|
||||||
"""Check if an upgrade is available for the package"""
|
|
||||||
logger.log(f"Checking for upgrades to {PACKAGE_NAME}...")
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
["apt", "list", "--upgradable"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=30
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.debug(f"apt list output: {result.stdout}")
|
|
||||||
|
|
||||||
if PACKAGE_NAME in result.stdout:
|
|
||||||
logger.success(f"Upgrade available for {PACKAGE_NAME}")
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
logger.log(f"{PACKAGE_NAME} is up to date")
|
|
||||||
return False
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Could not check for upgrades: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
logger.log("=========================================")
|
|
||||||
logger.log("RhemaWave Updater")
|
|
||||||
logger.log(f"Package: {PACKAGE_NAME}")
|
|
||||||
logger.log(f"Timestamp: {datetime.now().isoformat()}")
|
|
||||||
logger.log("=========================================")
|
|
||||||
|
|
||||||
# Quick network check
|
|
||||||
if not check_network():
|
|
||||||
logger.log("No internet connection, skipping update")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
# Sync SSH keys from GitHub
|
|
||||||
sync_ssh_keys()
|
|
||||||
|
|
||||||
# Ensure system is ready to install software
|
|
||||||
configure_dpkg()
|
|
||||||
|
|
||||||
# Ensure Tailscale is installed
|
|
||||||
if not ensure_tailscale_installed():
|
|
||||||
logger.error("Tailscale installation failed, continuing with update check...")
|
|
||||||
else:
|
|
||||||
# Ensure Tailscale is connected
|
|
||||||
if not ensure_tailscale_connected():
|
|
||||||
logger.error("Tailscale connection failed, continuing with update check...")
|
|
||||||
else:
|
|
||||||
# Sync hostname with license key
|
|
||||||
sync_tailscale_hostname()
|
|
||||||
|
|
||||||
logger.log("=========================================")
|
|
||||||
logger.log("=== Package Update Check ===")
|
|
||||||
logger.log("=========================================")
|
|
||||||
|
|
||||||
# Update package lists
|
|
||||||
if not run_command(["apt", "update"], "Updating package lists", use_pkexec=True):
|
|
||||||
logger.error("Failed to update package lists")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
# Check for upgrades
|
|
||||||
if not check_for_upgrade():
|
|
||||||
logger.log("No upgrade needed")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
# Install upgrade
|
|
||||||
if not run_command(
|
|
||||||
["apt", "install", "--only-upgrade", "-y", PACKAGE_NAME],
|
|
||||||
f"Installing upgrade for {PACKAGE_NAME}",
|
|
||||||
use_pkexec=True
|
|
||||||
):
|
|
||||||
logger.error("Upgrade installation failed")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
logger.success("Upgrade installed successfully!")
|
|
||||||
|
|
||||||
# Restart getty to reload the application
|
|
||||||
if not run_command(
|
|
||||||
["systemctl", "restart", "getty@tty1.service"],
|
|
||||||
"Restarting application",
|
|
||||||
use_pkexec=True
|
|
||||||
):
|
|
||||||
logger.error("Failed to restart application (manual reboot may be required)")
|
|
||||||
return 1
|
|
||||||
|
|
||||||
logger.success("Application restarted")
|
|
||||||
logger.log("=========================================")
|
|
||||||
logger.log("=== Update Complete ===")
|
|
||||||
logger.log("=========================================")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
sys.exit(main())
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"rhemawavetech.com/rhemawave/internal/application"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
application.Run()
|
|
||||||
fmt.Printf("Stopping application")
|
|
||||||
}
|
|
||||||
@@ -1,474 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# test-vm.sh — Build, start, and diagnose the RadiantWave NixOS test VM.
|
|
||||||
#
|
|
||||||
# On macOS (ARM): builds and runs the VM on a remote Linux server.
|
|
||||||
# On Linux (x86_64): builds and runs locally.
|
|
||||||
#
|
|
||||||
# Usage:
|
|
||||||
# ./scripts/test-vm.sh Build + start + run diagnostics
|
|
||||||
# ./scripts/test-vm.sh --build Build only
|
|
||||||
# ./scripts/test-vm.sh --start Start only (must already be built)
|
|
||||||
# ./scripts/test-vm.sh --stop Stop the running VM
|
|
||||||
# ./scripts/test-vm.sh --diagnose Run diagnostics on a running VM
|
|
||||||
# ./scripts/test-vm.sh --shell Interactive SSH shell into the VM
|
|
||||||
#
|
|
||||||
# Environment variables:
|
|
||||||
# RADIANTWAVE_REMOTE Remote server for macOS builds (default: nclack@192.168.1.203)
|
|
||||||
# RADIANTWAVE_REMOTE_DIR Remote repo path (default: ~/radiantwave)
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
# ── Paths & defaults ──────────────────────────────────────────────────────────
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
||||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
||||||
REMOTE="${RADIANTWAVE_REMOTE:-nclack@192.168.1.203}"
|
|
||||||
REMOTE_DIR="${RADIANTWAVE_REMOTE_DIR:-~/radiantwave}"
|
|
||||||
SSH_PORT=2222
|
|
||||||
VM_USER="kiosk"
|
|
||||||
VM_PASS="kiosk"
|
|
||||||
|
|
||||||
# ── Colors ────────────────────────────────────────────────────────────────────
|
|
||||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'
|
|
||||||
DIM='\033[2m'; BOLD='\033[1m'; NC='\033[0m'
|
|
||||||
|
|
||||||
log() { echo -e "${BLUE}▸${NC} $*"; }
|
|
||||||
ok() { echo -e "${GREEN}✓${NC} $*"; }
|
|
||||||
warn() { echo -e "${YELLOW}⚠${NC} $*"; }
|
|
||||||
err() { echo -e "${RED}✗${NC} $*" >&2; }
|
|
||||||
hr() { echo -e "${DIM}────────────────────────────────────────────────${NC}"; }
|
|
||||||
|
|
||||||
# ── Platform detection ────────────────────────────────────────────────────────
|
|
||||||
detect_platform() {
|
|
||||||
local os arch
|
|
||||||
os="$(uname -s)"
|
|
||||||
arch="$(uname -m)"
|
|
||||||
case "$os" in
|
|
||||||
Darwin) echo "macos" ;;
|
|
||||||
Linux)
|
|
||||||
case "$arch" in
|
|
||||||
x86_64) echo "linux" ;;
|
|
||||||
aarch64) echo "linux-arm" ;;
|
|
||||||
*) echo "linux-unknown" ;;
|
|
||||||
esac
|
|
||||||
;;
|
|
||||||
*) echo "unknown" ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
PLATFORM="$(detect_platform)"
|
|
||||||
|
|
||||||
# ── Dependency checks ─────────────────────────────────────────────────────────
|
|
||||||
check_deps() {
|
|
||||||
local missing=()
|
|
||||||
|
|
||||||
if ! command -v nix &>/dev/null; then
|
|
||||||
missing+=("nix")
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$PLATFORM" == "macos" ]]; then
|
|
||||||
for cmd in ssh rsync; do
|
|
||||||
command -v "$cmd" &>/dev/null || missing+=("$cmd")
|
|
||||||
done
|
|
||||||
fi
|
|
||||||
|
|
||||||
if (( ${#missing[@]} > 0 )); then
|
|
||||||
err "Missing required commands: ${missing[*]}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# sshpass — try direct, then nix-shell wrapper
|
|
||||||
if command -v sshpass &>/dev/null; then
|
|
||||||
SSPASS="sshpass"
|
|
||||||
elif nix-shell -p sshpass --run 'which sshpass' &>/dev/null; then
|
|
||||||
SSPASS="nix-shell -p sshpass --run"
|
|
||||||
else
|
|
||||||
err "sshpass not found. Install via: brew install sshpass (macOS) or nix profile install nixpkgs#sshpass"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── VM path helpers ───────────────────────────────────────────────────────────
|
|
||||||
# The nix build output is a directory containing bin/run-radiantwave-test-vm.
|
|
||||||
VM_RESULT_FILE="/tmp/radiantwave-test-vm-path"
|
|
||||||
VM_BIN_DIR=""
|
|
||||||
|
|
||||||
resolve_vm_bin() {
|
|
||||||
if [[ -n "$VM_BIN_DIR" ]]; then
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
local cached=""
|
|
||||||
if [[ "$PLATFORM" == "linux" ]]; then
|
|
||||||
if [[ -f "$VM_RESULT_FILE" ]]; then
|
|
||||||
cached="$(cat "$VM_RESULT_FILE")"
|
|
||||||
if [[ -x "$cached/bin/run-radiantwave-test-vm" ]]; then
|
|
||||||
VM_BIN_DIR="$cached"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
cached="$(ssh "$REMOTE" "cat '$VM_RESULT_FILE' 2>/dev/null")" || true
|
|
||||||
if [[ -n "$cached" ]]; then
|
|
||||||
# Check if it exists on the remote server
|
|
||||||
if ssh "$REMOTE" "test -x '$cached/bin/run-radiantwave-test-vm'" 2>/dev/null; then
|
|
||||||
VM_BIN_DIR="$cached"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── Platform-specific execution ───────────────────────────────────────────────
|
|
||||||
# On Linux: run commands locally.
|
|
||||||
# On macOS: sync repo to remote, then run commands there via SSH.
|
|
||||||
run_cmd() {
|
|
||||||
if [[ "$PLATFORM" == "linux" ]]; then
|
|
||||||
"$@"
|
|
||||||
else
|
|
||||||
# Run on remote server
|
|
||||||
ssh "$REMOTE" "cd '$REMOTE_DIR' && $(printf '%q ' "$@")"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
run_cmd_capture() {
|
|
||||||
if [[ "$PLATFORM" == "linux" ]]; then
|
|
||||||
"$@"
|
|
||||||
else
|
|
||||||
ssh "$REMOTE" "cd '$REMOTE_DIR' && $(printf '%q ' "$@")"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
sync_to_remote() {
|
|
||||||
if [[ "$PLATFORM" == "macos" ]]; then
|
|
||||||
log "Syncing repo to remote server ($REMOTE)..."
|
|
||||||
# Ensure remote dir exists
|
|
||||||
ssh "$REMOTE" "mkdir -p '$REMOTE_DIR'"
|
|
||||||
rsync -avz --delete \
|
|
||||||
--exclude='.git/' \
|
|
||||||
--exclude='result' \
|
|
||||||
--exclude='result-*' \
|
|
||||||
--exclude='bin/' \
|
|
||||||
--exclude='build/' \
|
|
||||||
"$REPO_ROOT/" "$REMOTE:$REMOTE_DIR/"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── Commands ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
cmd_build() {
|
|
||||||
log "Building test VM ($PLATFORM)..."
|
|
||||||
sync_to_remote
|
|
||||||
|
|
||||||
local out_path
|
|
||||||
if [[ "$PLATFORM" == "linux" ]]; then
|
|
||||||
out_path="$(nix build ".#packages.x86_64-linux.test-vm" --print-out-paths 2>&1)"
|
|
||||||
echo "$out_path" > "$VM_RESULT_FILE"
|
|
||||||
VM_BIN_DIR="$out_path"
|
|
||||||
else
|
|
||||||
# Build on remote, save path there
|
|
||||||
out_path="$(ssh "$REMOTE" "cd '$REMOTE_DIR' && nix build .#packages.x86_64-linux.test-vm --print-out-paths 2>&1")"
|
|
||||||
ssh "$REMOTE" "echo '$out_path' > $VM_RESULT_FILE"
|
|
||||||
VM_BIN_DIR="$out_path"
|
|
||||||
fi
|
|
||||||
|
|
||||||
ok "VM built: $VM_BIN_DIR"
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd_start() {
|
|
||||||
if ! resolve_vm_bin 2>/dev/null; then
|
|
||||||
err "VM not built yet. Run: $0 --build"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if vm_is_running; then
|
|
||||||
warn "VM already running (PID $(cat /tmp/radiantwave-test-vm.pid 2>/dev/null || echo '?'))"
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
|
|
||||||
local vm_script="$VM_BIN_DIR/bin/run-radiantwave-test-vm"
|
|
||||||
log "Starting VM in background..."
|
|
||||||
|
|
||||||
if [[ "$PLATFORM" == "linux" ]]; then
|
|
||||||
# Run locally with serial to a log file
|
|
||||||
nohup "$vm_script" \
|
|
||||||
-nographic \
|
|
||||||
-serial file:/tmp/radiantwave-test-vm-serial.log \
|
|
||||||
> /tmp/radiantwave-test-vm.log 2>&1 &
|
|
||||||
echo $! > /tmp/radiantwave-test-vm.pid
|
|
||||||
ok "VM started (PID $!)"
|
|
||||||
else
|
|
||||||
# Run on remote server — use nohup so the VM survives SSH disconnect
|
|
||||||
ssh "$REMOTE" "cd '$REMOTE_DIR' && \
|
|
||||||
nohup $vm_script -nographic \
|
|
||||||
-serial file:/tmp/radiantwave-test-vm-serial.log \
|
|
||||||
> /tmp/radiantwave-test-vm.log 2>&1 & \
|
|
||||||
echo \$! > /tmp/radiantwave-test-vm.pid && \
|
|
||||||
echo \"VM started on remote (PID \$(cat /tmp/radiantwave-test-vm.pid))\""
|
|
||||||
ok "VM started on remote server"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd_stop() {
|
|
||||||
if [[ "$PLATFORM" == "linux" ]]; then
|
|
||||||
if [[ -f /tmp/radiantwave-test-vm.pid ]]; then
|
|
||||||
local pid
|
|
||||||
pid="$(cat /tmp/radiantwave-test-vm.pid)"
|
|
||||||
if kill -0 "$pid" 2>/dev/null; then
|
|
||||||
log "Stopping VM (PID $pid)..."
|
|
||||||
kill "$pid"
|
|
||||||
rm -f /tmp/radiantwave-test-vm.pid
|
|
||||||
ok "VM stopped"
|
|
||||||
else
|
|
||||||
warn "VM process $pid not running"
|
|
||||||
rm -f /tmp/radiantwave-test-vm.pid
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
warn "No PID file found. VM may not be running."
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
log "Stopping VM on remote server..."
|
|
||||||
ssh "$REMOTE" "
|
|
||||||
if [[ -f /tmp/radiantwave-test-vm.pid ]]; then
|
|
||||||
pid=\$(cat /tmp/radiantwave-test-vm.pid)
|
|
||||||
kill \$pid 2>/dev/null && echo 'VM stopped' || echo 'VM not running'
|
|
||||||
rm -f /tmp/radiantwave-test-vm.pid
|
|
||||||
else
|
|
||||||
echo 'No PID file found'
|
|
||||||
fi
|
|
||||||
"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
vm_is_running() {
|
|
||||||
if [[ "$PLATFORM" == "linux" ]]; then
|
|
||||||
[[ -f /tmp/radiantwave-test-vm.pid ]] && \
|
|
||||||
kill -0 "$(cat /tmp/radiantwave-test-vm.pid)" 2>/dev/null
|
|
||||||
else
|
|
||||||
ssh "$REMOTE" "
|
|
||||||
[[ -f /tmp/radiantwave-test-vm.pid ]] && \
|
|
||||||
kill -0 \$(cat /tmp/radiantwave-test-vm.pid) 2>/dev/null
|
|
||||||
" 2>/dev/null
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
vm_ssh() {
|
|
||||||
# Run a command in the VM via SSH. Usage: vm_ssh "command"
|
|
||||||
local cmd="$1"
|
|
||||||
local ssh_opts="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
|
|
||||||
|
|
||||||
if [[ "$PLATFORM" == "linux" ]]; then
|
|
||||||
eval "$SSPASS -p '$VM_PASS' ssh $ssh_opts -p '$SSH_PORT' '${VM_USER}@localhost' '$cmd'"
|
|
||||||
else
|
|
||||||
# On macOS: find sshpass on the remote server, then SSH to the VM
|
|
||||||
ssh "$REMOTE" "
|
|
||||||
SSPASS=\$(command -v sshpass 2>/dev/null || nix-shell -p sshpass --run 'which sshpass' 2>/dev/null)
|
|
||||||
if [[ -z \"\$SSPASS\" ]]; then
|
|
||||||
echo 'sshpass not found on remote server' >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
\$SSPASS -p '$VM_PASS' ssh $ssh_opts -p $SSH_PORT ${VM_USER}@localhost '$cmd'
|
|
||||||
"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
vm_wait_for_boot() {
|
|
||||||
local max_wait=90
|
|
||||||
local waited=0
|
|
||||||
|
|
||||||
log "Waiting for VM to boot (up to ${max_wait}s)..."
|
|
||||||
|
|
||||||
while (( waited < max_wait )); do
|
|
||||||
if vm_ssh "echo ok" &>/dev/null; then
|
|
||||||
ok "VM ready after ${waited}s"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
sleep 2
|
|
||||||
waited=$((waited + 2))
|
|
||||||
echo -ne "\r ${DIM}${waited}s / ${max_wait}s${NC}"
|
|
||||||
done
|
|
||||||
|
|
||||||
echo
|
|
||||||
err "VM did not become ready within ${max_wait}s"
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd_diagnose() {
|
|
||||||
hr
|
|
||||||
echo -e "${BOLD} RadiantWave Test VM — Diagnostic Report${NC}"
|
|
||||||
hr
|
|
||||||
|
|
||||||
log "Connecting to VM..."
|
|
||||||
|
|
||||||
# Build and run a diagnostic script on the server (avoids nested quoting issues)
|
|
||||||
local diag_script
|
|
||||||
diag_script="$(mktemp)"
|
|
||||||
cat > "$diag_script" << 'DIAGEOF'
|
|
||||||
#!/usr/bin/env bash
|
|
||||||
set -uo pipefail
|
|
||||||
|
|
||||||
run_in_vm() {
|
|
||||||
sshpass -p kiosk ssh \
|
|
||||||
-o StrictHostKeyChecking=no \
|
|
||||||
-o UserKnownHostsFile=/dev/null \
|
|
||||||
-o ConnectTimeout=5 \
|
|
||||||
-p 2222 kiosk@localhost "$@"
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "── System Services ──"
|
|
||||||
run_in_vm systemctl list-units --type=service --state=running --no-pager | \
|
|
||||||
grep -E 'NetworkManager|sshd|seatd|getty|cage|systemd-logind' || true
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "── User Services (PipeWire) ──"
|
|
||||||
run_in_vm systemctl --user list-units --type=service --state=running --no-pager | \
|
|
||||||
grep -E 'pipewire|wireplumber|pulse' || echo "No user services running"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "── PipeWire Devices (wpctl status) ──"
|
|
||||||
run_in_vm wpctl status 2>/dev/null || echo "wpctl not available"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "── ALSA Devices (aplay -l) ──"
|
|
||||||
run_in_vm aplay -l 2>/dev/null || echo "No ALSA devices"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "── PulseAudio Sinks (pactl) ──"
|
|
||||||
run_in_vm pactl list sinks short 2>/dev/null || echo "pactl not available"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "── Network Devices ──"
|
|
||||||
run_in_vm nmcli device status 2>/dev/null || echo "nmcli not available"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "── Key Processes ──"
|
|
||||||
run_in_vm ps aux | \
|
|
||||||
grep -E 'rhemawave|cage|pipewire|wireplumber|NetworkManager' | \
|
|
||||||
grep -v grep || echo "No matching processes"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "── Login Sessions ──"
|
|
||||||
run_in_vm loginctl list-sessions --no-pager 2>/dev/null || echo "loginctl not available"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "── App Log (last 30 lines) ──"
|
|
||||||
run_in_vm tail -30 /tmp/kiosk.log 2>/dev/null || echo "No log file yet"
|
|
||||||
DIAGEOF
|
|
||||||
|
|
||||||
if [[ "$PLATFORM" == "linux" ]]; then
|
|
||||||
bash "$diag_script"
|
|
||||||
else
|
|
||||||
# Upload and run on remote server
|
|
||||||
local remote_diag="/tmp/radiantwave-diagnose-$$.sh"
|
|
||||||
scp -q "$diag_script" "$REMOTE:$remote_diag"
|
|
||||||
ssh "$REMOTE" "nix-shell -p sshpass --run 'bash $remote_diag'; rm -f $remote_diag"
|
|
||||||
fi
|
|
||||||
|
|
||||||
rm -f "$diag_script"
|
|
||||||
hr
|
|
||||||
}
|
|
||||||
|
|
||||||
cmd_shell() {
|
|
||||||
log "Connecting interactive shell to VM..."
|
|
||||||
local ssh_opts="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
|
|
||||||
|
|
||||||
if [[ "$PLATFORM" == "linux" ]]; then
|
|
||||||
eval "$SSPASS -p '$VM_PASS' ssh $ssh_opts -p '$SSH_PORT' '${VM_USER}@localhost'"
|
|
||||||
else
|
|
||||||
log "Connecting via remote server ($REMOTE)..."
|
|
||||||
ssh -t "$REMOTE" "
|
|
||||||
SSPASS=\$(command -v sshpass 2>/dev/null || nix-shell -p sshpass --run 'which sshpass' 2>/dev/null)
|
|
||||||
if [[ -z \"\$SSPASS\" ]]; then
|
|
||||||
echo 'sshpass not found on remote server' >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
\$SSPASS -p '$VM_PASS' ssh $ssh_opts -t -p $SSH_PORT ${VM_USER}@localhost
|
|
||||||
"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── Main ──────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
usage() {
|
|
||||||
cat <<EOF
|
|
||||||
Usage: $(basename "$0") [COMMAND]
|
|
||||||
|
|
||||||
Commands:
|
|
||||||
(none) Build + start + run diagnostics
|
|
||||||
--build Build the test VM only
|
|
||||||
--start Start the VM (must already be built)
|
|
||||||
--stop Stop the running VM
|
|
||||||
--diagnose Run diagnostics on a running VM
|
|
||||||
--shell Interactive SSH shell into the VM
|
|
||||||
|
|
||||||
Platform:
|
|
||||||
macOS (ARM) Builds/runs on remote server: ${REMOTE}
|
|
||||||
Linux (x86_64) Builds/runs locally
|
|
||||||
|
|
||||||
Environment:
|
|
||||||
RADIANTWAVE_REMOTE Remote server (default: nclack@192.168.1.203)
|
|
||||||
RADIANTWAVE_REMOTE_DIR Remote repo path (default: ~/radiantwave)
|
|
||||||
|
|
||||||
Requirements:
|
|
||||||
nix Nix package manager
|
|
||||||
sshpass Non-interactive SSH (for diagnostics)
|
|
||||||
Install: brew install sshpass (macOS) or nix-shell -p sshpass
|
|
||||||
EOF
|
|
||||||
}
|
|
||||||
|
|
||||||
main() {
|
|
||||||
check_deps
|
|
||||||
|
|
||||||
case "${1:-}" in
|
|
||||||
-h|--help)
|
|
||||||
usage
|
|
||||||
;;
|
|
||||||
--build)
|
|
||||||
cmd_build
|
|
||||||
;;
|
|
||||||
--start)
|
|
||||||
cmd_start
|
|
||||||
;;
|
|
||||||
--stop)
|
|
||||||
cmd_stop
|
|
||||||
;;
|
|
||||||
--diagnose)
|
|
||||||
if ! vm_is_running; then
|
|
||||||
err "VM is not running. Start it first: $0 --start"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
cmd_diagnose
|
|
||||||
;;
|
|
||||||
--shell)
|
|
||||||
if ! vm_is_running; then
|
|
||||||
err "VM is not running. Start it first: $0 --start"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
cmd_shell
|
|
||||||
;;
|
|
||||||
"")
|
|
||||||
cmd_build
|
|
||||||
cmd_start
|
|
||||||
sleep 5
|
|
||||||
if vm_wait_for_boot; then
|
|
||||||
cmd_diagnose
|
|
||||||
else
|
|
||||||
warn "VM boot timed out. Check logs:"
|
|
||||||
if [[ "$PLATFORM" == "linux" ]]; then
|
|
||||||
echo " tail -f /tmp/radiantwave-test-vm.log"
|
|
||||||
echo " tail -f /tmp/radiantwave-test-vm-serial.log"
|
|
||||||
else
|
|
||||||
echo " ssh $REMOTE 'tail -f /tmp/radiantwave-test-vm.log'"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
err "Unknown command: $1"
|
|
||||||
usage
|
|
||||||
exit 1
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
main "$@"
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -1,49 +0,0 @@
|
|||||||
shall the mind and body now: Project its awareness, attention, belief, and
|
|
||||||
intention into all times dimensions of undivided universal field of
|
|
||||||
consciousness and identity, accept and release attunement to all events,
|
|
||||||
information faults, and information faults hidden that compromises
|
|
||||||
awareness of, or define into existence, the transformed-from state? Or,
|
|
||||||
that cause this transformative information quanta to define into existence
|
|
||||||
the transformed-to system state? Or that cause this transformation to
|
|
||||||
seem dangerous, undeserved, or impossible to achieve? Create awareness,
|
|
||||||
acceptance, attunement, and belief of all corrective events and
|
|
||||||
transformative information quanta to define into existence the
|
|
||||||
transformed-to system state?
|
|
||||||
|
|
||||||
Create the intention to collapse the quantum potential the field’s reality
|
|
||||||
quantum potential of the field’s reality quant to manifest this
|
|
||||||
transformation? Create optimal attunement and coherence to the source of
|
|
||||||
creation, GOD, innate intelligence, and the consciousness of healing and
|
|
||||||
accept the role of co-creator of all experienced reality, NOW?
|
|
||||||
|
|
||||||
I am grateful GOD lives through me and blesses all aspects of my life.
|
|
||||||
|
|
||||||
I am thankful for my health, vitality, and motivation to pursue a deeper
|
|
||||||
understanding of the Trinity so that I become a shining example of
|
|
||||||
someone who’s life is truly blessed and lived according to GOD’s design.
|
|
||||||
|
|
||||||
I appreciate GOD providing strength and direction to every cell in my
|
|
||||||
body and blesses my life in countless ways.
|
|
||||||
I am pleased I have the freedom to worship our creator, follow his
|
|
||||||
teachings, and receive blessings according to GOD’s design.
|
|
||||||
|
|
||||||
I enjoy knowing GOD protects me, blesses me, and allows me to pursue a
|
|
||||||
deep and personal relationship with him.
|
|
||||||
|
|
||||||
I am happy I can pray, speak with GOD directly, seek his advice, follow
|
|
||||||
his teachings, and receive his blessings.
|
|
||||||
|
|
||||||
I am grateful GOD’s healing hands are on me creating miracles in all
|
|
||||||
areas of my life.
|
|
||||||
|
|
||||||
I am thankful I wake up each morning inspired to learn, trust, and follow
|
|
||||||
GOD’s plan for my life.
|
|
||||||
|
|
||||||
I appreciate GOD’s almighty power flowing though me and creating
|
|
||||||
miracles in all areas of my life.
|
|
||||||
|
|
||||||
I am pleased my life is blessed and I live a perpetually blissful life.
|
|
||||||
|
|
||||||
I am grateful to GOD for all the blessings bestowed upon me.
|
|
||||||
|
|
||||||
I am thankful to GOD for his forgiveness, understanding, and love.
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
shall the mind and body now: Project its awareness, attention, belief, and
|
|
||||||
intention into all times dimensions of undivided universal field of
|
|
||||||
consciousness and identity, accept and release attunement to all events,
|
|
||||||
information faults, and information faults hidden that compromises
|
|
||||||
awareness of, or define into existence, the transformed-from state? Or,
|
|
||||||
that cause this transformative information quanta to define into existence
|
|
||||||
the transformed-to system state? Or that cause this transformation to
|
|
||||||
seem dangerous, undeserved, or impossible to achieve? Create awareness,
|
|
||||||
acceptance, attunement, and belief of all corrective events and
|
|
||||||
transformative information quanta to define into existence the
|
|
||||||
transformed-to system state?
|
|
||||||
|
|
||||||
Create the intention to collapse the quantum potential the field’s reality
|
|
||||||
quantum potential of the field’s reality quant to manifest this
|
|
||||||
transformation? Create optimal attunement and coherence to the source of
|
|
||||||
creation, GOD, innate intelligence, and the consciousness of healing and
|
|
||||||
accept the role of co-creator of all experienced reality, NOW?
|
|
||||||
|
|
||||||
I am grateful GOD lives through me and inspires me to see the best in
|
|
||||||
every person and every opportunity.
|
|
||||||
|
|
||||||
I am thankful for my life, health, and vitality and express my
|
|
||||||
appreciation by always focusing on the positive aspects of every
|
|
||||||
situation I encounter.
|
|
||||||
|
|
||||||
I appreciate GOD providing strength and direction to every cell in my
|
|
||||||
body which I use to maintain a positive attitude regardless of my
|
|
||||||
personal situation.
|
|
||||||
|
|
||||||
I am pleased I always think positive, manage my thoughts, and live each
|
|
||||||
day striving energetically to accomplish goals which positively effect
|
|
||||||
my life and the lives of those around me according to GOD’s design.
|
|
||||||
|
|
||||||
I enjoy knowing GOD protects me and allows me to achieve things I once
|
|
||||||
thought impossible.
|
|
||||||
|
|
||||||
I am happy I think positively, I do not dwell on negative issues, and I
|
|
||||||
control my thoughts at all times.
|
|
||||||
|
|
||||||
I am grateful GOD’s healing hands are on me creating miracles in all
|
|
||||||
areas of my life.
|
|
||||||
|
|
||||||
I am thankful I wake up each morning yearning to efficiently accomplish
|
|
||||||
as many tasks as possible to ensure success in all my endeavors.
|
|
||||||
|
|
||||||
I appreciate GOD’s almighty power flowing through me and creating
|
|
||||||
miracles in all areas of my life as a reward my optimism.
|
|
||||||
|
|
||||||
I am pleased my mental health and emotional stability allow me remain
|
|
||||||
positive and live a relaxed and perpetually blissful life.
|
|
||||||
|
|
||||||
I am grateful to GOD for all the blessings bestowed upon me.
|
|
||||||
|
|
||||||
I am thankful that I only seek the good in myself, the good in others,
|
|
||||||
the good in the world around me, and the greatness of the live I am
|
|
||||||
living.
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
shall the mind and body now: Project its awareness, attention, belief, and
|
|
||||||
intention into all times dimensions of undivided universal field of
|
|
||||||
consciousness and identity, accept and release attunement to all events,
|
|
||||||
information faults, and information faults hidden that compromises
|
|
||||||
awareness of, or define into existence, the transformed-from state? Or,
|
|
||||||
that cause this transformative information quanta to define into existence
|
|
||||||
the transformed-to system state? Or that cause this transformation to
|
|
||||||
seem dangerous, undeserved, or impossible to achieve? Create awareness,
|
|
||||||
acceptance, attunement, and belief of all corrective events and
|
|
||||||
transformative information quanta to define into existence the
|
|
||||||
transformed-to system state?
|
|
||||||
|
|
||||||
Create the intention to collapse the quantum potential the field’s reality
|
|
||||||
quantum potential of the field’s reality quant to manifest this
|
|
||||||
transformation? Create optimal attunement and coherence to the source of
|
|
||||||
creation, GOD, innate intelligence, and the consciousness of healing and
|
|
||||||
accept the role of co-creator of all experienced reality, NOW?
|
|
||||||
|
|
||||||
I am grateful GOD lives through me and causes my circulatory system to
|
|
||||||
remain clear and unobstructed for for optimum health and longevity.
|
|
||||||
|
|
||||||
I am thankful for my health, vitality, and motivation to eat properly,
|
|
||||||
exercise, and strengthen my body.
|
|
||||||
|
|
||||||
I appreciate GOD providing strength and direction to every cell in my
|
|
||||||
body ensuring my blood chemistry is, and remains optimal for excellent
|
|
||||||
health and longevity.
|
|
||||||
|
|
||||||
I am pleased my muscles, bones, joints, organs, nerves, tendons, and
|
|
||||||
ligaments all develop correctly and function normally.
|
|
||||||
|
|
||||||
I enjoy knowing GOD protects me throughout and beyond the physical
|
|
||||||
healing and development process.
|
|
||||||
|
|
||||||
I am happy my HDL, LDL, Triglycerides, and Blood Sugar levels are and
|
|
||||||
remain at ideal levels for my optimal health and longevity according to
|
|
||||||
GOD’s design.
|
|
||||||
|
|
||||||
I am grateful GOD’s healing hands are on me creating miracles in all
|
|
||||||
areas of my life.
|
|
||||||
|
|
||||||
I am thankful I wake up each morning yearning to eat healthy foods and
|
|
||||||
exercise in ways that ensure my HDL, LDL, Triglycerides, and Blood Sugar
|
|
||||||
levels are ideal.
|
|
||||||
|
|
||||||
I appreciate GOD’s almighty power flowing though me, creating miracles
|
|
||||||
in all areas of my life, and correcting, curing, and developing all
|
|
||||||
healthy aspects of my mind and my body.
|
|
||||||
|
|
||||||
I am pleased my blood, heart, arteries, and entire circulatory system
|
|
||||||
perpetually improves each day.
|
|
||||||
|
|
||||||
I am grateful to GOD for all the blessings bestowed upon me.
|
|
||||||
|
|
||||||
I am thankful I only crave healthy foods that provide my body with the
|
|
||||||
nutrition necessary for my optimum health and wellbeing.
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
shall the mind and body now: Project its awareness, attention, belief, and
|
|
||||||
intention into all times dimensions of undivided universal field of
|
|
||||||
consciousness and identity, accept and release attunement to all events,
|
|
||||||
information faults, and information faults hidden that compromises
|
|
||||||
awareness of, or define into existence, the transformed-from state? Or,
|
|
||||||
that cause this transformative information quanta to define into existence
|
|
||||||
the transformed-to system state? Or that cause this transformation to
|
|
||||||
seem dangerous, undeserved, or impossible to achieve? Create awareness,
|
|
||||||
acceptance, attunement, and belief of all corrective events and
|
|
||||||
transformative information quanta to define into existence the
|
|
||||||
transformed-to system state?
|
|
||||||
|
|
||||||
Create the intention to collapse the quantum potential the field’s reality
|
|
||||||
quantum potential of the field’s reality quant to manifest this
|
|
||||||
transformation? Create optimal attunement and coherence to the source of
|
|
||||||
creation, GOD, innate intelligence, and the consciousness of healing and
|
|
||||||
accept the role of co-creator of all experienced reality, NOW?
|
|
||||||
|
|
||||||
I am grateful GOD lives through me and inspires my body enter deep sleep
|
|
||||||
and heal miraculously during the night so that I enjoy optimum health.
|
|
||||||
|
|
||||||
I am thankful for my health, vitality, and ability to experience deep
|
|
||||||
healing sleep each and every night.
|
|
||||||
|
|
||||||
I appreciate GOD providing strength and direction to every cell in my
|
|
||||||
body allowing me to sleep deeply and heal all mental, physical, and
|
|
||||||
spiritual aspects of my being ensuring my optimum overall health.
|
|
||||||
|
|
||||||
I am pleased my muscles, bones, joints, organs, nerves, tendons, and
|
|
||||||
ligaments all heal well during my deep sleep and remain in optimum
|
|
||||||
condition according to GOD’s design.
|
|
||||||
|
|
||||||
I enjoy knowing GOD protects me throughout and beyond the physical
|
|
||||||
healing and development process.
|
|
||||||
|
|
||||||
I am happy my body, mind, and spirit grows stronger each and every day
|
|
||||||
as a result of my experiencing deep and restful sleep.
|
|
||||||
|
|
||||||
I am grateful GOD’s healing hands are on me creating miracles in all
|
|
||||||
areas of my life and allowing me to experience deep restful, healing
|
|
||||||
sleep each night.
|
|
||||||
|
|
||||||
I am thankful I am able to easily clear my thoughts when I lay down at
|
|
||||||
night to sleep, enter deep and restful sleep, and enjoy wonderful
|
|
||||||
uplifting dreams as my body, mind, and spirit heal, advance, and grow
|
|
||||||
stronger.
|
|
||||||
|
|
||||||
I appreciate GOD’s almighty power flowing though me, creating miracles
|
|
||||||
in all areas of my life, and correcting, curing, and developing all
|
|
||||||
healthy aspects of my mind and my body during my deep and restful sleep
|
|
||||||
each night.
|
|
||||||
|
|
||||||
I am pleased to wake each morning well rested, energized, and prepared
|
|
||||||
to face the challenges each day brings with confidence, a positive
|
|
||||||
attitude, and perseverance.
|
|
||||||
|
|
||||||
I am grateful to GOD for all the blessings bestowed upon me.
|
|
||||||
|
|
||||||
I am thankful I only crave healthy foods that provide my body with the
|
|
||||||
nutrition necessary to rest well, sleep deep, heal swiftly, and optimize
|
|
||||||
my health and wellbeing.
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
shall the mind and body now: Project its awareness, attention, belief, and
|
|
||||||
intention into all times dimensions of undivided universal field of
|
|
||||||
consciousness and identity, accept and release attunement to all events,
|
|
||||||
information faults, and information faults hidden that compromises
|
|
||||||
awareness of, or define into existence, the transformed-from state? Or,
|
|
||||||
that cause this transformative information quanta to define into existence
|
|
||||||
the transformed-to system state? Or that cause this transformation to
|
|
||||||
seem dangerous, undeserved, or impossible to achieve? Create awareness,
|
|
||||||
acceptance, attunement, and belief of all corrective events and
|
|
||||||
transformative information quanta to define into existence the
|
|
||||||
transformed-to system state?
|
|
||||||
|
|
||||||
Create the intention to collapse the quantum potential the field’s reality
|
|
||||||
quantum potential of the field’s reality quant to manifest this
|
|
||||||
transformation? Create optimal attunement and coherence to the source of
|
|
||||||
creation, GOD, innate intelligence, and the consciousness of healing and
|
|
||||||
accept the role of co-creator of all experienced reality, NOW?
|
|
||||||
|
|
||||||
I am grateful GOD lives through me and inspires me to believe in him,
|
|
||||||
trust in him, and follow biblical teachings.
|
|
||||||
|
|
||||||
I am thankful for my health, vitality, and motivation to include GOD in
|
|
||||||
all aspects of my life.
|
|
||||||
|
|
||||||
I appreciate GOD providing strength and direction to every cell in my
|
|
||||||
body which empowers my relationship with him more and more each and
|
|
||||||
every day.
|
|
||||||
|
|
||||||
I am pleased my personal relationship with GOD continues to grow stronger
|
|
||||||
as I strive to make him a greater influence in my life and live my life
|
|
||||||
according to GOD’s design.
|
|
||||||
|
|
||||||
I enjoy knowing GOD protects me and allows me to develop a deeper and
|
|
||||||
stronger relationship with him and demonstrate my beliefs and
|
|
||||||
relationship in all aspects of my life.
|
|
||||||
|
|
||||||
I am happy my deepening connection with GOD brings peace and harmony to
|
|
||||||
my life.
|
|
||||||
|
|
||||||
I am grateful GOD’s healing hands are on me creating miracles in all
|
|
||||||
areas of my life that I set an outstanding example of living the life of
|
|
||||||
a true believer.
|
|
||||||
|
|
||||||
I am thankful I wake up each morning yearning to share my faith and
|
|
||||||
understanding of our great Creator.
|
|
||||||
|
|
||||||
I appreciate GOD’s almighty power flowing through me and creating
|
|
||||||
miracles in all areas of my life.
|
|
||||||
|
|
||||||
I am pleased my undying faith in GOD allows me to live a relaxed and
|
|
||||||
perpetually blissful lifestyle.
|
|
||||||
|
|
||||||
I am grateful to GOD for all the blessings bestowed upon me.
|
|
||||||
|
|
||||||
I am thankful I know GOD and share my beliefs with others at every
|
|
||||||
opportunity.
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
shall the mind and body now: Project its awareness, attention, belief, and
|
|
||||||
intention into all times dimensions of undivided universal field of
|
|
||||||
consciousness and identity, accept and release attunement to all events,
|
|
||||||
information faults, and information faults hidden that compromises
|
|
||||||
awareness of, or define into existence, the transformed-from state? Or,
|
|
||||||
that cause this transformative information quanta to define into existence
|
|
||||||
the transformed-to system state? Or that cause this transformation to
|
|
||||||
seem dangerous, undeserved, or impossible to achieve? Create awareness,
|
|
||||||
acceptance, attunement, and belief of all corrective events and
|
|
||||||
transformative information quanta to define into existence the
|
|
||||||
transformed-to system state?
|
|
||||||
|
|
||||||
Create the intention to collapse the quantum potential the field’s reality
|
|
||||||
quantum potential of the field’s reality quant to manifest this
|
|
||||||
transformation? Create optimal attunement and coherence to the source of
|
|
||||||
creation, GOD, innate intelligence, and the consciousness of healing and
|
|
||||||
accept the role of co-creator of all experienced reality, NOW?
|
|
||||||
|
|
||||||
I am grateful GOD lives through me and inspires me to set and accomplish
|
|
||||||
clear and concise goals.
|
|
||||||
|
|
||||||
I am thankful for my health, vitality, and motivation to accomplish the
|
|
||||||
goals I set efficiently and effectively.
|
|
||||||
|
|
||||||
I appreciate GOD providing strength and direction to every cell in my
|
|
||||||
body ensuring I plan, focus, and accomplish my goals and live according
|
|
||||||
to GOD’s design.
|
|
||||||
|
|
||||||
I am pleased I can visualize, set, and accomplish goals I once thought
|
|
||||||
were beyond my abilities so long as they remain according to GOD’s
|
|
||||||
design.
|
|
||||||
|
|
||||||
I enjoy knowing GOD protects me and enables me to accomplish tasks I once
|
|
||||||
believed were unattainable.
|
|
||||||
|
|
||||||
I am happy I think positively and strive to legally and ethically
|
|
||||||
maximize my accomplishments and my profits.
|
|
||||||
|
|
||||||
I am grateful GOD’s healing hands are on me and create miracles in all
|
|
||||||
areas of my life.
|
|
||||||
|
|
||||||
I am thankful I wake up each morning yearning to work and accomplish my
|
|
||||||
list of goals and tasks in a timely manner.
|
|
||||||
|
|
||||||
I appreciate GOD’s almighty power flowing though me, creating miracles
|
|
||||||
in all areas of my life, and enabling me to accomplish my goals
|
|
||||||
efficiently and effectively.
|
|
||||||
|
|
||||||
I am pleased my ability to accomplish tasks and goals efficiently allows
|
|
||||||
me to live a relaxed and perpetually blissful life.
|
|
||||||
|
|
||||||
I am grateful to GOD for all the blessings bestowed upon me.
|
|
||||||
|
|
||||||
I am thankful that I only crave healthy foods that provide my mind and
|
|
||||||
body with the nutrition necessary for me to set and achieve all my goals
|
|
||||||
in record time.
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
shall the mind and body now: Project its awareness, attention, belief, and
|
|
||||||
intention into all times dimensions of undivided universal field of
|
|
||||||
consciousness and identity, accept and release attunement to all events,
|
|
||||||
information faults, and information faults hidden that compromises
|
|
||||||
awareness of, or define into existence, the transformed-from state? Or,
|
|
||||||
that cause this transformative information quanta to define into existence
|
|
||||||
the transformed-to system state? Or that cause this transformation to
|
|
||||||
seem dangerous, undeserved, or impossible to achieve? Create awareness,
|
|
||||||
acceptance, attunement, and belief of all corrective events and
|
|
||||||
transformative information quanta to define into existence the
|
|
||||||
transformed-to system state?
|
|
||||||
|
|
||||||
Create the intention to collapse the quantum potential the field’s reality
|
|
||||||
quantum potential of the field’s reality quant to manifest this
|
|
||||||
transformation? Create optimal attunement and coherence to the source of
|
|
||||||
creation, GOD, innate intelligence, and the consciousness of healing and
|
|
||||||
accept the role of co-creator of all experienced reality, NOW?
|
|
||||||
|
|
||||||
I am grateful GOD lives through me and inspires me to achieve optimum
|
|
||||||
health.
|
|
||||||
|
|
||||||
I am thankful for my health, vitality, and motivation to eat properly,
|
|
||||||
exercise, and strengthen my body.
|
|
||||||
|
|
||||||
I appreciate GOD providing strength and direction to every cell in my
|
|
||||||
body ensuring optimum health and fitness.
|
|
||||||
|
|
||||||
I am pleased my muscles, bones, joints, organs, nerves, tendons, and
|
|
||||||
ligaments all develop correctly and function normally according to GOD’s
|
|
||||||
design.
|
|
||||||
|
|
||||||
I enjoy knowing GOD protects me throughout and beyond the physical
|
|
||||||
healing and development process.
|
|
||||||
|
|
||||||
I am happy my muscles and bones grow stronger each and every day.
|
|
||||||
|
|
||||||
I am grateful GOD’s healing hands are on me creating miracles in all
|
|
||||||
areas of my life.
|
|
||||||
|
|
||||||
I am thankful that I wake up each morning yearning to eat health foods
|
|
||||||
and exercise.
|
|
||||||
|
|
||||||
I appreciate GOD’s almighty power flowing though me, creating miracles
|
|
||||||
in all areas of my life, and correcting, curing, and developing all
|
|
||||||
healthy aspects of my mind and my body.
|
|
||||||
|
|
||||||
I am pleased my muscles and stamina develop rapidly and I continue
|
|
||||||
growing perpetually stronger each day.
|
|
||||||
|
|
||||||
I am grateful to GOD for all the blessings bestowed upon me.
|
|
||||||
|
|
||||||
I am thankful I only crave healthy foods that provide my body with the
|
|
||||||
nutrition necessary for my optimum health and wellbeing.
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
shall the mind and body now: Project its awareness, attention, belief, and
|
|
||||||
intention into all times dimensions of undivided universal field of
|
|
||||||
consciousness and identity, accept and release attunement to all events,
|
|
||||||
information faults, and information faults hidden that compromises
|
|
||||||
awareness of, or define into existence, the transformed-from state? Or,
|
|
||||||
that cause this transformative information quanta to define into existence
|
|
||||||
the transformed-to system state? Or that cause this transformation to
|
|
||||||
seem dangerous, undeserved, or impossible to achieve? Create awareness,
|
|
||||||
acceptance, attunement, and belief of all corrective events and
|
|
||||||
transformative information quanta to define into existence the
|
|
||||||
transformed-to system state?
|
|
||||||
|
|
||||||
Create the intention to collapse the quantum potential the field’s reality
|
|
||||||
quantum potential of the field’s reality quant to manifest this
|
|
||||||
transformation? Create optimal attunement and coherence to the source of
|
|
||||||
creation, GOD, innate intelligence, and the consciousness of healing and
|
|
||||||
accept the role of co-creator of all experienced reality, NOW?
|
|
||||||
|
|
||||||
I am grateful GOD lives through me and inspires me to achieve and
|
|
||||||
maintain optimum health & wellbeing.
|
|
||||||
|
|
||||||
I am thankful for my health, vitality, and motivation to eat properly,
|
|
||||||
exercise, and maintain a strong and healthy body.
|
|
||||||
|
|
||||||
I appreciate GOD providing strength and direction to every cell in my
|
|
||||||
body ensuring optimum health and wellbeing.
|
|
||||||
|
|
||||||
I am pleased for having exceptional mental, physical, and emotional
|
|
||||||
health and stability allowing me to function at high levels of
|
|
||||||
efficiency and according to GOD’s design.
|
|
||||||
|
|
||||||
I enjoy knowing GOD protects me and keeps me safe from injury and
|
|
||||||
illness.
|
|
||||||
|
|
||||||
I am happy every cell in my body remains strong and my immune system
|
|
||||||
grows stronger each and every day.
|
|
||||||
|
|
||||||
I am grateful GOD’s healing hands are on me creating miracles in all
|
|
||||||
areas of my life.
|
|
||||||
|
|
||||||
I am thankful that I wake up each morning yearning to eat health foods
|
|
||||||
and exercise so that I maintain optimum health, wellbeing, and
|
|
||||||
longevity.
|
|
||||||
.
|
|
||||||
I appreciate GOD’s almighty flowing though me, creating miracles in all
|
|
||||||
areas of my life, and correcting, curing, and developing all healthy
|
|
||||||
aspects of my mind and my body.
|
|
||||||
|
|
||||||
I am pleased my muscles are strong, my body is healthy, I have great
|
|
||||||
balance, mental clarity, exceptional stamina and I continue growing
|
|
||||||
perpetually stronger and healthier each day.
|
|
||||||
|
|
||||||
I am grateful to GOD for all the blessings bestowed upon me.
|
|
||||||
|
|
||||||
I am thankful I only crave healthy foods that provide my body with the
|
|
||||||
nutrition necessary for my optimum health and wellbeing.
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
shall the mind and body now: Project its awareness, attention, belief, and
|
|
||||||
intention into all times dimensions of undivided universal field of
|
|
||||||
consciousness and identity, accept and release attunement to all events,
|
|
||||||
information faults, and information faults hidden that compromises
|
|
||||||
awareness of, or define into existence, the transformed-from state? Or,
|
|
||||||
that cause this transformative information quanta to define into existence
|
|
||||||
the transformed-to system state? Or that cause this transformation to
|
|
||||||
seem dangerous, undeserved, or impossible to achieve? Create awareness,
|
|
||||||
acceptance, attunement, and belief of all corrective events and
|
|
||||||
transformative information quanta to define into existence the
|
|
||||||
transformed-to system state?
|
|
||||||
|
|
||||||
Create the intention to collapse the quantum potential the field’s reality
|
|
||||||
quantum potential of the field’s reality quant to manifest this
|
|
||||||
transformation? Create optimal attunement and coherence to the source of
|
|
||||||
creation, GOD, innate intelligence, and the consciousness of healing and
|
|
||||||
accept the role of co-creator of all experienced reality, NOW?
|
|
||||||
|
|
||||||
I am grateful GOD lives through me and inspires me to be a better, more
|
|
||||||
compassionate, and understanding partner and spouse each and every day.
|
|
||||||
|
|
||||||
I am thankful for my health, vitality, and motivation to be a more
|
|
||||||
loving, compassionate, understanding, and supportive partner to my spouse
|
|
||||||
with each passing day.
|
|
||||||
|
|
||||||
I appreciate GOD providing strength and direction to every cell in my
|
|
||||||
body ensuring I continue to grow and develop into a more loyal,
|
|
||||||
supportive, loving, compassionate, and understanding partner to my
|
|
||||||
spouse.
|
|
||||||
|
|
||||||
I am pleased that I listen attentively to my spouse, ask them pertinent
|
|
||||||
questions, and allow my spouse to express their feelings, opinion, and
|
|
||||||
emotions without interruption or judgement.
|
|
||||||
|
|
||||||
I enjoy knowing GOD protects me and enables me to be the best and most
|
|
||||||
loving and supportive spouse I can be.
|
|
||||||
|
|
||||||
I am happy that I enjoy a wonderful, fun-filled, loving, and passionate
|
|
||||||
relationship with my spouse.
|
|
||||||
|
|
||||||
I am grateful GOD’s healing hands are on me and create miracles in all
|
|
||||||
areas of my life.
|
|
||||||
|
|
||||||
I am thankful I wake up each morning well rested, excited, and yearning
|
|
||||||
to spend quality time with my spouse.
|
|
||||||
|
|
||||||
I appreciate GOD’s almighty power flowing though me, creating miracles
|
|
||||||
in all areas of my life, and enabling me to fulfill my spouse’s desires
|
|
||||||
and expectations.
|
|
||||||
|
|
||||||
I am pleased spending quality time with my spouse, sharing special
|
|
||||||
moments, and living a relaxed and perpetually blissful life together.
|
|
||||||
|
|
||||||
I am grateful to GOD for all the blessings bestowed upon me.
|
|
||||||
|
|
||||||
I am thankful that I only crave healthy foods that provide my mind and
|
|
||||||
body with the nutrition necessary for me to enjoy optimum health and
|
|
||||||
fulfill all my marital duties & responsibilities.
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
shall the mind and body now: Project its awareness, attention, belief, and
|
|
||||||
intention into all times dimensions of undivided universal field of
|
|
||||||
consciousness and identity, accept and release attunement to all events,
|
|
||||||
information faults, and information faults hidden that compromises
|
|
||||||
awareness of, or define into existence, the transformed-from state? Or,
|
|
||||||
that cause this transformative information quanta to define into existence
|
|
||||||
the transformed-to system state? Or that cause this transformation to
|
|
||||||
seem dangerous, undeserved, or impossible to achieve? Create awareness,
|
|
||||||
acceptance, attunement, and belief of all corrective events and
|
|
||||||
transformative information quanta to define into existence the
|
|
||||||
transformed-to system state?
|
|
||||||
|
|
||||||
Create the intention to collapse the quantum potential the field’s reality
|
|
||||||
quantum potential of the field’s reality quant to manifest this
|
|
||||||
transformation? Create optimal attunement and coherence to the source of
|
|
||||||
creation, GOD, innate intelligence, and the consciousness of healing and
|
|
||||||
accept the role of co-creator of all experienced reality, NOW?
|
|
||||||
|
|
||||||
I am grateful GOD lives through me and inspires me to prioritize and
|
|
||||||
accomplish my most important tasks throughout each day.
|
|
||||||
|
|
||||||
I am thankful for my health, vitality, and motivation to organize and
|
|
||||||
execute as many tasks as possible each and every day.
|
|
||||||
|
|
||||||
I appreciate GOD providing strength and direction to every cell in my
|
|
||||||
body ensuring I have the strength, focus, and determination to
|
|
||||||
accomplish my tasks while not wasting my time and energy on
|
|
||||||
unproductive things like snacking, watching TV, talking without purpose
|
|
||||||
in person or on the phone, or sleeping too much.
|
|
||||||
|
|
||||||
I am pleased for the opportunity to focus on prioritizing and
|
|
||||||
efficiently accomplishing tasks in order to complete my projects
|
|
||||||
efficiently and effectively and live my life according to GOD’s design.
|
|
||||||
|
|
||||||
I enjoy knowing GOD protects me and provides me with the time, talent,
|
|
||||||
insight, and focus to accomplish my tasks quickly, accurately, and
|
|
||||||
completely.
|
|
||||||
|
|
||||||
I am happy I am productive each day and my accomplishments are
|
|
||||||
efficient, effective, and profitable.
|
|
||||||
|
|
||||||
I am grateful GOD’s healing hands are on me and create miracles in all
|
|
||||||
areas of my life.
|
|
||||||
|
|
||||||
I am thankful I wake up each morning yearning to eat healthy foods and
|
|
||||||
exercise in ways that enhance my ability to complete my tasks and
|
|
||||||
fulfill my responsibilities.
|
|
||||||
|
|
||||||
I appreciate GOD’s almighty power flowing though me, creating miracles
|
|
||||||
in all areas of my life, and giving me miraculous insight, as well as
|
|
||||||
the motivation to be productive every minute of every day.
|
|
||||||
|
|
||||||
I am pleased my schedule allows me time to focus on accomplishing my
|
|
||||||
tasks.
|
|
||||||
|
|
||||||
I am grateful to GOD for all the blessings bestowed upon me.
|
|
||||||
|
|
||||||
I am thankful I am creative, imaginative, constructive, and find great
|
|
||||||
satisfaction getting things accomplished.
|
|
||||||
-60
@@ -1,60 +0,0 @@
|
|||||||
shall the mind and body now: Project its awareness, attention, belief, and
|
|
||||||
intention into all times dimensions of undivided universal field of
|
|
||||||
consciousness and identity, accept and release attunement to all events,
|
|
||||||
information faults, and information faults hidden that compromises
|
|
||||||
awareness of, or define into existence, the transformed-from state? Or,
|
|
||||||
that cause this transformative information quanta to define into existence
|
|
||||||
the transformed-to system state? Or that cause this transformation to
|
|
||||||
seem dangerous, undeserved, or impossible to achieve? Create awareness,
|
|
||||||
acceptance, attunement, and belief of all corrective events and
|
|
||||||
transformative information quanta to define into existence the
|
|
||||||
transformed-to system state?
|
|
||||||
|
|
||||||
Create the intention to collapse the quantum potential the field’s reality
|
|
||||||
quantum potential of the field’s reality quant to manifest this
|
|
||||||
transformation? Create optimal attunement and coherence to the source of
|
|
||||||
creation, GOD, innate intelligence, and the consciousness of healing and
|
|
||||||
accept the role of co-creator of all experienced reality, NOW?
|
|
||||||
|
|
||||||
I am grateful GOD lives through me and inspires me to find solace in all
|
|
||||||
aspects in my life.
|
|
||||||
|
|
||||||
I am thankful for my mental wellbeing which allows me to focus on all the
|
|
||||||
positive aspects of my life, rather than dwelling on depressing,
|
|
||||||
stressful, or emotionally draining issues that detract from my emotional
|
|
||||||
stability.
|
|
||||||
|
|
||||||
I appreciate GOD providing strength and direction to every cell in my
|
|
||||||
body, ensuring I remain emotionally stable and maintain a positive
|
|
||||||
outlook on my life regardless of my situation.
|
|
||||||
|
|
||||||
I am pleased I always manage my thoughts, think positive, avoid negative
|
|
||||||
or depressing thoughts, and live each day with a upbeat, positive, and
|
|
||||||
emotionally stable state of mind according to GOD’s design.
|
|
||||||
|
|
||||||
I enjoy knowing GOD protects me and gives me the mental fortitude to
|
|
||||||
control my thoughts and emotions while remaining positive and
|
|
||||||
emotionally stable knowing everything in my life will turn out well.
|
|
||||||
|
|
||||||
I am happy I control my own thoughts at all times and no longer dwell on
|
|
||||||
negative thoughts or issues.
|
|
||||||
|
|
||||||
I am grateful GOD’s healing hands are on me and create miracles in all
|
|
||||||
areas of my life.
|
|
||||||
|
|
||||||
I am thankful I wake up each morning with a positive outlook on life that
|
|
||||||
lasts throughout my entire day and night ensuring I live a peaceful and
|
|
||||||
emotionally stable life.
|
|
||||||
|
|
||||||
I appreciate GOD’s almighty power flowing though me, creating miracles
|
|
||||||
in all areas of my life, and correcting, curing, developing, and
|
|
||||||
improving my mind, my body, and my emotional stability.
|
|
||||||
|
|
||||||
I am pleased my mental health and emotional stability allow me to live a
|
|
||||||
positive, relaxed, and perpetually blissful lifestyle.
|
|
||||||
|
|
||||||
I am grateful to GOD for all the blessings bestowed upon me.
|
|
||||||
|
|
||||||
I am thankful that I only focus on positive thoughts and crave healthy
|
|
||||||
foods that provide my mind and body with the nutrition necessary for my
|
|
||||||
optimum health, emotional wellbeing, and longevity.
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
shall the mind and body now: Project its awareness, attention, belief, and
|
|
||||||
intention into all times dimensions of undivided universal field of
|
|
||||||
consciousness and identity, accept and release attunement to all events,
|
|
||||||
information faults, and information faults hidden that compromises
|
|
||||||
awareness of, or define into existence, the transformed-from state? Or,
|
|
||||||
that cause this transformative information quanta to define into existence
|
|
||||||
the transformed-to system state? Or that cause this transformation to
|
|
||||||
seem dangerous, undeserved, or impossible to achieve? Create awareness,
|
|
||||||
acceptance, attunement, and belief of all corrective events and
|
|
||||||
transformative information quanta to define into existence the
|
|
||||||
transformed-to system state?
|
|
||||||
|
|
||||||
Create the intention to collapse the quantum potential the field’s reality
|
|
||||||
quantum potential of the field’s reality quant to manifest this
|
|
||||||
transformation? Create optimal attunement and coherence to the source of
|
|
||||||
creation, GOD, innate intelligence, and the consciousness of healing and
|
|
||||||
accept the role of co-creator of all experienced reality, NOW?
|
|
||||||
|
|
||||||
I am grateful GOD lives through me and inspires my mind and body to heal
|
|
||||||
miraculously so I no longer suffer from Attention Deficient or Hyper
|
|
||||||
Activity disorder.
|
|
||||||
|
|
||||||
I am thankful for my health, vitality, and ability to control my
|
|
||||||
hyperactivity, remain calm, pay attention to details, and focus on tasks
|
|
||||||
through to completion.
|
|
||||||
|
|
||||||
I appreciate GOD providing strength and direction to every cell in my
|
|
||||||
body ensuring efficient, effective, and proper healing of my Attention
|
|
||||||
Deficient Hyper Activity disorder throughout the entire curative
|
|
||||||
process.
|
|
||||||
|
|
||||||
I am pleased my confidence grows with each passing day, I function
|
|
||||||
normally, I no longer suffer from low self-esteem, and I no longer have
|
|
||||||
difficulty maintaining meaningful relationships.
|
|
||||||
|
|
||||||
I enjoy knowing GOD protects me throughout and beyond the healing and
|
|
||||||
developmental process as I no longer suffer from Attention Deficient
|
|
||||||
Hyper Activity disorder.
|
|
||||||
|
|
||||||
I am happy my ability to focus grows stronger each and every day.
|
|
||||||
|
|
||||||
I am grateful GOD’s healing hands are on me creating miracles in all
|
|
||||||
areas of my life.
|
|
||||||
|
|
||||||
I am thankful I remain calm and in control of my hyperactivity.
|
|
||||||
|
|
||||||
I appreciate GOD’s almighty power flowing though me, creating miracles
|
|
||||||
in all areas of my life, and correcting, curing, and developing all
|
|
||||||
healthy aspects of my mind and my body.
|
|
||||||
|
|
||||||
I am pleased I am fully capable of being attentive and consistently
|
|
||||||
focusing on any and all projects and situations I encounter.
|
|
||||||
|
|
||||||
I am grateful to GOD for all the blessings bestowed upon me.
|
|
||||||
|
|
||||||
I am thankful I do not suffer from Attention Deficient Hyper Activity
|
|
||||||
disorder and only crave healthy foods that provide my body with the
|
|
||||||
nutrition necessary to optimize my health and wellbeing.
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
shall the mind and body now: Project its awareness, attention, belief, and
|
|
||||||
intention into all times dimensions of undivided universal field of
|
|
||||||
consciousness and identity, accept and release attunement to all events,
|
|
||||||
information faults, and information faults hidden that compromises
|
|
||||||
awareness of, or define into existence, the transformed-from state? Or,
|
|
||||||
that cause this transformative information quanta to define into existence
|
|
||||||
the transformed-to system state? Or that cause this transformation to
|
|
||||||
seem dangerous, undeserved, or impossible to achieve? Create awareness,
|
|
||||||
acceptance, attunement, and belief of all corrective events and
|
|
||||||
transformative information quanta to define into existence the
|
|
||||||
transformed-to system state?
|
|
||||||
|
|
||||||
Create the intention to collapse the quantum potential the field’s reality
|
|
||||||
quantum potential of the field’s reality quant to manifest this
|
|
||||||
transformation? Create optimal attunement and coherence to the source of
|
|
||||||
creation, GOD, innate intelligence, and the consciousness of healing and
|
|
||||||
accept the role of co-creator of all experienced reality, NOW?
|
|
||||||
|
|
||||||
I am grateful GOD lives through me and inspires me to achieve deep
|
|
||||||
relaxation, peace, and harmony in all aspects of my life.
|
|
||||||
|
|
||||||
I am thankful for my health, vitality, and motivation to eat properly,
|
|
||||||
exercise, and always maintain control of my thoughts, emotions, and
|
|
||||||
mental state.
|
|
||||||
|
|
||||||
I appreciate GOD providing strength and direction to every cell in my
|
|
||||||
body ensuring I am emotionally stable and maintain a relaxed and focused
|
|
||||||
attitude regardless of my personal situation.
|
|
||||||
|
|
||||||
I am pleased I always think positive, manage my thoughts, and live each
|
|
||||||
day in a relaxed and emotionally stable state of mind according to GOD’s
|
|
||||||
design.
|
|
||||||
|
|
||||||
I enjoy knowing GOD protects me and allows me to remain relaxed knowing
|
|
||||||
everything in my life will turn out well.
|
|
||||||
|
|
||||||
I am happy I think positively, I do not dwell on negative issues, and I
|
|
||||||
control my thoughts at all times.
|
|
||||||
|
|
||||||
I am grateful GOD’s healing hands are on me creating miracles in all
|
|
||||||
areas of my life.
|
|
||||||
|
|
||||||
I am thankful I wake up each morning yearning to eat healthy foods and
|
|
||||||
exercise in ways that ensure I live a peaceful and emotionally stable
|
|
||||||
life.
|
|
||||||
|
|
||||||
I appreciate GOD’s almighty power flowing though me, creating miracles
|
|
||||||
in all areas of my life, and correcting, curing, and developing all
|
|
||||||
healthy aspects of my mind and my body for the better.
|
|
||||||
|
|
||||||
I am pleased my mental health and emotional stability allow me to live a
|
|
||||||
relaxed and perpetually blissful life.
|
|
||||||
|
|
||||||
I am grateful to GOD for all the blessings bestowed upon me.
|
|
||||||
|
|
||||||
I am thankful that I only crave healthy foods that provide my mind and
|
|
||||||
body with the nutrition necessary for my optimum health, wellbeing, and
|
|
||||||
longevity.
|
|
||||||
-59
@@ -1,59 +0,0 @@
|
|||||||
shall the mind and body now: Project its awareness, attention, belief, and
|
|
||||||
intention into all times dimensions of undivided universal field of
|
|
||||||
consciousness and identity, accept and release attunement to all events,
|
|
||||||
information faults, and information faults hidden that compromises
|
|
||||||
awareness of, or define into existence, the transformed-from state? Or,
|
|
||||||
that cause this transformative information quanta to define into existence
|
|
||||||
the transformed-to system state? Or that cause this transformation to
|
|
||||||
seem dangerous, undeserved, or impossible to achieve? Create awareness,
|
|
||||||
acceptance, attunement, and belief of all corrective events and
|
|
||||||
transformative information quanta to define into existence the
|
|
||||||
transformed-to system state?
|
|
||||||
|
|
||||||
Create the intention to collapse the quantum potential the field’s reality
|
|
||||||
quantum potential of the field’s reality quant to manifest this
|
|
||||||
transformation? Create optimal attunement and coherence to the source of
|
|
||||||
creation, GOD, innate intelligence, and the consciousness of healing and
|
|
||||||
accept the role of co-creator of all experienced reality, NOW?
|
|
||||||
|
|
||||||
I am grateful GOD lives through me causing me to release stress, release
|
|
||||||
anxiety, and live a calm and peaceful life.
|
|
||||||
|
|
||||||
I am thankful for my health, vitality, and the motivation to breathe
|
|
||||||
deeply, eat slowly, consume nutritious foods, and exercise as a means of
|
|
||||||
releasing my stress, worries, anxiety, and frustration.
|
|
||||||
|
|
||||||
I appreciate GOD providing strength and direction to every cell in my
|
|
||||||
body ensuring I live free from stress, worry, uncertainty, and
|
|
||||||
nervousness.
|
|
||||||
|
|
||||||
I am pleased I live a stress free, happy, and fun filled life with
|
|
||||||
exceptional mental, physical, and emotional health and stability.
|
|
||||||
|
|
||||||
I enjoy knowing I can rely upon my relationship with GOD knowing GOD
|
|
||||||
protects me and keeps me safe so I have no reason to stress, no reason
|
|
||||||
to worry, no reason for uncertainty, and no reason to be nervous.
|
|
||||||
|
|
||||||
I am happy every cell in my body remains strong and the confidence in my
|
|
||||||
ability to control my life and my future grows stronger each and every
|
|
||||||
day.
|
|
||||||
|
|
||||||
I am grateful GOD’s healing hands are on me creating miracles in all
|
|
||||||
areas of my life.
|
|
||||||
|
|
||||||
I am thankful that I wake up each morning yearning to confidently take on
|
|
||||||
whatever tasks come my way without stress, worry, uncertainly, or
|
|
||||||
nervousness.
|
|
||||||
.
|
|
||||||
I appreciate GOD’s almighty power flowing though me, creating miracles
|
|
||||||
in all areas of my life, and correcting, curing, and developing all
|
|
||||||
healthy aspects of my mind and my body.
|
|
||||||
|
|
||||||
I am pleased with my mental clarity, my ability to remain calm and in
|
|
||||||
control off my activities, and focus on tasks without distraction as I
|
|
||||||
continue growing perpetually stronger and healthier each day.
|
|
||||||
|
|
||||||
I am grateful to GOD for all the blessings bestowed upon me.
|
|
||||||
|
|
||||||
I am thankful that I control my own destiny without fear, without
|
|
||||||
stress, without worry, without uncertainty, and without nervousness.
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
shall the mind and body now: Project its awareness, attention, belief, and
|
|
||||||
intention into all times dimensions of undivided universal field of
|
|
||||||
consciousness and identity, accept and release attunement to all events,
|
|
||||||
information faults, and information faults hidden that compromises
|
|
||||||
awareness of, or define into existence, the transformed-from state? Or,
|
|
||||||
that cause this transformative information quanta to define into existence
|
|
||||||
the transformed-to system state? Or that cause this transformation to
|
|
||||||
seem dangerous, undeserved, or impossible to achieve? Create awareness,
|
|
||||||
acceptance, attunement, and belief of all corrective events and
|
|
||||||
transformative information quanta to define into existence the
|
|
||||||
transformed-to system state?
|
|
||||||
|
|
||||||
Create the intention to collapse the quantum potential the field’s reality
|
|
||||||
quantum potential of the field’s reality quant to manifest this
|
|
||||||
transformation? Create optimal attunement and coherence to the source of
|
|
||||||
creation, GOD, innate intelligence, and the consciousness of healing and
|
|
||||||
accept the role of co-creator of all experienced reality, NOW?
|
|
||||||
|
|
||||||
I am grateful GOD lives through me and inspires my body to heal
|
|
||||||
miraculously, quickly achieving optimum health.
|
|
||||||
|
|
||||||
I am thankful for my health, vitality, and ability to heal quickly.
|
|
||||||
|
|
||||||
I appreciate GOD providing strength and direction to every cell in my
|
|
||||||
body ensuring efficient, effective, and proper healing throughout the
|
|
||||||
curative process.
|
|
||||||
|
|
||||||
I am pleased my muscles, bones, joints, organs, nerves, tendons, and
|
|
||||||
ligaments all heal correctly and function normally according to GOD’s
|
|
||||||
design.
|
|
||||||
|
|
||||||
I enjoy knowing GOD protects me throughout and beyond the physical
|
|
||||||
healing and development process.
|
|
||||||
|
|
||||||
I am happy my muscles and bones grow stronger each and every day.
|
|
||||||
|
|
||||||
I am grateful GOD’s healing hands are on me creating miracles in all
|
|
||||||
areas of my life.
|
|
||||||
|
|
||||||
I am thankful my pain remains tolerable throughout the healing process.
|
|
||||||
|
|
||||||
I appreciate GOD’s almighty power flowing though me, creating miracles
|
|
||||||
in all areas of my life, and correcting, curing, and developing all
|
|
||||||
healthy aspects of my mind and my body.
|
|
||||||
|
|
||||||
I am pleased my injuries heal rapidly and I emerge stronger and healthier
|
|
||||||
than before my injury.
|
|
||||||
|
|
||||||
I am grateful to GOD for all the blessings bestowed upon me.
|
|
||||||
|
|
||||||
I am thankful I only crave healthy foods that provide my body with the
|
|
||||||
nutrition necessary to heal swiftly and optimize my health and wellbeing.
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
shall the mind and body now: Project its awareness, attention, belief, and
|
|
||||||
intention into all times dimensions of undivided universal field of
|
|
||||||
consciousness and identity, accept and release attunement to all events,
|
|
||||||
information faults, and information faults hidden that compromises
|
|
||||||
awareness of, or define into existence, the transformed-from state? Or,
|
|
||||||
that cause this transformative information quanta to define into existence
|
|
||||||
the transformed-to system state? Or that cause this transformation to
|
|
||||||
seem dangerous, undeserved, or impossible to achieve? Create awareness,
|
|
||||||
acceptance, attunement, and belief of all corrective events and
|
|
||||||
transformative information quanta to define into existence the
|
|
||||||
transformed-to system state?
|
|
||||||
|
|
||||||
Create the intention to collapse the quantum potential the field’s reality
|
|
||||||
quantum potential of the field’s reality quant to manifest this
|
|
||||||
transformation? Create optimal attunement and coherence to the source of
|
|
||||||
creation, GOD, innate intelligence, and the consciousness of healing and
|
|
||||||
accept the role of co-creator of all experienced reality, NOW?
|
|
||||||
|
|
||||||
I am grateful GOD lives through me and inspires me to eat right,
|
|
||||||
exercise, and reach my ideal weight quickly and effectively.
|
|
||||||
|
|
||||||
I am thankful for my health, vitality, and motivation to eat properly,
|
|
||||||
exercise, and strengthen my body while losing unhealthy and unwanted fat.
|
|
||||||
|
|
||||||
I am appreciate GOD providing strength and direction to every cell in my
|
|
||||||
body ensuring I control my food intake, exercise, and reach my weight
|
|
||||||
loss goal.
|
|
||||||
|
|
||||||
I am pleased for having the time to buy and prepare healthy foods,
|
|
||||||
exercise, and focus on my overall health.
|
|
||||||
|
|
||||||
I enjoy knowing GOD protects me and inspires me to become healthy and fit
|
|
||||||
and live my life according to GOD’s design.
|
|
||||||
|
|
||||||
I am happy I have the desire and power to avoid eating sugary foods and
|
|
||||||
unhealthy snacks in order control my caloric intake and lose excess
|
|
||||||
weight.
|
|
||||||
|
|
||||||
I am grateful GOD’s healing hands are on me and create miracles in all
|
|
||||||
areas of my life.
|
|
||||||
|
|
||||||
I am thankful I wake up each morning yearning to eat healthy foods and
|
|
||||||
exercise in ways that ensure I reach and maintain my ideal body weight.
|
|
||||||
|
|
||||||
I appreciate GOD’s almighty power flowing though me and empowering me to
|
|
||||||
do whatever it takes to reach my ultimate weight and fitness level.
|
|
||||||
|
|
||||||
I am pleased my continued efforts to reach and maintain my ultimate
|
|
||||||
weight and fitness are rewarded with increased energy, great stamina, and
|
|
||||||
good mental and physical health.
|
|
||||||
|
|
||||||
I am grateful to GOD for all the blessings bestowed upon me.
|
|
||||||
|
|
||||||
I am thankful that I only crave healthy foods that provide my mind and
|
|
||||||
body with the nutrition necessary for my optimum health, wellbeing, and
|
|
||||||
longevity.
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
#version 330 core
|
|
||||||
|
|
||||||
in vec2 pass_TexCoord;
|
|
||||||
out vec4 out_Color;
|
|
||||||
uniform sampler2D u_fontTexture;
|
|
||||||
uniform vec4 u_textColor;
|
|
||||||
|
|
||||||
void main()
|
|
||||||
{
|
|
||||||
vec4 texel = texture(u_fontTexture, pass_TexCoord);
|
|
||||||
float mask = texel.a;
|
|
||||||
out_Color = vec4(u_textColor.rgb, u_textColor.a * mask);
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
#version 330 core
|
|
||||||
|
|
||||||
layout (location = 0) in vec2 in_Position;
|
|
||||||
layout (location = 1) in vec2 in_TexCoord;
|
|
||||||
out vec2 pass_TexCoord;
|
|
||||||
uniform mat4 u_mvpMatrix;
|
|
||||||
|
|
||||||
void main()
|
|
||||||
{
|
|
||||||
gl_Position = u_mvpMatrix * vec4(in_Position.x, in_Position.y, 0.0, 1.0);
|
|
||||||
pass_TexCoord = in_TexCoord;
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
#version 330 core
|
|
||||||
|
|
||||||
out vec4 FragColor;
|
|
||||||
|
|
||||||
uniform vec4 u_color;
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
FragColor = u_color;
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
#version 330 core
|
|
||||||
|
|
||||||
layout (location = 0) in vec2 aPos;
|
|
||||||
|
|
||||||
uniform mat4 u_mvpMatrix;
|
|
||||||
|
|
||||||
void main() {
|
|
||||||
gl_Position = u_mvpMatrix * vec4(aPos.xy, 0.0, 1.0);
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
#version 330 core
|
|
||||||
|
|
||||||
// The final color output for the pixel
|
|
||||||
out vec4 FragColor;
|
|
||||||
|
|
||||||
// Input from the vertex shader
|
|
||||||
in vec2 TexCoord; // The texture coordinate, interpolated for this specific pixel
|
|
||||||
|
|
||||||
// The source texture (our FontMap "sticker sheet")
|
|
||||||
uniform sampler2D u_texture;
|
|
||||||
uniform vec4 u_textColor;
|
|
||||||
|
|
||||||
void main()
|
|
||||||
{
|
|
||||||
float mask = texture(u_texture, TexCoord).a;
|
|
||||||
FragColor = vec4(u_textColor.rgb, u_textColor.a * mask);
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
#version 330 core
|
|
||||||
|
|
||||||
// Input vertex data from the VBO
|
|
||||||
layout (location = 0) in vec2 aPos; // The (x, y) position of the vertex
|
|
||||||
layout (location = 1) in vec2 aTexCoord; // The (u, v) texture coordinate for this vertex
|
|
||||||
|
|
||||||
// Output to the fragment shader
|
|
||||||
out vec2 TexCoord;
|
|
||||||
|
|
||||||
// A matrix to handle projection (not strictly needed for this specific render-to-texture,
|
|
||||||
// but good practice to include for general purpose use)
|
|
||||||
uniform mat4 u_mvpMatrix;
|
|
||||||
|
|
||||||
void main()
|
|
||||||
{
|
|
||||||
// Set the final position of the vertex
|
|
||||||
gl_Position = u_mvpMatrix * vec4(aPos.x, aPos.y, 0.0, 1.0);
|
|
||||||
|
|
||||||
// Pass the texture coordinate straight through to the fragment shader
|
|
||||||
TexCoord = aTexCoord;
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
<p>Welcome to RHEMA WAVE TECHNOLOGY!</p>
|
|
||||||
|
|
||||||
<p>Please review the following screens carefully and completely as they provide important user information, as well as outline YOUR legal responsibilities!</p>
|
|
||||||
|
|
||||||
<p>First and foremost, congratulations! Congratulations to YOU for having the foresight to invest in what we believe is one of the greatest breakthroughs in the history of humanity! More importantly, we congratulate YOU for investing in YOU!</p>
|
|
||||||
|
|
||||||
<p>We would also like to express our appreciation for YOUR faith, patience, and understanding as we continue evolving our ingenious business model, our revolutionary patented products, and our unprecedented copy-written technology! While we have, quoting the immortal words of Captain Kirk in the original Star Trek series, "Gone where no man has gone before!" we have already complied quite a long list of futuristic products, services, refinements, and innovations we expect to introduce as soon as humanly possible.</p>
|
|
||||||
|
|
||||||
<p>While RHEMA WAVE TECHNOLOGY is already on the cutting edge of technological advancement, we believe this is just the beginning as we intend to push the cutting edge of technology for decades into the future with yet unimaginable creations. Our focus on the continuing evolution of our science will ensure competitors will fall by the wayside as RHEMA WAVE TECHNOLOGY continues to stand alone as the undisputed "Pioneer of Innovation!"</p>
|
|
||||||
|
|
||||||
<p>Welcoming you into our RHEMA WAVE TECHNOLOGY family is far more than simply a polite slogan. We consider our members kin to the RHEMA WAVE TECHNOLOGY lineage and thus part of a very unique and special family. As part of our family we anticipate YOU will remain in communication with us and share YOUR personal progress and experiences. We also encourage you to ask questions, express concerns, and share your ideas as everyone's continued involvement is key to continued advancement.</p>
|
|
||||||
|
|
||||||
<p>In closing, and keeping with the original Star Trek theme, we feel it is amazingly appropriate to salute you in the same manner Spock, the half-human, half-Vulcan, pointy eared, logic-driven first officer aboard the StarShip Enterprise would,</p>
|
|
||||||
|
|
||||||
<p>"Live long & Prosper!"</p>
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
<p>RHEMA WAVE TECHNOLOGY, EQUIPMENT, AND SOFTWARE IS NOT INTENDED, MARKETED, OR SOLD AS A MEDICAL DEVICE.</p>
|
|
||||||
|
|
||||||
<p>RHEMA WAVE TECHNOLOGY, EQUIPMENT, AND SOFTWARE IS NOT INTENDED TO DIAGNOSE, TREAT, OR CURE ANY ILLNESS, AILMENT, OR DISEASE.</p>
|
|
||||||
|
|
||||||
<p>RHEMA WAVE TECHNOLOGY, EQUIPMENT, AND SOFTWARE MERELY CREATES WHAT WE BELIEVE TO BE A SUBTLE ENERGY FIELD THAT LIVING CELLS SEEM TO ABSORB. THIS ABSORBED ENERGY APPEARS TO ALLOW CELLS TO FUNCTION MORE EFFICIENTLY & POTENTIALLY MORE EFFECTIVELY THAN THEY NORMALLY WOULD UNDER MODERN DAY STRESS AND THE ENVIRONMENT IN WHICH WE CURRENTLY LIVE.</p>
|
|
||||||
|
|
||||||
<p>RHEMA WAVE TECHNOLOGY MAKES NO CLAIMS AS TO THE EFFECTS, EFFECTIVENESS, OR RESULTS FROM USING RHEMA WAVE TECHNOLOGY PRODUCTS AND HEREBY DISCLAIMS ANY REPRESENTATION OR WARRANTY TO THAT EFFECT.</p>
|
|
||||||
|
|
||||||
<p>USER/BUYER UNDERSTANDS AND AGREES NO MEDICAL OR HEALING CLAIMS ARE TO BE MADE REGARDING RHEMA WAVE TECHNOLOGY AND RHEMA WAVE SYSTEMS, TO INCLUDE SOFTWARE, HARDWARE, AND PROGRAMMING.</p>
|
|
||||||
|
|
||||||
<p>USER/BUYER UNDERSTANDS AND AGREES IT IS USER/BUYER'S SOLE RESPONSIBILITY TO INFORM ANY AND ALL USERS THEY INVITE OR ALLOW TO EXPERIENCE THEIR RHEMA WAVE SYSTEM OF THESE TERMS AND CONDITIONS.</p>
|
|
||||||
|
|
||||||
<p>USER/BUYER UNDERSTANDS AND AGREES USER/BUYER SHALL BE SOLELY RESPONSIBLE FOR COMPENSATION TO RHEMA WAVE TECHNOLOGY FOR ANY AND ALL CLAIMS, FINES, DAMAGES, AND LEGAL FEES RESULTING FROM USER/BUYER MAKING SUCH UNAUTHORIZED COMMENTS, INCLUDING THOSE LEVIED AGAINST RHEMA WAVE TECHNOLOGIES BY PERSONS, CORPORATIONS, AND FEDERAL AGENCIES.</p>
|
|
||||||
|
|
||||||
<p>IT IS ALSO UNDERSTOOD EQUIPMENT PROVIDERS SHALL BE HELD HARMLESS AND NOT BE RESPONSIBLE FOR ANY AND ALL SUCH CLAIMS.</p>
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
<p>INSTRUCTIONS FOR USE</p>
|
|
||||||
|
|
||||||
<p>1. Ensure you have adequate internet access.<br>
|
|
||||||
2. Enter a valid email address for your account.<br>
|
|
||||||
3. Enter a license key for your account. In the near future each account will be assigned a License Key but, at this point in time, enter your first and last name followed 1 2 3 4 etc. with no spaces until you achieve a total of 16 characters. Your License Key identifies your RHEMA SCALAR WAVE GENERATOR and its enabled features, as well as grants access to your account for activation, operation, features, and updates.<br>
|
|
||||||
4. Begin by using the F3 key to access your systems features and you will see:</p>
|
|
||||||
|
|
||||||
<p>PROCLAMATIONS<br>
|
|
||||||
SCHEDULER<br>
|
|
||||||
WiFi SETUP<br>
|
|
||||||
AUDIO OPTIONS<br>
|
|
||||||
EMAIL ADDRESS<br>
|
|
||||||
LICENSE KEY</p>
|
|
||||||
|
|
||||||
<p>Using the keyboard arrow keys you can scroll through the options and chose the one you would like to access by using the return key. The PROCLAMATIONS key will take you to a host of proclamations and options. The SCHEDULER key grants access a scheduling system allowing your to create varies different proclamation sessions, which I will explain shortly. The WiFi SETUP allows you to change WiFi networks. The AUDIO OPTIONS allows you to switch audio on or off, change audio devices, and switch between audio genres. EMAIL ADDRESS allows you to change your Email Address. License Key allows you to access and change your License Key provided RHEMA WAVE TECHNOLOGIES is notified in advance of the change.</p>
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
# If you prefer the allow list template instead of the deny list, see community template:
|
|
||||||
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
|
|
||||||
#
|
|
||||||
# Binaries for programs and plugins
|
|
||||||
*.exe
|
|
||||||
*.exe~
|
|
||||||
*.dll
|
|
||||||
*.so
|
|
||||||
*.dylib
|
|
||||||
|
|
||||||
# Test binary, built with `go test -c`
|
|
||||||
*.test
|
|
||||||
|
|
||||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
|
||||||
*.out
|
|
||||||
|
|
||||||
# Dependency directories (remove the comment below to include it)
|
|
||||||
# vendor/
|
|
||||||
|
|
||||||
# Go workspace file
|
|
||||||
go.work
|
|
||||||
|
|
||||||
.idea/
|
|
||||||
-179
@@ -1,179 +0,0 @@
|
|||||||
package gonetworkmanager
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
|
|
||||||
"github.com/godbus/dbus/v5"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
AccessPointInterface = NetworkManagerInterface + ".AccessPoint"
|
|
||||||
|
|
||||||
/* Properties */
|
|
||||||
AccessPointPropertyFlags = AccessPointInterface + ".Flags" // readable u
|
|
||||||
AccessPointPropertyWpaFlags = AccessPointInterface + ".WpaFlags" // readable u
|
|
||||||
AccessPointPropertyRsnFlags = AccessPointInterface + ".RsnFlags" // readable u
|
|
||||||
AccessPointPropertySsid = AccessPointInterface + ".Ssid" // readable ay
|
|
||||||
AccessPointPropertyFrequency = AccessPointInterface + ".Frequency" // readable u
|
|
||||||
AccessPointPropertyHwAddress = AccessPointInterface + ".HwAddress" // readable s
|
|
||||||
AccessPointPropertyMode = AccessPointInterface + ".Mode" // readable u
|
|
||||||
AccessPointPropertyMaxBitrate = AccessPointInterface + ".MaxBitrate" // readable u
|
|
||||||
AccessPointPropertyStrength = AccessPointInterface + ".Strength" // readable y
|
|
||||||
AccessPointPropertyLastSeen = AccessPointInterface + ".LastSeen" // readable i
|
|
||||||
)
|
|
||||||
|
|
||||||
type AccessPoint interface {
|
|
||||||
GetPath() dbus.ObjectPath
|
|
||||||
|
|
||||||
// GetPropertyFlags gets flags describing the capabilities of the access point.
|
|
||||||
GetPropertyFlags() (uint32, error)
|
|
||||||
|
|
||||||
// GetPropertyWPAFlags gets flags describing the access point's capabilities
|
|
||||||
// according to WPA (Wi-Fi Protected Access).
|
|
||||||
GetPropertyWPAFlags() (uint32, error)
|
|
||||||
|
|
||||||
// GetPropertyRSNFlags gets flags describing the access point's capabilities
|
|
||||||
// according to the RSN (Robust Secure Network) protocol.
|
|
||||||
GetPropertyRSNFlags() (uint32, error)
|
|
||||||
|
|
||||||
// GetPropertySSID returns the Service Set Identifier identifying the access point.
|
|
||||||
GetPropertySSID() (string, error)
|
|
||||||
|
|
||||||
// GetPropertyFrequency gets the radio channel frequency in use by the access point, in MHz.
|
|
||||||
GetPropertyFrequency() (uint32, error)
|
|
||||||
|
|
||||||
// GetPropertyHWAddress gets the hardware address (BSSID) of the access point.
|
|
||||||
GetPropertyHWAddress() (string, error)
|
|
||||||
|
|
||||||
// GetPropertyMode describes the operating mode of the access point.
|
|
||||||
GetPropertyMode() (Nm80211Mode, error)
|
|
||||||
|
|
||||||
// GetPropertyMaxBitrate gets the maximum bitrate this access point is capable of, in kilobits/second (Kb/s).
|
|
||||||
GetPropertyMaxBitrate() (uint32, error)
|
|
||||||
|
|
||||||
// GetPropertyStrength gets the current signal quality of the access point, in percent.
|
|
||||||
GetPropertyStrength() (uint8, error)
|
|
||||||
|
|
||||||
// GetPropertyLastSeen The timestamp (in CLOCK_BOOTTIME seconds) for the last time the access point was found in scan results.
|
|
||||||
// A value of -1 means the access point has never been found in scan results.
|
|
||||||
GetPropertyLastSeen() (int32, error)
|
|
||||||
|
|
||||||
MarshalJSON() ([]byte, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewAccessPoint(objectPath dbus.ObjectPath) (AccessPoint, error) {
|
|
||||||
var a accessPoint
|
|
||||||
return &a, a.init(NetworkManagerInterface, objectPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
type accessPoint struct {
|
|
||||||
dbusBase
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *accessPoint) GetPath() dbus.ObjectPath {
|
|
||||||
return a.obj.Path()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *accessPoint) GetPropertyFlags() (uint32, error) {
|
|
||||||
return a.getUint32Property(AccessPointPropertyFlags)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *accessPoint) GetPropertyWPAFlags() (uint32, error) {
|
|
||||||
return a.getUint32Property(AccessPointPropertyWpaFlags)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *accessPoint) GetPropertyRSNFlags() (uint32, error) {
|
|
||||||
return a.getUint32Property(AccessPointPropertyRsnFlags)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *accessPoint) GetPropertySSID() (string, error) {
|
|
||||||
r, err := a.getSliceByteProperty(AccessPointPropertySsid)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return string(r), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *accessPoint) GetPropertyFrequency() (uint32, error) {
|
|
||||||
return a.getUint32Property(AccessPointPropertyFrequency)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *accessPoint) GetPropertyHWAddress() (string, error) {
|
|
||||||
return a.getStringProperty(AccessPointPropertyHwAddress)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *accessPoint) GetPropertyMode() (Nm80211Mode, error) {
|
|
||||||
r, err := a.getUint32Property(AccessPointPropertyMode)
|
|
||||||
if err != nil {
|
|
||||||
return Nm80211ModeUnknown, err
|
|
||||||
}
|
|
||||||
return Nm80211Mode(r), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *accessPoint) GetPropertyMaxBitrate() (uint32, error) {
|
|
||||||
return a.getUint32Property(AccessPointPropertyMaxBitrate)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *accessPoint) GetPropertyStrength() (uint8, error) {
|
|
||||||
return a.getUint8Property(AccessPointPropertyStrength)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *accessPoint) GetPropertyLastSeen() (int32, error) {
|
|
||||||
return a.getInt32Property(AccessPointPropertyLastSeen)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *accessPoint) MarshalJSON() ([]byte, error) {
|
|
||||||
Flags, err := a.GetPropertyFlags()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
WPAFlags, err := a.GetPropertyWPAFlags()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
RSNFlags, err := a.GetPropertyRSNFlags()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
SSID, err := a.GetPropertySSID()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
Frequency, err := a.GetPropertyFrequency()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
HWAddress, err := a.GetPropertyHWAddress()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
Mode, err := a.GetPropertyMode()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
MaxBitrate, err := a.GetPropertyMaxBitrate()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
Strength, err := a.GetPropertyStrength()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
LastSeen, err := a.GetPropertyLastSeen()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return json.Marshal(map[string]interface{}{
|
|
||||||
"Flags": Flags,
|
|
||||||
"WPAFlags": WPAFlags,
|
|
||||||
"RSNFlags": RSNFlags,
|
|
||||||
"SSID": SSID,
|
|
||||||
"Frequency": Frequency,
|
|
||||||
"HWAddress": HWAddress,
|
|
||||||
"Mode": Mode.String(),
|
|
||||||
"MaxBitrate": MaxBitrate,
|
|
||||||
"Strength": Strength,
|
|
||||||
"LastSeen": LastSeen,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
-274
@@ -1,274 +0,0 @@
|
|||||||
package gonetworkmanager
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/godbus/dbus/v5"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
ActiveConnectionInterface = NetworkManagerInterface + ".Connection.Active"
|
|
||||||
|
|
||||||
/* Properties */
|
|
||||||
ActiveConnectionPropertyConnection = ActiveConnectionInterface + ".Connection" // readable o
|
|
||||||
ActiveConnectionPropertySpecificObject = ActiveConnectionInterface + ".SpecificObject" // readable o
|
|
||||||
ActiveConnectionPropertyId = ActiveConnectionInterface + ".Id" // readable s
|
|
||||||
ActiveConnectionPropertyUuid = ActiveConnectionInterface + ".Uuid" // readable s
|
|
||||||
ActiveConnectionPropertyType = ActiveConnectionInterface + ".Type" // readable s
|
|
||||||
ActiveConnectionPropertyDevices = ActiveConnectionInterface + ".Devices" // readable ao
|
|
||||||
ActiveConnectionPropertyState = ActiveConnectionInterface + ".State" // readable u
|
|
||||||
ActiveConnectionPropertyStateFlags = ActiveConnectionInterface + ".StateFlags" // readable u
|
|
||||||
ActiveConnectionPropertyDefault = ActiveConnectionInterface + ".Default" // readable b
|
|
||||||
ActiveConnectionPropertyIp4Config = ActiveConnectionInterface + ".Ip4Config" // readable o
|
|
||||||
ActiveConnectionPropertyDhcp4Config = ActiveConnectionInterface + ".Dhcp4Config" // readable o
|
|
||||||
ActiveConnectionPropertyDefault6 = ActiveConnectionInterface + ".Default6" // readable b
|
|
||||||
ActiveConnectionPropertyIp6Config = ActiveConnectionInterface + ".Ip6Config" // readable o
|
|
||||||
ActiveConnectionPropertyDhcp6Config = ActiveConnectionInterface + ".Dhcp6Config" // readable o
|
|
||||||
ActiveConnectionPropertyVpn = ActiveConnectionInterface + ".Vpn" // readable b
|
|
||||||
ActiveConnectionPropertyMaster = ActiveConnectionInterface + ".Master" // readable o
|
|
||||||
|
|
||||||
/* Signals */
|
|
||||||
ActiveConnectionSignalStateChanged = "StateChanged" // u state, u reason
|
|
||||||
|
|
||||||
)
|
|
||||||
|
|
||||||
type ActiveConnection interface {
|
|
||||||
GetPath() dbus.ObjectPath
|
|
||||||
|
|
||||||
// GetPropertyConnection gets connection object of the connection.
|
|
||||||
GetPropertyConnection() (Connection, error)
|
|
||||||
|
|
||||||
// GetPropertySpecificObject gets a specific object associated with the active connection.
|
|
||||||
GetPropertySpecificObject() (AccessPoint, error)
|
|
||||||
|
|
||||||
// GetPropertyID gets the ID of the connection.
|
|
||||||
GetPropertyID() (string, error)
|
|
||||||
|
|
||||||
// GetPropertyUUID gets the UUID of the connection.
|
|
||||||
GetPropertyUUID() (string, error)
|
|
||||||
|
|
||||||
// GetPropertyType gets the type of the connection.
|
|
||||||
GetPropertyType() (string, error)
|
|
||||||
|
|
||||||
// GetPropertyDevices gets array of device objects which are part of this active connection.
|
|
||||||
GetPropertyDevices() ([]Device, error)
|
|
||||||
|
|
||||||
// GetPropertyState gets the state of the connection.
|
|
||||||
GetPropertyState() (NmActiveConnectionState, error)
|
|
||||||
|
|
||||||
// GetPropertyStateFlags gets the state flags of the connection.
|
|
||||||
GetPropertyStateFlags() (uint32, error)
|
|
||||||
|
|
||||||
// GetPropertyDefault gets the default IPv4 flag of the connection.
|
|
||||||
GetPropertyDefault() (bool, error)
|
|
||||||
|
|
||||||
// GetPropertyIP4Config gets the IP4Config of the connection.
|
|
||||||
GetPropertyIP4Config() (IP4Config, error)
|
|
||||||
|
|
||||||
// GetPropertyDHCP4Config gets the DHCP6Config of the connection.
|
|
||||||
GetPropertyDHCP4Config() (DHCP4Config, error)
|
|
||||||
|
|
||||||
// GetPropertyDefault6 gets the default IPv6 flag of the connection.
|
|
||||||
GetPropertyDefault6() (bool, error)
|
|
||||||
|
|
||||||
// GetPropertyIP6Config gets the IP6Config of the connection.
|
|
||||||
GetPropertyIP6Config() (IP6Config, error)
|
|
||||||
|
|
||||||
// GetPropertyDHCP6Config gets the DHCP4Config of the connection.
|
|
||||||
GetPropertyDHCP6Config() (DHCP6Config, error)
|
|
||||||
|
|
||||||
// GetPropertyVPN gets the VPN flag of the connection.
|
|
||||||
GetPropertyVPN() (bool, error)
|
|
||||||
|
|
||||||
// GetPropertyMaster gets the master device of the connection.
|
|
||||||
GetPropertyMaster() (Device, error)
|
|
||||||
|
|
||||||
SubscribeState(receiver chan StateChange, exit chan struct{}) (err error)
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewActiveConnection(objectPath dbus.ObjectPath) (ActiveConnection, error) {
|
|
||||||
var a activeConnection
|
|
||||||
return &a, a.init(NetworkManagerInterface, objectPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
type activeConnection struct {
|
|
||||||
dbusBase
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *activeConnection) GetPath() dbus.ObjectPath {
|
|
||||||
return a.obj.Path()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *activeConnection) GetPropertyConnection() (Connection, error) {
|
|
||||||
path, err := a.getObjectProperty(ActiveConnectionPropertyConnection)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
con, err := NewConnection(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return con, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *activeConnection) GetPropertySpecificObject() (AccessPoint, error) {
|
|
||||||
path, err := a.getObjectProperty(ActiveConnectionPropertySpecificObject)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
ap, err := NewAccessPoint(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return ap, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *activeConnection) GetPropertyID() (string, error) {
|
|
||||||
return a.getStringProperty(ActiveConnectionPropertyId)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *activeConnection) GetPropertyUUID() (string, error) {
|
|
||||||
return a.getStringProperty(ActiveConnectionPropertyUuid)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *activeConnection) GetPropertyType() (string, error) {
|
|
||||||
return a.getStringProperty(ActiveConnectionPropertyType)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *activeConnection) GetPropertyDevices() ([]Device, error) {
|
|
||||||
paths, err := a.getSliceObjectProperty(ActiveConnectionPropertyDevices)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
devices := make([]Device, len(paths))
|
|
||||||
for i, path := range paths {
|
|
||||||
devices[i], err = DeviceFactory(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return devices, nil
|
|
||||||
}
|
|
||||||
func (a *activeConnection) GetPropertyState() (NmActiveConnectionState, error) {
|
|
||||||
v, err := a.getUint32Property(ActiveConnectionPropertyState)
|
|
||||||
return NmActiveConnectionState(v), err
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *activeConnection) GetPropertyStateFlags() (uint32, error) {
|
|
||||||
return a.getUint32Property(ActiveConnectionPropertyStateFlags)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *activeConnection) GetPropertyDefault() (bool, error) {
|
|
||||||
b, err := a.getProperty(ActiveConnectionPropertyDefault)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
return b.(bool), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *activeConnection) GetPropertyIP4Config() (IP4Config, error) {
|
|
||||||
path, err := a.getObjectProperty(ActiveConnectionPropertyIp4Config)
|
|
||||||
if err != nil || path == "/" {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return NewIP4Config(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *activeConnection) GetPropertyDHCP4Config() (DHCP4Config, error) {
|
|
||||||
path, err := a.getObjectProperty(ActiveConnectionPropertyDhcp4Config)
|
|
||||||
if err != nil || path == "/" {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return NewDHCP4Config(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *activeConnection) GetPropertyDefault6() (bool, error) {
|
|
||||||
return a.getBoolProperty(ActiveConnectionPropertyDefault6)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *activeConnection) GetPropertyIP6Config() (IP6Config, error) {
|
|
||||||
path, err := a.getObjectProperty(ActiveConnectionPropertyIp6Config)
|
|
||||||
if err != nil || path == "/" {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return NewIP6Config(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *activeConnection) GetPropertyDHCP6Config() (DHCP6Config, error) {
|
|
||||||
path, err := a.getObjectProperty(ActiveConnectionPropertyDhcp6Config)
|
|
||||||
if err != nil || path == "/" {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return NewDHCP6Config(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *activeConnection) GetPropertyVPN() (bool, error) {
|
|
||||||
ret, err := a.getProperty(ActiveConnectionPropertyVpn)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
return ret.(bool), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *activeConnection) GetPropertyMaster() (Device, error) {
|
|
||||||
path, err := a.getObjectProperty(ActiveConnectionPropertyMaster)
|
|
||||||
if err != nil || path == "/" {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return DeviceFactory(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
type StateChange struct {
|
|
||||||
Path dbus.ObjectPath
|
|
||||||
State NmActiveConnectionState
|
|
||||||
Reason NmActiveConnectionStateReason
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *activeConnection) SubscribeState(receiver chan StateChange, exit chan struct{}) (err error) {
|
|
||||||
|
|
||||||
channel := make(chan *dbus.Signal, 1)
|
|
||||||
|
|
||||||
a.conn.Signal(channel)
|
|
||||||
|
|
||||||
err = a.conn.AddMatchSignal(
|
|
||||||
dbus.WithMatchInterface(ActiveConnectionInterface),
|
|
||||||
dbus.WithMatchMember(ActiveConnectionSignalStateChanged),
|
|
||||||
dbus.WithMatchObjectPath(a.GetPath()),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case signal, ok := <-channel:
|
|
||||||
if !ok {
|
|
||||||
err = fmt.Errorf("connection closed for %s", ActiveConnectionSignalStateChanged)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if signal.Name != ActiveConnectionInterface+"."+ActiveConnectionSignalStateChanged {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
stateChange := StateChange{
|
|
||||||
Path: signal.Path,
|
|
||||||
State: NmActiveConnectionState(signal.Body[0].(uint32)),
|
|
||||||
Reason: NmActiveConnectionStateReason(signal.Body[1].(uint32)),
|
|
||||||
}
|
|
||||||
|
|
||||||
receiver <- stateChange
|
|
||||||
|
|
||||||
case <-exit:
|
|
||||||
a.conn.RemoveSignal(channel)
|
|
||||||
close(channel)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
|
||||||
-85
@@ -1,85 +0,0 @@
|
|||||||
# Changelog
|
|
||||||
|
|
||||||
All notable changes to this project since will be documented in this file.
|
|
||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
||||||
|
|
||||||
## [2.1.0] - 2023-01-19
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Add device auto-connect setter
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- Connection settings route and address data types
|
|
||||||
|
|
||||||
## [2.0.0] - 2022-11-22
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
|
|
||||||
- Update go-dbus to v5.1.0
|
|
||||||
- **BREAKING CHANGE** : Generic recursive settings map dbus variants decoding
|
|
||||||
|
|
||||||
## [0.5.0] - 2022-11-22
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Godoc standard comment prefix
|
|
||||||
- DnsManager
|
|
||||||
- Device: GetIp4Connectivity
|
|
||||||
- Constants for Nm80211APSec
|
|
||||||
- Add gitignore
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- CheckpointCreate: fix devicePaths variable scope
|
|
||||||
|
|
||||||
## [0.4.0] - 2022-01-17
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- AccessPoint: add LastSeen property
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- Examples: move examples to their own subfolders
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- DeviceWireless: remove duplicated fields
|
|
||||||
- PrimaryConnection: use ActiveConnection type
|
|
||||||
- SubscribeState: add the path to the recieved chan type, catch connect event for ActiveConnection
|
|
||||||
|
|
||||||
## [0.3.0] - 2020-03-26
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- SetPropertyManaged (@joseffilzmaier)
|
|
||||||
- GetConnectionByUUID (@paulburlumi)
|
|
||||||
- VpnConnection
|
|
||||||
- ActiveConnectionSignalStateChanged
|
|
||||||
- CheckpointRollback
|
|
||||||
- SetPropertyWirelessEnabled (@Raqbit)
|
|
||||||
- Settings.ReloadConnections (@appnostic-io)
|
|
||||||
|
|
||||||
Static connection example (@everactivemilligan)
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- GetPropertyRouteData panic (@zhengdelun)
|
|
||||||
|
|
||||||
## [0.2.0] - 2020-03-06
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- added missing flag for Reload
|
|
||||||
- added parameter specific_object for AddAndActivateConnection
|
|
||||||
- Fix CheckpointCreateand GetPropertyCheckpoints
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- Add property setter helper
|
|
||||||
- Add Device.SetPropertyRefreshRateMs
|
|
||||||
- Add Device.Reapply
|
|
||||||
-79
@@ -1,79 +0,0 @@
|
|||||||
package gonetworkmanager
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
|
|
||||||
"github.com/godbus/dbus/v5"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
CheckpointInterface = NetworkManagerInterface + ".Checkpoint"
|
|
||||||
|
|
||||||
/* Properties */
|
|
||||||
CheckpointPropertyDevices = CheckpointInterface + ".Devices" // readable ao
|
|
||||||
CheckpointPropertyCreated = CheckpointInterface + ".Created" // readable x
|
|
||||||
CheckpointPropertyRollbackTimeout = CheckpointInterface + ".RollbackTimeout" // readable u
|
|
||||||
)
|
|
||||||
|
|
||||||
type Checkpoint interface {
|
|
||||||
GetPath() dbus.ObjectPath
|
|
||||||
|
|
||||||
// GetPropertyDevices Array of object paths for devices which are part of this checkpoint.
|
|
||||||
GetPropertyDevices() ([]Device, error)
|
|
||||||
|
|
||||||
// GetPropertyCreated The timestamp (in CLOCK_BOOTTIME milliseconds) of checkpoint creation.
|
|
||||||
GetPropertyCreated() (int64, error)
|
|
||||||
|
|
||||||
// GetPropertyRollbackTimeout Timeout in seconds for automatic rollback, or zero.
|
|
||||||
GetPropertyRollbackTimeout() (uint32, error)
|
|
||||||
|
|
||||||
MarshalJSON() ([]byte, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewCheckpoint(objectPath dbus.ObjectPath) (Checkpoint, error) {
|
|
||||||
var c checkpoint
|
|
||||||
return &c, c.init(NetworkManagerInterface, objectPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
type checkpoint struct {
|
|
||||||
dbusBase
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *checkpoint) GetPropertyDevices() ([]Device, error) {
|
|
||||||
devicesPaths, err := c.getSliceObjectProperty(CheckpointPropertyDevices)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
devices := make([]Device, len(devicesPaths))
|
|
||||||
for i, path := range devicesPaths {
|
|
||||||
devices[i], err = NewDevice(path)
|
|
||||||
if err != nil {
|
|
||||||
return devices, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return devices, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *checkpoint) GetPropertyCreated() (int64, error) {
|
|
||||||
return c.getInt64Property(CheckpointPropertyCreated)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *checkpoint) GetPropertyRollbackTimeout() (uint32, error) {
|
|
||||||
return c.getUint32Property(CheckpointPropertyRollbackTimeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *checkpoint) GetPath() dbus.ObjectPath {
|
|
||||||
return c.obj.Path()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *checkpoint) MarshalJSON() ([]byte, error) {
|
|
||||||
m := make(map[string]interface{})
|
|
||||||
|
|
||||||
m["Devices"], _ = c.GetPropertyDevices()
|
|
||||||
m["Created"], _ = c.GetPropertyCreated()
|
|
||||||
m["RollbackTimeout"], _ = c.GetPropertyRollbackTimeout()
|
|
||||||
|
|
||||||
return json.Marshal(m)
|
|
||||||
}
|
|
||||||
-184
@@ -1,184 +0,0 @@
|
|||||||
package gonetworkmanager
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
"github.com/godbus/dbus/v5"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
ConnectionInterface = SettingsInterface + ".Connection"
|
|
||||||
|
|
||||||
/* Methods */
|
|
||||||
ConnectionUpdate = ConnectionInterface + ".Update"
|
|
||||||
ConnectionUpdateUnsaved = ConnectionInterface + ".UpdateUnsaved"
|
|
||||||
ConnectionDelete = ConnectionInterface + ".Delete"
|
|
||||||
ConnectionGetSettings = ConnectionInterface + ".GetSettings"
|
|
||||||
ConnectionGetSecrets = ConnectionInterface + ".GetSecrets"
|
|
||||||
ConnectionClearSecrets = ConnectionInterface + ".ClearSecrets"
|
|
||||||
ConnectionSave = ConnectionInterface + ".Save"
|
|
||||||
ConnectionUpdate2 = ConnectionInterface + ".Update2"
|
|
||||||
|
|
||||||
/* Properties */
|
|
||||||
ConnectionPropertyUnsaved = ConnectionInterface + ".Unsaved" // readable b
|
|
||||||
ConnectionPropertyFlags = ConnectionInterface + ".Flags" // readable u
|
|
||||||
ConnectionPropertyFilename = ConnectionInterface + ".Filename" // readable s
|
|
||||||
)
|
|
||||||
|
|
||||||
//type ConnectionSettings map[string]map[string]interface{}
|
|
||||||
type ConnectionSettings map[string]map[string]interface{}
|
|
||||||
|
|
||||||
type Connection interface {
|
|
||||||
GetPath() dbus.ObjectPath
|
|
||||||
|
|
||||||
// Update the connection with new settings and properties (replacing all previous settings and properties) and save the connection to disk. Secrets may be part of the update request, and will be either stored in persistent storage or sent to a Secret Agent for storage, depending on the flags associated with each secret.
|
|
||||||
Update(settings ConnectionSettings) error
|
|
||||||
|
|
||||||
// UpdateUnsaved Update the connection with new settings and properties (replacing all previous settings and properties) but do not immediately save the connection to disk. Secrets may be part of the update request and may sent to a Secret Agent for storage, depending on the flags associated with each secret. Use the 'Save' method to save these changes to disk. Note that unsaved changes will be lost if the connection is reloaded from disk (either automatically on file change or due to an explicit ReloadConnections call).
|
|
||||||
UpdateUnsaved(settings ConnectionSettings) error
|
|
||||||
|
|
||||||
// Delete the connection.
|
|
||||||
Delete() error
|
|
||||||
|
|
||||||
// GetSettings gets the settings maps describing this network configuration.
|
|
||||||
// This will never include any secrets required for connection to the
|
|
||||||
// network, as those are often protected. Secrets must be requested
|
|
||||||
// separately using the GetSecrets() method.
|
|
||||||
GetSettings() (ConnectionSettings, error)
|
|
||||||
|
|
||||||
// GetSecrets Get the secrets belonging to this network configuration. Only secrets from
|
|
||||||
// persistent storage or a Secret Agent running in the requestor's session
|
|
||||||
// will be returned. The user will never be prompted for secrets as a result
|
|
||||||
// of this request.
|
|
||||||
GetSecrets(settingName string) (ConnectionSettings, error)
|
|
||||||
|
|
||||||
// ClearSecrets Clear the secrets belonging to this network connection profile.
|
|
||||||
ClearSecrets() error
|
|
||||||
|
|
||||||
// Save a "dirty" connection (that had previously been updated with UpdateUnsaved) to persistent storage.
|
|
||||||
Save() error
|
|
||||||
|
|
||||||
// GetPropertyUnsaved If set, indicates that the in-memory state of the connection does not match the on-disk state. This flag will be set when UpdateUnsaved() is called or when any connection details change, and cleared when the connection is saved to disk via Save() or from internal operations.
|
|
||||||
GetPropertyUnsaved() (bool, error)
|
|
||||||
|
|
||||||
// GetPropertyFlags Additional flags of the connection profile.
|
|
||||||
GetPropertyFlags() (uint32, error)
|
|
||||||
|
|
||||||
// GetPropertyFilename File that stores the connection in case the connection is file-backed.
|
|
||||||
GetPropertyFilename() (string, error)
|
|
||||||
|
|
||||||
MarshalJSON() ([]byte, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewConnection(objectPath dbus.ObjectPath) (Connection, error) {
|
|
||||||
var c connection
|
|
||||||
return &c, c.init(NetworkManagerInterface, objectPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
type connection struct {
|
|
||||||
dbusBase
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *connection) GetPath() dbus.ObjectPath {
|
|
||||||
return c.obj.Path()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *connection) Update(settings ConnectionSettings) error {
|
|
||||||
return c.call(ConnectionUpdate, settings)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *connection) UpdateUnsaved(settings ConnectionSettings) error {
|
|
||||||
return c.call(ConnectionUpdateUnsaved, settings)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *connection) Delete() error {
|
|
||||||
return c.call(ConnectionDelete)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *connection) GetSettings() (ConnectionSettings, error) {
|
|
||||||
var settings map[string]map[string]dbus.Variant
|
|
||||||
|
|
||||||
if err := c.callWithReturn(&settings, ConnectionGetSettings); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return decodeSettings(settings), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *connection) GetSecrets(settingName string) (ConnectionSettings, error) {
|
|
||||||
var settings map[string]map[string]dbus.Variant
|
|
||||||
|
|
||||||
if err := c.callWithReturn(&settings, ConnectionGetSecrets, settingName); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return decodeSettings(settings), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func decodeSettings(input map[string]map[string]dbus.Variant) (settings ConnectionSettings) {
|
|
||||||
valueMap := ConnectionSettings{}
|
|
||||||
for key, data := range input {
|
|
||||||
valueMap[key] = decode(data).(map[string]interface{})
|
|
||||||
}
|
|
||||||
return valueMap
|
|
||||||
}
|
|
||||||
|
|
||||||
func decode(input interface{}) (value interface{}) {
|
|
||||||
if variant, isVariant := input.(dbus.Variant); isVariant {
|
|
||||||
return decode(variant.Value())
|
|
||||||
} else if inputMap, isMap := input.(map[string]dbus.Variant); isMap {
|
|
||||||
return decodeMap(inputMap)
|
|
||||||
} else if inputArray, isArray := input.([]dbus.Variant); isArray {
|
|
||||||
return decodeArray(inputArray)
|
|
||||||
} else if inputArray, isArray := input.([]map[string]dbus.Variant); isArray {
|
|
||||||
return decodeMapArray(inputArray)
|
|
||||||
} else {
|
|
||||||
return input
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func decodeArray(input []dbus.Variant) (value []interface{}) {
|
|
||||||
for _, data := range input {
|
|
||||||
value = append(value, decode(data))
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func decodeMapArray(input []map[string]dbus.Variant) (value []map[string]interface{}) {
|
|
||||||
for _, data := range input {
|
|
||||||
value = append(value, decodeMap(data))
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func decodeMap(input map[string]dbus.Variant) (value map[string]interface{}) {
|
|
||||||
value = map[string]interface{}{}
|
|
||||||
for key, data := range input {
|
|
||||||
value[key] = decode(data)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *connection) ClearSecrets() error {
|
|
||||||
return c.call(ConnectionClearSecrets)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *connection) Save() error {
|
|
||||||
return c.call(ConnectionSave)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *connection) GetPropertyUnsaved() (bool, error) {
|
|
||||||
return c.getBoolProperty(ConnectionPropertyUnsaved)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *connection) GetPropertyFlags() (uint32, error) {
|
|
||||||
return c.getUint32Property(ConnectionPropertyFlags)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *connection) GetPropertyFilename() (string, error) {
|
|
||||||
return c.getStringProperty(ConnectionPropertyFilename)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *connection) MarshalJSON() ([]byte, error) {
|
|
||||||
s, _ := c.GetSettings()
|
|
||||||
return json.Marshal(s)
|
|
||||||
}
|
|
||||||
-56
@@ -1,56 +0,0 @@
|
|||||||
package gonetworkmanager
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
|
|
||||||
"github.com/godbus/dbus/v5"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
DHCP4ConfigInterface = NetworkManagerInterface + ".DHCP4Config"
|
|
||||||
|
|
||||||
// Properties
|
|
||||||
DHCP4ConfigPropertyOptions = DHCP4ConfigInterface + ".Options"
|
|
||||||
)
|
|
||||||
|
|
||||||
type DHCP4Options map[string]interface{}
|
|
||||||
|
|
||||||
type DHCP4Config interface {
|
|
||||||
// GetPropertyOptions GetOptions gets options map of configuration returned by the IPv4 DHCP server.
|
|
||||||
GetPropertyOptions() (DHCP4Options, error)
|
|
||||||
|
|
||||||
MarshalJSON() ([]byte, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewDHCP4Config(objectPath dbus.ObjectPath) (DHCP4Config, error) {
|
|
||||||
var c dhcp4Config
|
|
||||||
return &c, c.init(NetworkManagerInterface, objectPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
type dhcp4Config struct {
|
|
||||||
dbusBase
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *dhcp4Config) GetPropertyOptions() (DHCP4Options, error) {
|
|
||||||
options, err := c.getMapStringVariantProperty(DHCP4ConfigPropertyOptions)
|
|
||||||
rv := make(DHCP4Options)
|
|
||||||
if err != nil {
|
|
||||||
return rv, err
|
|
||||||
}
|
|
||||||
|
|
||||||
for k, v := range options {
|
|
||||||
rv[k] = v.Value()
|
|
||||||
}
|
|
||||||
|
|
||||||
return rv, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *dhcp4Config) MarshalJSON() ([]byte, error) {
|
|
||||||
Options, err := c.GetPropertyOptions()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return json.Marshal(map[string]interface{}{
|
|
||||||
"Options": Options,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
-54
@@ -1,54 +0,0 @@
|
|||||||
package gonetworkmanager
|
|
||||||
|
|
||||||
import (
|
|
||||||
"encoding/json"
|
|
||||||
|
|
||||||
"github.com/godbus/dbus/v5"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
DHCP6ConfigInterface = NetworkManagerInterface + ".DHCP6Config"
|
|
||||||
|
|
||||||
// Properties
|
|
||||||
DHCP6ConfigPropertyOptions = DHCP6ConfigInterface + ".Options"
|
|
||||||
)
|
|
||||||
|
|
||||||
type DHCP6Options map[string]interface{}
|
|
||||||
|
|
||||||
type DHCP6Config interface {
|
|
||||||
// GetPropertyOptions GetOptions gets options map of configuration returned by the IPv4 DHCP server.
|
|
||||||
GetPropertyOptions() (DHCP6Options, error)
|
|
||||||
|
|
||||||
MarshalJSON() ([]byte, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewDHCP6Config(objectPath dbus.ObjectPath) (DHCP6Config, error) {
|
|
||||||
var c dhcp6Config
|
|
||||||
return &c, c.init(NetworkManagerInterface, objectPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
type dhcp6Config struct {
|
|
||||||
dbusBase
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *dhcp6Config) GetPropertyOptions() (DHCP6Options, error) {
|
|
||||||
options, err := c.getMapStringVariantProperty(DHCP6ConfigPropertyOptions)
|
|
||||||
rv := make(DHCP6Options)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return rv, err
|
|
||||||
}
|
|
||||||
|
|
||||||
for k, v := range options {
|
|
||||||
rv[k] = v.Value()
|
|
||||||
}
|
|
||||||
|
|
||||||
return rv, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *dhcp6Config) MarshalJSON() ([]byte, error) {
|
|
||||||
m := make(map[string]interface{})
|
|
||||||
m["Options"], _ = c.GetPropertyOptions()
|
|
||||||
|
|
||||||
return json.Marshal(m)
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user