Remove archive code: Accidental commit

This commit is contained in:
NeilClack
2026-08-13 17:25:16 -05:00
parent 515056a92c
commit c53abede6a
488 changed files with 0 additions and 443547 deletions
-1
View File
@@ -1 +0,0 @@
use flake
-66
View File
@@ -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"}'
-63
View File
@@ -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"}'
-261
View File
@@ -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.
-24
View File
@@ -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
-90
View File
@@ -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
-19
View File
@@ -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
-
-44
View File
@@ -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
-42
View File
@@ -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
-22
View File
@@ -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:
-61
View File
@@ -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
}
-116
View File
@@ -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;
};
}
-27
View File
@@ -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
)
-24
View File
@@ -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=
-548
View File
@@ -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
}
-214
View File
@@ -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}
)
-112
View File
@@ -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{},
)
}
-58
View File
@@ -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
}
-14
View File
@@ -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
}
-239
View File
@@ -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
}
-890
View File
@@ -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
}
-128
View File
@@ -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
}
-79
View File
@@ -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
}
-73
View File
@@ -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
}
-231
View File
@@ -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...) }
-496
View File
@@ -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)
}
}
})
}
}
-38
View File
@@ -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)
}
})
}
}
-83
View File
@@ -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
}
-577
View File
@@ -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
-626
View File
@@ -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
}
-29
View File
@@ -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
}
-407
View File
@@ -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()
}
-419
View File
@@ -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()
}
-107
View File
@@ -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)
}
}
-459
View File
@@ -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()
}
-273
View File
@@ -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()
}
-236
View File
@@ -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
}
-83
View File
@@ -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
}
-79
View File
@@ -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)
}
-829
View File
@@ -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
}
}
}
-255
View File
@@ -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
}
-656
View File
@@ -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
}
-127
View File
@@ -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
}
-144
View File
@@ -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
}
-31
View File
@@ -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 ];
};
}
-144
View File
@@ -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";
}
-3
View File
@@ -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;
}
}
}
});
-3
View File
@@ -1,3 +0,0 @@
[Service]
ExecStart=
ExecStart=-/sbin/agetty --autologin kiosk --noclear %I $TERM
-24
View File
@@ -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
-564
View File
@@ -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())
-12
View File
@@ -1,12 +0,0 @@
package main
import (
"fmt"
"rhemawavetech.com/rhemawave/internal/application"
)
func main() {
application.Run()
fmt.Printf("Stopping application")
}
-474
View File
@@ -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.
@@ -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.
@@ -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.
@@ -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);
}
-17
View File
@@ -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);
}
-21
View File
@@ -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 &amp; 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 &amp; 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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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