Initial release: RadiantWave v1.0
This commit is contained in:
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(cat:*)",
|
||||||
|
"Bash(git checkout:*)",
|
||||||
|
"Bash(dpkg:*)",
|
||||||
|
"Bash(chmod:*)",
|
||||||
|
"Bash(git add:*)",
|
||||||
|
"Bash(git commit:*)",
|
||||||
|
"Bash(./build.sh:*)",
|
||||||
|
"Bash(ls:*)",
|
||||||
|
"Bash(umask)",
|
||||||
|
"Bash(namei:*)",
|
||||||
|
"Bash(cd /home/nclack/Work/Rhema\\\\ Wave/RhemaWave && go list:*)",
|
||||||
|
"Bash(go mod download:*)",
|
||||||
|
"Bash(CGO_ENABLED=1 go build:*)",
|
||||||
|
"Bash(go vet:*)",
|
||||||
|
"Bash(go version:*)",
|
||||||
|
"Bash(dlv version:*)",
|
||||||
|
"Bash(sudo nixos-rebuild:*)",
|
||||||
|
"Bash(nix develop:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
# 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
|
||||||
|
go.work.sum
|
||||||
|
|
||||||
|
# env file
|
||||||
|
.env
|
||||||
|
|
||||||
|
__debug*
|
||||||
|
|
||||||
|
# Project files used as symlinks for development
|
||||||
|
settings.json
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
bin/
|
||||||
|
*.pprof
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Audio assets (large binary files)
|
||||||
|
share/rhemawave/audio/*.wav
|
||||||
|
|
||||||
|
# Nix
|
||||||
|
.direnv/
|
||||||
|
result
|
||||||
|
|
||||||
|
notes.txt
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
# Rhema Wave Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## IMPORTANT!
|
||||||
|
`9cdef97` - This commit hash is considered the "Great Reset Point".
|
||||||
|
Many bugs, issues, and complexities have been discovered, leading to a restructure
|
||||||
|
of the project, packaging, deployment and even target machine environment.
|
||||||
|
All tags prior to this commit have been deleted. All releases removed, all archived
|
||||||
|
packages dropped from the server, and even the server structure has been changed.
|
||||||
|
v0.9.0 will represent the "beta" release from this point forward. Until full release, we will
|
||||||
|
remain at v0.9.x.
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- TOS screens now display real content from the RWT Welcome Screen document (10 sections)
|
||||||
|
- TOS content is stored in the database and seeded from HTML files on disk, making it easy to update without recompiling
|
||||||
|
- WiFi setup page now appears automatically before TOS screens if no internet connection is detected
|
||||||
|
- Dev script `setup.sh` for syncing assets to /usr/ on development machines
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [v0.9.22]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Batched texture rendering path (BeginTextureRendering/RenderTextureBatched/EndTextureRendering) for ScrollerPage
|
||||||
|
- Ring buffer (LineDeque) for ScrollerPage line management — O(1) prepend/append/remove
|
||||||
|
- In-memory config value cache in db package — eliminates repeated SQLite queries
|
||||||
|
- Buffered channel-based logger — single writer goroutine replaces per-log goroutine spawning
|
||||||
|
- Cached uniform locations on Shader struct — looked up once at load, reused every frame
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- FontManager now reuses a persistent FBO, VAO, and VBO for string texture rendering instead of creating/deleting per call
|
||||||
|
- RenderSolidColorQuad uses cached uniform locations and removes per-frame glGetError calls
|
||||||
|
- Welcome page caches init_font_size at Init instead of querying the database every frame
|
||||||
|
- ScrollerPage.Destroy uses gl.Flush instead of gl.Finish to avoid blocking CPU on page transitions
|
||||||
|
- Scroll offset calculation uses simple subtraction instead of math.Mod with float64 round-trip
|
||||||
|
- Application cleanup now properly shuts down the logger goroutine via logger.Close()
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- Removed per-frame CheckGLError calls from RenderSolidColorQuad (caused GPU pipeline stalls)
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
RhemaWave is a Go-based fullscreen SDL2/OpenGL application designed to run on Linux kiosk systems. It displays proclamations with audio playback and animated visual patterns. The application uses a page-based navigation system and includes network connectivity validation, license key management, and an auto-update mechanism.
|
||||||
|
|
||||||
|
## Build & Development
|
||||||
|
|
||||||
|
### Development Workflow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nix develop # enter dev shell (provides Go, SDL2, etc.)
|
||||||
|
go build -o bin/rhemawave rhemawave.go # build binary
|
||||||
|
./bin/rhemawave # run (requires display server + audio)
|
||||||
|
./dev_scripts/reset.sh # wipe local database and start fresh
|
||||||
|
```
|
||||||
|
|
||||||
|
The binary at `bin/rhemawave` resolves assets via `../share/rhemawave` relative to itself, so the repo layout (`bin/` + `share/rhemawave/`) works out of the box.
|
||||||
|
|
||||||
|
### Nix Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nix build # produces ./result/bin/rhemawave + ./result/share/rhemawave/
|
||||||
|
```
|
||||||
|
|
||||||
|
The flake's `postInstall` copies `share/` into `$out/`, preserving the same relative layout.
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
There are currently no automated tests in this codebase.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Application Entry Point
|
||||||
|
- `rhemawave.go` is the main entry point, calls `internal/application.Run()`
|
||||||
|
- `internal/application/application.go` contains the core application lifecycle
|
||||||
|
|
||||||
|
### Core Application Flow
|
||||||
|
1. **Initialization**
|
||||||
|
- Create application data directory (`~/.local/share/rhemawave`)
|
||||||
|
- Initialize SQLite database (`data.db`) with GORM
|
||||||
|
- Initialize SDL2 subsystems (VIDEO, AUDIO)
|
||||||
|
- Initialize SDL_ttf for font rendering
|
||||||
|
- Create fullscreen OpenGL 3.3 Core Profile window
|
||||||
|
- Initialize shader and font managers
|
||||||
|
- Hide cursor and enable relative mouse mode
|
||||||
|
|
||||||
|
2. **Validation Phase** (startup page stack, top → bottom)
|
||||||
|
- WiFi setup page (if no internet connection detected) — shown first
|
||||||
|
- TOS sections from database (unaccepted only, ordered by section number)
|
||||||
|
- Email address page (if missing)
|
||||||
|
- License key page (if missing)
|
||||||
|
- Welcome page — always at bottom
|
||||||
|
- License key is also validated against the API
|
||||||
|
|
||||||
|
3. **Event Loop** (60 FPS target)
|
||||||
|
- Poll SDL events
|
||||||
|
- Handle keyboard events via keybinds system
|
||||||
|
- Update current page with delta time
|
||||||
|
- Render current page
|
||||||
|
- Apply pending page transitions
|
||||||
|
|
||||||
|
### Page System
|
||||||
|
Pages implement the `page.Page` interface:
|
||||||
|
- `Init(app ApplicationInterface) error` - Setup resources
|
||||||
|
- `HandleEvent(event *sdl.Event) error` - Handle input
|
||||||
|
- `Update(dt float32) error` - Update state
|
||||||
|
- `Render() error` - Draw to screen
|
||||||
|
- `Destroy() error` - Cleanup resources
|
||||||
|
|
||||||
|
Pages can embed `page.Base` for common functionality (OpenGL quad rendering, projection matrix, screen dimensions).
|
||||||
|
|
||||||
|
**Page Navigation:**
|
||||||
|
- `PushPage(p)` - Add page to stack (creates history)
|
||||||
|
- `SwitchPage(p)` - Replace current page
|
||||||
|
- `UnwindToPage(p)` - Pop back to a specific page (or create if not in stack)
|
||||||
|
|
||||||
|
**Available Pages:**
|
||||||
|
- `Welcome` - Initial screen showing version
|
||||||
|
- `ScrollerPage` - Main proclamation player (F2)
|
||||||
|
- `Settings` - Settings menu (F3)
|
||||||
|
- `WiFiSetupPage` - Network configuration
|
||||||
|
- `LicenseKeyPage` - License key entry
|
||||||
|
- `EmailAddressPage` - Email address entry
|
||||||
|
- `AudioDevices` - Audio device selection
|
||||||
|
- `ProclamationOptions` - Proclamation selection
|
||||||
|
- `TOSPage` - Terms of Service acceptance screen (scrollable, Y/N to accept/decline)
|
||||||
|
|
||||||
|
### Key Global Keybinds
|
||||||
|
Defined in `application.initKeybinds()`:
|
||||||
|
- `F1` - Navigate to Welcome page
|
||||||
|
- `F2` - Navigate to Player (ScrollerPage)
|
||||||
|
- `F3` - Navigate to Settings
|
||||||
|
- `SHIFT+UP` - Increase volume
|
||||||
|
- `SHIFT+DOWN` - Decrease volume
|
||||||
|
- `RIGHT_CTRL+Q` - Quit application
|
||||||
|
|
||||||
|
### Package Structure
|
||||||
|
|
||||||
|
**`internal/application`** - Core application lifecycle, event loop, page management, validation
|
||||||
|
|
||||||
|
**`internal/page`** - Page interface and all page implementations
|
||||||
|
|
||||||
|
**`internal/db`** - Database layer using GORM with SQLite
|
||||||
|
- Models: `Config`, `Proclamations`, `Log`, `TOSSection`
|
||||||
|
- Manages configuration key-value pairs
|
||||||
|
- Seeds default proclamations from `share/rhemawave/proclamations/`
|
||||||
|
- Seeds TOS sections from `share/rhemawave/tos/*.html` (filename format: `{NN} - {Title}.html`)
|
||||||
|
|
||||||
|
**`internal/audio`** - SDL2 audio subsystem integration (currently minimal wrapper)
|
||||||
|
|
||||||
|
**`internal/mixer`** - Audio playback manager for music and proclamations
|
||||||
|
|
||||||
|
**`internal/graphics`** - OpenGL utilities and display orientation handling
|
||||||
|
|
||||||
|
**`internal/shaderManager`** - Loads and manages OpenGL shaders from `share/rhemawave/shaders/`
|
||||||
|
|
||||||
|
**`internal/fontManager`** - Loads and manages TrueType fonts from `share/rhemawave/fonts/`
|
||||||
|
|
||||||
|
**`internal/keybinds`** - Global keyboard shortcut registration and dispatch
|
||||||
|
|
||||||
|
**`internal/network`** - Network connectivity manager (uses D-Bus for NetworkManager on Linux)
|
||||||
|
|
||||||
|
**`internal/logger`** - Logging system that writes to database and stdout
|
||||||
|
|
||||||
|
**`internal/colors`** - Color utilities
|
||||||
|
|
||||||
|
**`internal/pattern`** - Visual pattern generation (line patterns, color patterns)
|
||||||
|
|
||||||
|
### Repository Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
bin/ # build output (gitignored)
|
||||||
|
rhemawave
|
||||||
|
share/rhemawave/ # assets (checked in)
|
||||||
|
audio/ # Audio files (audio.wav, etc.)
|
||||||
|
fonts/ # TrueType fonts
|
||||||
|
shaders/ # GLSL shader files
|
||||||
|
proclamations/ # Proclamation text files
|
||||||
|
home/ # Non-commercial proclamations
|
||||||
|
commercial/ # Commercial proclamations
|
||||||
|
tos/ # TOS HTML files (seeded into DB on first run)
|
||||||
|
packaging/ # deployment configs (kiosk, updater, polkit)
|
||||||
|
kiosk/ # kiosk system configs
|
||||||
|
rhemawave-updater.py # auto-updater script
|
||||||
|
internal/ # Go source packages
|
||||||
|
dev_scripts/
|
||||||
|
reset.sh # deletes ~/.local/share/rhemawave/
|
||||||
|
flake.nix # Nix build and dev shell
|
||||||
|
```
|
||||||
|
|
||||||
|
### Important Implementation Details
|
||||||
|
|
||||||
|
**Asset Resolution:** `resolveAssetsDir()` computes `filepath.Join(filepath.Dir(exe), "..", "share", "rhemawave")` — works in both dev (`bin/rhemawave` → `share/rhemawave/`) and Nix install (`$out/bin/rhemawave` → `$out/share/rhemawave/`).
|
||||||
|
|
||||||
|
**OpenGL Context:** Uses OpenGL 3.3 Core Profile with VSync enabled. The page base class sets up a reusable quad VAO/VBO for texture and solid color rendering.
|
||||||
|
|
||||||
|
**Database Location:** `~/.local/share/rhemawave/data.db` - Contains configuration, proclamations, and log entries
|
||||||
|
|
||||||
|
**Fullscreen Mode:** Uses `SDL_WINDOW_FULLSCREEN_DESKTOP` to match native display resolution
|
||||||
|
|
||||||
|
**Display Orientation:** Handled by `graphics.SetDisplayOrientation()` which swaps width/height for portrait displays
|
||||||
|
|
||||||
|
**Version Injection:** Git version is injected at build time via ldflags: `-X 'rhemawavetech.com/rhemawave/internal/page.GitVersion=${VERSION}'`
|
||||||
|
|
||||||
|
**Network Manager:** Uses D-Bus to communicate with NetworkManager for WiFi configuration and connectivity status
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
### Adding a New Page
|
||||||
|
1. Create `internal/page/<pagename>.go`
|
||||||
|
2. Implement the `page.Page` interface
|
||||||
|
3. Embed `page.Base` for common functionality
|
||||||
|
4. Register keybind in `application.initKeybinds()` if needed
|
||||||
|
5. Add navigation logic from existing pages
|
||||||
|
|
||||||
|
### Modifying Database Schema
|
||||||
|
1. Update models in `internal/db/db.go`
|
||||||
|
2. GORM AutoMigrate will handle schema changes on next run
|
||||||
|
3. Add seed data logic if needed in `ensureDefaultsExist()`, `seedDefaultProclamations()`, or `seedDefaultTOSSections()`
|
||||||
|
|
||||||
|
### Adding New Assets
|
||||||
|
1. Place files in appropriate `share/rhemawave/` subdirectory
|
||||||
|
2. They will be included automatically in Nix builds via `postInstall`
|
||||||
|
3. Access via config values (`assets_dir`) or direct paths
|
||||||
|
|
||||||
|
### Working with Shaders
|
||||||
|
1. Create `.vert` and `.frag` files in `share/rhemawave/shaders/`
|
||||||
|
2. Load via `shaderManager.GetShader("<name>")`
|
||||||
|
3. Shader must have uniforms: `u_mvpMatrix`, `u_color` or `u_textColor`, `u_texture` (if using textures)
|
||||||
|
|
||||||
|
## Important Constraints
|
||||||
|
|
||||||
|
- Must build with `CGO_ENABLED=1` for SDL2 bindings
|
||||||
|
- Target platform is Linux (uses D-Bus for NetworkManager)
|
||||||
|
- Application expects to run as kiosk user with polkit rules for system operations
|
||||||
|
- Assets are resolved relative to the binary (`../share/rhemawave`), so `bin/` and `share/` must be siblings
|
||||||
|
- Version string comes from git tags (release) or commit hash (dev)
|
||||||
|
- ShaderManager, FontManager, and Logger use `sync.Once` singletons — they must be initialized (via their `Init()` or `Initialize()` functions) before calling `Get()`; calling `Get()` before initialization panics
|
||||||
|
- Deployed via NixOS-Anywhere to kiosk hardware
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Rhema Wave User Manual
|
||||||
|
|
||||||
|
## Keybinds
|
||||||
|
These are the basic keybinds for navigating Rhema Wave and operating the program.
|
||||||
|
Should you find a keybind that does something and is not listed here, please email nclack@rhemawavetech.com
|
||||||
|
|
||||||
|
> When you see key combinations such as "SHIFT + UP ARROW", press and hold the first key, in this case SHIFT
|
||||||
|
> then press the second key. Do not hold the second key as right now, there is no way to stop a flood of commands
|
||||||
|
> from being sent to the program, and you could send more than the program can handle, causing things to "continue happening after you've let go of the keys".
|
||||||
|
|
||||||
|
|
||||||
|
### Navigation
|
||||||
|
|
||||||
|
**F1** - The "Welcome" screen. The screen that appears when you first start Rhema Wave and is useful for finding the current version of Rhema Wave you have installed.
|
||||||
|
**F2** - The "Player". This is the main application where your proclamations and audio will be played.
|
||||||
|
**F3** - The "Settings" menu. Use this menu to connect to WiFi, manage Audio Output Devices, and select your active proclamations to be played.
|
||||||
|
|
||||||
|
### Controls
|
||||||
|
|
||||||
|
**SHIFT + UP ARROW** - Volume Up
|
||||||
|
**SHIFT + DOWN ARROW** - Volume Down
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# RhemaWave
|
||||||
|
|
||||||
|
A Go-based fullscreen SDL2/OpenGL application for Linux kiosk systems. Displays proclamations with audio playback and animated visual patterns.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
Development uses Nix for a reproducible build environment.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nix develop # enter dev shell
|
||||||
|
go build -o bin/rhemawave rhemawave.go # build
|
||||||
|
./bin/rhemawave # run
|
||||||
|
./dev_scripts/reset.sh # reset database (if needed)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Nix with flakes enabled
|
||||||
|
- A display server (X11/Wayland) and audio device for running the application
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- **Page-based UI** — SDL2/OpenGL 3.3 fullscreen rendering with a page stack for navigation
|
||||||
|
- **Proclamation player** — scrolling text display with synchronized audio playback
|
||||||
|
- **Startup validation** — WiFi, TOS acceptance, email, license key checks
|
||||||
|
- **Singleton managers** — ShaderManager, FontManager, Logger (initialized once via `sync.Once`)
|
||||||
|
|
||||||
|
### Key Directories
|
||||||
|
|
||||||
|
```
|
||||||
|
bin/ # build output (gitignored)
|
||||||
|
share/rhemawave/ # assets (fonts, shaders, proclamations, TOS)
|
||||||
|
internal/ # Go source packages
|
||||||
|
packaging/ # deployment configs (kiosk, updater, polkit)
|
||||||
|
dev_scripts/ # developer utilities
|
||||||
|
```
|
||||||
|
|
||||||
|
### Runtime Data
|
||||||
|
|
||||||
|
Stored in `~/.local/share/rhemawave/`:
|
||||||
|
- `data.db` — SQLite database (config, proclamations, logs)
|
||||||
|
- `log.txt` — application log
|
||||||
|
- `license_key` — license key file
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
Deployed to kiosk hardware via NixOS-Anywhere. See `packaging/` for kiosk system configs.
|
||||||
Executable
+5
@@ -0,0 +1,5 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
rm -rf ~/.local/share/rhemawave/
|
||||||
|
|
||||||
|
echo "Data directory deleted, application reset."
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# Greetd Auto-Login Setup for Ubuntu Server 24
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
This guide configures greetd for automatic login of the `kiosk` user on Ubuntu Server 24, bypassing password authentication while keeping the user account password-protected for other purposes.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install greetd
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install greetd
|
||||||
|
|
||||||
|
# Disable other display managers if running
|
||||||
|
sudo systemctl disable gdm3 lightdm sddm
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Edit `/etc/greetd/config.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[terminal]
|
||||||
|
vt = 1
|
||||||
|
|
||||||
|
[default_session]
|
||||||
|
command = "agreety --cmd /bin/bash"
|
||||||
|
user = "kiosk"
|
||||||
|
|
||||||
|
[initial_session]
|
||||||
|
command = "/bin/bash"
|
||||||
|
user = "kiosk"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Auto-Start Application
|
||||||
|
|
||||||
|
Create `/home/kiosk/.bash_profile`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# Auto-start RhemaWave on login
|
||||||
|
if [ -z "$DISPLAY" ] && [ "$(tty)" = "/dev/tty1" ]; then
|
||||||
|
exec /usr/local/bin/rhemawave
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
Make it executable:
|
||||||
|
```bash
|
||||||
|
chmod +x /home/kiosk/.bash_profile
|
||||||
|
chown kiosk:kiosk /home/kiosk/.bash_profile
|
||||||
|
```
|
||||||
|
|
||||||
|
## Enable Service
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl enable greetd
|
||||||
|
sudo systemctl start greetd
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Reboot the system. The `kiosk` user should auto-login on tty1 and launch RhemaWave automatically.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The `kiosk` user password remains active for SSH, sudo, and manual logins on other TTYs
|
||||||
|
- Auto-login only applies to tty1 (the primary console)
|
||||||
|
- If RhemaWave crashes, the user remains logged in at a bash prompt
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
# RhemaWave Kiosk Setup Guide - Ubuntu Server 24
|
||||||
|
|
||||||
|
Complete setup guide for a fresh Ubuntu Server 24 installation configured as a RhemaWave kiosk.
|
||||||
|
|
||||||
|
## Initial System Setup
|
||||||
|
|
||||||
|
### 1. Create Kiosk User
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo adduser kiosk
|
||||||
|
sudo usermod -aG sudo kiosk
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Install Dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install -y \
|
||||||
|
libsdl2-2.0-0 \
|
||||||
|
libsdl2-ttf-2.0-0 \
|
||||||
|
libsdl2-mixer-2.0-0 \
|
||||||
|
libegl1-mesa \
|
||||||
|
libgles2-mesa \
|
||||||
|
libgbm1 \
|
||||||
|
libinput10 \
|
||||||
|
libxkbcommon0 \
|
||||||
|
dbus \
|
||||||
|
network-manager
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Install Cage (Wayland Compositor)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install -y cage
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Install Greetd
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install -y greetd
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Configure PAM for Greetd
|
||||||
|
|
||||||
|
Create `/etc/pam.d/greetd`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo tee /etc/pam.d/greetd <<EOF
|
||||||
|
#%PAM-1.0
|
||||||
|
auth required pam_unix.so
|
||||||
|
auth required pam_env.so
|
||||||
|
account required pam_unix.so
|
||||||
|
password required pam_unix.so
|
||||||
|
session required pam_unix.so
|
||||||
|
session required pam_limits.so
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Configure Greetd Auto-Login
|
||||||
|
|
||||||
|
Create `/etc/greetd/config.toml`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo tee /etc/greetd/config.toml <<EOF
|
||||||
|
[terminal]
|
||||||
|
vt = 1
|
||||||
|
|
||||||
|
[initial_session]
|
||||||
|
command = "cage -s -- /usr/local/bin/rhemawave"
|
||||||
|
user = "kiosk"
|
||||||
|
|
||||||
|
[default_session]
|
||||||
|
command = "cage -s -- /usr/local/bin/rhemawave"
|
||||||
|
user = "kiosk"
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Disable Network Wait Service
|
||||||
|
|
||||||
|
Speed up boot by disabling network wait:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl disable systemd-networkd-wait-online.service
|
||||||
|
sudo systemctl mask systemd-networkd-wait-online.service
|
||||||
|
```
|
||||||
|
|
||||||
|
## Install RhemaWave
|
||||||
|
|
||||||
|
### Option 1: Via APT Repository (Recommended)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Add repository
|
||||||
|
echo "deb [trusted=yes] https://repository.rhemawavetech.com/release ./" | \
|
||||||
|
sudo tee /etc/apt/sources.list.d/rhemawave.list
|
||||||
|
|
||||||
|
# Install
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install rhemawave
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 2: Manual Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Download and install .deb package
|
||||||
|
wget https://repository.rhemawavetech.com/rhemawave_X.X.X_amd64.deb
|
||||||
|
sudo dpkg -i rhemawave_X.X.X_amd64.deb
|
||||||
|
sudo apt install -f
|
||||||
|
```
|
||||||
|
|
||||||
|
## Enable and Start Greetd
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl enable greetd
|
||||||
|
sudo systemctl start greetd
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verify Setup
|
||||||
|
|
||||||
|
Check greetd status:
|
||||||
|
```bash
|
||||||
|
sudo systemctl status greetd
|
||||||
|
```
|
||||||
|
|
||||||
|
Check logs if issues occur:
|
||||||
|
```bash
|
||||||
|
journalctl -xeu greetd
|
||||||
|
```
|
||||||
|
|
||||||
|
## Reboot
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo reboot
|
||||||
|
```
|
||||||
|
|
||||||
|
The system should auto-login as `kiosk` and launch RhemaWave in fullscreen via cage.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Greetd fails to start
|
||||||
|
- Check PAM config: `cat /etc/pam.d/greetd`
|
||||||
|
- Check greetd config syntax: `cat /etc/greetd/config.toml`
|
||||||
|
- View detailed logs: `journalctl -xeu greetd`
|
||||||
|
|
||||||
|
### Black screen after login
|
||||||
|
- Verify cage is installed: `which cage`
|
||||||
|
- Verify rhemawave exists: `ls -l /usr/local/bin/rhemawave`
|
||||||
|
- Check cage can access GPU: `ls -l /dev/dri/`
|
||||||
|
- Add kiosk to video group: `sudo usermod -aG video kiosk`
|
||||||
|
|
||||||
|
### Network not connecting
|
||||||
|
- Check NetworkManager status: `systemctl status NetworkManager`
|
||||||
|
- Use nmtui for WiFi setup: `sudo nmtui`
|
||||||
|
- RhemaWave will show WiFi setup page if no connection detected
|
||||||
|
|
||||||
|
### Auto-updates not working
|
||||||
|
- Verify polkit rules: `ls -l /etc/polkit-1/rules.d/99-rhemawave-updater.rules`
|
||||||
|
- Check updater script: `ls -l /usr/local/bin/rhemawave-updater.py`
|
||||||
|
- Test manual update: `sudo apt update && sudo apt list --upgradable`
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
|
||||||
|
- The kiosk user keeps their password for SSH and sudo access
|
||||||
|
- Auto-login only applies to tty1 (the primary console)
|
||||||
|
- Other TTYs (Ctrl+Alt+F2-F6) still require password
|
||||||
|
- Polkit rules restrict kiosk user to only RhemaWave package updates
|
||||||
|
|
||||||
|
## Remote Management
|
||||||
|
|
||||||
|
### SSH Access
|
||||||
|
```bash
|
||||||
|
ssh kiosk@<kiosk-ip>
|
||||||
|
```
|
||||||
|
|
||||||
|
### View RhemaWave Logs
|
||||||
|
```bash
|
||||||
|
# From SSH session
|
||||||
|
sudo journalctl -u greetd -f
|
||||||
|
```
|
||||||
|
|
||||||
|
### Restart RhemaWave
|
||||||
|
```bash
|
||||||
|
sudo systemctl restart greetd
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update RhemaWave
|
||||||
|
```bash
|
||||||
|
sudo apt update
|
||||||
|
sudo apt upgrade rhemawave
|
||||||
|
```
|
||||||
Generated
+61
@@ -0,0 +1,61 @@
|
|||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"flake-utils": {
|
||||||
|
"inputs": {
|
||||||
|
"systems": "systems"
|
||||||
|
},
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1731533236,
|
||||||
|
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "numtide",
|
||||||
|
"repo": "flake-utils",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1771848320,
|
||||||
|
"narHash": "sha256-0MAd+0mun3K/Ns8JATeHT1sX28faLII5hVLq0L3BdZU=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "2fc6539b481e1d2569f25f8799236694180c0993",
|
||||||
|
"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
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
{
|
||||||
|
description = "RhemaWave";
|
||||||
|
|
||||||
|
inputs = {
|
||||||
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
|
flake-utils.url = "github:numtide/flake-utils";
|
||||||
|
};
|
||||||
|
|
||||||
|
outputs = { self, nixpkgs, flake-utils }:
|
||||||
|
(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
|
||||||
|
wayland
|
||||||
|
wayland-protocols
|
||||||
|
libxkbcommon
|
||||||
|
libdecor
|
||||||
|
libx11
|
||||||
|
libxext
|
||||||
|
];
|
||||||
|
|
||||||
|
nativeBuildInputs = with pkgs; [
|
||||||
|
go_1_24
|
||||||
|
pkg-config
|
||||||
|
];
|
||||||
|
|
||||||
|
audioFile = pkgs.fetchurl {
|
||||||
|
url = "https://files.neilclack.com/audio.wav";
|
||||||
|
sha256 = "sha256-5Dikxx9RKOoPfY1ukIgMoVQ7tzpMirp+XPj6OphfEW0=";
|
||||||
|
};
|
||||||
|
in
|
||||||
|
{
|
||||||
|
packages.default = pkgs.buildGoModule {
|
||||||
|
pname = "rhemawave";
|
||||||
|
version = "v1.0.0";
|
||||||
|
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 = ''
|
||||||
|
cp -r share $out/share
|
||||||
|
mkdir -p $out/share/rhemawave/audio
|
||||||
|
cp ${audioFile} $out/share/rhemawave/audio/audio.wav
|
||||||
|
'';
|
||||||
|
|
||||||
|
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"
|
||||||
|
echo "Reset: ./dev_scripts/reset.sh"
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)) // {
|
||||||
|
nixosModules.default = import ./nixos/module.nix self;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
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
|
||||||
|
github.com/godbus/dbus/v5 v5.1.0
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
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.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.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/mutecomm/go-sqlcipher/v4 v4.4.2 h1:eM10bFtI4UvibIsKr10/QT7Yfz+NADfjZYh0GKrXUNc=
|
||||||
|
github.com/mutecomm/go-sqlcipher/v4 v4.4.2/go.mod h1:mF2UmIpBnzFeBdu/ypTDb/LdbS0nk0dfSN1WUsWTjMA=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
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=
|
||||||
@@ -0,0 +1,694 @@
|
|||||||
|
package application
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-gl/gl/v3.3-core/gl"
|
||||||
|
"github.com/veandco/go-sdl2/sdl"
|
||||||
|
"github.com/veandco/go-sdl2/ttf"
|
||||||
|
"rhemawavetech.com/rhemawave/internal/db"
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
targetFPS = 60.0
|
||||||
|
volumeStepSize = 16
|
||||||
|
openGLMajorVersion = 3
|
||||||
|
openGLMinorVersion = 3
|
||||||
|
apiURL = "http://localhost:8000"
|
||||||
|
apiPath = "/api/v1/licenseKey/validate"
|
||||||
|
audioFileURL = "https://files.neilclack.com/audio.wav"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Application struct {
|
||||||
|
Window *sdl.Window
|
||||||
|
License *license.Response
|
||||||
|
pageStack []page.Page
|
||||||
|
pendingAction func()
|
||||||
|
running bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Main Entry Point
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func Run() {
|
||||||
|
logger.Info("Starting application")
|
||||||
|
|
||||||
|
appDataDir, err := initializeApplicationDirectory()
|
||||||
|
if err != nil {
|
||||||
|
logger.Fatal("Failed to initialize application directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("App directory: %s", appDataDir)
|
||||||
|
logger.AddFile(filepath.Join(appDataDir, "log.txt"))
|
||||||
|
|
||||||
|
if err := initializeDatabase(appDataDir); err != nil {
|
||||||
|
logger.Fatal("Database initialization failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
app := &Application{}
|
||||||
|
|
||||||
|
app.initializeSDLAndOpenGL()
|
||||||
|
defer app.cleanup()
|
||||||
|
|
||||||
|
app.initializeManagers()
|
||||||
|
app.initializeAudio()
|
||||||
|
app.validateLicenseKey()
|
||||||
|
app.startupPageStack()
|
||||||
|
app.runEventLoop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Initialization Functions
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func initializeApplicationDirectory() (string, error) {
|
||||||
|
homeDir, err := os.UserHomeDir()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to get user home directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
appDataDir := filepath.Join(homeDir, ".local", "share", "rhemawave")
|
||||||
|
|
||||||
|
if err := os.MkdirAll(appDataDir, 0755); err != nil {
|
||||||
|
return "", fmt.Errorf("could not create application data directory %s: %w", appDataDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
testFile := filepath.Join(appDataDir, ".access_test")
|
||||||
|
f, err := os.Create(testFile)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("application data directory %s is not writable: %w", appDataDir, err)
|
||||||
|
}
|
||||||
|
f.Close()
|
||||||
|
os.Remove(testFile)
|
||||||
|
|
||||||
|
return appDataDir, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func initializeDatabase(appDataDir string) error {
|
||||||
|
dbPath := filepath.Join(appDataDir, "data.db")
|
||||||
|
if err := db.InitDatabase(dbPath); err != nil {
|
||||||
|
return fmt.Errorf("failed to initialize database: %w", err)
|
||||||
|
}
|
||||||
|
logger.AddDatabase(db.DB)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type progressWriter struct {
|
||||||
|
w io.Writer
|
||||||
|
written *atomic.Int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pw *progressWriter) Write(p []byte) (int, error) {
|
||||||
|
n, err := pw.w.Write(p)
|
||||||
|
pw.written.Add(int64(n))
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureAudioFile checks whether the selected audio file exists. If it does,
|
||||||
|
// it returns nil. Otherwise it starts a background download and returns a
|
||||||
|
// DownloadProgressPage that tracks the download so it can be pushed onto the
|
||||||
|
// page stack.
|
||||||
|
func ensureAudioFile() *page.DownloadProgressPage {
|
||||||
|
assetsDir, err := db.GetConfigValue("assets_dir")
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("Failed to get assets_dir: %v", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
selectedAudio, err := db.GetConfigValue("selected_audio")
|
||||||
|
if err != nil || selectedAudio == "" {
|
||||||
|
logger.Warning("No selected_audio configured")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
audioPath := filepath.Join(assetsDir, selectedAudio)
|
||||||
|
if _, err := os.Stat(audioPath); err == nil {
|
||||||
|
logger.Info("Audio file found: %s", audioPath)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("Audio file not found at %s, downloading from %s", audioPath, audioFileURL)
|
||||||
|
|
||||||
|
totalBytes := &atomic.Int64{}
|
||||||
|
bytesWritten := &atomic.Int64{}
|
||||||
|
done := &atomic.Bool{}
|
||||||
|
hasError := &atomic.Bool{}
|
||||||
|
|
||||||
|
downloadPage := &page.DownloadProgressPage{
|
||||||
|
TotalBytes: totalBytes,
|
||||||
|
BytesWritten: bytesWritten,
|
||||||
|
Done: done,
|
||||||
|
HasError: hasError,
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer done.Store(true)
|
||||||
|
|
||||||
|
if err := os.MkdirAll(filepath.Dir(audioPath), 0755); err != nil {
|
||||||
|
logger.Error("Failed to create audio directory: %v", err)
|
||||||
|
hasError.Store(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.Get(audioFileURL)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("Failed to download audio file: %v", err)
|
||||||
|
hasError.Store(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
logger.Error("Audio download returned status %d", resp.StatusCode)
|
||||||
|
hasError.Store(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.ContentLength > 0 {
|
||||||
|
totalBytes.Store(resp.ContentLength)
|
||||||
|
}
|
||||||
|
|
||||||
|
tmp := audioPath + ".tmp"
|
||||||
|
f, err := os.Create(tmp)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("Failed to create audio file: %v", err)
|
||||||
|
hasError.Store(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pw := &progressWriter{w: f, written: bytesWritten}
|
||||||
|
_, err = io.Copy(pw, resp.Body)
|
||||||
|
f.Close()
|
||||||
|
if err != nil {
|
||||||
|
os.Remove(tmp)
|
||||||
|
logger.Error("Failed to write audio file: %v", err)
|
||||||
|
hasError.Store(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Rename(tmp, audioPath); err != nil {
|
||||||
|
os.Remove(tmp)
|
||||||
|
logger.Error("Failed to move audio file into place: %v", err)
|
||||||
|
hasError.Store(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("Audio file downloaded successfully (%d bytes)", bytesWritten.Load())
|
||||||
|
}()
|
||||||
|
|
||||||
|
return downloadPage
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *Application) initializeSDLAndOpenGL() {
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *Application) initializeManagers() {
|
||||||
|
logger.Info("Initializing ShaderManager")
|
||||||
|
if err := shaderManager.InitShaderManager(); err != nil {
|
||||||
|
logger.Fatal("ShaderManager initialization failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("Initializing FontManager")
|
||||||
|
if err := fontManager.InitFontManager(); err != nil {
|
||||||
|
logger.Fatal("FontManager initialization failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *Application) initializeAudio() {
|
||||||
|
logger.Info("Initializing audio")
|
||||||
|
audioDevice, err := db.GetConfigValue("audio_device_name")
|
||||||
|
if err != nil {
|
||||||
|
logger.Warning("Failed to get audio device name from config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := mixer.Init(audioDevice); err != nil {
|
||||||
|
logger.Error("Unable to initialize audio device '%s': %v", audioDevice, err)
|
||||||
|
logger.Error("Audio will be unavailable until a device is configured")
|
||||||
|
} else {
|
||||||
|
logger.Info("Audio initialized with device: '%s'", audioDevice)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *Application) cleanup() {
|
||||||
|
logger.Info("Cleaning up")
|
||||||
|
|
||||||
|
for _, p := range app.pageStack {
|
||||||
|
p.Destroy()
|
||||||
|
}
|
||||||
|
app.pageStack = nil
|
||||||
|
|
||||||
|
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 len(app.pageStack) == 0 {
|
||||||
|
logger.Fatal("No initial page loaded, pageStack 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.handleEvents()
|
||||||
|
if !app.running {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
deltaTime := float32(time.Since(lastTime).Seconds())
|
||||||
|
lastTime = time.Now()
|
||||||
|
|
||||||
|
app.updateCurrentPage(deltaTime)
|
||||||
|
app.renderFrame()
|
||||||
|
app.applyPendingAction()
|
||||||
|
|
||||||
|
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.currentPage(); 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.currentPage(); 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.currentPage(); currentPage != nil {
|
||||||
|
currentPage.Render()
|
||||||
|
}
|
||||||
|
|
||||||
|
graphics.BlitToScreen()
|
||||||
|
app.Window.GLSwap()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Keybind Registration
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (app *Application) initKeybinds() {
|
||||||
|
keybinds.Register(sdl.K_F3, sdl.KMOD_NONE, func() {
|
||||||
|
app.PushPage(&page.Settings{})
|
||||||
|
})
|
||||||
|
|
||||||
|
keybinds.Register(sdl.K_F2, sdl.KMOD_NONE, func() {
|
||||||
|
downloadPage := ensureAudioFile()
|
||||||
|
if downloadPage != nil {
|
||||||
|
downloadPage.NextPage = &page.ScrollerPage{}
|
||||||
|
app.PushPage(downloadPage)
|
||||||
|
} else {
|
||||||
|
app.PushPage(&page.ScrollerPage{})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
keybinds.Register(sdl.K_F1, sdl.KMOD_NONE, func() {
|
||||||
|
app.UnwindToPage(&page.Welcome{})
|
||||||
|
})
|
||||||
|
|
||||||
|
keybinds.Register(sdl.K_q, sdl.KMOD_RCTRL, func() {
|
||||||
|
app.Stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
keybinds.Register(sdl.K_UP, sdl.KMOD_SHIFT, func() {
|
||||||
|
mixer.SetVolume128(volumeStepSize)
|
||||||
|
})
|
||||||
|
|
||||||
|
keybinds.Register(sdl.K_DOWN, sdl.KMOD_SHIFT, func() {
|
||||||
|
mixer.SetVolume128(-volumeStepSize)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *Application) Stop() {
|
||||||
|
app.running = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Page Management
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (app *Application) currentPage() page.Page {
|
||||||
|
if len(app.pageStack) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return app.pageStack[len(app.pageStack)-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *Application) applyPendingAction() {
|
||||||
|
if app.pendingAction != nil {
|
||||||
|
app.pendingAction()
|
||||||
|
app.pendingAction = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *Application) schedulePush(p page.Page) {
|
||||||
|
app.pendingAction = func() {
|
||||||
|
if err := p.Init(app); err != nil {
|
||||||
|
logger.Error("Failed to init page on push: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if topPage := app.currentPage(); topPage != nil {
|
||||||
|
topPage.Destroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
app.pageStack = append(app.pageStack, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *Application) scheduleSwitch(p page.Page) {
|
||||||
|
app.pendingAction = func() {
|
||||||
|
if topPage := app.currentPage(); topPage != nil {
|
||||||
|
topPage.Destroy()
|
||||||
|
app.pageStack = app.pageStack[:len(app.pageStack)-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := p.Init(app); err != nil {
|
||||||
|
logger.Error("Failed to init page on switch: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
app.pageStack = append(app.pageStack, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *Application) scheduleUnwind(targetPage page.Page) {
|
||||||
|
app.pendingAction = func() {
|
||||||
|
foundIndex := -1
|
||||||
|
for i := len(app.pageStack) - 1; i >= 0; i-- {
|
||||||
|
if app.pageStack[i] == targetPage {
|
||||||
|
foundIndex = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if foundIndex != -1 {
|
||||||
|
for i := len(app.pageStack) - 1; i > foundIndex; i-- {
|
||||||
|
app.pageStack[i].Destroy()
|
||||||
|
}
|
||||||
|
app.pageStack = app.pageStack[:foundIndex+1]
|
||||||
|
} else {
|
||||||
|
if topPage := app.currentPage(); topPage != nil {
|
||||||
|
topPage.Destroy()
|
||||||
|
app.pageStack = app.pageStack[:len(app.pageStack)-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := targetPage.Init(app); err != nil {
|
||||||
|
logger.Error("Failed to init page on unwind: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
app.pageStack = append(app.pageStack, targetPage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *Application) SchedulePop() {
|
||||||
|
app.pendingAction = func() {
|
||||||
|
if len(app.pageStack) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
topPage := app.pageStack[len(app.pageStack)-1]
|
||||||
|
topPage.Destroy()
|
||||||
|
app.pageStack = app.pageStack[:len(app.pageStack)-1]
|
||||||
|
|
||||||
|
if newTop := app.currentPage(); newTop != nil {
|
||||||
|
if err := newTop.Init(app); err != nil {
|
||||||
|
logger.Error("Failed to re-init page after pop: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Public Page Navigation Interface
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (app *Application) PushPage(p page.Page) {
|
||||||
|
app.schedulePush(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *Application) SwitchPage(p page.Page) {
|
||||||
|
app.scheduleSwitch(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *Application) UnwindToPage(p page.Page) {
|
||||||
|
app.scheduleUnwind(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *Application) GetDrawableSize() (w, h int32) {
|
||||||
|
return graphics.VirtualWidth, graphics.VirtualHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *Application) PopPage() {
|
||||||
|
app.SchedulePop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// License Validation
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (app *Application) validateLicenseKey() {
|
||||||
|
logger.Info("Validating license key")
|
||||||
|
validator := license.NewValidator(apiURL, apiPath)
|
||||||
|
|
||||||
|
key, err := db.GetConfigValue("license_key")
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("Failed to fetch license key from database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
email, err := db.GetConfigValue("email_address")
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("Failed to fetch email from database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := validator.Validate(key, email)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("License validation failed: %v", err)
|
||||||
|
app.License = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("License validation successful")
|
||||||
|
app.License = result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
// Startup Page Stack
|
||||||
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (app *Application) startupPageStack() {
|
||||||
|
app.PushPage(&page.Welcome{})
|
||||||
|
app.applyPendingAction()
|
||||||
|
|
||||||
|
key, _ := db.GetConfigValue("license_key")
|
||||||
|
if key == "" {
|
||||||
|
logger.Info("No license key found, pushing license key page")
|
||||||
|
app.PushPage(&page.LicenseKeyPage{})
|
||||||
|
app.applyPendingAction()
|
||||||
|
} else {
|
||||||
|
// Ensure the license key file exists for the updater script.
|
||||||
|
// Older versions only stored the key in the database.
|
||||||
|
if homeDir, err := os.UserHomeDir(); err == nil {
|
||||||
|
keyPath := filepath.Join(homeDir, ".local", "share", "rhemawave", "license_key")
|
||||||
|
if _, err := os.Stat(keyPath); os.IsNotExist(err) {
|
||||||
|
logger.Info("License key file missing, writing from database")
|
||||||
|
if err := os.MkdirAll(filepath.Dir(keyPath), 0755); err != nil {
|
||||||
|
logger.Error("Failed to create license key directory: %v", err)
|
||||||
|
} else if err := os.WriteFile(keyPath, []byte(key), 0644); err != nil {
|
||||||
|
logger.Error("Failed to write license key file: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
email, _ := db.GetConfigValue("email_address")
|
||||||
|
if email == "" {
|
||||||
|
logger.Info("No email address found, pushing email address page")
|
||||||
|
app.PushPage(&page.EmailAddressPage{})
|
||||||
|
app.applyPendingAction()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TOS pages — must be accepted before anything else.
|
||||||
|
// Pushed last so they sit on top of the stack (shown first).
|
||||||
|
sections, err := db.GetTOSSections()
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("Failed to load TOS sections: %v", err)
|
||||||
|
}
|
||||||
|
// Push in reverse order so section 1 is on top of the stack
|
||||||
|
for i := len(sections) - 1; i >= 0; i-- {
|
||||||
|
s := sections[i]
|
||||||
|
configKey := fmt.Sprintf("tos_section_%d_accepted", s.SectionNumber)
|
||||||
|
accepted, _ := db.GetConfigValue(configKey)
|
||||||
|
if accepted != "true" {
|
||||||
|
app.PushPage(&page.TOSPage{
|
||||||
|
Title: s.Title,
|
||||||
|
Content: s.Content,
|
||||||
|
ConfigKey: configKey,
|
||||||
|
})
|
||||||
|
app.applyPendingAction()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internet check — pushed last so it appears first if no internet.
|
||||||
|
// Skip WiFi setup if ethernet is connected (it auto-configures) or if
|
||||||
|
// internet is already reachable through any interface.
|
||||||
|
nm, err := network.New()
|
||||||
|
if err != nil {
|
||||||
|
logger.Warning("Could not initialize network manager: %v", err)
|
||||||
|
} else {
|
||||||
|
status, err := nm.CheckInternetConnection()
|
||||||
|
if err != nil {
|
||||||
|
logger.Warning("Network connectivity check failed: %v", err)
|
||||||
|
} else {
|
||||||
|
switch status.StatusCode {
|
||||||
|
case network.StatusEthernetConnectedInternetUp,
|
||||||
|
network.StatusEthernetConnectedNoInternet,
|
||||||
|
network.StatusWifiConnectedInternetUp:
|
||||||
|
// Internet is up or ethernet is connected and will auto-configure
|
||||||
|
default:
|
||||||
|
logger.Info("No internet connection, pushing WiFi setup page")
|
||||||
|
app.PushPage(&page.WiFiSetupPage{})
|
||||||
|
app.applyPendingAction()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (app *Application) Quit() {
|
||||||
|
logger.Info("User has quit the application")
|
||||||
|
app.running = false
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
// 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}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Oranges
|
||||||
|
var (
|
||||||
|
OrangeRed = sdl.Color{R: 255, G: 69, B: 0, A: 255}
|
||||||
|
Tomato = sdl.Color{R: 255, G: 99, B: 71, A: 255}
|
||||||
|
Coral = sdl.Color{R: 255, G: 127, B: 80, A: 255}
|
||||||
|
DarkOrange = sdl.Color{R: 255, G: 140, B: 0, A: 255}
|
||||||
|
Orange = sdl.Color{R: 255, G: 165, 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}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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}
|
||||||
|
MediumSlateBlue = sdl.Color{R: 123, G: 104, B: 238, A: 255}
|
||||||
|
RoyalBlue = sdl.Color{R: 65, G: 105, B: 225, 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}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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}
|
||||||
|
Honeydew = sdl.Color{R: 240, G: 255, B: 240, 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}
|
||||||
|
LavenderBlush = sdl.Color{R: 255, G: 240, B: 245, A: 255}
|
||||||
|
MistyRose = sdl.Color{R: 255, G: 228, B: 225, A: 255}
|
||||||
|
)
|
||||||
@@ -0,0 +1,488 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"rhemawavetech.com/rhemawave/internal/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
var DB *gorm.DB
|
||||||
|
|
||||||
|
// configCache is an in-memory cache of config values to avoid repeated DB queries.
|
||||||
|
var configCache = struct {
|
||||||
|
sync.RWMutex
|
||||||
|
m map[string]string
|
||||||
|
}{m: make(map[string]string)}
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
gorm.Model
|
||||||
|
Key string `gorm:"uniqueIndex"`
|
||||||
|
Value string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Proclamations struct {
|
||||||
|
gorm.Model
|
||||||
|
Title string
|
||||||
|
Content string
|
||||||
|
Commercial bool
|
||||||
|
Selected bool
|
||||||
|
Favorite bool
|
||||||
|
Available bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type Log struct {
|
||||||
|
gorm.Model
|
||||||
|
Timestamp string
|
||||||
|
Level string
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Log) TableName() string {
|
||||||
|
return "log_entries"
|
||||||
|
}
|
||||||
|
|
||||||
|
type TOSSection struct {
|
||||||
|
gorm.Model
|
||||||
|
SectionNumber int `gorm:"uniqueIndex"`
|
||||||
|
Title string
|
||||||
|
Content string // raw HTML
|
||||||
|
}
|
||||||
|
|
||||||
|
var defaultConfigValues = map[string]string{
|
||||||
|
"home_dir": "",
|
||||||
|
"assets_dir": "", // Set dynamically in seedDefaults() to $HOME/.local/share/rhemawave
|
||||||
|
"log_dir": "", // Set dynamically in seedDefaults() to $HOME/.local/share/rhemawave/logs.log
|
||||||
|
"email_address": "",
|
||||||
|
"license_key": "",
|
||||||
|
"system_type": "home",
|
||||||
|
"system_capabilities": "",
|
||||||
|
"selected_audio": "audio/audio.wav",
|
||||||
|
"audio_device_name": "",
|
||||||
|
"application_font": "Roboto-Regular",
|
||||||
|
"init_font_size": "128",
|
||||||
|
"standard_font_size": "32",
|
||||||
|
"line_pattern": "fibonacci",
|
||||||
|
"last_volume": "128",
|
||||||
|
"audio_enabled": "true",
|
||||||
|
"tos_section_1_accepted": "false",
|
||||||
|
"tos_section_2_accepted": "false",
|
||||||
|
"tos_section_3_accepted": "false",
|
||||||
|
"tos_section_4_accepted": "false",
|
||||||
|
"tos_section_5_accepted": "false",
|
||||||
|
"tos_section_6_accepted": "false",
|
||||||
|
"tos_section_7_accepted": "false",
|
||||||
|
"tos_section_8_accepted": "false",
|
||||||
|
"tos_section_9_accepted": "false",
|
||||||
|
"tos_section_10_accepted": "false",
|
||||||
|
}
|
||||||
|
|
||||||
|
// InitDatabase initializes the SQLite database connection
|
||||||
|
// and performs auto-migration for all models
|
||||||
|
func InitDatabase(dbpath string) error {
|
||||||
|
// Create the directory for the database if it doesn't exist
|
||||||
|
dbDir := filepath.Dir(dbpath)
|
||||||
|
if err := os.MkdirAll(dbDir, 0700); err != nil {
|
||||||
|
log.Printf("Error creating database directory %s: %v", dbDir, err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if database file exists
|
||||||
|
_, err := os.Stat(dbpath)
|
||||||
|
dbExists := !os.IsNotExist(err)
|
||||||
|
|
||||||
|
if !dbExists {
|
||||||
|
log.Printf("Database file does not exist at %s, creating new database", dbpath)
|
||||||
|
}
|
||||||
|
|
||||||
|
dsn := fmt.Sprintf("file:%s", dbpath)
|
||||||
|
|
||||||
|
DB, err = gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error opening database: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get underlying sql.DB to set pragmas
|
||||||
|
sqlDB, err := DB.DB()
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("Error getting underlying database connection: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure SQLite for reliability and performance
|
||||||
|
pragmas := []string{
|
||||||
|
"PRAGMA journal_mode=WAL", // Write-Ahead Logging for better concurrency
|
||||||
|
"PRAGMA synchronous=FULL", // Maximum durability
|
||||||
|
"PRAGMA foreign_keys=ON", // Enable foreign key constraints
|
||||||
|
"PRAGMA busy_timeout=5000", // Wait up to 5 seconds for locks
|
||||||
|
"PRAGMA temp_store=MEMORY", // Keep temp tables in memory
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, pragma := range pragmas {
|
||||||
|
if _, err := sqlDB.Exec(pragma); err != nil {
|
||||||
|
logger.Error("Error setting pragma %s: %v", pragma, err)
|
||||||
|
return fmt.Errorf("failed to set pragma: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := sqlDB.Ping(); err != nil {
|
||||||
|
logger.Error("Error pinging database: %v", err)
|
||||||
|
return fmt.Errorf("database connection failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("Database connected successfully")
|
||||||
|
|
||||||
|
// Auto-migrate models
|
||||||
|
err = DB.AutoMigrate(&Config{}, &Proclamations{}, &Log{}, &TOSSection{})
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("Error migrating database: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.Println("Database migrated successfully")
|
||||||
|
|
||||||
|
// Seed defaults
|
||||||
|
err = ensureDefaultsExist()
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("Error seeding default configuration values: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = seedDefaultProclamations()
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("Error seeding default proclamations: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = seedDefaultTOSSections()
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("Error seeding default TOS sections: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("Database initialized successfully")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedDefaultProclamations reads proclamation text files from the assets directory
|
||||||
|
// and populates the database on first run
|
||||||
|
func seedDefaultProclamations() error {
|
||||||
|
var count int64
|
||||||
|
err := DB.Model(&Proclamations{}).Count(&count).Error
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("Error counting proclamations: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only seed if table is empty
|
||||||
|
if count > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the assets directory from config
|
||||||
|
assetsDir, err := GetConfigValue("assets_dir")
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("Error getting assets_dir: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
proclamationsBaseDir := assetsDir + "/proclamations"
|
||||||
|
|
||||||
|
// Process both home and commercial directories
|
||||||
|
subdirs := []string{"home", "commercial"}
|
||||||
|
totalSeeded := 0
|
||||||
|
|
||||||
|
for _, subdir := range subdirs {
|
||||||
|
proclamationsDir := proclamationsBaseDir + "/" + subdir
|
||||||
|
isCommercial := (subdir == "commercial")
|
||||||
|
|
||||||
|
// Read all .txt files from the subdirectory
|
||||||
|
files, err := os.ReadDir(proclamationsDir)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("Error reading proclamations directory %s: %v", proclamationsDir, err)
|
||||||
|
continue // Continue with other directories even if one fails
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, file := range files {
|
||||||
|
if file.IsDir() || !strings.HasSuffix(file.Name(), ".txt") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
filePath := proclamationsDir + "/" + file.Name()
|
||||||
|
content, err := os.ReadFile(filePath)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("Error reading file %s: %v", filePath, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract title from filename (remove .txt extension)
|
||||||
|
title := strings.TrimSuffix(file.Name(), ".txt")
|
||||||
|
|
||||||
|
// Select "Relaxation" as the default proclamation
|
||||||
|
isSelected := (title == "Relaxation")
|
||||||
|
|
||||||
|
proclamation := Proclamations{
|
||||||
|
Title: title,
|
||||||
|
Content: string(content),
|
||||||
|
Commercial: isCommercial,
|
||||||
|
Selected: isSelected,
|
||||||
|
Favorite: false,
|
||||||
|
Available: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := DB.Create(&proclamation).Error; err != nil {
|
||||||
|
logger.Error("Error creating proclamation from %s: %v", file.Name(), err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if isSelected {
|
||||||
|
logger.Error("Set default proclamation: %s", title)
|
||||||
|
}
|
||||||
|
|
||||||
|
totalSeeded++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Error("Seeded %d default proclamations from %s", totalSeeded, proclamationsBaseDir)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConfigValue retrieves a configuration value by key.
|
||||||
|
// Results are cached in memory to avoid repeated DB queries.
|
||||||
|
func GetConfigValue(key string) (string, error) {
|
||||||
|
configCache.RLock()
|
||||||
|
if v, ok := configCache.m[key]; ok {
|
||||||
|
configCache.RUnlock()
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
configCache.RUnlock()
|
||||||
|
|
||||||
|
var config Config
|
||||||
|
result := DB.First(&config, "key = ?", key)
|
||||||
|
if result.Error != nil {
|
||||||
|
return "", result.Error
|
||||||
|
}
|
||||||
|
|
||||||
|
configCache.Lock()
|
||||||
|
configCache.m[key] = config.Value
|
||||||
|
configCache.Unlock()
|
||||||
|
|
||||||
|
return config.Value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetConfigValues retrieves all configuration key-value pairs
|
||||||
|
// Returns a map of key-value pairs OR any error encountered
|
||||||
|
// This is mainly for compatibility with legacy code, but can
|
||||||
|
// be useful for bulkl config referrals
|
||||||
|
func GetConfigValues() (map[string]string, error) {
|
||||||
|
var configs []Config
|
||||||
|
result := DB.Find(&configs)
|
||||||
|
if result.Error != nil {
|
||||||
|
return nil, result.Error
|
||||||
|
}
|
||||||
|
configMap := make(map[string]string)
|
||||||
|
for _, config := range configs {
|
||||||
|
configMap[config.Key] = config.Value
|
||||||
|
}
|
||||||
|
return configMap, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetConfigValue sets a configuration value by key
|
||||||
|
// Returns any error encountered || nil
|
||||||
|
func SetConfigValue(key, value string) error {
|
||||||
|
// First try to find existing config
|
||||||
|
var config Config
|
||||||
|
result := DB.Where("key = ?", key).First(&config)
|
||||||
|
|
||||||
|
if result.Error != nil {
|
||||||
|
if result.Error == gorm.ErrRecordNotFound {
|
||||||
|
// Record doesn't exist, create new one
|
||||||
|
config = Config{Key: key, Value: value}
|
||||||
|
if err := DB.Create(&config).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to create config value for key %s: %w", key, err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Some other error occurred
|
||||||
|
return fmt.Errorf("failed to query config for key %s: %w", key, result.Error)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Record exists, update it
|
||||||
|
config.Value = value
|
||||||
|
if err := DB.Save(&config).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to update config value for key %s: %w", key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update cache
|
||||||
|
configCache.Lock()
|
||||||
|
configCache.m[key] = value
|
||||||
|
configCache.Unlock()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetProclamations retrieves all proclamation entries from the database
|
||||||
|
// Returns a slice of Proclamations OR any error encountered
|
||||||
|
func GetProclamations() ([]Proclamations, error) {
|
||||||
|
var proclamations []Proclamations
|
||||||
|
result := DB.Find(&proclamations)
|
||||||
|
if result.Error != nil {
|
||||||
|
return nil, result.Error
|
||||||
|
}
|
||||||
|
return proclamations, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedDefaultTOSSections reads HTML files from the assets directory and populates the
|
||||||
|
// TOS sections table on first run.
|
||||||
|
func seedDefaultTOSSections() error {
|
||||||
|
var count int64
|
||||||
|
if err := DB.Model(&TOSSection{}).Count(&count).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
assetsDir, err := GetConfigValue("assets_dir")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("getting assets_dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tosDir := filepath.Join(assetsDir, "tos")
|
||||||
|
files, err := os.ReadDir(tosDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading tos directory %s: %w", tosDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
seeded := 0
|
||||||
|
for _, file := range files {
|
||||||
|
if file.IsDir() || !strings.HasSuffix(file.Name(), ".html") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse filename: "01 - WELCOME.html" -> number=1, title="WELCOME"
|
||||||
|
name := strings.TrimSuffix(file.Name(), ".html")
|
||||||
|
parts := strings.SplitN(name, " - ", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
logger.Error("Skipping TOS file with unexpected name format: %s", file.Name())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var sectionNum int
|
||||||
|
if _, err := fmt.Sscanf(parts[0], "%d", §ionNum); err != nil {
|
||||||
|
logger.Error("Skipping TOS file, cannot parse section number: %s", file.Name())
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
title := strings.TrimSpace(parts[1])
|
||||||
|
|
||||||
|
content, err := os.ReadFile(filepath.Join(tosDir, file.Name()))
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("Error reading TOS file %s: %v", file.Name(), err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
section := TOSSection{
|
||||||
|
SectionNumber: sectionNum,
|
||||||
|
Title: title,
|
||||||
|
Content: string(content),
|
||||||
|
}
|
||||||
|
if err := DB.Create(§ion).Error; err != nil {
|
||||||
|
return fmt.Errorf("creating TOS section from %s: %w", file.Name(), err)
|
||||||
|
}
|
||||||
|
seeded++
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Info("Seeded %d TOS sections from %s", seeded, tosDir)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTOSSections returns all TOS sections ordered by section number.
|
||||||
|
func GetTOSSections() ([]TOSSection, error) {
|
||||||
|
var sections []TOSSection
|
||||||
|
result := DB.Order("section_number asc").Find(§ions)
|
||||||
|
if result.Error != nil {
|
||||||
|
return nil, result.Error
|
||||||
|
}
|
||||||
|
return sections, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLogs retrieves log entries from the database, ordered by creation time ascending.
|
||||||
|
// If limit > 0, at most that many entries are returned.
|
||||||
|
func GetLogs(limit int) ([]Log, error) {
|
||||||
|
var logs []Log
|
||||||
|
query := DB.Order("created_at DESC")
|
||||||
|
if limit > 0 {
|
||||||
|
query = query.Limit(limit)
|
||||||
|
}
|
||||||
|
return logs, query.Find(&logs).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureDefaultsExist checks the current configs table rows and inserts any new rows
|
||||||
|
// from defaultConfigValues to the database if they don't exist.
|
||||||
|
// This is essentially an automatic migration for new config values.
|
||||||
|
func ensureDefaultsExist() error {
|
||||||
|
for key, value := range defaultConfigValues {
|
||||||
|
var config Config
|
||||||
|
result := DB.Where("key = ?", key).First(&config)
|
||||||
|
|
||||||
|
// Always re-resolve path-based config values from the current binary location
|
||||||
|
switch key {
|
||||||
|
case "assets_dir":
|
||||||
|
value = resolveAssetsDir()
|
||||||
|
case "log_dir":
|
||||||
|
value = filepath.Join(resolveAssetsDir(), "logs.log")
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Error == gorm.ErrRecordNotFound {
|
||||||
|
// Key doesn't exist, create it with default value
|
||||||
|
if err := DB.Create(&Config{Key: key, Value: value}).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to create default config for %s: %w", key, err)
|
||||||
|
}
|
||||||
|
log.Printf("Added missing config key: %s", key)
|
||||||
|
} else if result.Error != nil {
|
||||||
|
return result.Error
|
||||||
|
} else if (key == "assets_dir" || key == "log_dir") && config.Value != value {
|
||||||
|
// Update stale path-based values (e.g. after Nix store update)
|
||||||
|
config.Value = value
|
||||||
|
if err := DB.Save(&config).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to update config for %s: %w", key, err)
|
||||||
|
}
|
||||||
|
log.Printf("Updated stale config key: %s", key)
|
||||||
|
} else if key == "selected_audio" && config.Value == "audio/7.83.wav" {
|
||||||
|
// Migrate renamed audio file
|
||||||
|
config.Value = value
|
||||||
|
if err := DB.Save(&config).Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to migrate config for %s: %w", key, err)
|
||||||
|
}
|
||||||
|
log.Printf("Migrated config key: %s", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func AudioEnabled() bool {
|
||||||
|
on, err := GetConfigValue("audio_enabled")
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return on == "true"
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveAssetsDir returns the assets directory by resolving relative to the
|
||||||
|
// running binary. For a binary at .../bin/rhemawave, assets are at
|
||||||
|
// .../share/rhemawave. Falls back to /usr/local/share/rhemawave.
|
||||||
|
func resolveAssetsDir() string {
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err == nil {
|
||||||
|
exe, err = filepath.EvalSymlinks(exe)
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
return filepath.Join(filepath.Dir(exe), "..", "share", "rhemawave")
|
||||||
|
}
|
||||||
|
return filepath.Join("/usr", "local", "share", "rhemawave")
|
||||||
|
}
|
||||||
@@ -0,0 +1,829 @@
|
|||||||
|
package fontManager
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"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() 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),
|
||||||
|
}
|
||||||
|
|
||||||
|
assetsDir, err := db.GetConfigValue("assets_dir")
|
||||||
|
if err != nil {
|
||||||
|
initErr = fmt.Errorf("could not get assets_dir from config: %w", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fontPath := filepath.Join(assetsDir, "fonts")
|
||||||
|
if err := fm.loadFonts(fontPath); 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 := "Roboto-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) 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)
|
||||||
|
baseFontSizeStr, err := db.GetConfigValue("init_font_size")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not get standard_font_size from config: %w", err)
|
||||||
|
}
|
||||||
|
baseFontSize, err := strconv.Atoi(baseFontSizeStr)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package graphics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/go-gl/gl/v3.3-core/gl"
|
||||||
|
"github.com/veandco/go-sdl2/sdl"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CreateTextureFromSurface uploads an SDL surface to an OpenGL texture and returns the texture ID.
|
||||||
|
func CreateTextureFromSurface(surface *sdl.Surface) (uint32, error) {
|
||||||
|
if surface == nil {
|
||||||
|
return 0, fmt.Errorf("CreateTextureFromSurface: input surface is nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
var textureID uint32
|
||||||
|
var err error
|
||||||
|
|
||||||
|
surfaceToUpload := surface
|
||||||
|
var convertedSurface *sdl.Surface = nil
|
||||||
|
|
||||||
|
sdlFormatEnum := surface.Format.Format
|
||||||
|
var dataFormat uint32
|
||||||
|
var internalFormat int32 = gl.RGBA8
|
||||||
|
|
||||||
|
if sdlFormatEnum == sdl.PIXELFORMAT_INDEX8 {
|
||||||
|
convertedSurface, err = surface.ConvertFormat(sdl.PIXELFORMAT_ARGB8888, 0)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to convert INDEX8 surface to ARGB8888: %w", err)
|
||||||
|
}
|
||||||
|
defer convertedSurface.Free()
|
||||||
|
|
||||||
|
surfaceToUpload = convertedSurface
|
||||||
|
sdlFormatEnum = surfaceToUpload.Format.Format
|
||||||
|
}
|
||||||
|
|
||||||
|
switch sdlFormatEnum {
|
||||||
|
case sdl.PIXELFORMAT_ARGB8888:
|
||||||
|
dataFormat = gl.BGRA
|
||||||
|
case sdl.PIXELFORMAT_ABGR8888:
|
||||||
|
dataFormat = gl.RGBA
|
||||||
|
case sdl.PIXELFORMAT_RGBA8888:
|
||||||
|
dataFormat = gl.RGBA
|
||||||
|
case sdl.PIXELFORMAT_BGRA8888:
|
||||||
|
dataFormat = gl.BGRA
|
||||||
|
default:
|
||||||
|
return 0, fmt.Errorf("unhandled SDL pixel format for OpenGL upload: %s", sdl.GetPixelFormatName(uint(sdlFormatEnum)))
|
||||||
|
}
|
||||||
|
|
||||||
|
for errCode := gl.GetError(); errCode != gl.NO_ERROR; errCode = gl.GetError() {
|
||||||
|
log.Printf("OpenGL error pending before GenTextures: 0x%X", errCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
gl.GenTextures(1, &textureID)
|
||||||
|
if errCode := gl.GetError(); errCode != gl.NO_ERROR {
|
||||||
|
return 0, fmt.Errorf("OpenGL error 0x%X after GenTextures", errCode)
|
||||||
|
}
|
||||||
|
if textureID == 0 {
|
||||||
|
return 0, fmt.Errorf("gl.GenTextures returned textureID 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
gl.BindTexture(gl.TEXTURE_2D, textureID)
|
||||||
|
|
||||||
|
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST)
|
||||||
|
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST)
|
||||||
|
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
|
||||||
|
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
|
||||||
|
|
||||||
|
gl.PixelStorei(gl.UNPACK_ALIGNMENT, 1)
|
||||||
|
|
||||||
|
expectedPitch := surfaceToUpload.W * int32(surfaceToUpload.Format.BytesPerPixel)
|
||||||
|
if int32(surfaceToUpload.Pitch) != expectedPitch {
|
||||||
|
gl.PixelStorei(gl.UNPACK_ROW_LENGTH, surfaceToUpload.Pitch/int32(surfaceToUpload.Format.BytesPerPixel))
|
||||||
|
} else {
|
||||||
|
gl.PixelStorei(gl.UNPACK_ROW_LENGTH, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
gl.TexImage2D(
|
||||||
|
gl.TEXTURE_2D,
|
||||||
|
0,
|
||||||
|
internalFormat,
|
||||||
|
surfaceToUpload.W,
|
||||||
|
surfaceToUpload.H,
|
||||||
|
0,
|
||||||
|
dataFormat,
|
||||||
|
gl.UNSIGNED_BYTE,
|
||||||
|
gl.Ptr(surfaceToUpload.Pixels()),
|
||||||
|
)
|
||||||
|
|
||||||
|
gl.PixelStorei(gl.UNPACK_ALIGNMENT, 4)
|
||||||
|
gl.PixelStorei(gl.UNPACK_ROW_LENGTH, 0)
|
||||||
|
|
||||||
|
if glErr := gl.GetError(); glErr != gl.NO_ERROR {
|
||||||
|
gl.DeleteTextures(1, &textureID)
|
||||||
|
return 0, fmt.Errorf("OpenGL error 0x%X after TexImage2D", glErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
gl.BindTexture(gl.TEXTURE_2D, 0)
|
||||||
|
return textureID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckGLError queries OpenGL for errors and returns the first one found.
|
||||||
|
func CheckGLError() error {
|
||||||
|
errCode := gl.GetError()
|
||||||
|
if errCode == gl.NO_ERROR {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch errCode {
|
||||||
|
case gl.INVALID_ENUM:
|
||||||
|
return fmt.Errorf("INVALID_ENUM")
|
||||||
|
case gl.INVALID_VALUE:
|
||||||
|
return fmt.Errorf("INVALID_VALUE")
|
||||||
|
case gl.INVALID_OPERATION:
|
||||||
|
return fmt.Errorf("INVALID_OPERATION")
|
||||||
|
case gl.STACK_OVERFLOW:
|
||||||
|
return fmt.Errorf("STACK_OVERFLOW")
|
||||||
|
case gl.STACK_UNDERFLOW:
|
||||||
|
return fmt.Errorf("STACK_UNDERFLOW")
|
||||||
|
case gl.OUT_OF_MEMORY:
|
||||||
|
return fmt.Errorf("OUT_OF_MEMORY")
|
||||||
|
case gl.INVALID_FRAMEBUFFER_OPERATION:
|
||||||
|
return fmt.Errorf("INVALID_FRAMEBUFFER_OPERATION")
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("Unknown GL error: 0x%X", errCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
package graphics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/go-gl/gl/v3.3-core/gl"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
VirtualWidth int32 = 1920
|
||||||
|
VirtualHeight int32 = 1080
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
vfbo uint32
|
||||||
|
vfboTexture uint32
|
||||||
|
vfboDepthStencil uint32
|
||||||
|
blitVAO uint32
|
||||||
|
blitVBO uint32
|
||||||
|
blitShaderProg uint32
|
||||||
|
nativeW, nativeH int32
|
||||||
|
)
|
||||||
|
|
||||||
|
// InitVirtualFramebuffer creates the FBO at virtual resolution and the blit shader.
|
||||||
|
// Call after OpenGL context is created.
|
||||||
|
func InitVirtualFramebuffer(displayW, displayH int32) error {
|
||||||
|
nativeW = displayW
|
||||||
|
nativeH = displayH
|
||||||
|
|
||||||
|
// --- FBO setup ---
|
||||||
|
gl.GenFramebuffers(1, &vfbo)
|
||||||
|
gl.BindFramebuffer(gl.FRAMEBUFFER, vfbo)
|
||||||
|
|
||||||
|
gl.GenTextures(1, &vfboTexture)
|
||||||
|
gl.BindTexture(gl.TEXTURE_2D, vfboTexture)
|
||||||
|
gl.TexImage2D(gl.TEXTURE_2D, 0, gl.RGBA8, VirtualWidth, VirtualHeight, 0, gl.RGBA, gl.UNSIGNED_BYTE, nil)
|
||||||
|
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
|
||||||
|
gl.TexParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
|
||||||
|
gl.FramebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, vfboTexture, 0)
|
||||||
|
|
||||||
|
// Depth/stencil renderbuffer
|
||||||
|
gl.GenRenderbuffers(1, &vfboDepthStencil)
|
||||||
|
gl.BindRenderbuffer(gl.RENDERBUFFER, vfboDepthStencil)
|
||||||
|
gl.RenderbufferStorage(gl.RENDERBUFFER, gl.DEPTH24_STENCIL8, VirtualWidth, VirtualHeight)
|
||||||
|
gl.FramebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_STENCIL_ATTACHMENT, gl.RENDERBUFFER, vfboDepthStencil)
|
||||||
|
|
||||||
|
if status := gl.CheckFramebufferStatus(gl.FRAMEBUFFER); status != gl.FRAMEBUFFER_COMPLETE {
|
||||||
|
return fmt.Errorf("virtual framebuffer incomplete: 0x%X", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
gl.BindFramebuffer(gl.FRAMEBUFFER, 0)
|
||||||
|
|
||||||
|
// --- Fullscreen quad ---
|
||||||
|
vertices := []float32{
|
||||||
|
// pos (x,y) texcoord (u,v)
|
||||||
|
-1, -1, 0, 0,
|
||||||
|
1, -1, 1, 0,
|
||||||
|
-1, 1, 0, 1,
|
||||||
|
1, 1, 1, 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
gl.GenVertexArrays(1, &blitVAO)
|
||||||
|
gl.GenBuffers(1, &blitVBO)
|
||||||
|
gl.BindVertexArray(blitVAO)
|
||||||
|
gl.BindBuffer(gl.ARRAY_BUFFER, blitVBO)
|
||||||
|
gl.BufferData(gl.ARRAY_BUFFER, len(vertices)*4, gl.Ptr(vertices), gl.STATIC_DRAW)
|
||||||
|
|
||||||
|
gl.EnableVertexAttribArray(0)
|
||||||
|
gl.VertexAttribPointerWithOffset(0, 2, gl.FLOAT, false, 4*4, 0)
|
||||||
|
gl.EnableVertexAttribArray(1)
|
||||||
|
gl.VertexAttribPointerWithOffset(1, 2, gl.FLOAT, false, 4*4, 2*4)
|
||||||
|
|
||||||
|
gl.BindVertexArray(0)
|
||||||
|
|
||||||
|
// --- Blit shader ---
|
||||||
|
var err error
|
||||||
|
blitShaderProg, err = compileBlitShader()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("blit shader compilation failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BeginVirtualRender binds the FBO so all subsequent draws go to the virtual 1080p buffer.
|
||||||
|
func BeginVirtualRender() {
|
||||||
|
gl.BindFramebuffer(gl.FRAMEBUFFER, vfbo)
|
||||||
|
gl.Viewport(0, 0, VirtualWidth, VirtualHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlitToScreen draws the virtual buffer to the actual screen at native resolution.
|
||||||
|
func BlitToScreen() {
|
||||||
|
gl.BindFramebuffer(gl.FRAMEBUFFER, 0)
|
||||||
|
gl.Viewport(0, 0, nativeW, nativeH)
|
||||||
|
gl.ClearColor(0, 0, 0, 1)
|
||||||
|
gl.Clear(gl.COLOR_BUFFER_BIT)
|
||||||
|
|
||||||
|
gl.UseProgram(blitShaderProg)
|
||||||
|
gl.ActiveTexture(gl.TEXTURE0)
|
||||||
|
gl.BindTexture(gl.TEXTURE_2D, vfboTexture)
|
||||||
|
|
||||||
|
gl.BindVertexArray(blitVAO)
|
||||||
|
gl.DrawArrays(gl.TRIANGLE_STRIP, 0, 4)
|
||||||
|
gl.BindVertexArray(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DestroyVirtualFramebuffer cleans up all FBO resources.
|
||||||
|
func DestroyVirtualFramebuffer() {
|
||||||
|
if blitShaderProg != 0 {
|
||||||
|
gl.DeleteProgram(blitShaderProg)
|
||||||
|
}
|
||||||
|
if blitVBO != 0 {
|
||||||
|
gl.DeleteBuffers(1, &blitVBO)
|
||||||
|
}
|
||||||
|
if blitVAO != 0 {
|
||||||
|
gl.DeleteVertexArrays(1, &blitVAO)
|
||||||
|
}
|
||||||
|
if vfboDepthStencil != 0 {
|
||||||
|
gl.DeleteRenderbuffers(1, &vfboDepthStencil)
|
||||||
|
}
|
||||||
|
if vfboTexture != 0 {
|
||||||
|
gl.DeleteTextures(1, &vfboTexture)
|
||||||
|
}
|
||||||
|
if vfbo != 0 {
|
||||||
|
gl.DeleteFramebuffers(1, &vfbo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func compileBlitShader() (uint32, error) {
|
||||||
|
vertSrc := `#version 330 core
|
||||||
|
layout(location = 0) in vec2 aPos;
|
||||||
|
layout(location = 1) in vec2 aTexCoord;
|
||||||
|
out vec2 vTexCoord;
|
||||||
|
void main() {
|
||||||
|
vTexCoord = aTexCoord;
|
||||||
|
gl_Position = vec4(aPos, 0.0, 1.0);
|
||||||
|
}` + "\x00"
|
||||||
|
|
||||||
|
fragSrc := `#version 330 core
|
||||||
|
in vec2 vTexCoord;
|
||||||
|
out vec4 FragColor;
|
||||||
|
uniform sampler2D screenTexture;
|
||||||
|
void main() {
|
||||||
|
FragColor = texture(screenTexture, vTexCoord);
|
||||||
|
}` + "\x00"
|
||||||
|
|
||||||
|
vertShader, err := compileShader(vertSrc, gl.VERTEX_SHADER)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
fragShader, err := compileShader(fragSrc, gl.FRAGMENT_SHADER)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
prog := gl.CreateProgram()
|
||||||
|
gl.AttachShader(prog, vertShader)
|
||||||
|
gl.AttachShader(prog, fragShader)
|
||||||
|
gl.LinkProgram(prog)
|
||||||
|
|
||||||
|
var status int32
|
||||||
|
gl.GetProgramiv(prog, gl.LINK_STATUS, &status)
|
||||||
|
if status == gl.FALSE {
|
||||||
|
var logLen int32
|
||||||
|
gl.GetProgramiv(prog, gl.INFO_LOG_LENGTH, &logLen)
|
||||||
|
logStr := make([]byte, logLen)
|
||||||
|
gl.GetProgramInfoLog(prog, logLen, nil, &logStr[0])
|
||||||
|
return 0, fmt.Errorf("shader link error: %s", string(logStr))
|
||||||
|
}
|
||||||
|
|
||||||
|
gl.DeleteShader(vertShader)
|
||||||
|
gl.DeleteShader(fragShader)
|
||||||
|
return prog, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func compileShader(src string, shaderType uint32) (uint32, error) {
|
||||||
|
shader := gl.CreateShader(shaderType)
|
||||||
|
csrc, free := gl.Strs(src)
|
||||||
|
gl.ShaderSource(shader, 1, csrc, nil)
|
||||||
|
free()
|
||||||
|
gl.CompileShader(shader)
|
||||||
|
|
||||||
|
var status int32
|
||||||
|
gl.GetShaderiv(shader, gl.COMPILE_STATUS, &status)
|
||||||
|
if status == gl.FALSE {
|
||||||
|
var logLen int32
|
||||||
|
gl.GetShaderiv(shader, gl.INFO_LOG_LENGTH, &logLen)
|
||||||
|
logStr := make([]byte, logLen)
|
||||||
|
gl.GetShaderInfoLog(shader, logLen, nil, &logStr[0])
|
||||||
|
return 0, fmt.Errorf("shader compile error: %s", string(logStr))
|
||||||
|
}
|
||||||
|
return shader, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// package keybinds provides keybind functionality for the entire application
|
||||||
|
package keybinds
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/veandco/go-sdl2/sdl"
|
||||||
|
)
|
||||||
|
|
||||||
|
// KeyCombo represents a combination of a key and it's active modifier.
|
||||||
|
type KeyCombo struct {
|
||||||
|
Key sdl.Keycode
|
||||||
|
Mod sdl.Keymod
|
||||||
|
}
|
||||||
|
|
||||||
|
// Binds contains all currently registered Keybinds.
|
||||||
|
var Binds = make(map[KeyCombo]func())
|
||||||
|
|
||||||
|
// Enabled controls whether PerformAction dispatches keybinds. Set to false
|
||||||
|
// to temporarily suppress all global keybinds (e.g. during a modal page).
|
||||||
|
var Enabled = true
|
||||||
|
|
||||||
|
// Regiser registers a new keybind in Binds
|
||||||
|
func Register(k sdl.Keycode, m sdl.Keymod, action func()) {
|
||||||
|
mod := NormalizeModifiers(m)
|
||||||
|
newCombo := KeyCombo{k, mod}
|
||||||
|
_, ok := Binds[newCombo]
|
||||||
|
if ok {
|
||||||
|
}
|
||||||
|
Binds[newCombo] = action
|
||||||
|
}
|
||||||
|
|
||||||
|
// BindKey adds a new KeyCombo and Function (the action to take) to the keybinds.Binds
|
||||||
|
func BindKey(k KeyCombo, f func()) {
|
||||||
|
k.Mod = NormalizeModifiers(k.Mod)
|
||||||
|
Binds[k] = f
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnbindKey removes a KeyCombo from the available binds
|
||||||
|
func UnbindKey(k KeyCombo) {
|
||||||
|
k.Mod = NormalizeModifiers(k.Mod)
|
||||||
|
delete(Binds, k)
|
||||||
|
}
|
||||||
|
|
||||||
|
func PerformAction(event sdl.Event) {
|
||||||
|
if !Enabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch e := event.(type) {
|
||||||
|
case *sdl.KeyboardEvent:
|
||||||
|
if e.Type == sdl.KEYDOWN {
|
||||||
|
key := e.Keysym.Sym
|
||||||
|
rawMod := e.Keysym.Mod
|
||||||
|
mod := NormalizeModifiers(sdl.Keymod(rawMod))
|
||||||
|
combo := KeyCombo{key, mod}
|
||||||
|
action, ok := Binds[combo]
|
||||||
|
if ok {
|
||||||
|
action()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeModifiers converts a raw SDL modifier bitmask into a canonical form
|
||||||
|
// focusing on common modifiers (SHIFT, CTRL, ALT, GUI) and ignoring others like CapsLock.
|
||||||
|
func NormalizeModifiers(mod sdl.Keymod) sdl.Keymod {
|
||||||
|
var normalized sdl.Keymod = sdl.KMOD_NONE
|
||||||
|
if mod&sdl.KMOD_SHIFT != 0 {
|
||||||
|
normalized |= sdl.KMOD_SHIFT
|
||||||
|
}
|
||||||
|
if mod&sdl.KMOD_CTRL != 0 {
|
||||||
|
normalized |= sdl.KMOD_CTRL
|
||||||
|
}
|
||||||
|
if mod&sdl.KMOD_ALT != 0 {
|
||||||
|
normalized |= sdl.KMOD_ALT
|
||||||
|
}
|
||||||
|
if mod&sdl.KMOD_GUI != 0 {
|
||||||
|
normalized |= sdl.KMOD_GUI
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package license
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"path"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var Level int
|
||||||
|
|
||||||
|
type Validator struct {
|
||||||
|
client *http.Client
|
||||||
|
apiURL string
|
||||||
|
apiPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Response struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Active bool `json:"active"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Level string `json:"level"`
|
||||||
|
LastCheck time.Time `json:"lastCheck"`
|
||||||
|
NewCheck time.Time `json:"newCheck"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewValidator(apiURL, apiPath string) *Validator {
|
||||||
|
return &Validator{
|
||||||
|
client: &http.Client{
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
Transport: &http.Transport{
|
||||||
|
MaxIdleConns: 10,
|
||||||
|
IdleConnTimeout: 30 * time.Second,
|
||||||
|
DisableCompression: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
apiURL: apiURL,
|
||||||
|
apiPath: apiPath,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *Validator) Validate(key, email string) (*Response, error) {
|
||||||
|
u, err := url.Parse(v.apiURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
u.Path = path.Join(u.Path, v.apiPath)
|
||||||
|
|
||||||
|
q := u.Query()
|
||||||
|
q.Set("licenseKey", key)
|
||||||
|
q.Set("emailAddress", email)
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
resp, err := v.client.Get(u.String())
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("request failed: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("unexpected status: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result Response
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed ot decode response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package logger
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Level int
|
||||||
|
|
||||||
|
const (
|
||||||
|
LevelDebug Level = iota
|
||||||
|
LevelInfo
|
||||||
|
LevelWarning
|
||||||
|
LevelError
|
||||||
|
LevelFatal
|
||||||
|
)
|
||||||
|
|
||||||
|
func (l Level) String() string {
|
||||||
|
return [...]string{"DEBUG", "INFO", "WARNING", "ERROR", "FATAL"}[l]
|
||||||
|
}
|
||||||
|
|
||||||
|
type logEntry struct {
|
||||||
|
timestamp string
|
||||||
|
level string
|
||||||
|
message string
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
db *gorm.DB
|
||||||
|
file *os.File
|
||||||
|
console = log.New(os.Stderr, "", 0)
|
||||||
|
|
||||||
|
dbChan chan logEntry
|
||||||
|
dbChanOnce sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
// startDBWriter starts a single background goroutine to drain log entries to the database.
|
||||||
|
func startDBWriter() {
|
||||||
|
dbChan = make(chan logEntry, 256)
|
||||||
|
go func() {
|
||||||
|
for entry := range dbChan {
|
||||||
|
row := map[string]any{
|
||||||
|
"timestamp": entry.timestamp,
|
||||||
|
"level": entry.level,
|
||||||
|
"message": entry.message,
|
||||||
|
}
|
||||||
|
if err := db.Table("log_entries").Create(row).Error; err != nil {
|
||||||
|
mu.Lock()
|
||||||
|
console.Printf("Failed to write log to database: %v", err)
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddFile attaches or replaces the file backend
|
||||||
|
func AddFile(path string) error {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
if file != nil {
|
||||||
|
file.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
file = f
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddDatabase attaches or replaces the database backend
|
||||||
|
func AddDatabase(d *gorm.DB) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
db = d
|
||||||
|
dbChanOnce.Do(startDBWriter)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close shuts down the database writer goroutine and closes the file backend.
|
||||||
|
func Close() error {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
// Stop the DB writer goroutine by closing the channel
|
||||||
|
if dbChan != nil {
|
||||||
|
close(dbChan)
|
||||||
|
dbChan = nil
|
||||||
|
db = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if file != nil {
|
||||||
|
err := file.Close()
|
||||||
|
file = nil
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func logMsg(level Level, format string, args ...any) {
|
||||||
|
message := fmt.Sprintf(format, args...)
|
||||||
|
timestamp := time.Now().Format("2006-01-02 15:04:05.000")
|
||||||
|
line := fmt.Sprintf("%s [%s] %s", timestamp, level, message)
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
console.Println(line)
|
||||||
|
|
||||||
|
if file != nil {
|
||||||
|
fmt.Fprintln(file, line)
|
||||||
|
}
|
||||||
|
|
||||||
|
if db != nil && dbChan != nil { // both cleared by Close()
|
||||||
|
select {
|
||||||
|
case dbChan <- logEntry{timestamp: timestamp, level: level.String(), message: message}:
|
||||||
|
default:
|
||||||
|
// Channel full, drop the DB write rather than blocking
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func Debug(format string, args ...any) { logMsg(LevelDebug, format, args...) }
|
||||||
|
func Info(format string, args ...any) { logMsg(LevelInfo, format, args...) }
|
||||||
|
func Warning(format string, args ...any) { logMsg(LevelWarning, format, args...) }
|
||||||
|
func Error(format string, args ...any) { logMsg(LevelError, format, args...) }
|
||||||
|
|
||||||
|
func Fatal(format string, args ...any) {
|
||||||
|
logMsg(LevelFatal, format, args...)
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
// internal/audio/mixer/mixer.go
|
||||||
|
package mixer
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"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
|
||||||
|
currentVolume int // 0..128, defaults to MAX_VOLUME
|
||||||
|
|
||||||
|
// 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
|
||||||
|
)
|
||||||
|
|
||||||
|
// Init initializes SDL audio + SDL_mixer. Safe to call multiple times.
|
||||||
|
func Init(wantedDevice string) error {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if inited {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Let SDL choose the best audio driver for the system
|
||||||
|
// Remove hardcoded ALSA preference to support PulseAudio, PipeWire, etc.
|
||||||
|
// Users can set SDL_AUDIODRIVER environment variable if needed
|
||||||
|
|
||||||
|
if err := sdl.InitSubSystem(sdl.INIT_AUDIO); err != nil {
|
||||||
|
return fmt.Errorf("SDL audio init failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WAV-only does not require mix.Init(); add when you support OGG/MP3/etc.
|
||||||
|
if err := openDeviceLocked(wantedDevice); err != nil {
|
||||||
|
// Clean up the subsystem on failure so callers can retry later.
|
||||||
|
sdl.QuitSubSystem(sdl.INIT_AUDIO)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the volume is stored in the config; if not, default to max.
|
||||||
|
storedVolume, err := db.GetConfigValue("last_volume")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("retrieving last_volume from config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
lastVolume, err := strconv.Atoi(storedVolume)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parsing last_volume %q: %w", storedVolume, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if lastVolume < 0 {
|
||||||
|
lastVolume = int(mix.MAX_VOLUME)
|
||||||
|
currentVolume = lastVolume
|
||||||
|
if err := db.SetConfigValue("last_volume", strconv.Itoa(lastVolume)); err != nil {
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
currentVolume = lastVolume
|
||||||
|
}
|
||||||
|
|
||||||
|
// mix.VolumeMusic(currentVolume)
|
||||||
|
|
||||||
|
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) error {
|
||||||
|
if !db.AudioEnabled() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
if !inited || !deviceOpen {
|
||||||
|
return errors.New("audio mixer not initialized")
|
||||||
|
}
|
||||||
|
if path == "" {
|
||||||
|
return errors.New("empty audio path")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop and free any prior music
|
||||||
|
mix.HaltMusic()
|
||||||
|
if currentMusic != nil {
|
||||||
|
currentMusic.Free()
|
||||||
|
currentMusic = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
m, err := mix.LoadMUS(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("LoadMUS(%q): %w", path, err)
|
||||||
|
}
|
||||||
|
currentMusic = m
|
||||||
|
currentPath = path
|
||||||
|
if loopForever {
|
||||||
|
currentLoops = -1
|
||||||
|
} else {
|
||||||
|
currentLoops = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open the device
|
||||||
|
if err := openDeviceLocked(deviceName); err != nil {
|
||||||
|
logger.Error("Failed to open device: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply remembered volume
|
||||||
|
mix.VolumeMusic(currentVolume)
|
||||||
|
|
||||||
|
if err := currentMusic.Play(currentLoops); err != nil {
|
||||||
|
currentMusic.Free()
|
||||||
|
currentMusic = nil
|
||||||
|
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) {
|
||||||
|
if !db.AudioEnabled() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetVolume128 sets music volume using SDL_mixer’s 0..128 scale.
|
||||||
|
func SetVolume128(increment int) {
|
||||||
|
if !db.AudioEnabled() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
currentVolume += increment
|
||||||
|
|
||||||
|
if currentVolume < 0 {
|
||||||
|
currentVolume = 0
|
||||||
|
} else if currentVolume > mix.MAX_VOLUME {
|
||||||
|
currentVolume = int(mix.MAX_VOLUME)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store in config
|
||||||
|
if err := db.SetConfigValue("last_volume", strconv.Itoa(currentVolume)); err != nil {
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply to SDL_mixer
|
||||||
|
if inited && deviceOpen {
|
||||||
|
mix.VolumeMusic(currentVolume)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsPlaying reports whether music is currently playing (or fading).
|
||||||
|
func IsPlaying() bool {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if !inited {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return mix.PlayingMusic()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
return deviceName
|
||||||
|
}
|
||||||
|
|
||||||
|
// SwitchDevice switches output devices, preserving volume and resuming playback of the current track/loop state if any.
|
||||||
|
func SwitchDevice(newName string) error {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
if !inited {
|
||||||
|
return errors.New("audio mixer not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log device switch attempt
|
||||||
|
oldDevice := deviceName
|
||||||
|
if oldDevice == "" {
|
||||||
|
oldDevice = "system default"
|
||||||
|
}
|
||||||
|
newDeviceLog := newName
|
||||||
|
if newDeviceLog == "" {
|
||||||
|
newDeviceLog = "system default"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture current state
|
||||||
|
wasPlaying := mix.PlayingMusic()
|
||||||
|
savedPath := currentPath
|
||||||
|
savedLoops := currentLoops
|
||||||
|
savedVolume := currentVolume
|
||||||
|
|
||||||
|
// Stop and release current music before reopening device
|
||||||
|
if wasPlaying {
|
||||||
|
}
|
||||||
|
mix.HaltMusic()
|
||||||
|
if currentMusic != nil {
|
||||||
|
currentMusic.Free()
|
||||||
|
currentMusic = nil
|
||||||
|
}
|
||||||
|
currentPath = ""
|
||||||
|
currentLoops = 0
|
||||||
|
|
||||||
|
// Close and reopen device
|
||||||
|
if deviceOpen {
|
||||||
|
mix.CloseAudio()
|
||||||
|
deviceOpen = false
|
||||||
|
}
|
||||||
|
if err := openDeviceLocked(newName); err != nil {
|
||||||
|
// Best effort: try to reopen the previous device (or default) so we don't leave audio dead
|
||||||
|
if restoreErr := openDeviceLocked(oldDevice); restoreErr != nil {
|
||||||
|
} else {
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore volume
|
||||||
|
mix.VolumeMusic(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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetVolume128() int {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
return currentVolume
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 you add mix.Init(decoderFlags) in the future, pair it with mix.Quit() here.
|
||||||
|
if inited {
|
||||||
|
sdl.QuitSubSystem(sdl.INIT_AUDIO)
|
||||||
|
inited = false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,428 @@
|
|||||||
|
// Package network provides a high-level utility for managing network
|
||||||
|
// connections via the system's D-Bus and NetworkManager.
|
||||||
|
package network
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/godbus/dbus/v5"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// --- D-Bus NetworkManager Constants ---
|
||||||
|
const (
|
||||||
|
nmService = "org.freedesktop.NetworkManager"
|
||||||
|
nmPath = "/org/freedesktop/NetworkManager"
|
||||||
|
nmInterface = "org.freedesktop.NetworkManager"
|
||||||
|
nmDeviceInterface = "org.freedesktop.NetworkManager.Device"
|
||||||
|
nmWirelessDeviceInterface = "org.freedesktop.NetworkManager.Device.Wireless"
|
||||||
|
nmAPInterface = "org.freedesktop.NetworkManager.AccessPoint"
|
||||||
|
|
||||||
|
nmDeviceTypeEthernet uint32 = 1
|
||||||
|
nmDeviceTypeWifi uint32 = 2
|
||||||
|
nmDeviceStateActivated uint32 = 100
|
||||||
|
APFlagsPrivacy uint32 = 0x00000001 // Equivalent to NM_802_11_AP_FLAGS_PRIVACY
|
||||||
|
APSecKeyMgmtEAP uint32 = 0x00000020
|
||||||
|
APSecKeyMgmtPSK uint32 = 0x00000100
|
||||||
|
APSecKeyMgmtOWE uint32 = 0x00000200
|
||||||
|
APSecKeyMgmtSAE uint32 = 0x00000400
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrScanAborted is returned when a scan is cancelled via the stop channel.
|
||||||
|
var ErrScanAborted = errors.New("network scan aborted by caller")
|
||||||
|
|
||||||
|
// Status defines the type for various connection status codes.
|
||||||
|
type Status int
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusError Status = iota
|
||||||
|
StatusNoDevicesFound
|
||||||
|
StatusWifiAvailableNotConnected
|
||||||
|
StatusWifiConnectedNoInternet
|
||||||
|
StatusWifiConnectedInternetUp
|
||||||
|
StatusEthernetNotConnected
|
||||||
|
StatusEthernetConnectedNoInternet
|
||||||
|
StatusEthernetConnectedInternetUp
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConnectionStatus holds the detailed result of a connectivity check.
|
||||||
|
type ConnectionStatus struct {
|
||||||
|
StatusCode Status
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceType defines the kind of network device.
|
||||||
|
type DeviceType int
|
||||||
|
|
||||||
|
const (
|
||||||
|
TypeUnknown DeviceType = iota
|
||||||
|
TypeEthernet
|
||||||
|
TypeWifi
|
||||||
|
)
|
||||||
|
|
||||||
|
// Device holds information about a single network device.
|
||||||
|
type Device struct {
|
||||||
|
Path dbus.ObjectPath
|
||||||
|
InterfaceName string
|
||||||
|
Type DeviceType
|
||||||
|
State uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manager handles all D-Bus communication with NetworkManager.
|
||||||
|
type Manager struct {
|
||||||
|
conn *dbus.Conn
|
||||||
|
nmObject dbus.BusObject
|
||||||
|
Devices []Device
|
||||||
|
}
|
||||||
|
|
||||||
|
// AccessPoint holds essential information about a discovered Wi-Fi network.
|
||||||
|
type AccessPoint struct {
|
||||||
|
ObjectPath dbus.ObjectPath
|
||||||
|
SSID string
|
||||||
|
Strength byte
|
||||||
|
IsProtected bool
|
||||||
|
IsActive bool
|
||||||
|
WpaFlags uint32
|
||||||
|
RsnFlags uint32
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new network Manager and connects to the system D-Bus.
|
||||||
|
func New() (*Manager, error) {
|
||||||
|
conn, err := dbus.ConnectSystemBus()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to connect to system bus: %w", err)
|
||||||
|
}
|
||||||
|
nmObject := conn.Object(nmService, nmPath)
|
||||||
|
return &Manager{conn: conn, nmObject: nmObject}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conn returns the underlying dbus.Conn for advanced queries.
|
||||||
|
func (m *Manager) Conn() *dbus.Conn {
|
||||||
|
return m.conn
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close disconnects from the D-Bus.
|
||||||
|
func (m *Manager) Close() {
|
||||||
|
if m.conn != nil {
|
||||||
|
m.conn.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindDevices scans the system for all available network devices (Ethernet and Wi-Fi).
|
||||||
|
func (m *Manager) FindDevices() error {
|
||||||
|
var devicePaths []dbus.ObjectPath
|
||||||
|
call := m.nmObject.Call(nmInterface+".GetDevices", 0)
|
||||||
|
if call.Err != nil {
|
||||||
|
return fmt.Errorf("GetDevices D-Bus call failed: %w", call.Err)
|
||||||
|
}
|
||||||
|
if err := call.Store(&devicePaths); err != nil {
|
||||||
|
return fmt.Errorf("could not parse device list: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.Devices = []Device{}
|
||||||
|
for _, path := range devicePaths {
|
||||||
|
deviceObj := m.conn.Object(nmService, path)
|
||||||
|
typeVariant, err := deviceObj.GetProperty(nmDeviceInterface + ".DeviceType")
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("network: Skipping device %s, could not get DeviceType: %v", path, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
deviceType, _ := typeVariant.Value().(uint32)
|
||||||
|
|
||||||
|
var newDevice Device
|
||||||
|
switch deviceType {
|
||||||
|
case nmDeviceTypeEthernet:
|
||||||
|
newDevice.Type = TypeEthernet
|
||||||
|
case nmDeviceTypeWifi:
|
||||||
|
newDevice.Type = TypeWifi
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
newDevice.Path = path
|
||||||
|
ifaceVariant, _ := deviceObj.GetProperty(nmDeviceInterface + ".Interface")
|
||||||
|
newDevice.InterfaceName, _ = ifaceVariant.Value().(string)
|
||||||
|
stateVariant, _ := deviceObj.GetProperty(nmDeviceInterface + ".State")
|
||||||
|
newDevice.State, _ = stateVariant.Value().(uint32)
|
||||||
|
m.Devices = append(m.Devices, newDevice)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckInternetConnection performs a comprehensive check for an active internet connection.
|
||||||
|
func (m *Manager) CheckInternetConnection() (*ConnectionStatus, error) {
|
||||||
|
if err := m.FindDevices(); err != nil {
|
||||||
|
return &ConnectionStatus{StatusError, "Failed to scan for network devices"}, err
|
||||||
|
}
|
||||||
|
if len(m.Devices) == 0 {
|
||||||
|
return &ConnectionStatus{StatusNoDevicesFound, "No network hardware found."}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
hasWifiAdapter := false
|
||||||
|
hasEthernetAdapter := false
|
||||||
|
|
||||||
|
for _, device := range m.Devices {
|
||||||
|
if device.Type == TypeEthernet {
|
||||||
|
hasEthernetAdapter = true
|
||||||
|
if device.State == nmDeviceStateActivated {
|
||||||
|
if pingTest() {
|
||||||
|
return &ConnectionStatus{StatusEthernetConnectedInternetUp, "Ethernet connected with internet access."}, nil
|
||||||
|
}
|
||||||
|
return &ConnectionStatus{StatusEthernetConnectedNoInternet, "Ethernet connected, but no internet access."}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, device := range m.Devices {
|
||||||
|
if device.Type == TypeWifi {
|
||||||
|
hasWifiAdapter = true
|
||||||
|
if device.State == nmDeviceStateActivated {
|
||||||
|
if pingTest() {
|
||||||
|
return &ConnectionStatus{StatusWifiConnectedInternetUp, "Wi-Fi connected with internet access."}, nil
|
||||||
|
}
|
||||||
|
return &ConnectionStatus{StatusWifiConnectedNoInternet, "Wi-Fi connected, but no internet access."}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasWifiAdapter {
|
||||||
|
return &ConnectionStatus{StatusWifiAvailableNotConnected, "Wi-Fi adapter is available but not connected."}, nil
|
||||||
|
}
|
||||||
|
if hasEthernetAdapter {
|
||||||
|
return &ConnectionStatus{StatusEthernetNotConnected, "Ethernet adapter is available but not connected."}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ConnectionStatus{StatusNoDevicesFound, "No recognizable network hardware found."}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartScan performs a Wi-Fi scan asynchronously. The optional stopChan can be used to abort.
|
||||||
|
func (m *Manager) StartScan(resultsChan chan<- []AccessPoint, errorChan chan<- error, stopChan <-chan struct{}) {
|
||||||
|
var wifiDevice *Device
|
||||||
|
for i := range m.Devices {
|
||||||
|
if m.Devices[i].Type == TypeWifi {
|
||||||
|
wifiDevice = &m.Devices[i]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if wifiDevice == nil {
|
||||||
|
errorChan <- fmt.Errorf("no Wi-Fi device available for scanning")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure Wi-Fi is enabled
|
||||||
|
nmObj := m.conn.Object(nmService, nmPath)
|
||||||
|
prop, err := nmObj.GetProperty(nmInterface + ".WirelessEnabled")
|
||||||
|
if err != nil {
|
||||||
|
errorChan <- fmt.Errorf("could not read WirelessEnabled property: %w", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !prop.Value().(bool) {
|
||||||
|
call := nmObj.Call("org.freedesktop.DBus.Properties.Set", 0,
|
||||||
|
nmInterface,
|
||||||
|
"WirelessEnabled",
|
||||||
|
dbus.MakeVariant(true),
|
||||||
|
)
|
||||||
|
if call.Err != nil {
|
||||||
|
errorChan <- fmt.Errorf("could not enable Wi-Fi: %w", call.Err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(2 * time.Second) // Give NetworkManager time to bring up the device
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request a scan
|
||||||
|
wifiDeviceWireless := m.conn.Object(nmService, wifiDevice.Path)
|
||||||
|
call := wifiDeviceWireless.Call(nmWirelessDeviceInterface+".RequestScan", 0, make(map[string]dbus.Variant))
|
||||||
|
if call.Err != nil {
|
||||||
|
errorChan <- fmt.Errorf("scan request failed: %w", call.Err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
case <-stopChan:
|
||||||
|
errorChan <- ErrScanAborted
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var apPaths []dbus.ObjectPath
|
||||||
|
call = wifiDeviceWireless.Call(nmWirelessDeviceInterface+".GetAllAccessPoints", 0)
|
||||||
|
if call.Err != nil {
|
||||||
|
errorChan <- fmt.Errorf("could not retrieve network list: %w", call.Err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := call.Store(&apPaths); err != nil {
|
||||||
|
errorChan <- fmt.Errorf("could not parse network list: %w", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
processedAPs, err := m.scanAccessPoints(apPaths)
|
||||||
|
if err != nil {
|
||||||
|
errorChan <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resultsChan <- processedAPs
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanAccessPoints filters and sorts the raw list of D-Bus AP paths.
|
||||||
|
func (m *Manager) scanAccessPoints(apPaths []dbus.ObjectPath) ([]AccessPoint, error) {
|
||||||
|
tempNetworkMap := make(map[string]AccessPoint)
|
||||||
|
for _, apPath := range apPaths {
|
||||||
|
apObj := m.conn.Object(nmService, apPath)
|
||||||
|
props, err := m.getAllProperties(apObj, nmAPInterface)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("network: Failed to get properties for AP %s: %v", apPath, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
ssidBytes, _ := props["Ssid"].Value().([]byte)
|
||||||
|
if len(ssidBytes) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
ssidStr := string(ssidBytes)
|
||||||
|
strength, _ := props["Strength"].Value().(byte)
|
||||||
|
flags, _ := props["Flags"].Value().(uint32)
|
||||||
|
wpaFlags, _ := props["WpaFlags"].Value().(uint32)
|
||||||
|
rsnFlags, _ := props["RsnFlags"].Value().(uint32)
|
||||||
|
|
||||||
|
isProtected := (flags&APFlagsPrivacy != 0) || (wpaFlags != 0) || (rsnFlags != 0)
|
||||||
|
|
||||||
|
currentAP := AccessPoint{
|
||||||
|
ObjectPath: apPath,
|
||||||
|
SSID: ssidStr,
|
||||||
|
Strength: strength,
|
||||||
|
IsProtected: isProtected,
|
||||||
|
WpaFlags: wpaFlags,
|
||||||
|
RsnFlags: rsnFlags,
|
||||||
|
}
|
||||||
|
if existingAP, ok := tempNetworkMap[ssidStr]; !ok || strength > existingAP.Strength {
|
||||||
|
tempNetworkMap[ssidStr] = currentAP
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
networkList := make([]AccessPoint, 0, len(tempNetworkMap))
|
||||||
|
for _, ap := range tempNetworkMap {
|
||||||
|
networkList = append(networkList, ap)
|
||||||
|
}
|
||||||
|
sort.Slice(networkList, func(i, j int) bool {
|
||||||
|
if networkList[i].Strength != networkList[j].Strength {
|
||||||
|
return networkList[i].Strength > networkList[j].Strength
|
||||||
|
}
|
||||||
|
return strings.ToLower(networkList[i].SSID) < strings.ToLower(networkList[j].SSID)
|
||||||
|
})
|
||||||
|
return networkList, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect attempts to connect a Wi-Fi device to a given access point.
|
||||||
|
func (m *Manager) Connect(wifiDevice Device, ap AccessPoint, passphrase string) error {
|
||||||
|
if wifiDevice.Type != TypeWifi {
|
||||||
|
return fmt.Errorf("cannot connect: provided device is not a Wi-Fi device")
|
||||||
|
}
|
||||||
|
|
||||||
|
settings := map[string]map[string]dbus.Variant{
|
||||||
|
"connection": {
|
||||||
|
"type": dbus.MakeVariant("802-11-wireless"),
|
||||||
|
"uuid": dbus.MakeVariant(uuid.NewString()),
|
||||||
|
"id": dbus.MakeVariant(ap.SSID),
|
||||||
|
"interface-name": dbus.MakeVariant(wifiDevice.InterfaceName),
|
||||||
|
},
|
||||||
|
"802-11-wireless": {
|
||||||
|
"ssid": dbus.MakeVariant([]byte(ap.SSID)),
|
||||||
|
"mode": dbus.MakeVariant("infrastructure"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if ap.IsProtected {
|
||||||
|
settings["802-11-wireless"]["security"] = dbus.MakeVariant("802-11-wireless-security")
|
||||||
|
secSettings := make(map[string]dbus.Variant)
|
||||||
|
|
||||||
|
// Default to WPA-PSK, but override with more specific types if found.
|
||||||
|
secSettings["key-mgmt"] = dbus.MakeVariant("wpa-psk")
|
||||||
|
if (ap.RsnFlags & APSecKeyMgmtSAE) != 0 {
|
||||||
|
secSettings["key-mgmt"] = dbus.MakeVariant("sae") // WPA3
|
||||||
|
} else if (ap.RsnFlags&APSecKeyMgmtEAP) != 0 || (ap.WpaFlags&APSecKeyMgmtEAP) != 0 {
|
||||||
|
log.Println("network: Enterprise AP (EAP) detected. Full configuration may be required.")
|
||||||
|
secSettings["key-mgmt"] = dbus.MakeVariant("wpa-eap")
|
||||||
|
}
|
||||||
|
|
||||||
|
secSettings["psk"] = dbus.MakeVariant(passphrase)
|
||||||
|
settings["802-11-wireless-security"] = secSettings
|
||||||
|
}
|
||||||
|
|
||||||
|
call := m.nmObject.Call(nmInterface+".AddAndActivateConnection", 0, settings, wifiDevice.Path, ap.ObjectPath)
|
||||||
|
if call.Err != nil {
|
||||||
|
return fmt.Errorf("AddAndActivateConnection D-Bus call error: %w", call.Err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pingTest performs a simple DNS lookup to check for internet connectivity.
|
||||||
|
func pingTest() bool {
|
||||||
|
_, err := net.LookupHost("www.google.com")
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getAllProperties is a helper to fetch all properties for a D-Bus object interface.
|
||||||
|
func (m *Manager) getAllProperties(obj dbus.BusObject, iface string) (map[string]dbus.Variant, error) {
|
||||||
|
var props map[string]dbus.Variant
|
||||||
|
call := obj.Call("org.freedesktop.DBus.Properties.GetAll", 0, iface)
|
||||||
|
if call.Err != nil {
|
||||||
|
return nil, call.Err
|
||||||
|
}
|
||||||
|
if err := call.Store(&props); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return props, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WaitForInternet blocks until CheckInternetConnection reports Internet up,
|
||||||
|
// or until maxWait elapses. When it first sees "up", it requires that state
|
||||||
|
// to be stable for at least 'stableFor' before returning success to avoid flapping.
|
||||||
|
func (m *Manager) WaitForInternet(maxWait, stableFor time.Duration) (*ConnectionStatus, error) {
|
||||||
|
deadline := time.Now().Add(maxWait)
|
||||||
|
var lastErr error
|
||||||
|
var lastStatus *ConnectionStatus
|
||||||
|
var upSince time.Time
|
||||||
|
|
||||||
|
// Reasonable poll cadence; NM typically completes within a few seconds.
|
||||||
|
const poll = 500 * time.Millisecond
|
||||||
|
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
st, err := m.CheckInternetConnection()
|
||||||
|
if err != nil {
|
||||||
|
lastErr = err
|
||||||
|
lastStatus = &ConnectionStatus{StatusError, "NetworkManager not ready"}
|
||||||
|
upSince = time.Time{} // reset stability
|
||||||
|
} else {
|
||||||
|
lastStatus = st
|
||||||
|
switch st.StatusCode {
|
||||||
|
case StatusEthernetConnectedInternetUp, StatusWifiConnectedInternetUp:
|
||||||
|
if upSince.IsZero() {
|
||||||
|
upSince = time.Now()
|
||||||
|
}
|
||||||
|
if time.Since(upSince) >= stableFor {
|
||||||
|
return st, nil // stable "up"
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// not up; keep waiting
|
||||||
|
upSince = time.Time{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
time.Sleep(poll)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timed out; return the last observed status and error (if any).
|
||||||
|
if lastStatus == nil {
|
||||||
|
lastStatus = &ConnectionStatus{StatusError, "Timed out waiting for connectivity"}
|
||||||
|
}
|
||||||
|
if lastErr != nil {
|
||||||
|
return lastStatus, fmt.Errorf("timeout waiting for internet: %w", lastErr)
|
||||||
|
}
|
||||||
|
return lastStatus, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
package page
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"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/mixer"
|
||||||
|
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AudioDevices struct {
|
||||||
|
Base
|
||||||
|
font *fontManager.FontEntry
|
||||||
|
textShader *shaderManager.Shader
|
||||||
|
solidShader *shaderManager.Shader
|
||||||
|
title StringItem
|
||||||
|
prompt StringItem
|
||||||
|
devices []StringItem
|
||||||
|
highlightIndex int
|
||||||
|
selectedIndex int
|
||||||
|
verticalMargin int32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AudioDevices) Init(app ApplicationInterface) error {
|
||||||
|
var err error
|
||||||
|
p.verticalMargin = int32(20)
|
||||||
|
|
||||||
|
// Get font
|
||||||
|
fm := fontManager.Get()
|
||||||
|
font, ok := fm.GetFont("Roboto-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(app); err != nil {
|
||||||
|
return fmt.Errorf("base init failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get font size from database
|
||||||
|
baseFontSizeStr, err := db.GetConfigValue("init_font_size")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("retrieving init_font_size from db: %w", err)
|
||||||
|
}
|
||||||
|
baseFontSize, err := strconv.Atoi(baseFontSizeStr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parsing init_font_size %q: %w", baseFontSizeStr, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate scales
|
||||||
|
titleScale := float32(64) / float32(baseFontSize)
|
||||||
|
itemScale := float32(32) / float32(baseFontSize)
|
||||||
|
|
||||||
|
// Create title
|
||||||
|
p.title, err = NewStringItem("Select an Audio Output Device", p.font, colors.White)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating title: %w", err)
|
||||||
|
}
|
||||||
|
p.title.Scale(titleScale)
|
||||||
|
|
||||||
|
// Create prompt
|
||||||
|
p.prompt, err = NewStringItem("Use Arrow Keys to select, Enter to confirm", p.font, colors.White)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating prompt: %w", err)
|
||||||
|
}
|
||||||
|
p.prompt.Scale(itemScale)
|
||||||
|
|
||||||
|
// Get list of audio output devices
|
||||||
|
devices := mixer.ListDevices()
|
||||||
|
if len(devices) == 0 {
|
||||||
|
// Create a default entry so the page doesn't break
|
||||||
|
devices = []string{"System Default"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get currently selected device from database
|
||||||
|
currentDevice, err := db.GetConfigValue("audio_device_name")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("retrieving audio_device_name from db: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create UI items for each device
|
||||||
|
for i, name := range devices { // Iterate over the devices
|
||||||
|
item, err := NewStringItem(name, p.font, colors.White) // Create a new string string texture
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create device item name: %w", err)
|
||||||
|
}
|
||||||
|
item.Scale(itemScale) // Scale the new string item
|
||||||
|
p.devices = append(p.devices, item) // Add the new scaled string item to the list of items
|
||||||
|
|
||||||
|
// Mark currently selected device
|
||||||
|
if name == currentDevice {
|
||||||
|
p.selectedIndex = i
|
||||||
|
p.highlightIndex = i // Start with cursor on current device
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
on, err := db.GetConfigValue("audio_enabled")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to query database for audio_enabled: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
audioStatus := "Turn Audio On"
|
||||||
|
if on == "true" {
|
||||||
|
audioStatus = "Turn Audio Off"
|
||||||
|
}
|
||||||
|
|
||||||
|
audioStatusStringItem, err := NewStringItem(audioStatus, p.font, colors.White)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create Turn Audio Off string item: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scale the new option
|
||||||
|
audioStatusStringItem.Scale(itemScale)
|
||||||
|
|
||||||
|
p.devices = append(p.devices, audioStatusStringItem)
|
||||||
|
|
||||||
|
p.layout()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AudioDevices) layout() {
|
||||||
|
// Position title at bottom
|
||||||
|
p.title.X = p.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 - p.prompt.W/2
|
||||||
|
p.prompt.Y = p.title.Y - p.verticalMargin*2 - p.prompt.H
|
||||||
|
|
||||||
|
// Position device items stacked upward from prompt
|
||||||
|
for i := range p.devices {
|
||||||
|
p.devices[i].X = p.ScreenCenterX - p.devices[i].W/2
|
||||||
|
if i == 0 {
|
||||||
|
p.devices[i].Y = p.prompt.Y - p.verticalMargin*2 - p.devices[i].H
|
||||||
|
} else {
|
||||||
|
p.devices[i].Y = p.devices[i-1].Y - p.devices[i].H - 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AudioDevices) 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_UP:
|
||||||
|
if p.highlightIndex > 0 {
|
||||||
|
p.highlightIndex--
|
||||||
|
}
|
||||||
|
|
||||||
|
case sdl.K_DOWN:
|
||||||
|
if p.highlightIndex < len(p.devices)-1 {
|
||||||
|
p.highlightIndex++
|
||||||
|
}
|
||||||
|
|
||||||
|
case sdl.K_RETURN:
|
||||||
|
selection := p.devices[p.highlightIndex].content
|
||||||
|
|
||||||
|
// Handle audio disable
|
||||||
|
if selection == "Turn Audio Off" || selection == "Turn Audio On" {
|
||||||
|
on, err := db.GetConfigValue("audio_enabled")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to fetch audio_enabled from database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
currentStatus, err := strconv.ParseBool(on)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to parse audio_enabled value: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
newStatus := strconv.FormatBool(!currentStatus)
|
||||||
|
if err := db.SetConfigValue("audio_enabled", newStatus); err != nil {
|
||||||
|
return fmt.Errorf("failed to update audio_enabled: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.App.UnwindToPage(&Settings{})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map "System Default" to empty string for SDL
|
||||||
|
deviceName := selection
|
||||||
|
if selection == "System Default" {
|
||||||
|
deviceName = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate by attempting the switch
|
||||||
|
if err := mixer.SwitchDevice(deviceName); err != nil {
|
||||||
|
return fmt.Errorf("failed to switch to device %q: %w", selection, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save settings
|
||||||
|
if err := db.SetConfigValue("audio_device_name", deviceName); err != nil {
|
||||||
|
return fmt.Errorf("failed to save audio device: %w", err)
|
||||||
|
}
|
||||||
|
if err := db.SetConfigValue("audio_enabled", "true"); err != nil {
|
||||||
|
return fmt.Errorf("failed to enable audio: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.App.UnwindToPage(&Settings{})
|
||||||
|
|
||||||
|
case sdl.K_BACKSPACE:
|
||||||
|
p.App.UnwindToPage(&Settings{})
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AudioDevices) Render() error {
|
||||||
|
// Always 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)
|
||||||
|
|
||||||
|
// No devices to display
|
||||||
|
if len(p.devices) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render highlight box behind highlighted item
|
||||||
|
p.RenderSolidColorQuad(
|
||||||
|
p.solidShader,
|
||||||
|
p.devices[p.highlightIndex].Position(),
|
||||||
|
p.devices[p.highlightIndex].RawDimensions(),
|
||||||
|
colors.White,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Render device items with appropriate colors
|
||||||
|
for i, item := range p.devices {
|
||||||
|
if i == p.highlightIndex && i == p.selectedIndex {
|
||||||
|
// Currently highlighted AND selected device - dark green
|
||||||
|
item.Color = colors.DarkGreen
|
||||||
|
} else if i == p.highlightIndex {
|
||||||
|
// Just highlighted - black (shows on white background)
|
||||||
|
item.Color = colors.Black
|
||||||
|
} else if i == p.selectedIndex {
|
||||||
|
// Just selected - green
|
||||||
|
item.Color = colors.Green
|
||||||
|
} else {
|
||||||
|
// Normal item - white
|
||||||
|
item.Color = colors.White
|
||||||
|
}
|
||||||
|
p.RenderTexture(p.textShader, item.ID, item.Position(), item.RawDimensions(), item.Color)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AudioDevices) Destroy() error {
|
||||||
|
gl.DeleteTextures(1, &p.title.ID)
|
||||||
|
gl.DeleteTextures(1, &p.prompt.ID)
|
||||||
|
for _, item := range p.devices {
|
||||||
|
gl.DeleteTextures(1, &item.ID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
package page
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
|
"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/shaderManager"
|
||||||
|
)
|
||||||
|
|
||||||
|
type downloadState int
|
||||||
|
|
||||||
|
const (
|
||||||
|
downloadStateDownloading downloadState = iota
|
||||||
|
downloadStateFinished
|
||||||
|
)
|
||||||
|
|
||||||
|
// DownloadProgressPage displays a progress bar while an audio file is downloading.
|
||||||
|
// The page is inescapable: no input is accepted while downloading, and only
|
||||||
|
// Enter is accepted once the download finishes.
|
||||||
|
type DownloadProgressPage struct {
|
||||||
|
Base
|
||||||
|
textShader *shaderManager.Shader
|
||||||
|
solidShader *shaderManager.Shader
|
||||||
|
|
||||||
|
// Set by the application before pushing this page.
|
||||||
|
TotalBytes *atomic.Int64
|
||||||
|
BytesWritten *atomic.Int64
|
||||||
|
Done *atomic.Bool
|
||||||
|
HasError *atomic.Bool
|
||||||
|
NextPage Page // where to navigate on success
|
||||||
|
|
||||||
|
state downloadState
|
||||||
|
title StringItem
|
||||||
|
prompt StringItem
|
||||||
|
percentText StringItem
|
||||||
|
lastPercent int
|
||||||
|
|
||||||
|
barX, barY, barW, barH int32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *DownloadProgressPage) Init(app ApplicationInterface) error {
|
||||||
|
if err := p.Base.Init(app); err != nil {
|
||||||
|
return fmt.Errorf("failed base page initialization: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fm := fontManager.Get()
|
||||||
|
sm := shaderManager.Get()
|
||||||
|
|
||||||
|
font, ok := fm.GetFont("Roboto-Regular")
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("failed to get font")
|
||||||
|
}
|
||||||
|
|
||||||
|
p.textShader, ok = sm.Get("text")
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("failed to get text shader")
|
||||||
|
}
|
||||||
|
p.solidShader, ok = sm.Get("solid_color")
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("failed to get solid_color shader")
|
||||||
|
}
|
||||||
|
|
||||||
|
baseFontSizeStr, err := db.GetConfigValue("init_font_size")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("retrieving init_font_size from db: %w", err)
|
||||||
|
}
|
||||||
|
baseFontSize, err := strconv.Atoi(baseFontSizeStr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parsing init_font_size: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
titleScale := float32(48) / float32(baseFontSize)
|
||||||
|
p.title, err = NewStringItem("Downloading Audio...", font, colors.White)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating title: %w", err)
|
||||||
|
}
|
||||||
|
p.title.Scale(titleScale)
|
||||||
|
|
||||||
|
promptScale := float32(32) / float32(baseFontSize)
|
||||||
|
p.prompt, err = NewStringItem("Press Enter to continue", font, colors.White)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating prompt: %w", err)
|
||||||
|
}
|
||||||
|
p.prompt.Scale(promptScale)
|
||||||
|
|
||||||
|
p.percentText, err = NewStringItem("0%", font, colors.White)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating percent text: %w", err)
|
||||||
|
}
|
||||||
|
p.percentText.Scale(promptScale)
|
||||||
|
p.lastPercent = 0
|
||||||
|
|
||||||
|
// Progress bar dimensions: 60% of screen width, 30px tall, centered
|
||||||
|
p.barW = int32(float32(p.ScreenWidth) * 0.6)
|
||||||
|
p.barH = 30
|
||||||
|
p.barX = p.ScreenCenterX - p.barW/2
|
||||||
|
p.barY = p.ScreenCenterY - p.barH/2
|
||||||
|
|
||||||
|
// Position title above bar
|
||||||
|
p.title.X = p.ScreenCenterX - p.title.W/2
|
||||||
|
p.title.Y = p.barY + p.barH + 40
|
||||||
|
|
||||||
|
p.state = downloadStateDownloading
|
||||||
|
keybinds.Enabled = false
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *DownloadProgressPage) Update(dt float32) error {
|
||||||
|
if p.state == downloadStateFinished {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.Done.Load() {
|
||||||
|
p.state = downloadStateFinished
|
||||||
|
|
||||||
|
// Force progress to 100% on success
|
||||||
|
if !p.HasError.Load() && p.TotalBytes.Load() > 0 {
|
||||||
|
p.BytesWritten.Store(p.TotalBytes.Load())
|
||||||
|
p.lastPercent = 100
|
||||||
|
|
||||||
|
fm := fontManager.Get()
|
||||||
|
font, ok := fm.GetFont("Roboto-Regular")
|
||||||
|
if ok {
|
||||||
|
gl.DeleteTextures(1, &p.percentText.ID)
|
||||||
|
baseFontSizeStr, _ := db.GetConfigValue("init_font_size")
|
||||||
|
baseFontSize, _ := strconv.Atoi(baseFontSizeStr)
|
||||||
|
percentScale := float32(32) / float32(baseFontSize)
|
||||||
|
newText, err := NewStringItem("100%", font, colors.White)
|
||||||
|
if err == nil {
|
||||||
|
newText.Scale(percentScale)
|
||||||
|
p.percentText = newText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update title to reflect result
|
||||||
|
fm := fontManager.Get()
|
||||||
|
font, ok := fm.GetFont("Roboto-Regular")
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
baseFontSizeStr, _ := db.GetConfigValue("init_font_size")
|
||||||
|
baseFontSize, _ := strconv.Atoi(baseFontSizeStr)
|
||||||
|
titleScale := float32(48) / float32(baseFontSize)
|
||||||
|
|
||||||
|
gl.DeleteTextures(1, &p.title.ID)
|
||||||
|
|
||||||
|
var titleText string
|
||||||
|
if p.HasError.Load() {
|
||||||
|
titleText = "Download Failed"
|
||||||
|
} else {
|
||||||
|
titleText = "Download Complete"
|
||||||
|
}
|
||||||
|
newTitle, err := NewStringItem(titleText, font, colors.White)
|
||||||
|
if err == nil {
|
||||||
|
newTitle.Scale(titleScale)
|
||||||
|
p.title = newTitle
|
||||||
|
p.title.X = p.ScreenCenterX - p.title.W/2
|
||||||
|
p.title.Y = p.barY + p.barH + 40
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update percentage texture when it changes
|
||||||
|
percent := 0
|
||||||
|
if p.TotalBytes.Load() > 0 {
|
||||||
|
percent = int(p.BytesWritten.Load() * 100 / p.TotalBytes.Load())
|
||||||
|
if percent > 100 {
|
||||||
|
percent = 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if percent != p.lastPercent {
|
||||||
|
p.lastPercent = percent
|
||||||
|
|
||||||
|
fm := fontManager.Get()
|
||||||
|
font, ok := fm.GetFont("Roboto-Regular")
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
gl.DeleteTextures(1, &p.percentText.ID)
|
||||||
|
|
||||||
|
baseFontSizeStr, _ := db.GetConfigValue("init_font_size")
|
||||||
|
baseFontSize, _ := strconv.Atoi(baseFontSizeStr)
|
||||||
|
percentScale := float32(32) / float32(baseFontSize)
|
||||||
|
|
||||||
|
newText, err := NewStringItem(fmt.Sprintf("%d%%", percent), font, colors.White)
|
||||||
|
if err == nil {
|
||||||
|
newText.Scale(percentScale)
|
||||||
|
p.percentText = newText
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *DownloadProgressPage) HandleEvent(event *sdl.Event) error {
|
||||||
|
// Inescapable while downloading
|
||||||
|
if p.state != downloadStateFinished {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch e := (*event).(type) {
|
||||||
|
case *sdl.KeyboardEvent:
|
||||||
|
if e.Type != sdl.KEYDOWN {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if e.Keysym.Sym == sdl.K_RETURN {
|
||||||
|
if p.HasError.Load() || p.NextPage == nil {
|
||||||
|
p.App.SchedulePop()
|
||||||
|
} else {
|
||||||
|
p.App.SwitchPage(p.NextPage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *DownloadProgressPage) Render() error {
|
||||||
|
// Render title
|
||||||
|
p.RenderTexture(p.textShader, p.title.ID, p.title.Position(), p.title.RawDimensions(), p.title.Color)
|
||||||
|
|
||||||
|
// Progress bar background (dark gray)
|
||||||
|
bgColor := sdl.Color{R: 60, G: 60, B: 60, A: 255}
|
||||||
|
barPos := mgl32.Vec2{float32(p.barX), float32(p.barY)}
|
||||||
|
barDim := mgl32.Vec2{float32(p.barW), float32(p.barH)}
|
||||||
|
p.RenderSolidColorQuad(p.solidShader, barPos, barDim, bgColor)
|
||||||
|
|
||||||
|
// Progress bar foreground
|
||||||
|
progress := float32(0)
|
||||||
|
if p.TotalBytes.Load() > 0 {
|
||||||
|
progress = float32(p.BytesWritten.Load()) / float32(p.TotalBytes.Load())
|
||||||
|
if progress > 1.0 {
|
||||||
|
progress = 1.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if progress > 0 {
|
||||||
|
fgColor := sdl.Color{R: 255, G: 255, B: 255, A: 255}
|
||||||
|
fgDim := mgl32.Vec2{float32(p.barW) * progress, float32(p.barH)}
|
||||||
|
p.RenderSolidColorQuad(p.solidShader, barPos, fgDim, fgColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render percentage text below bar
|
||||||
|
p.percentText.X = p.ScreenCenterX - p.percentText.W/2
|
||||||
|
p.percentText.Y = p.barY - p.percentText.H - 20
|
||||||
|
p.RenderTexture(p.textShader, p.percentText.ID, p.percentText.Position(), p.percentText.RawDimensions(), p.percentText.Color)
|
||||||
|
|
||||||
|
// Show prompt when finished
|
||||||
|
if p.state == downloadStateFinished {
|
||||||
|
p.prompt.X = p.ScreenCenterX - p.prompt.W/2
|
||||||
|
p.prompt.Y = p.barY - p.percentText.H - 20 - p.prompt.H - 20
|
||||||
|
p.RenderTexture(p.textShader, p.prompt.ID, p.prompt.Position(), p.prompt.RawDimensions(), p.prompt.Color)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *DownloadProgressPage) Destroy() error {
|
||||||
|
keybinds.Enabled = true
|
||||||
|
gl.DeleteTextures(1, &p.title.ID)
|
||||||
|
gl.DeleteTextures(1, &p.prompt.ID)
|
||||||
|
gl.DeleteTextures(1, &p.percentText.ID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
package page
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(app ApplicationInterface) error {
|
||||||
|
|
||||||
|
if err := p.Base.Init(app); err != nil {
|
||||||
|
return fmt.Errorf("failed base page initialization: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.title = "Your Email Address"
|
||||||
|
|
||||||
|
// Get application font from database
|
||||||
|
applicationFont, err := db.GetConfigValue("application_font")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("retrieving application_font from db: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
emailAddress, err := db.GetConfigValue("email_address")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("retrieving email_address from db: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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:
|
||||||
|
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.App.UnwindToPage(&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
|
||||||
|
if err := db.SetConfigValue("email_address", p.inputText); err != nil {
|
||||||
|
return fmt.Errorf("failed to save email address: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.isFirstTimeSetup {
|
||||||
|
// First-time setup: navigate to License Key page
|
||||||
|
p.App.PushPage(&LicenseKeyPage{})
|
||||||
|
} else {
|
||||||
|
// Coming from settings: rewind back to settings
|
||||||
|
p.App.UnwindToPage(&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 = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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, err := db.GetConfigValue("application_font")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("retrieving application_font from db: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
// Get base font size from database
|
||||||
|
baseFontSizeStr, err := db.GetConfigValue("init_font_size")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("retrieving init_font_size from db: %w", err)
|
||||||
|
}
|
||||||
|
baseFontSize, err := strconv.Atoi(baseFontSizeStr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parsing init_font_size: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,458 @@
|
|||||||
|
package page
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
|
||||||
|
// isValidLicenseKey checks if the license key has exactly 16 alphanumeric characters
|
||||||
|
func isValidLicenseKey(key string) bool {
|
||||||
|
cleaned := strings.ReplaceAll(key, "-", "")
|
||||||
|
if len(cleaned) != 16 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range cleaned {
|
||||||
|
if !((r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init performs basic page initialization and creation of static assets.
|
||||||
|
func (p *LicenseKeyPage) Init(app ApplicationInterface) error {
|
||||||
|
|
||||||
|
if err := p.Base.Init(app); err != nil {
|
||||||
|
return fmt.Errorf("failed base page initialization: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.title = "License Key"
|
||||||
|
|
||||||
|
// Get application font from database
|
||||||
|
applicationFont, err := db.GetConfigValue("application_font")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("retrieving application_font from db: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
licenseKey, err := db.GetConfigValue("license_key")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("retrieving license_key from db: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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:
|
||||||
|
// 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.App.UnwindToPage(&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
|
||||||
|
if err := db.SetConfigValue("license_key", p.inputText); err != nil {
|
||||||
|
return fmt.Errorf("failed to save license key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.App.UnwindToPage(&Welcome{})
|
||||||
|
} else {
|
||||||
|
// Coming from settings: rewind back to settings
|
||||||
|
p.App.UnwindToPage(&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 = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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, err := db.GetConfigValue("application_font")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("retrieving application_font from db: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
// Get base font size from database
|
||||||
|
baseFontSizeStr, err := db.GetConfigValue("init_font_size")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("retrieving init_font_size from db: %w", err)
|
||||||
|
}
|
||||||
|
baseFontSize, err := strconv.Atoi(baseFontSizeStr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parsing init_font_size: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
package page
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"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/db"
|
||||||
|
"rhemawavetech.com/rhemawave/internal/fontManager"
|
||||||
|
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LogViewerPage struct {
|
||||||
|
Base
|
||||||
|
|
||||||
|
titleTextureID uint32
|
||||||
|
titleTextureWidth int32
|
||||||
|
titleTextureHeight int32
|
||||||
|
|
||||||
|
lines []string
|
||||||
|
lineTextureIDs []uint32
|
||||||
|
lineTextureWidths []int32
|
||||||
|
lineTextureHeights []int32
|
||||||
|
|
||||||
|
hintTextureID uint32
|
||||||
|
hintTextureWidth int32
|
||||||
|
hintTextureHeight int32
|
||||||
|
|
||||||
|
scrollOffset int32
|
||||||
|
maxScrollOffset int32
|
||||||
|
lineHeight int32
|
||||||
|
visibleHeight int32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *LogViewerPage) Init(app ApplicationInterface) error {
|
||||||
|
if err := p.Base.Init(app); err != nil {
|
||||||
|
return fmt.Errorf("failed base page initialization: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
logs, err := db.GetLogs(1000)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get logs: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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")...)
|
||||||
|
}
|
||||||
|
|
||||||
|
applicationFont, err := db.GetConfigValue("application_font")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("retrieving application_font from db: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
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.App.SchedulePop()
|
||||||
|
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 {
|
||||||
|
baseFontSizeStr, err := db.GetConfigValue("init_font_size")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("retrieving init_font_size from db: %w", err)
|
||||||
|
}
|
||||||
|
baseFontSize, err := strconv.Atoi(baseFontSizeStr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parsing init_font_size: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sm := shaderManager.Get()
|
||||||
|
textShader, _ := sm.Get("text")
|
||||||
|
|
||||||
|
white := sdl.Color{R: 255, G: 255, B: 255, A: 255}
|
||||||
|
grey := sdl.Color{R: 180, G: 180, B: 180, A: 255}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
contentColumnWidth := int32(float32(p.Base.ScreenWidth) * 0.55)
|
||||||
|
contentLeftX := p.Base.ScreenCenterX - contentColumnWidth/2
|
||||||
|
|
||||||
|
// 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}, 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}, grey)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
|
p.Base.RenderTexture(textShader, p.lineTextureIDs[i], mgl32.Vec2{float32(contentLeftX), float32(currentY)}, mgl32.Vec2{lineW, lineH}, white)
|
||||||
|
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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
// 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(app ApplicationInterface) error
|
||||||
|
HandleEvent(event *sdl.Event) error
|
||||||
|
Update(dt float32) error
|
||||||
|
Render() error
|
||||||
|
// Destroy cleans up the page's resources. The Application's page manager
|
||||||
|
// is responsible for calling this method
|
||||||
|
Destroy() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplicationInterface defines the set of methods that a page can use to
|
||||||
|
// interact with the main application.
|
||||||
|
type ApplicationInterface interface {
|
||||||
|
SwitchPage(p Page)
|
||||||
|
PushPage(p Page)
|
||||||
|
UnwindToPage(p Page)
|
||||||
|
GetDrawableSize() (int32, int32)
|
||||||
|
SchedulePop()
|
||||||
|
Quit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base is a helper struct that pages can embed to get common functionality.
|
||||||
|
type Base struct {
|
||||||
|
App ApplicationInterface
|
||||||
|
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(app ApplicationInterface) error {
|
||||||
|
p.App = app
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
screenWidth, screenHeight := p.App.GetDrawableSize()
|
||||||
|
p.Projection = mgl32.Ortho(0, float32(screenWidth), 0, float32(screenHeight), -1, 1)
|
||||||
|
p.ScreenHeight = screenHeight
|
||||||
|
p.ScreenWidth = screenWidth
|
||||||
|
p.ScreenCenterX = screenWidth / 2
|
||||||
|
p.ScreenCenterY = screenHeight / 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 }
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package page
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/go-gl/mathgl/mgl32"
|
||||||
|
"github.com/veandco/go-sdl2/sdl"
|
||||||
|
"rhemawavetech.com/rhemawave/internal/fontManager"
|
||||||
|
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PageItem defines the basic features of any item on any page
|
||||||
|
// This coupling makes creating, updating and managing pages easier
|
||||||
|
type PageItem struct {
|
||||||
|
ID uint32
|
||||||
|
W int32
|
||||||
|
H int32
|
||||||
|
X int32 // Horizontal position
|
||||||
|
Y int32 // Vertical position
|
||||||
|
Color sdl.Color
|
||||||
|
// index int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scale updates the W and H of an item based on a scale factor (usually determined by base font size / desired font size)
|
||||||
|
func (i *PageItem) Scale(scale float32) {
|
||||||
|
i.W = int32(float32(i.W) * scale)
|
||||||
|
i.H = int32(float32(i.H) * scale)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dimensions returns an mgl32.Vec2 containing the W and H of an item, respectively
|
||||||
|
func (i *PageItem) Dimensions(scale float32) mgl32.Vec2 {
|
||||||
|
return mgl32.Vec2{float32(float32(i.W) * scale), float32(float32(i.H) * scale)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RawDimensions returns a mgl32.Vec2 containing the W and H of an item, without scaling
|
||||||
|
// This method will replace Dimensions in the future. Future PageItems should first call Scale(), then RawDimensions()
|
||||||
|
func (i *PageItem) RawDimensions() mgl32.Vec2 {
|
||||||
|
return mgl32.Vec2{float32(i.W), float32(i.H)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Position returns an mgl32.Vec2 contianing the X and Y positions of an item, respectively
|
||||||
|
func (i *PageItem) Position() mgl32.Vec2 {
|
||||||
|
return mgl32.Vec2{float32(i.X), float32(i.Y)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StringItem is an extended PageItem that contains a string content and string specific methods
|
||||||
|
type StringItem struct {
|
||||||
|
PageItem
|
||||||
|
content string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStringItem creates a new StringItem with the given content, font, and color.
|
||||||
|
// It initializes the texture ID, width, and height by creating a string texture using the font
|
||||||
|
// and shader manager.
|
||||||
|
func NewStringItem(content string, font *fontManager.FontEntry, color sdl.Color) (StringItem, error) {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
fm := fontManager.Get()
|
||||||
|
sm := shaderManager.Get()
|
||||||
|
|
||||||
|
shader, ok := sm.Get("text")
|
||||||
|
if !ok {
|
||||||
|
return StringItem{}, fmt.Errorf("failed to fetch shader")
|
||||||
|
}
|
||||||
|
|
||||||
|
id, width, height, err := fm.CreateStringTexture(font.Name, content, shader)
|
||||||
|
if err != nil {
|
||||||
|
return StringItem{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
newStringItem := StringItem{
|
||||||
|
PageItem: PageItem{
|
||||||
|
ID: id,
|
||||||
|
W: width,
|
||||||
|
H: height,
|
||||||
|
Color: color,
|
||||||
|
},
|
||||||
|
content: content,
|
||||||
|
}
|
||||||
|
|
||||||
|
return newStringItem, err
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
package page
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"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 ProclamationOptions struct {
|
||||||
|
Base
|
||||||
|
font *fontManager.FontEntry
|
||||||
|
textShader *shaderManager.Shader
|
||||||
|
solidShader *shaderManager.Shader
|
||||||
|
title StringItem
|
||||||
|
prompt StringItem
|
||||||
|
proclamations []StringItem
|
||||||
|
proclamationIDs []uint // Database IDs corresponding to each proclamation
|
||||||
|
activeMap map[int]bool // Maps display index to selected state
|
||||||
|
highlightIndex int
|
||||||
|
verticalMargin int32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ProclamationOptions) Init(app ApplicationInterface) error {
|
||||||
|
var err error
|
||||||
|
p.verticalMargin = int32(20)
|
||||||
|
p.activeMap = make(map[int]bool)
|
||||||
|
p.proclamationIDs = make([]uint, 0)
|
||||||
|
|
||||||
|
// Get font
|
||||||
|
fm := fontManager.Get()
|
||||||
|
font, ok := fm.GetFont("Roboto-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(app); err != nil {
|
||||||
|
return fmt.Errorf("base init failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get font size from database
|
||||||
|
baseFontSizeStr, err := db.GetConfigValue("init_font_size")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("retrieving init_font_size from db: %w", err)
|
||||||
|
}
|
||||||
|
baseFontSize, err := strconv.Atoi(baseFontSizeStr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parsing init_font_size %q: %w", baseFontSizeStr, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate scales
|
||||||
|
titleScale := float32(64) / float32(baseFontSize)
|
||||||
|
itemScale := float32(32) / float32(baseFontSize)
|
||||||
|
|
||||||
|
// Create title
|
||||||
|
p.title, err = NewStringItem("Select Proclamations", p.font, colors.White)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating title: %w", err)
|
||||||
|
}
|
||||||
|
p.title.Scale(titleScale)
|
||||||
|
|
||||||
|
// Create prompt
|
||||||
|
p.prompt, err = NewStringItem("Arrow Keys to navigate | Space to toggle | F2 to run | Enter to return", p.font, colors.White)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating prompt: %w", err)
|
||||||
|
}
|
||||||
|
p.prompt.Scale(itemScale)
|
||||||
|
|
||||||
|
// Load proclamations from database
|
||||||
|
proclamations, err := db.GetProclamations()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("retrieving proclamations from db: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create UI items for each available proclamation
|
||||||
|
for _, proclamation := range proclamations {
|
||||||
|
if !proclamation.Available {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
label, err := NewStringItem(proclamation.Title, p.font, colors.White)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating proclamation label: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
label.Scale(itemScale)
|
||||||
|
p.proclamations = append(p.proclamations, label)
|
||||||
|
p.proclamationIDs = append(p.proclamationIDs, proclamation.ID)
|
||||||
|
|
||||||
|
// Set initial selection state
|
||||||
|
if proclamation.Selected {
|
||||||
|
p.activeMap[len(p.proclamations)-1] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p.layout()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ProclamationOptions) layout() {
|
||||||
|
// Position title at bottom
|
||||||
|
p.title.X = p.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 - p.prompt.W/2
|
||||||
|
p.prompt.Y = p.title.Y - p.verticalMargin*2 - p.prompt.H
|
||||||
|
|
||||||
|
// Position proclamation items stacked upward from prompt
|
||||||
|
for i := range p.proclamations {
|
||||||
|
p.proclamations[i].X = p.ScreenCenterX - p.proclamations[i].W/2
|
||||||
|
if i == 0 {
|
||||||
|
p.proclamations[i].Y = p.prompt.Y - p.verticalMargin*2 - p.proclamations[i].H
|
||||||
|
} else {
|
||||||
|
p.proclamations[i].Y = p.proclamations[i-1].Y - p.proclamations[i].H - 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ProclamationOptions) 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.proclamations)-1 {
|
||||||
|
p.highlightIndex++
|
||||||
|
}
|
||||||
|
case sdl.K_BACKSPACE:
|
||||||
|
p.App.UnwindToPage(&Settings{})
|
||||||
|
case sdl.K_SPACE:
|
||||||
|
// Toggle selection for highlighted item
|
||||||
|
if p.activeMap[p.highlightIndex] {
|
||||||
|
delete(p.activeMap, p.highlightIndex)
|
||||||
|
} else {
|
||||||
|
p.activeMap[p.highlightIndex] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Immediately save to database
|
||||||
|
if err := p.toggleProclamation(p.highlightIndex); err != nil {
|
||||||
|
return fmt.Errorf("failed to toggle proclamation: %w", err)
|
||||||
|
}
|
||||||
|
case sdl.K_RETURN:
|
||||||
|
// Just return to settings - no saving needed
|
||||||
|
p.App.UnwindToPage(&Settings{})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// toggleProclamation immediately updates the database for the proclamation at the given display index
|
||||||
|
func (p *ProclamationOptions) toggleProclamation(displayIndex int) error {
|
||||||
|
if displayIndex < 0 || displayIndex >= len(p.proclamationIDs) {
|
||||||
|
return fmt.Errorf("invalid display index %d", displayIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
dbID := p.proclamationIDs[displayIndex]
|
||||||
|
isSelected := p.activeMap[displayIndex]
|
||||||
|
|
||||||
|
// Get the proclamation from database
|
||||||
|
var proclamation db.Proclamations
|
||||||
|
if err := db.DB.First(&proclamation, dbID).Error; err != nil {
|
||||||
|
return fmt.Errorf("retrieving proclamation %d: %w", dbID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the Selected field
|
||||||
|
proclamation.Selected = isSelected
|
||||||
|
if err := db.DB.Save(&proclamation).Error; err != nil {
|
||||||
|
return fmt.Errorf("updating proclamation %d: %w", dbID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ProclamationOptions) Render() error {
|
||||||
|
// Always 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)
|
||||||
|
|
||||||
|
// No proclamations to display
|
||||||
|
if len(p.proclamations) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render highlight box behind selected item
|
||||||
|
p.RenderSolidColorQuad(
|
||||||
|
p.solidShader,
|
||||||
|
p.proclamations[p.highlightIndex].Position(),
|
||||||
|
p.proclamations[p.highlightIndex].RawDimensions(),
|
||||||
|
colors.White,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Render proclamation items with appropriate colors
|
||||||
|
for i, item := range p.proclamations {
|
||||||
|
if p.activeMap[i] {
|
||||||
|
// Selected items are green
|
||||||
|
item.Color = colors.Green
|
||||||
|
} else if i == p.highlightIndex {
|
||||||
|
// Highlighted item is black (shows on white background)
|
||||||
|
item.Color = colors.Black
|
||||||
|
} else {
|
||||||
|
// Normal items are white
|
||||||
|
item.Color = colors.White
|
||||||
|
}
|
||||||
|
p.RenderTexture(p.textShader, item.ID, item.Position(), item.RawDimensions(), item.Color)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ProclamationOptions) Destroy() error {
|
||||||
|
gl.DeleteTextures(1, &p.title.ID)
|
||||||
|
gl.DeleteTextures(1, &p.prompt.ID)
|
||||||
|
for _, item := range p.proclamations {
|
||||||
|
gl.DeleteTextures(1, &item.ID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,697 @@
|
|||||||
|
// ATTENTION: TED
|
||||||
|
//
|
||||||
|
// # Integrated Multi-Domain Rhema Wave Generation System
|
||||||
|
//
|
||||||
|
// This module implements the complete Rhema Wave generation system by synchronizing multiple
|
||||||
|
// subsystems that operate simultaneously across spatial, temporal, and chromatic
|
||||||
|
// domains to produce a unified frequency-structured visual output for subliminal proclamation display.
|
||||||
|
//
|
||||||
|
// The technology addresses the fundamental challenge of presenting textual proclamation content
|
||||||
|
// in a format optimized for subconscious processing rather than conscious reading. Conventional
|
||||||
|
// subliminal text systems use isolated techniques (simple masking, single-frequency flashing, or
|
||||||
|
// uniform scrolling) that operate in only one domain. Our system integrates four mathematically-
|
||||||
|
// synchronized subsystems, each generating Fibonacci-based patterns in different domains, creating
|
||||||
|
// a compound frequency signature designed to enhance subconscious absorption while maintaining
|
||||||
|
// conscious illegibility.
|
||||||
|
//
|
||||||
|
// The system architecture integrates:
|
||||||
|
//
|
||||||
|
// 1. Spatial Character Transformation - Each rendered character undergoes Fibonacci-based
|
||||||
|
// block scrambling (described separately), creating local spatial frequency patterns at
|
||||||
|
// the character level.
|
||||||
|
//
|
||||||
|
// 2. Spatial Line Distribution - Text lines contain varying word counts following an oscillating
|
||||||
|
// Fibonacci sequence (1, 1, 2, 3, 5, 8, 13... then reversing), creating a macro-level
|
||||||
|
// two-dimensional wave pattern in the display geometry and ensuring balanced temporal
|
||||||
|
// exposure of proclamation content segments.
|
||||||
|
//
|
||||||
|
// 3. Temporal Color Modulation - Sequential words receive colors from an intent-based palette,
|
||||||
|
// with color duration determined by an oscillating Fibonacci sequence, creating rhythmic
|
||||||
|
// chromatic variation synchronized with natural mathematical progression.
|
||||||
|
//
|
||||||
|
// 4. Temporal Velocity Modulation - Display scroll velocity oscillates between negative and
|
||||||
|
// positive extremes with acceleration/deceleration rates and hold periods, creating a
|
||||||
|
// "breathing" temporal rhythm for the entire visual field.
|
||||||
|
//
|
||||||
|
// The key innovation lies in the temporal and mathematical synchronization of these subsystems.
|
||||||
|
// All four operate on Fibonacci-derived parameters, creating harmonic relationships between the
|
||||||
|
// spatial frequencies (character scrambling, line wave patterns), chromatic frequencies (color
|
||||||
|
// duration oscillation), and temporal frequencies (scroll velocity modulation). This multi-domain
|
||||||
|
// integration produces what we term the "Rhema Wave" - a compound frequency signature where
|
||||||
|
// spatial, temporal, and chromatic patterns reinforce each other through mathematical harmony.
|
||||||
|
//
|
||||||
|
// Key differentiators from existing solutions:
|
||||||
|
// - Integrates four distinct Fibonacci-based frequency generators in a single system
|
||||||
|
// - Synchronizes pattern generation across spatial, temporal, and chromatic domains
|
||||||
|
// - Creates compound frequency signatures rather than isolated single-domain effects
|
||||||
|
// - Maintains character-specific and content-specific mathematical structure throughout
|
||||||
|
// - Uses bidirectional oscillation across multiple subsystems for rhythmic coherence
|
||||||
|
// - Combines macro-level (line patterns, velocity) and micro-level (character, color) modulation
|
||||||
|
//
|
||||||
|
// The system loads proclamation text content, processes it through all four subsystems simultaneously,
|
||||||
|
// and generates a continuously scrolling display where every element - from individual character
|
||||||
|
// pixels to overall scroll rhythm - contributes to the unified Rhema Wave output. The mathematical
|
||||||
|
// relationships between subsystems create interference patterns and harmonic resonances designed
|
||||||
|
// to enhance subconscious processing of the proclamation content while maintaining conscious
|
||||||
|
// illegibility through the character scrambling subsystem.
|
||||||
|
package page
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"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/mixer"
|
||||||
|
colorPattern "rhemawavetech.com/rhemawave/internal/pattern/color"
|
||||||
|
linePattern "rhemawavetech.com/rhemawave/internal/pattern/line"
|
||||||
|
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Line struct {
|
||||||
|
words []StringItem
|
||||||
|
width int32
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScrollerPage struct {
|
||||||
|
Base
|
||||||
|
|
||||||
|
words []string
|
||||||
|
wordIndex int
|
||||||
|
lines *LineDeque
|
||||||
|
lineHeight int32
|
||||||
|
fontScale float32
|
||||||
|
shader *shaderManager.Shader
|
||||||
|
activePattern linePattern.LinePattern
|
||||||
|
font *fontManager.FontEntry
|
||||||
|
spaceWidth int32
|
||||||
|
lineCtx *linePattern.LineRenderContext
|
||||||
|
colorPattern *colorPattern.ColorPattern
|
||||||
|
scrollOffset float32
|
||||||
|
scrollY float32
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ScrollerPage) Init(app ApplicationInterface) error {
|
||||||
|
p.increment = 100.0
|
||||||
|
p.velocity = 800.0
|
||||||
|
p.SetAutoVelocity(true, -4000.0, 4000.0, 100.0, 2.5, 6.0)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
p.Base.Init(app)
|
||||||
|
|
||||||
|
// Get text shader
|
||||||
|
textShader, ok := sm.Get("text")
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("text shader not found")
|
||||||
|
}
|
||||||
|
p.shader = textShader
|
||||||
|
|
||||||
|
// Load proclamation words from database
|
||||||
|
if err := p.loadProclamations(); err != nil {
|
||||||
|
// Don't stop execution, just show fallback words
|
||||||
|
p.words = strings.Fields("No Proclamations Selected!")
|
||||||
|
}
|
||||||
|
p.wordIndex = 0
|
||||||
|
|
||||||
|
// Get line pattern from database
|
||||||
|
linePatternName, err := db.GetConfigValue("line_pattern")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("retrieving line_pattern from db: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.activePattern, err = linePattern.Get(linePatternName)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to load line pattern '%s': %w", linePatternName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize color pattern
|
||||||
|
var baseColors = []sdl.Color{
|
||||||
|
colors.Tan,
|
||||||
|
colors.PeachPuff,
|
||||||
|
colors.LightCyan,
|
||||||
|
colors.LightSeaGreen,
|
||||||
|
colors.MediumTurquoise,
|
||||||
|
colors.DarkCyan,
|
||||||
|
colors.MediumPurple,
|
||||||
|
}
|
||||||
|
p.colorPattern = colorPattern.NewColorPattern(baseColors)
|
||||||
|
|
||||||
|
// Get font sizes from database (optimize: load once, cache as int32)
|
||||||
|
standardFontSizeStr, err := db.GetConfigValue("standard_font_size")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("retrieving standard_font_size from db: %w", err)
|
||||||
|
}
|
||||||
|
standardFontSize, err := strconv.Atoi(standardFontSizeStr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parsing standard_font_size: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
baseFontSizeStr, err := db.GetConfigValue("init_font_size")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("retrieving init_font_size from db: %w", err)
|
||||||
|
}
|
||||||
|
baseFontSize, err := strconv.Atoi(baseFontSizeStr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parsing init_font_size: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Font setup (optimized: calculate once, reuse)
|
||||||
|
p.fontScale = float32(standardFontSize) / float32(baseFontSize)
|
||||||
|
nLines := int(p.ScreenHeight / int32(standardFontSize))
|
||||||
|
p.lines = NewLineDeque(nLines * 2)
|
||||||
|
|
||||||
|
// Calculate space width once
|
||||||
|
spaceGlyph, ok := fm.GetGlyph(p.font.Name, ' ')
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("failed to get space glyph")
|
||||||
|
}
|
||||||
|
p.spaceWidth = int32(float32(spaceGlyph.Advance) * p.fontScale)
|
||||||
|
|
||||||
|
// Get line height from a sample string
|
||||||
|
tmpStrID, _, H, err := fm.NewCreateStringTexture(p.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: p.font,
|
||||||
|
FontScale: p.fontScale,
|
||||||
|
SpaceWidth: p.spaceWidth,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create initial lines
|
||||||
|
for i := 0; i < nLines; i++ {
|
||||||
|
if err := p.createLine(i, true); err != nil {
|
||||||
|
return fmt.Errorf("failed to create initial line %d: %w", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audio setup
|
||||||
|
audioDeviceName, err := db.GetConfigValue("audio_device_name")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("retrieving audio_device_name from db: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switch audio devices if needed
|
||||||
|
if mixer.CurrentDevice() != audioDeviceName {
|
||||||
|
if err := mixer.SwitchDevice(audioDeviceName); err != nil {
|
||||||
|
} else {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start audio playback
|
||||||
|
selectedAudio, err := db.GetConfigValue("selected_audio")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("retrieving selected_audio from db: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assetsDir, err := db.GetConfigValue("assets_dir")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("retrieving assets_dir from db: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if selectedAudio == "" {
|
||||||
|
} else {
|
||||||
|
if err := mixer.Play(filepath.Join(assetsDir, selectedAudio), true); err != nil {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
currentDevice := mixer.CurrentDevice()
|
||||||
|
if currentDevice == "" {
|
||||||
|
currentDevice = "default"
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadProclamations loads selected proclamations from the database
|
||||||
|
func (p *ScrollerPage) loadProclamations() error {
|
||||||
|
p.words = []string{}
|
||||||
|
|
||||||
|
// Get all selected proclamations from database
|
||||||
|
proclamations, err := db.GetProclamations()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("retrieving proclamations from db: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build word list from selected, available proclamations
|
||||||
|
for _, proclamation := range proclamations {
|
||||||
|
if proclamation.Selected && proclamation.Available {
|
||||||
|
// Split content into words and add to list
|
||||||
|
words := strings.Fields(proclamation.Content)
|
||||||
|
p.words = append(p.words, words...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(p.words) == 0 {
|
||||||
|
p.words = strings.Fields("No Proclamations Selected!")
|
||||||
|
return fmt.Errorf("no proclamations selected or available")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.activePattern.GetNextLine(p.words, &p.wordIndex, p.lineCtx)
|
||||||
|
line := Line{words: []StringItem{}}
|
||||||
|
|
||||||
|
for _, word := range words {
|
||||||
|
if word == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
item, err := NewStringItem(word, p.font, p.colorPattern.Color())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to create string item for word '%s': %w", word, err)
|
||||||
|
}
|
||||||
|
item.W = int32(float32(item.W) * p.fontScale)
|
||||||
|
item.H = int32(float32(item.H) * p.fontScale)
|
||||||
|
|
||||||
|
if len(line.words) > 0 {
|
||||||
|
line.width += p.spaceWidth
|
||||||
|
}
|
||||||
|
line.words = append(line.words, item)
|
||||||
|
line.width += item.W
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate horizontal position (centered)
|
||||||
|
startX := p.ScreenCenterX - (line.width / 2)
|
||||||
|
|
||||||
|
// Calculate vertical position
|
||||||
|
var baseY int32
|
||||||
|
if isInitial {
|
||||||
|
baseY = int32(p.scrollY) - int32(p.lineHeight)*int32(index)
|
||||||
|
} else {
|
||||||
|
baseY = 0 // Will be set by caller based on context
|
||||||
|
}
|
||||||
|
|
||||||
|
// Position all words in the line
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
case sdl.K_a:
|
||||||
|
p.autoVelocity = !p.autoVelocity
|
||||||
|
if p.autoVelocity {
|
||||||
|
// Reset to initial state when enabling
|
||||||
|
p.velocity = p.velocityFloor
|
||||||
|
p.velocityDirection = 1
|
||||||
|
p.velocityState = 0
|
||||||
|
p.velocityTimer = 0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
old := p.lines.PopFront()
|
||||||
|
for _, word := range old.words {
|
||||||
|
gl.DeleteTextures(1, &word.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
old := p.lines.PopBack()
|
||||||
|
for _, word := range old.words {
|
||||||
|
gl.DeleteTextures(1, &word.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
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.activePattern.GetNextLine(p.words, &p.wordIndex, p.lineCtx)
|
||||||
|
line := Line{words: []StringItem{}}
|
||||||
|
|
||||||
|
for _, word := range newWords {
|
||||||
|
if word == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
item, err := NewStringItem(word, p.font, p.colorPattern.Color())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to create string item for word '%s': %w", word, err)
|
||||||
|
}
|
||||||
|
item.W = int32(float32(item.W) * p.fontScale)
|
||||||
|
item.H = int32(float32(item.H) * p.fontScale)
|
||||||
|
|
||||||
|
if len(line.words) > 0 {
|
||||||
|
line.width += p.spaceWidth
|
||||||
|
}
|
||||||
|
line.words = append(line.words, item)
|
||||||
|
line.width += item.W
|
||||||
|
}
|
||||||
|
|
||||||
|
startX := p.ScreenCenterX - (line.width / 2)
|
||||||
|
|
||||||
|
// Position relative to last existing line
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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.activePattern.GetNextLine(p.words, &p.wordIndex, p.lineCtx)
|
||||||
|
line := Line{words: []StringItem{}}
|
||||||
|
|
||||||
|
for _, word := range newWords {
|
||||||
|
if word == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
item, err := NewStringItem(word, p.font, p.colorPattern.Color())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to create string item for word '%s': %w", word, err)
|
||||||
|
}
|
||||||
|
item.W = int32(float32(item.W) * p.fontScale)
|
||||||
|
item.H = int32(float32(item.H) * p.fontScale)
|
||||||
|
|
||||||
|
if len(line.words) > 0 {
|
||||||
|
line.width += p.spaceWidth
|
||||||
|
}
|
||||||
|
line.words = append(line.words, item)
|
||||||
|
line.width += item.W
|
||||||
|
}
|
||||||
|
|
||||||
|
startX := p.ScreenCenterX - (line.width / 2)
|
||||||
|
|
||||||
|
// Position relative to first existing line
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
mixer.Stop(1000)
|
||||||
|
|
||||||
|
// GL cleanup — delete all text textures
|
||||||
|
for i := 0; i < p.lines.Len(); i++ {
|
||||||
|
line := p.lines.Get(i)
|
||||||
|
for j := range line.words {
|
||||||
|
if id := line.words[j].ID; id != 0 {
|
||||||
|
gl.DeleteTextures(1, &line.words[j].ID)
|
||||||
|
line.words[j].ID = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
gl.Flush()
|
||||||
|
|
||||||
|
// Drop references for GC
|
||||||
|
p.lines = nil
|
||||||
|
p.words = nil
|
||||||
|
p.lineCtx = nil
|
||||||
|
p.font = nil
|
||||||
|
p.shader = nil
|
||||||
|
p.colorPattern = 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
package page
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"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 {
|
||||||
|
page Page
|
||||||
|
label StringItem
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Settings) Init(app ApplicationInterface) error {
|
||||||
|
var err error
|
||||||
|
p.verticalMargin = int32(20)
|
||||||
|
|
||||||
|
// Get font
|
||||||
|
fm := fontManager.Get()
|
||||||
|
font, ok := fm.GetFont("Roboto-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(app); 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{
|
||||||
|
page: &ProclamationOptions{},
|
||||||
|
label: proclamationsLabel,
|
||||||
|
})
|
||||||
|
|
||||||
|
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{
|
||||||
|
page: &WiFiSetupPage{},
|
||||||
|
label: wifiLabel,
|
||||||
|
})
|
||||||
|
|
||||||
|
audioLabel, err := NewStringItem("Audio Device", font, colors.White)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating audio label: %w", err)
|
||||||
|
}
|
||||||
|
p.options = append(p.options, Option{
|
||||||
|
page: &AudioDevices{},
|
||||||
|
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{
|
||||||
|
page: &EmailAddressPage{},
|
||||||
|
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{
|
||||||
|
page: &LicenseKeyPage{},
|
||||||
|
label: licenseKeyLabel,
|
||||||
|
})
|
||||||
|
|
||||||
|
logsLabel, err := NewStringItem("Logs", font, colors.White)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating logs label: %w", err)
|
||||||
|
}
|
||||||
|
p.options = append(p.options, Option{
|
||||||
|
page: &LogViewerPage{},
|
||||||
|
label: logsLabel,
|
||||||
|
})
|
||||||
|
|
||||||
|
updateLabel, err := NewStringItem("Check for Updates", font, colors.White)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating update label: %w", err)
|
||||||
|
}
|
||||||
|
p.options = append(p.options, Option{
|
||||||
|
page: &UpdateCheckPage{},
|
||||||
|
label: updateLabel,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get base font size from database
|
||||||
|
baseFontSizeStr, err := db.GetConfigValue("init_font_size")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("retrieving init_font_size from db: %w", err)
|
||||||
|
}
|
||||||
|
baseFontSize, err := strconv.Atoi(baseFontSizeStr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parsing init_font_size: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 - p.prompt.W/2
|
||||||
|
p.prompt.Y = p.title.Y - p.verticalMargin*2 - p.prompt.H
|
||||||
|
|
||||||
|
// Position options stacked upward from prompt
|
||||||
|
for i, option := range p.options {
|
||||||
|
p.options[i].label.X = p.ScreenCenterX - option.label.W/2
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.App.SwitchPage(selectedOption.page)
|
||||||
|
}
|
||||||
|
case sdl.K_BACKSPACE:
|
||||||
|
p.App.SwitchPage(&Welcome{})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
package page
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"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/db"
|
||||||
|
"rhemawavetech.com/rhemawave/internal/fontManager"
|
||||||
|
"rhemawavetech.com/rhemawave/internal/logger"
|
||||||
|
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TOSPage struct {
|
||||||
|
Base
|
||||||
|
|
||||||
|
Title string
|
||||||
|
Content string
|
||||||
|
ConfigKey string
|
||||||
|
|
||||||
|
titleTextureID uint32
|
||||||
|
titleTextureWidth int32
|
||||||
|
titleTextureHeight int32
|
||||||
|
|
||||||
|
lines []string
|
||||||
|
lineTextureIDs []uint32
|
||||||
|
lineTextureWidths []int32
|
||||||
|
lineTextureHeights []int32
|
||||||
|
|
||||||
|
promptTextureID uint32
|
||||||
|
promptTextureWidth int32
|
||||||
|
promptTextureHeight int32
|
||||||
|
|
||||||
|
scrollHintTextureID uint32
|
||||||
|
scrollHintTextureWidth int32
|
||||||
|
scrollHintTextureHeight int32
|
||||||
|
|
||||||
|
scrollOffset int32
|
||||||
|
maxScrollOffset int32
|
||||||
|
lineHeight int32
|
||||||
|
visibleHeight int32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *TOSPage) Init(app ApplicationInterface) error {
|
||||||
|
if err := p.Base.Init(app); err != nil {
|
||||||
|
return fmt.Errorf("failed base page initialization: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.lines = parseHTMLToLines(p.Content, 60)
|
||||||
|
|
||||||
|
applicationFont, err := db.GetConfigValue("application_font")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("retrieving application_font from db: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fm := fontManager.Get()
|
||||||
|
textShader, ok := shaderManager.Get().Get("text")
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("unable to fetch text shader")
|
||||||
|
}
|
||||||
|
|
||||||
|
titleTextureID, titleWidth, titleHeight, err := fm.CreateStringTexture(applicationFont, p.Title, textShader)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create title texture: %w", err)
|
||||||
|
}
|
||||||
|
p.titleTextureID = titleTextureID
|
||||||
|
p.titleTextureWidth = titleWidth
|
||||||
|
p.titleTextureHeight = titleHeight
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
promptText := "Press Y to Accept | Press N to Decline"
|
||||||
|
promptTextureID, promptWidth, promptHeight, err := fm.CreateStringTexture(applicationFont, promptText, textShader)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create prompt texture: %w", err)
|
||||||
|
}
|
||||||
|
p.promptTextureID = promptTextureID
|
||||||
|
p.promptTextureWidth = promptWidth
|
||||||
|
p.promptTextureHeight = promptHeight
|
||||||
|
|
||||||
|
scrollHintText := "Scroll down: Arrow Keys / PgDn"
|
||||||
|
scrollHintID, scrollHintW, scrollHintH, err := fm.CreateStringTexture(applicationFont, scrollHintText, textShader)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create scroll hint texture: %w", err)
|
||||||
|
}
|
||||||
|
p.scrollHintTextureID = scrollHintID
|
||||||
|
p.scrollHintTextureWidth = scrollHintW
|
||||||
|
p.scrollHintTextureHeight = scrollHintH
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseHTMLToLines converts HTML content to plain-text lines for rendering.
|
||||||
|
// Supported tags: <p>, <br>, <li>, <h1>-<h6>. All other tags are stripped.
|
||||||
|
// HTML entities & < > " ' are decoded.
|
||||||
|
func parseHTMLToLines(html string, maxCharsPerLine int) []string {
|
||||||
|
var lines []string
|
||||||
|
|
||||||
|
// Replace <br>, <br/>, <br /> with newline markers
|
||||||
|
br := strings.NewReplacer(
|
||||||
|
"<br>", "\n", "<br/>", "\n", "<br />", "\n",
|
||||||
|
"<BR>", "\n", "<BR/>", "\n", "<BR />", "\n",
|
||||||
|
)
|
||||||
|
html = br.Replace(html)
|
||||||
|
|
||||||
|
// Process block-level tags
|
||||||
|
// Replace </p>, </h1>-</h6> with paragraph break marker
|
||||||
|
for _, tag := range []string{"</p>", "</h1>", "</h2>", "</h3>", "</h4>", "</h5>", "</h6>"} {
|
||||||
|
html = strings.ReplaceAll(html, tag, "\n\n")
|
||||||
|
html = strings.ReplaceAll(html, strings.ToUpper(tag), "\n\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace <li> with list prefix marker
|
||||||
|
html = strings.ReplaceAll(html, "<li>", "\n- ")
|
||||||
|
html = strings.ReplaceAll(html, "<LI>", "\n- ")
|
||||||
|
|
||||||
|
// Strip all remaining HTML tags
|
||||||
|
var stripped strings.Builder
|
||||||
|
inTag := false
|
||||||
|
for _, r := range html {
|
||||||
|
if r == '<' {
|
||||||
|
inTag = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if r == '>' {
|
||||||
|
inTag = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !inTag {
|
||||||
|
stripped.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
text := stripped.String()
|
||||||
|
|
||||||
|
// Decode HTML entities
|
||||||
|
text = strings.ReplaceAll(text, "&", "&")
|
||||||
|
text = strings.ReplaceAll(text, "<", "<")
|
||||||
|
text = strings.ReplaceAll(text, ">", ">")
|
||||||
|
text = strings.ReplaceAll(text, """, "\"")
|
||||||
|
text = strings.ReplaceAll(text, "'", "'")
|
||||||
|
|
||||||
|
// Split into segments on double-newline (paragraph breaks) and single newline (line breaks)
|
||||||
|
paragraphs := strings.Split(text, "\n\n")
|
||||||
|
for i, para := range paragraphs {
|
||||||
|
para = strings.TrimSpace(para)
|
||||||
|
if para == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle single newlines within a paragraph (from <br> tags)
|
||||||
|
sublines := strings.Split(para, "\n")
|
||||||
|
for _, sub := range sublines {
|
||||||
|
sub = strings.TrimSpace(sub)
|
||||||
|
if sub == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
wrapped := wrapText(sub, maxCharsPerLine)
|
||||||
|
lines = append(lines, strings.Split(wrapped, "\n")...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add paragraph break (empty line) between paragraphs
|
||||||
|
if i < len(paragraphs)-1 {
|
||||||
|
lines = append(lines, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
func wrapText(text string, maxCharsPerLine int) string {
|
||||||
|
var result strings.Builder
|
||||||
|
words := strings.Fields(text)
|
||||||
|
lineLen := 0
|
||||||
|
|
||||||
|
for _, word := range words {
|
||||||
|
if lineLen+len(word)+1 > maxCharsPerLine && lineLen > 0 {
|
||||||
|
result.WriteString("\n")
|
||||||
|
lineLen = 0
|
||||||
|
}
|
||||||
|
if lineLen > 0 {
|
||||||
|
result.WriteString(" ")
|
||||||
|
lineLen++
|
||||||
|
}
|
||||||
|
result.WriteString(word)
|
||||||
|
lineLen += len(word)
|
||||||
|
}
|
||||||
|
return result.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *TOSPage) HandleEvent(event *sdl.Event) error {
|
||||||
|
actualEvent := *event
|
||||||
|
|
||||||
|
switch e := actualEvent.(type) {
|
||||||
|
case *sdl.KeyboardEvent:
|
||||||
|
if e.Type == sdl.KEYDOWN {
|
||||||
|
switch e.Keysym.Sym {
|
||||||
|
case sdl.K_n:
|
||||||
|
logger.Info("User declined TOS (%s); exiting", p.ConfigKey)
|
||||||
|
p.App.Quit()
|
||||||
|
case sdl.K_y:
|
||||||
|
if p.scrollOffset < p.maxScrollOffset {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
logger.Info("User accepted TOS (%s)", p.ConfigKey)
|
||||||
|
if err := db.SetConfigValue(p.ConfigKey, "true"); err != nil {
|
||||||
|
return fmt.Errorf("failed to save TOS acceptance (%s): %w", p.ConfigKey, err)
|
||||||
|
}
|
||||||
|
p.App.SchedulePop()
|
||||||
|
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 *TOSPage) Update(dt float32) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *TOSPage) Render() error {
|
||||||
|
baseFontSizeStr, err := db.GetConfigValue("init_font_size")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("retrieving init_font_size from db: %w", err)
|
||||||
|
}
|
||||||
|
baseFontSize, err := strconv.Atoi(baseFontSizeStr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parsing init_font_size: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sm := shaderManager.Get()
|
||||||
|
textShader, _ := sm.Get("text")
|
||||||
|
|
||||||
|
white := sdl.Color{R: 255, G: 255, B: 255, A: 255}
|
||||||
|
grey := sdl.Color{R: 180, G: 180, B: 180, A: 255}
|
||||||
|
|
||||||
|
titleFontSize := int32(48)
|
||||||
|
titleScale := float32(titleFontSize) / float32(baseFontSize)
|
||||||
|
contentFontSize := int32(32)
|
||||||
|
contentScale := float32(contentFontSize) / float32(baseFontSize)
|
||||||
|
promptFontSize := int32(20)
|
||||||
|
promptScale := float32(promptFontSize) / float32(baseFontSize)
|
||||||
|
|
||||||
|
margin := int32(40)
|
||||||
|
lineSpacing := int32(4)
|
||||||
|
paragraphSpacing := int32(16)
|
||||||
|
p.lineHeight = contentFontSize + lineSpacing
|
||||||
|
|
||||||
|
// Center a content column at ~55% of screen width, left-align text within it
|
||||||
|
contentColumnWidth := int32(float32(p.Base.ScreenWidth) * 0.55)
|
||||||
|
contentLeftX := p.Base.ScreenCenterX - contentColumnWidth/2
|
||||||
|
|
||||||
|
// 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}, white)
|
||||||
|
|
||||||
|
// Prompt dimensions (calculate even if not shown, for content bounds)
|
||||||
|
promptH := float32(p.promptTextureHeight) * promptScale
|
||||||
|
promptW := float32(p.promptTextureWidth) * promptScale
|
||||||
|
promptX := p.Base.ScreenCenterX - int32(promptW/2)
|
||||||
|
promptY := margin
|
||||||
|
|
||||||
|
// Content area bounds
|
||||||
|
contentTop := titleY - margin
|
||||||
|
contentBottom := promptY + int32(promptH) + margin
|
||||||
|
p.visibleHeight = contentTop - contentBottom
|
||||||
|
|
||||||
|
// Calculate total content height (accounting for paragraph breaks)
|
||||||
|
totalContentHeight := int32(0)
|
||||||
|
for _, line := range p.lines {
|
||||||
|
if strings.TrimSpace(line) == "" {
|
||||||
|
totalContentHeight += paragraphSpacing
|
||||||
|
} else {
|
||||||
|
totalContentHeight += 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 -= paragraphSpacing
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.lineTextureIDs[i] == 0 {
|
||||||
|
currentY -= p.lineHeight
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip lines outside visible area
|
||||||
|
if currentY > contentTop || currentY+p.lineHeight < contentBottom {
|
||||||
|
currentY -= p.lineHeight
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
lineW := float32(p.lineTextureWidths[i]) * contentScale
|
||||||
|
lineH := float32(p.lineTextureHeights[i]) * contentScale
|
||||||
|
|
||||||
|
p.Base.RenderTexture(textShader, p.lineTextureIDs[i], mgl32.Vec2{float32(contentLeftX), float32(currentY)}, mgl32.Vec2{lineW, lineH}, white)
|
||||||
|
currentY -= p.lineHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show accept/decline prompt when scrolled to bottom, scroll hint otherwise
|
||||||
|
if p.scrollOffset >= p.maxScrollOffset {
|
||||||
|
p.Base.RenderTexture(textShader, p.promptTextureID, mgl32.Vec2{float32(promptX), float32(promptY)}, mgl32.Vec2{promptW, promptH}, grey)
|
||||||
|
} else {
|
||||||
|
hintScale := promptScale
|
||||||
|
hintW := float32(p.scrollHintTextureWidth) * hintScale
|
||||||
|
hintH := float32(p.scrollHintTextureHeight) * hintScale
|
||||||
|
hintX := p.Base.ScreenCenterX - int32(hintW/2)
|
||||||
|
hintY := promptY
|
||||||
|
p.Base.RenderTexture(textShader, p.scrollHintTextureID, mgl32.Vec2{float32(hintX), float32(hintY)}, mgl32.Vec2{hintW, hintH}, grey)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *TOSPage) Destroy() error {
|
||||||
|
gl.DeleteTextures(1, &p.titleTextureID)
|
||||||
|
gl.DeleteTextures(1, &p.promptTextureID)
|
||||||
|
gl.DeleteTextures(1, &p.scrollHintTextureID)
|
||||||
|
|
||||||
|
for _, texID := range p.lineTextureIDs {
|
||||||
|
if texID != 0 {
|
||||||
|
gl.DeleteTextures(1, &texID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.Base.Destroy()
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
package page
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
"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/shaderManager"
|
||||||
|
)
|
||||||
|
|
||||||
|
type updateCheckState int
|
||||||
|
|
||||||
|
const (
|
||||||
|
updateCheckStateChecking updateCheckState = iota
|
||||||
|
updateCheckStateDone
|
||||||
|
)
|
||||||
|
|
||||||
|
type UpdateCheckPage struct {
|
||||||
|
Base
|
||||||
|
textShader *shaderManager.Shader
|
||||||
|
|
||||||
|
state updateCheckState
|
||||||
|
|
||||||
|
// "Checking for Updates." / ".." / "..."
|
||||||
|
checkingTextures [3]StringItem
|
||||||
|
|
||||||
|
// "No updates available" and "Press F3 to return"
|
||||||
|
doneMessage StringItem
|
||||||
|
doneHint StringItem
|
||||||
|
|
||||||
|
// Error message if service failed to start
|
||||||
|
errorMessage StringItem
|
||||||
|
hasError bool
|
||||||
|
|
||||||
|
pollTimer float32
|
||||||
|
dotIndex int
|
||||||
|
dotTimer float32
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *UpdateCheckPage) Init(app ApplicationInterface) error {
|
||||||
|
if err := p.Base.Init(app); err != nil {
|
||||||
|
return fmt.Errorf("failed base page initialization: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fm := fontManager.Get()
|
||||||
|
sm := shaderManager.Get()
|
||||||
|
|
||||||
|
font, ok := fm.GetFont("Roboto-Regular")
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("failed to get font")
|
||||||
|
}
|
||||||
|
|
||||||
|
p.textShader, ok = sm.Get("text")
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("failed to get text shader")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get scale
|
||||||
|
baseFontSizeStr, err := db.GetConfigValue("init_font_size")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("retrieving init_font_size from db: %w", err)
|
||||||
|
}
|
||||||
|
baseFontSize, err := strconv.Atoi(baseFontSizeStr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parsing init_font_size: %w", err)
|
||||||
|
}
|
||||||
|
scale := float32(48) / float32(baseFontSize)
|
||||||
|
|
||||||
|
// Create checking textures with animated dots
|
||||||
|
dots := []string{"Checking for Updates.", "Checking for Updates..", "Checking for Updates..."}
|
||||||
|
for i, text := range dots {
|
||||||
|
item, err := NewStringItem(text, font, colors.White)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating checking texture %d: %w", i, err)
|
||||||
|
}
|
||||||
|
item.Scale(scale)
|
||||||
|
p.checkingTextures[i] = item
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create done textures
|
||||||
|
hintScale := float32(32) / float32(baseFontSize)
|
||||||
|
|
||||||
|
p.doneMessage, err = NewStringItem("No updates available", font, colors.White)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating done message: %w", err)
|
||||||
|
}
|
||||||
|
p.doneMessage.Scale(scale)
|
||||||
|
|
||||||
|
p.doneHint, err = NewStringItem("Press F3 to return", font, colors.White)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating done hint: %w", err)
|
||||||
|
}
|
||||||
|
p.doneHint.Scale(hintScale)
|
||||||
|
|
||||||
|
// Start the update service
|
||||||
|
p.state = updateCheckStateChecking
|
||||||
|
if err := exec.Command("systemctl", "start", "rhemawave-update.service").Run(); err != nil {
|
||||||
|
p.errorMessage, _ = NewStringItem("Update service unavailable", font, colors.White)
|
||||||
|
p.errorMessage.Scale(scale)
|
||||||
|
p.hasError = true
|
||||||
|
p.state = updateCheckStateDone
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *UpdateCheckPage) Update(dt float32) error {
|
||||||
|
if p.state != updateCheckStateChecking {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animate dots
|
||||||
|
p.dotTimer += dt
|
||||||
|
if p.dotTimer >= 0.5 {
|
||||||
|
p.dotTimer -= 0.5
|
||||||
|
p.dotIndex = (p.dotIndex + 1) % 3
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll service status every 2 seconds
|
||||||
|
p.pollTimer += dt
|
||||||
|
if p.pollTimer >= 2.0 {
|
||||||
|
p.pollTimer -= 2.0
|
||||||
|
|
||||||
|
output, _ := exec.Command("systemctl", "is-active", "rhemawave-update.service").Output()
|
||||||
|
status := strings.TrimSpace(string(output))
|
||||||
|
if status != "active" && status != "activating" {
|
||||||
|
p.state = updateCheckStateDone
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *UpdateCheckPage) HandleEvent(event *sdl.Event) error {
|
||||||
|
if p.state != updateCheckStateDone {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch e := (*event).(type) {
|
||||||
|
case *sdl.KeyboardEvent:
|
||||||
|
if e.Type != sdl.KEYDOWN {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch e.Keysym.Sym {
|
||||||
|
case sdl.K_F3, sdl.K_BACKSPACE:
|
||||||
|
p.App.UnwindToPage(&Settings{})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *UpdateCheckPage) Render() error {
|
||||||
|
switch p.state {
|
||||||
|
case updateCheckStateChecking:
|
||||||
|
item := p.checkingTextures[p.dotIndex]
|
||||||
|
item.X = p.ScreenCenterX - item.W/2
|
||||||
|
item.Y = p.ScreenCenterY - item.H/2
|
||||||
|
p.RenderTexture(p.textShader, item.ID, item.Position(), item.RawDimensions(), item.Color)
|
||||||
|
|
||||||
|
case updateCheckStateDone:
|
||||||
|
var msg *StringItem
|
||||||
|
if p.hasError {
|
||||||
|
msg = &p.errorMessage
|
||||||
|
} else {
|
||||||
|
msg = &p.doneMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
msg.X = p.ScreenCenterX - msg.W/2
|
||||||
|
msg.Y = p.ScreenCenterY + msg.H/2
|
||||||
|
p.RenderTexture(p.textShader, msg.ID, msg.Position(), msg.RawDimensions(), msg.Color)
|
||||||
|
|
||||||
|
p.doneHint.X = p.ScreenCenterX - p.doneHint.W/2
|
||||||
|
p.doneHint.Y = msg.Y - p.doneHint.H - 20
|
||||||
|
p.RenderTexture(p.textShader, p.doneHint.ID, p.doneHint.Position(), p.doneHint.RawDimensions(), p.doneHint.Color)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *UpdateCheckPage) Destroy() error {
|
||||||
|
for i := range p.checkingTextures {
|
||||||
|
gl.DeleteTextures(1, &p.checkingTextures[i].ID)
|
||||||
|
}
|
||||||
|
gl.DeleteTextures(1, &p.doneMessage.ID)
|
||||||
|
gl.DeleteTextures(1, &p.doneHint.ID)
|
||||||
|
if p.hasError {
|
||||||
|
gl.DeleteTextures(1, &p.errorMessage.ID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,439 @@
|
|||||||
|
package page
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"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/shaderManager"
|
||||||
|
)
|
||||||
|
|
||||||
|
var GitVersion = "dev"
|
||||||
|
|
||||||
|
type Welcome struct {
|
||||||
|
Base
|
||||||
|
title StringItem
|
||||||
|
version StringItem
|
||||||
|
prompt StringItem
|
||||||
|
note StringItem
|
||||||
|
licenseInfoHeading StringItem
|
||||||
|
emailDisplay StringItem
|
||||||
|
licenseKeyDisplay StringItem
|
||||||
|
displayStatus StringItem
|
||||||
|
sdlDisplayStatus StringItem
|
||||||
|
hasLicenseInfo bool
|
||||||
|
lastDisplayUpdate time.Time
|
||||||
|
drmDisplayCount int
|
||||||
|
sdlDisplayCount int
|
||||||
|
baseFontSize int // cached from db at init
|
||||||
|
}
|
||||||
|
|
||||||
|
// countConnectedDisplays returns the number of connected displays via DRM
|
||||||
|
func countConnectedDisplays() int {
|
||||||
|
matches, err := filepath.Glob("/sys/class/drm/card*/status")
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
connected := 0
|
||||||
|
for _, path := range matches {
|
||||||
|
content, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
status := strings.TrimSpace(string(content))
|
||||||
|
if status == "connected" {
|
||||||
|
connected++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return connected
|
||||||
|
}
|
||||||
|
|
||||||
|
// countSDLDisplays returns the number of displays detected by SDL
|
||||||
|
func countSDLDisplays() int {
|
||||||
|
numDisplays, err := sdl.GetNumVideoDisplays()
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return numDisplays
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateDisplayCounts updates the display count textures
|
||||||
|
func (p *Welcome) updateDisplayCounts() error {
|
||||||
|
// Get font
|
||||||
|
fm := fontManager.Get()
|
||||||
|
font, ok := fm.GetFont("Roboto-Regular")
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("unable to fetch font")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get base font size
|
||||||
|
baseFontSizeStr, err := db.GetConfigValue("init_font_size")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("retrieving init_font_size from db: %w", err)
|
||||||
|
}
|
||||||
|
baseFontSize, err := strconv.Atoi(baseFontSizeStr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parsing init_font_size: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update DRM count
|
||||||
|
p.drmDisplayCount = countConnectedDisplays()
|
||||||
|
|
||||||
|
// Delete old texture
|
||||||
|
if p.displayStatus.ID != 0 {
|
||||||
|
gl.DeleteTextures(1, &p.displayStatus.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new texture
|
||||||
|
displayStatusText := fmt.Sprintf("Connected Displays: %d", p.drmDisplayCount)
|
||||||
|
p.displayStatus, err = NewStringItem(displayStatusText, font, colors.White)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to create display status texture: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
displayStatusScale := float32(24) / float32(baseFontSize)
|
||||||
|
p.displayStatus.Scale(displayStatusScale)
|
||||||
|
|
||||||
|
// Update SDL count
|
||||||
|
p.sdlDisplayCount = countSDLDisplays()
|
||||||
|
|
||||||
|
// Delete old texture
|
||||||
|
if p.sdlDisplayStatus.ID != 0 {
|
||||||
|
gl.DeleteTextures(1, &p.sdlDisplayStatus.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new texture
|
||||||
|
sdlDisplayStatusText := fmt.Sprintf("SDL Displays Detected: %d", p.sdlDisplayCount)
|
||||||
|
p.sdlDisplayStatus, err = NewStringItem(sdlDisplayStatusText, font, colors.White)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to create SDL display status texture: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.sdlDisplayStatus.Scale(displayStatusScale)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init performs basic page initialization and creation of static assets.
|
||||||
|
func (p *Welcome) Init(app ApplicationInterface) error {
|
||||||
|
|
||||||
|
title := "Rhema 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(app); err != nil {
|
||||||
|
return fmt.Errorf("failed base page initialization: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get font
|
||||||
|
fm := fontManager.Get()
|
||||||
|
font, ok := fm.GetFont("Roboto-Regular")
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("unable to fetch 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize display counts
|
||||||
|
p.lastDisplayUpdate = time.Now()
|
||||||
|
if err := p.updateDisplayCounts(); err != nil {
|
||||||
|
return fmt.Errorf("unable to update display counts: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load license information from database
|
||||||
|
emailAddress, err := db.GetConfigValue("email_address")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("retrieving email_address from db: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
licenseKey, err := db.GetConfigValue("license_key")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("retrieving license_key from db: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create license info textures if data exists
|
||||||
|
if emailAddress != "" || 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 emailAddress != "" {
|
||||||
|
emailText := fmt.Sprintf("Email: %s", 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 licenseKey != "" {
|
||||||
|
licenseKeyText := fmt.Sprintf("License Key: %s", 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
|
||||||
|
baseFontSizeStr, err := db.GetConfigValue("init_font_size")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("retrieving init_font_size from db: %w", err)
|
||||||
|
}
|
||||||
|
baseFontSize, err := strconv.Atoi(baseFontSizeStr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parsing init_font_size: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.baseFontSize = baseFontSize
|
||||||
|
|
||||||
|
// Scale the note text
|
||||||
|
fontSize := int32(32)
|
||||||
|
scale := float32(fontSize) / float32(baseFontSize)
|
||||||
|
p.note.Scale(scale * 0.75)
|
||||||
|
|
||||||
|
// Scale license info texts
|
||||||
|
if p.hasLicenseInfo {
|
||||||
|
licenseInfoScale := float32(28) / float32(baseFontSize)
|
||||||
|
p.licenseInfoHeading.Scale(licenseInfoScale)
|
||||||
|
if p.emailDisplay.ID != 0 {
|
||||||
|
p.emailDisplay.Scale(licenseInfoScale * 0.85)
|
||||||
|
}
|
||||||
|
if p.licenseKeyDisplay.ID != 0 {
|
||||||
|
p.licenseKeyDisplay.Scale(licenseInfoScale * 0.85)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleEvent processes SDL events and handles them appropriately for the Welcome page.
|
||||||
|
func (p *Welcome) HandleEvent(event *sdl.Event) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update updates animations and changing textures and other changes using `dt` which represents a time delta in float32.
|
||||||
|
func (p *Welcome) Update(dt float32) error {
|
||||||
|
// Update display counts every 2 seconds
|
||||||
|
if time.Since(p.lastDisplayUpdate) >= 2*time.Second {
|
||||||
|
if err := p.updateDisplayCounts(); err != nil {
|
||||||
|
}
|
||||||
|
p.lastDisplayUpdate = time.Now()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Welcome) Render() error {
|
||||||
|
baseFontSize := p.baseFontSize
|
||||||
|
sm := shaderManager.Get()
|
||||||
|
verticalMargin := int32(20)
|
||||||
|
|
||||||
|
// 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}
|
||||||
|
|
||||||
|
// p.note already scaled in Init via p.note.Scale(scale*0.75)
|
||||||
|
noteDim := p.note.RawDimensions()
|
||||||
|
noteW, noteH := noteDim[0], noteDim[1]
|
||||||
|
|
||||||
|
// 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) // Title sits at the top
|
||||||
|
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 display status in bottom left corner
|
||||||
|
displayStatusDim := p.displayStatus.RawDimensions()
|
||||||
|
displayStatusH := displayStatusDim[1]
|
||||||
|
|
||||||
|
// sdlDisplayStatusDim := p.sdlDisplayStatus.RawDimensions()
|
||||||
|
// sdlDisplayStatusH := sdlDisplayStatusDim[1]
|
||||||
|
|
||||||
|
displayStatusMargin := int32(5)
|
||||||
|
|
||||||
|
p.displayStatus.X = 15
|
||||||
|
p.displayStatus.Y = 15
|
||||||
|
|
||||||
|
p.sdlDisplayStatus.X = 15
|
||||||
|
p.sdlDisplayStatus.Y = 15 + int32(displayStatusH) + displayStatusMargin
|
||||||
|
|
||||||
|
// 7) Position license info at bottom center
|
||||||
|
if p.hasLicenseInfo {
|
||||||
|
licenseInfoMargin := int32(10)
|
||||||
|
bottomMargin := int32(20)
|
||||||
|
|
||||||
|
// License info items are already scaled in Init
|
||||||
|
licenseHeadingDim := p.licenseInfoHeading.RawDimensions()
|
||||||
|
licenseHeadingW, licenseHeadingH := licenseHeadingDim[0], licenseHeadingDim[1]
|
||||||
|
|
||||||
|
var emailDisplayH, licenseKeyDisplayH float32
|
||||||
|
if p.emailDisplay.ID != 0 {
|
||||||
|
emailDisplayDim := p.emailDisplay.RawDimensions()
|
||||||
|
emailDisplayH = emailDisplayDim[1]
|
||||||
|
}
|
||||||
|
if p.licenseKeyDisplay.ID != 0 {
|
||||||
|
licenseKeyDisplayDim := p.licenseKeyDisplay.RawDimensions()
|
||||||
|
licenseKeyDisplayH = licenseKeyDisplayDim[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
licenseKeyDisplayDim := p.licenseKeyDisplay.RawDimensions()
|
||||||
|
licenseKeyDisplayW := licenseKeyDisplayDim[0]
|
||||||
|
p.licenseKeyDisplay.X = p.Base.ScreenCenterX - int32(licenseKeyDisplayW/2)
|
||||||
|
p.licenseKeyDisplay.Y = currentY
|
||||||
|
currentY += int32(licenseKeyDisplayH) + licenseInfoMargin
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email
|
||||||
|
if p.emailDisplay.ID != 0 {
|
||||||
|
emailDisplayDim := p.emailDisplay.RawDimensions()
|
||||||
|
emailDisplayW := emailDisplayDim[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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, p.prompt.Position(), promptDim, color)
|
||||||
|
p.Base.RenderTexture(textShader, p.note.ID, p.note.Position(), p.note.RawDimensions(), color)
|
||||||
|
|
||||||
|
// Render display status
|
||||||
|
p.Base.RenderTexture(textShader, p.displayStatus.ID, p.displayStatus.Position(), p.displayStatus.RawDimensions(), color)
|
||||||
|
p.Base.RenderTexture(textShader, p.sdlDisplayStatus.ID, p.sdlDisplayStatus.Position(), p.sdlDisplayStatus.RawDimensions(), color)
|
||||||
|
|
||||||
|
// Render license info if it exists
|
||||||
|
if p.hasLicenseInfo {
|
||||||
|
p.Base.RenderTexture(textShader, p.licenseInfoHeading.ID, p.licenseInfoHeading.Position(), p.licenseInfoHeading.RawDimensions(), color)
|
||||||
|
if p.emailDisplay.ID != 0 {
|
||||||
|
p.Base.RenderTexture(textShader, p.emailDisplay.ID, p.emailDisplay.Position(), p.emailDisplay.RawDimensions(), color)
|
||||||
|
}
|
||||||
|
if p.licenseKeyDisplay.ID != 0 {
|
||||||
|
p.Base.RenderTexture(textShader, p.licenseKeyDisplay.ID, p.licenseKeyDisplay.Position(), p.licenseKeyDisplay.RawDimensions(), color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Destroy cleans up page-specific resources.
|
||||||
|
func (p *Welcome) 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.displayStatus.ID)
|
||||||
|
gl.DeleteTextures(1, &p.sdlDisplayStatus.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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,790 @@
|
|||||||
|
package page
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-gl/gl/v3.3-core/gl"
|
||||||
|
"github.com/go-gl/mathgl/mgl32"
|
||||||
|
"github.com/godbus/dbus/v5"
|
||||||
|
"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/network"
|
||||||
|
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
||||||
|
)
|
||||||
|
|
||||||
|
// accessPointUIItem holds both the network data and its rendered texture.
|
||||||
|
type accessPointUIItem struct {
|
||||||
|
AP network.AccessPoint
|
||||||
|
Item StringItem
|
||||||
|
}
|
||||||
|
|
||||||
|
// WiFiSetupPage manages the discovery and connection to Wi-Fi networks.
|
||||||
|
type WiFiSetupPage struct {
|
||||||
|
Base
|
||||||
|
networkManager *network.Manager
|
||||||
|
applicationFont *fontManager.FontEntry
|
||||||
|
|
||||||
|
// Page-specific UI elements
|
||||||
|
titleItem StringItem
|
||||||
|
scanningStatusItem StringItem
|
||||||
|
connectionStatusItem StringItem
|
||||||
|
passwordPromptItem StringItem
|
||||||
|
passwordInputItem StringItem
|
||||||
|
passwordErrorItem StringItem
|
||||||
|
|
||||||
|
// Network list and selection
|
||||||
|
accessPoints []accessPointUIItem
|
||||||
|
selectedIndex int
|
||||||
|
scrollOffset int
|
||||||
|
|
||||||
|
// State management
|
||||||
|
isScanning bool
|
||||||
|
isEnteringPassword bool
|
||||||
|
isConnecting bool
|
||||||
|
connectionError string
|
||||||
|
passwordInput string
|
||||||
|
selectedAP *network.AccessPoint
|
||||||
|
wifiDevice *network.Device
|
||||||
|
connectionStatus *network.ConnectionStatus
|
||||||
|
activeSSID string // Tracks the CONFIRMED active network
|
||||||
|
pendingSSID string // Tracks the network we are TRYING to connect to
|
||||||
|
connectionAttemptTime time.Time
|
||||||
|
connectionTimeout time.Duration
|
||||||
|
|
||||||
|
// Text input deduplication
|
||||||
|
textInputSupported bool
|
||||||
|
|
||||||
|
// Async management
|
||||||
|
resultsChan chan []network.AccessPoint
|
||||||
|
statusChan chan *network.ConnectionStatus
|
||||||
|
errorChan chan error
|
||||||
|
stopScan chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init sets up the page, initializes the network manager, and starts a scan.
|
||||||
|
func (p *WiFiSetupPage) Init(app ApplicationInterface) error {
|
||||||
|
|
||||||
|
if err := p.Base.Init(app); err != nil {
|
||||||
|
return fmt.Errorf("failed base page initialization: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get application font from database
|
||||||
|
applicationFontName, err := db.GetConfigValue("application_font")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("retrieving application_font from db: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fm := fontManager.Get()
|
||||||
|
font, ok := fm.GetFont(applicationFontName)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("could not find application font: %s", applicationFontName)
|
||||||
|
}
|
||||||
|
p.applicationFont = font
|
||||||
|
|
||||||
|
// Initialize network manager
|
||||||
|
nm, err := network.New()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to initialize network manager: %w", err)
|
||||||
|
}
|
||||||
|
p.networkManager = nm
|
||||||
|
|
||||||
|
// Find Wi-Fi device
|
||||||
|
if err := p.networkManager.FindDevices(); err != nil {
|
||||||
|
}
|
||||||
|
for _, dev := range p.networkManager.Devices {
|
||||||
|
if dev.Type == network.TypeWifi {
|
||||||
|
d := dev
|
||||||
|
p.wifiDevice = &d
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create UI elements
|
||||||
|
white := sdl.Color{R: 255, G: 255, B: 255, A: 255}
|
||||||
|
p.titleItem, err = NewStringItem("Wi-Fi Setup", p.applicationFont, white)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create title item: %w", err)
|
||||||
|
}
|
||||||
|
p.scanningStatusItem, err = NewStringItem("Scanning for networks...", p.applicationFont, white)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create scanning status item: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize channels and timeout
|
||||||
|
p.resultsChan = make(chan []network.AccessPoint)
|
||||||
|
p.statusChan = make(chan *network.ConnectionStatus, 1)
|
||||||
|
p.errorChan = make(chan error)
|
||||||
|
p.stopScan = make(chan struct{})
|
||||||
|
p.connectionTimeout = 15 * time.Second
|
||||||
|
|
||||||
|
// Start scanning and status monitoring
|
||||||
|
p.startScan()
|
||||||
|
go p.checkConnectionStatusLoop()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkConnectionStatusLoop periodically checks the network status.
|
||||||
|
func (p *WiFiSetupPage) checkConnectionStatusLoop() {
|
||||||
|
ticker := time.NewTicker(2 * time.Second) // Check more frequently for better UX
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
status, _ := p.networkManager.CheckInternetConnection()
|
||||||
|
p.statusChan <- status
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ticker.C:
|
||||||
|
status, _ := p.networkManager.CheckInternetConnection()
|
||||||
|
p.statusChan <- status
|
||||||
|
case <-p.stopScan:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// startScan triggers a new asynchronous network scan.
|
||||||
|
func (p *WiFiSetupPage) startScan() {
|
||||||
|
p.isScanning = true
|
||||||
|
p.selectedIndex = 0
|
||||||
|
p.scrollOffset = 0
|
||||||
|
p.accessPoints = nil
|
||||||
|
|
||||||
|
if p.wifiDevice != nil {
|
||||||
|
go p.networkManager.StartScan(p.resultsChan, p.errorChan, p.stopScan)
|
||||||
|
} else {
|
||||||
|
p.isScanning = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleEvent processes user input.
|
||||||
|
func (p *WiFiSetupPage) HandleEvent(event *sdl.Event) error {
|
||||||
|
if p.isEnteringPassword && !sdl.IsTextInputActive() {
|
||||||
|
p.enterPasswordMode()
|
||||||
|
}
|
||||||
|
|
||||||
|
switch e := (*event).(type) {
|
||||||
|
case *sdl.TextInputEvent:
|
||||||
|
if p.isEnteringPassword {
|
||||||
|
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.passwordInput += e.GetText()
|
||||||
|
p.updatePasswordTexture()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case *sdl.KeyboardEvent:
|
||||||
|
if e.Type != sdl.KEYDOWN {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if p.isEnteringPassword {
|
||||||
|
p.handlePasswordInput(e.Keysym.Sym, e.Keysym.Mod)
|
||||||
|
} else {
|
||||||
|
p.handleNetworkSelection(e.Keysym.Sym)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handlePasswordInput manages keyboard events during password entry.
|
||||||
|
func (p *WiFiSetupPage) handlePasswordInput(sym sdl.Keycode, mod uint16) {
|
||||||
|
switch sym {
|
||||||
|
case sdl.K_RETURN:
|
||||||
|
p.exitPasswordMode()
|
||||||
|
p.pendingSSID = p.selectedAP.SSID
|
||||||
|
p.isConnecting = true
|
||||||
|
p.connectionError = ""
|
||||||
|
p.connectionAttemptTime = time.Now()
|
||||||
|
err := p.networkManager.Connect(*p.wifiDevice, *p.selectedAP, p.passwordInput)
|
||||||
|
if err != nil {
|
||||||
|
p.isConnecting = false
|
||||||
|
p.pendingSSID = ""
|
||||||
|
p.connectionError = "Failed to initiate connection"
|
||||||
|
}
|
||||||
|
p.isEnteringPassword = false
|
||||||
|
case sdl.K_BACKSPACE:
|
||||||
|
if len(p.passwordInput) > 0 {
|
||||||
|
p.passwordInput = p.passwordInput[:len(p.passwordInput)-1]
|
||||||
|
p.updatePasswordTexture()
|
||||||
|
}
|
||||||
|
case sdl.K_ESCAPE:
|
||||||
|
p.isEnteringPassword = false
|
||||||
|
p.pendingSSID = ""
|
||||||
|
p.connectionError = ""
|
||||||
|
p.exitPasswordMode()
|
||||||
|
default:
|
||||||
|
// Keyboard fallback: if TextInputEvent was not received for this
|
||||||
|
// event cycle, convert printable keycodes to characters directly.
|
||||||
|
// This handles kiosk systems without an input method framework.
|
||||||
|
if !p.textInputSupported {
|
||||||
|
if ch := keycodeToChar(sym, mod); ch != 0 {
|
||||||
|
p.passwordInput += string(ch)
|
||||||
|
p.updatePasswordTexture()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// enterPasswordMode disables global keybinds and starts SDL text input.
|
||||||
|
func (p *WiFiSetupPage) enterPasswordMode() {
|
||||||
|
keybinds.Enabled = false
|
||||||
|
sdl.StartTextInput()
|
||||||
|
}
|
||||||
|
|
||||||
|
// exitPasswordMode re-enables global keybinds and stops SDL text input.
|
||||||
|
func (p *WiFiSetupPage) exitPasswordMode() {
|
||||||
|
keybinds.Enabled = true
|
||||||
|
sdl.StopTextInput()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleNetworkSelection manages keyboard events for the network list.
|
||||||
|
func (p *WiFiSetupPage) handleNetworkSelection(sym sdl.Keycode) {
|
||||||
|
switch sym {
|
||||||
|
case sdl.K_UP:
|
||||||
|
if p.selectedIndex > 0 {
|
||||||
|
p.selectedIndex--
|
||||||
|
}
|
||||||
|
case sdl.K_DOWN:
|
||||||
|
if p.selectedIndex < len(p.accessPoints)-1 {
|
||||||
|
p.selectedIndex++
|
||||||
|
}
|
||||||
|
case sdl.K_RETURN:
|
||||||
|
if len(p.accessPoints) > 0 && p.selectedIndex < len(p.accessPoints) {
|
||||||
|
p.selectedAP = &p.accessPoints[p.selectedIndex].AP
|
||||||
|
p.pendingSSID = p.selectedAP.SSID
|
||||||
|
p.connectionError = ""
|
||||||
|
if p.selectedAP.IsProtected {
|
||||||
|
p.isEnteringPassword = true
|
||||||
|
p.passwordInput = ""
|
||||||
|
p.updatePasswordTexture()
|
||||||
|
p.updatePasswordPromptTexture()
|
||||||
|
p.enterPasswordMode()
|
||||||
|
} else {
|
||||||
|
p.isConnecting = true
|
||||||
|
p.connectionAttemptTime = time.Now()
|
||||||
|
err := p.networkManager.Connect(*p.wifiDevice, *p.selectedAP, "")
|
||||||
|
if err != nil {
|
||||||
|
p.isConnecting = false
|
||||||
|
p.pendingSSID = ""
|
||||||
|
p.connectionError = "Failed to initiate connection"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case sdl.K_r:
|
||||||
|
if !p.isScanning {
|
||||||
|
p.startScan()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update checks for async results and updates UI state.
|
||||||
|
func (p *WiFiSetupPage) Update(dt float32) error {
|
||||||
|
select {
|
||||||
|
case results := <-p.resultsChan:
|
||||||
|
p.isScanning = false
|
||||||
|
p.buildNetworkUIList(results)
|
||||||
|
case status := <-p.statusChan:
|
||||||
|
p.connectionStatus = status
|
||||||
|
p.handleConnectionStatusUpdate(status)
|
||||||
|
p.updateConnectionStatusItem()
|
||||||
|
case err := <-p.errorChan:
|
||||||
|
p.isScanning = false
|
||||||
|
fmt.Printf("error scannign for networks %v", err)
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for connection timeout
|
||||||
|
if p.isConnecting && time.Since(p.connectionAttemptTime) > p.connectionTimeout {
|
||||||
|
p.isConnecting = false
|
||||||
|
p.connectionError = "Connection timed out - Incorrect password?"
|
||||||
|
if p.selectedAP != nil && p.selectedAP.IsProtected {
|
||||||
|
p.isEnteringPassword = true
|
||||||
|
p.passwordInput = ""
|
||||||
|
p.updatePasswordTexture()
|
||||||
|
p.updatePasswordPromptTexture()
|
||||||
|
p.updatePasswordErrorTexture()
|
||||||
|
p.enterPasswordMode()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleConnectionStatusUpdate checks if connection succeeded or failed
|
||||||
|
func (p *WiFiSetupPage) handleConnectionStatusUpdate(status *network.ConnectionStatus) {
|
||||||
|
if !p.isConnecting {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we successfully connected to our pending SSID
|
||||||
|
switch status.StatusCode {
|
||||||
|
case network.StatusWifiConnectedInternetUp:
|
||||||
|
activeSSID := p.getActiveSSID()
|
||||||
|
if activeSSID == p.pendingSSID {
|
||||||
|
p.isConnecting = false
|
||||||
|
p.pendingSSID = ""
|
||||||
|
p.connectionError = ""
|
||||||
|
p.refreshNetworkColors()
|
||||||
|
}
|
||||||
|
case network.StatusWifiAvailableNotConnected:
|
||||||
|
// Connection failed - we're trying to connect but now show as not connected
|
||||||
|
if time.Since(p.connectionAttemptTime) > 5*time.Second {
|
||||||
|
p.isConnecting = false
|
||||||
|
if p.selectedAP != nil && p.selectedAP.IsProtected {
|
||||||
|
p.connectionError = "Connection failed - Incorrect password?"
|
||||||
|
p.isEnteringPassword = true
|
||||||
|
p.passwordInput = ""
|
||||||
|
p.updatePasswordTexture()
|
||||||
|
p.updatePasswordPromptTexture()
|
||||||
|
p.updatePasswordErrorTexture()
|
||||||
|
p.enterPasswordMode()
|
||||||
|
} else {
|
||||||
|
p.connectionError = "Connection failed"
|
||||||
|
p.pendingSSID = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getActiveSSID queries NetworkManager for the currently active WiFi SSID
|
||||||
|
func (p *WiFiSetupPage) getActiveSSID() string {
|
||||||
|
if p.networkManager == nil || p.wifiDevice == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
nmObj := p.networkManager.Conn().Object("org.freedesktop.NetworkManager", "/org/freedesktop/NetworkManager")
|
||||||
|
prop, err := nmObj.GetProperty("org.freedesktop.NetworkManager.ActiveConnections")
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if paths, ok := prop.Value().([]dbus.ObjectPath); ok {
|
||||||
|
for _, path := range paths {
|
||||||
|
acObj := p.networkManager.Conn().Object("org.freedesktop.NetworkManager", path)
|
||||||
|
devProp, err := acObj.GetProperty("org.freedesktop.NetworkManager.Connection.Active.Devices")
|
||||||
|
if err == nil {
|
||||||
|
if devPaths, ok := devProp.Value().([]dbus.ObjectPath); ok {
|
||||||
|
for _, devPath := range devPaths {
|
||||||
|
if string(devPath) == string(p.wifiDevice.Path) {
|
||||||
|
idProp, err := acObj.GetProperty("org.freedesktop.NetworkManager.Connection.Active.Id")
|
||||||
|
if err == nil {
|
||||||
|
if id, ok := idProp.Value().(string); ok {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// refreshNetworkColors updates the colors of network items based on active status
|
||||||
|
func (p *WiFiSetupPage) refreshNetworkColors() {
|
||||||
|
activeSSID := p.getActiveSSID()
|
||||||
|
p.activeSSID = activeSSID
|
||||||
|
|
||||||
|
for i := range p.accessPoints {
|
||||||
|
if p.accessPoints[i].AP.SSID == activeSSID {
|
||||||
|
p.accessPoints[i].Item.Color = colors.LawnGreen
|
||||||
|
} else {
|
||||||
|
p.accessPoints[i].Item.Color = colors.White
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateConnectionStatusItem creates the colored status message item.
|
||||||
|
func (p *WiFiSetupPage) updateConnectionStatusItem() {
|
||||||
|
if p.connectionStatusItem.ID != 0 {
|
||||||
|
gl.DeleteTextures(1, &p.connectionStatusItem.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show connecting message if in progress
|
||||||
|
if p.isConnecting {
|
||||||
|
message := fmt.Sprintf("Connecting to %s...", p.pendingSSID)
|
||||||
|
item, err := NewStringItem(message, p.applicationFont, colors.Yellow)
|
||||||
|
if err == nil {
|
||||||
|
p.connectionStatusItem = item
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show connection error if present
|
||||||
|
if p.connectionError != "" {
|
||||||
|
item, err := NewStringItem(p.connectionError, p.applicationFont, colors.Red)
|
||||||
|
if err == nil {
|
||||||
|
p.connectionStatusItem = item
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.connectionStatus == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch p.connectionStatus.StatusCode {
|
||||||
|
case network.StatusWifiConnectedInternetUp:
|
||||||
|
message := fmt.Sprintf("Connected to %s - Internet accessible | F1 to exit", p.activeSSID)
|
||||||
|
item, err := NewStringItem(message, p.applicationFont, colors.LawnGreen)
|
||||||
|
if err == nil {
|
||||||
|
p.connectionStatusItem = item
|
||||||
|
}
|
||||||
|
p.refreshNetworkColors()
|
||||||
|
|
||||||
|
case network.StatusWifiAvailableNotConnected:
|
||||||
|
message := "WiFi available - Not connected | Select network or press R to rescan"
|
||||||
|
item, err := NewStringItem(message, p.applicationFont, colors.Yellow)
|
||||||
|
if err == nil {
|
||||||
|
p.connectionStatusItem = item
|
||||||
|
}
|
||||||
|
|
||||||
|
case network.StatusEthernetConnectedInternetUp:
|
||||||
|
message := "Ethernet connected - Internet accessible | F1 to exit"
|
||||||
|
item, err := NewStringItem(message, p.applicationFont, colors.LawnGreen)
|
||||||
|
if err == nil {
|
||||||
|
p.connectionStatusItem = item
|
||||||
|
}
|
||||||
|
|
||||||
|
case network.StatusEthernetNotConnected:
|
||||||
|
message := "No network connection | Select WiFi network or press R to rescan"
|
||||||
|
item, err := NewStringItem(message, p.applicationFont, colors.Red)
|
||||||
|
if err == nil {
|
||||||
|
p.connectionStatusItem = item
|
||||||
|
}
|
||||||
|
|
||||||
|
case network.StatusNoDevicesFound:
|
||||||
|
message := "No network devices found"
|
||||||
|
item, err := NewStringItem(message, p.applicationFont, colors.Red)
|
||||||
|
if err == nil {
|
||||||
|
p.connectionStatusItem = item
|
||||||
|
}
|
||||||
|
|
||||||
|
case network.StatusError:
|
||||||
|
message := "Network status check failed"
|
||||||
|
item, err := NewStringItem(message, p.applicationFont, colors.Red)
|
||||||
|
if err == nil {
|
||||||
|
p.connectionStatusItem = item
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
item, err := NewStringItem(p.connectionStatus.Message, p.applicationFont, colors.White)
|
||||||
|
if err == nil {
|
||||||
|
p.connectionStatusItem = item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildNetworkUIList creates the renderable textures for the list of discovered networks.
|
||||||
|
func (p *WiFiSetupPage) buildNetworkUIList(aps []network.AccessPoint) {
|
||||||
|
// Get the currently active Wi-Fi SSID
|
||||||
|
activeSSID := p.getActiveSSID()
|
||||||
|
p.activeSSID = activeSSID
|
||||||
|
|
||||||
|
// Set IsActive flag based on the actual active SSID
|
||||||
|
for i := range aps {
|
||||||
|
aps[i].IsActive = (aps[i].SSID == p.activeSSID)
|
||||||
|
}
|
||||||
|
|
||||||
|
p.accessPoints = make([]accessPointUIItem, len(aps))
|
||||||
|
white := sdl.Color{R: 255, G: 255, B: 255, A: 255}
|
||||||
|
|
||||||
|
for i, ap := range aps {
|
||||||
|
ssid := ap.SSID
|
||||||
|
if ap.IsProtected {
|
||||||
|
ssid += " [L]"
|
||||||
|
}
|
||||||
|
color := white
|
||||||
|
if ap.IsActive {
|
||||||
|
color = colors.LawnGreen
|
||||||
|
}
|
||||||
|
item, err := NewStringItem(ssid, p.applicationFont, color)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p.accessPoints[i] = accessPointUIItem{AP: ap, Item: item}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// updatePasswordTexture regenerates the texture for the password input field.
|
||||||
|
func (p *WiFiSetupPage) updatePasswordTexture() {
|
||||||
|
if p.passwordInputItem.ID != 0 {
|
||||||
|
gl.DeleteTextures(1, &p.passwordInputItem.ID)
|
||||||
|
}
|
||||||
|
maskedPassword := strings.Repeat("*", len(p.passwordInput))
|
||||||
|
if maskedPassword == "" {
|
||||||
|
maskedPassword = " "
|
||||||
|
}
|
||||||
|
item, err := NewStringItem(maskedPassword, p.applicationFont, sdl.Color{R: 255, G: 255, B: 255, A: 255})
|
||||||
|
if err == nil {
|
||||||
|
p.passwordInputItem = item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// updatePasswordPromptTexture creates the texture for the password prompt label.
|
||||||
|
func (p *WiFiSetupPage) updatePasswordPromptTexture() {
|
||||||
|
if p.passwordPromptItem.ID != 0 {
|
||||||
|
gl.DeleteTextures(1, &p.passwordPromptItem.ID)
|
||||||
|
}
|
||||||
|
prompt := fmt.Sprintf("Password for %s:", p.selectedAP.SSID)
|
||||||
|
item, err := NewStringItem(prompt, p.applicationFont, sdl.Color{R: 255, G: 255, B: 255, A: 255})
|
||||||
|
if err == nil {
|
||||||
|
p.passwordPromptItem = item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// updatePasswordErrorTexture creates the texture for password error message.
|
||||||
|
func (p *WiFiSetupPage) updatePasswordErrorTexture() {
|
||||||
|
if p.passwordErrorItem.ID != 0 {
|
||||||
|
gl.DeleteTextures(1, &p.passwordErrorItem.ID)
|
||||||
|
}
|
||||||
|
if p.connectionError != "" {
|
||||||
|
item, err := NewStringItem(p.connectionError, p.applicationFont, colors.DarkRed)
|
||||||
|
if err == nil {
|
||||||
|
p.passwordErrorItem = item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render draws the entire page.
|
||||||
|
func (p *WiFiSetupPage) Render() error {
|
||||||
|
// Get base font size from database
|
||||||
|
baseFontSizeStr, err := db.GetConfigValue("init_font_size")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("retrieving init_font_size from db: %w", err)
|
||||||
|
}
|
||||||
|
baseFontSize, err := strconv.ParseFloat(baseFontSizeStr, 32)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("parsing init_font_size: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Colors & Config
|
||||||
|
bgColor := sdl.Color{R: 0, G: 0, B: 0, A: 255}
|
||||||
|
selectionColor := sdl.Color{R: 68, G: 71, B: 90, A: 255}
|
||||||
|
white := sdl.Color{R: 255, G: 255, B: 255, A: 255}
|
||||||
|
verticalMargin := float32(20)
|
||||||
|
|
||||||
|
solidShader, _ := shaderManager.Get().Get("solid_color")
|
||||||
|
textShader, _ := shaderManager.Get().Get("text")
|
||||||
|
|
||||||
|
// Background
|
||||||
|
p.Base.RenderSolidColorQuad(solidShader, mgl32.Vec2{0, 0}, mgl32.Vec2{float32(p.ScreenWidth), float32(p.ScreenHeight)}, bgColor)
|
||||||
|
|
||||||
|
// Title
|
||||||
|
titleScale := float32(48) / float32(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
|
||||||
|
statusY := titleY - titleH - verticalMargin
|
||||||
|
if p.isScanning {
|
||||||
|
statusScale := float32(24) / float32(baseFontSize)
|
||||||
|
statusW := float32(p.scanningStatusItem.W) * statusScale
|
||||||
|
statusH := float32(p.scanningStatusItem.H) * statusScale
|
||||||
|
statusX := float32(p.ScreenCenterX) - (statusW / 2)
|
||||||
|
p.Base.RenderTexture(textShader, p.scanningStatusItem.ID, mgl32.Vec2{statusX, statusY}, mgl32.Vec2{statusW, statusH}, white)
|
||||||
|
} else if p.connectionStatusItem.ID != 0 {
|
||||||
|
statusScale := float32(24) / float32(baseFontSize)
|
||||||
|
statusW := float32(p.connectionStatusItem.W) * statusScale
|
||||||
|
statusH := float32(p.connectionStatusItem.H) * statusScale
|
||||||
|
statusX := float32(p.ScreenCenterX) - (statusW / 2)
|
||||||
|
p.Base.RenderTexture(textShader, p.connectionStatusItem.ID, mgl32.Vec2{statusX, statusY}, mgl32.Vec2{statusW, statusH}, p.connectionStatusItem.Color)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Network list
|
||||||
|
listY := statusY - verticalMargin
|
||||||
|
if len(p.accessPoints) > 0 {
|
||||||
|
itemHeight := int32(40)
|
||||||
|
listPadding := int32(10)
|
||||||
|
for i, apItem := range p.accessPoints {
|
||||||
|
itemYpos := listY - float32(int32(i+1)*itemHeight)
|
||||||
|
itemXpos := float32(p.ScreenCenterX - 350)
|
||||||
|
itemW := float32(700)
|
||||||
|
if i == p.selectedIndex {
|
||||||
|
p.Base.RenderSolidColorQuad(solidShader, mgl32.Vec2{itemXpos, itemYpos}, mgl32.Vec2{itemW, float32(itemHeight)}, selectionColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
textScale := float32(24) / float32(baseFontSize)
|
||||||
|
textW := float32(apItem.Item.W) * textScale
|
||||||
|
textH := float32(apItem.Item.H) * textScale
|
||||||
|
textY := itemYpos + (float32(itemHeight) / 2) - (textH / 2)
|
||||||
|
textX := itemXpos + float32(listPadding)
|
||||||
|
p.Base.RenderTexture(textShader, apItem.Item.ID, mgl32.Vec2{textX, textY}, mgl32.Vec2{textW, textH}, apItem.Item.Color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Password dialog overlay
|
||||||
|
if p.isEnteringPassword {
|
||||||
|
if err := p.renderPasswordDialog(solidShader, textShader, float32(baseFontSize)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderPasswordDialog draws the password entry modal.
|
||||||
|
func (p *WiFiSetupPage) renderPasswordDialog(solidShader, textShader *shaderManager.Shader, baseFontSize float32) error {
|
||||||
|
overlayColor := sdl.Color{R: 0, G: 0, B: 0, A: 150}
|
||||||
|
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 := sdl.Color{R: 255, G: 255, B: 255, A: 255}
|
||||||
|
|
||||||
|
dialogW, dialogH := int32(600), int32(250)
|
||||||
|
if p.connectionError != "" {
|
||||||
|
dialogH = int32(300) // Make dialog taller if there's an error
|
||||||
|
}
|
||||||
|
dialogX, dialogY := p.ScreenCenterX-(dialogW/2), p.ScreenCenterY-(dialogH/2)
|
||||||
|
|
||||||
|
// Overlay and dialog background
|
||||||
|
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
|
||||||
|
|
||||||
|
// Password error (if exists) - shown at the top in red
|
||||||
|
if p.passwordErrorItem.ID != 0 {
|
||||||
|
errorScale := float32(20) / baseFontSize
|
||||||
|
errorW := float32(p.passwordErrorItem.W) * errorScale
|
||||||
|
errorH := float32(p.passwordErrorItem.H) * errorScale
|
||||||
|
errorX := float32(p.ScreenCenterX) - (errorW / 2)
|
||||||
|
currentY -= errorH
|
||||||
|
p.Base.RenderTexture(textShader, p.passwordErrorItem.ID, mgl32.Vec2{errorX, currentY}, mgl32.Vec2{errorW, errorH}, colors.DarkRed)
|
||||||
|
currentY -= 20
|
||||||
|
}
|
||||||
|
|
||||||
|
// Password prompt
|
||||||
|
if p.passwordPromptItem.ID != 0 {
|
||||||
|
promptScale := float32(24) / baseFontSize
|
||||||
|
promptW := float32(p.passwordPromptItem.W) * promptScale
|
||||||
|
promptH := float32(p.passwordPromptItem.H) * promptScale
|
||||||
|
promptX := float32(p.ScreenCenterX) - (promptW / 2)
|
||||||
|
currentY -= promptH
|
||||||
|
p.Base.RenderTexture(textShader, p.passwordPromptItem.ID, mgl32.Vec2{promptX, currentY}, mgl32.Vec2{promptW, promptH}, white)
|
||||||
|
currentY -= 30
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input box
|
||||||
|
padding, border := int32(4), int32(3)
|
||||||
|
boxW := int32(500)
|
||||||
|
targetFontSize := int32(36)
|
||||||
|
boxH := targetFontSize + (padding * 2)
|
||||||
|
borderBoxW, borderBoxH := boxW+(border*2), boxH+(border*2)
|
||||||
|
borderBoxX := p.ScreenCenterX - (borderBoxW / 2)
|
||||||
|
borderBoxY := int32(currentY) - boxH - (padding * 2)
|
||||||
|
boxX, boxY := p.ScreenCenterX-(boxW/2), int32(currentY)-boxH-padding
|
||||||
|
|
||||||
|
p.Base.RenderSolidColorQuad(solidShader, mgl32.Vec2{float32(borderBoxX), float32(borderBoxY)}, mgl32.Vec2{float32(borderBoxW), float32(borderBoxH)}, boxBorderColor)
|
||||||
|
p.Base.RenderSolidColorQuad(solidShader, mgl32.Vec2{float32(boxX), float32(boxY)}, mgl32.Vec2{float32(boxW), float32(boxH)}, boxBgColor)
|
||||||
|
|
||||||
|
// Password input text
|
||||||
|
if p.passwordInputItem.ID != 0 {
|
||||||
|
inputScale := float32(targetFontSize) / baseFontSize
|
||||||
|
inputW := float32(p.passwordInputItem.W) * inputScale
|
||||||
|
inputH := float32(p.passwordInputItem.H) * inputScale
|
||||||
|
inputX := float32(boxX + padding)
|
||||||
|
inputY := float32(boxY + padding)
|
||||||
|
p.Base.RenderTexture(textShader, p.passwordInputItem.ID, mgl32.Vec2{inputX, inputY}, mgl32.Vec2{inputW, inputH}, white)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Destroy cleans up all resources used by the page.
|
||||||
|
func (p *WiFiSetupPage) Destroy() error {
|
||||||
|
// Re-enable global keybinds in case we're destroyed during password entry.
|
||||||
|
keybinds.Enabled = true
|
||||||
|
|
||||||
|
if p.stopScan != nil {
|
||||||
|
close(p.stopScan)
|
||||||
|
p.stopScan = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
p.networkManager.Close()
|
||||||
|
|
||||||
|
gl.DeleteTextures(1, &p.titleItem.ID)
|
||||||
|
gl.DeleteTextures(1, &p.scanningStatusItem.ID)
|
||||||
|
if p.connectionStatusItem.ID != 0 {
|
||||||
|
gl.DeleteTextures(1, &p.connectionStatusItem.ID)
|
||||||
|
}
|
||||||
|
if p.passwordPromptItem.ID != 0 {
|
||||||
|
gl.DeleteTextures(1, &p.passwordPromptItem.ID)
|
||||||
|
}
|
||||||
|
if p.passwordInputItem.ID != 0 {
|
||||||
|
gl.DeleteTextures(1, &p.passwordInputItem.ID)
|
||||||
|
}
|
||||||
|
if p.passwordErrorItem.ID != 0 {
|
||||||
|
gl.DeleteTextures(1, &p.passwordErrorItem.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, apItem := range p.accessPoints {
|
||||||
|
if apItem.Item.ID != 0 {
|
||||||
|
gl.DeleteTextures(1, &apItem.Item.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.Base.Destroy()
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package pattern
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/veandco/go-sdl2/sdl"
|
||||||
|
"rhemawavetech.com/rhemawave/internal/colors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ColorPattern struct {
|
||||||
|
Colors []sdl.Color
|
||||||
|
colorIndex int
|
||||||
|
repeatsLeft int
|
||||||
|
a, b int
|
||||||
|
increasing bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewColorPattern(colors []sdl.Color) *ColorPattern {
|
||||||
|
if len(colors) < 2 {
|
||||||
|
panic("ColorPattern requires at least 2 colors")
|
||||||
|
}
|
||||||
|
return &ColorPattern{
|
||||||
|
Colors: colors,
|
||||||
|
a: 0,
|
||||||
|
b: 1,
|
||||||
|
increasing: true,
|
||||||
|
repeatsLeft: 1, // Start with b (1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ATTENTION: TED
|
||||||
|
//
|
||||||
|
// # Fibonacci-Oscillating Temporal Color Distribution for Sequential Text Elements
|
||||||
|
//
|
||||||
|
// This method distributes intent-based colors across sequential text elements (words) using an
|
||||||
|
// oscillating Fibonacci sequence to control temporal color duration, creating rhythmic color
|
||||||
|
// patterns that maintain visual balance while varying exposure times.
|
||||||
|
//
|
||||||
|
// The technology addresses the challenge of applying multiple psychologically-selected colors
|
||||||
|
// to scrolling text in a way that maintains even distribution over time while creating natural
|
||||||
|
// rhythm. Conventional approaches either apply colors randomly (creating unbalanced temporal
|
||||||
|
// distribution) or use fixed intervals (producing mechanical, unnatural patterns that lack
|
||||||
|
// harmonic variation). Our method ensures each color in the palette appears throughout the
|
||||||
|
// entire display duration while varying its temporal presence according to natural mathematical
|
||||||
|
// progression.
|
||||||
|
//
|
||||||
|
// The approach uses a bidirectional Fibonacci sequence to determine how many consecutive words
|
||||||
|
// receive each color. The sequence increases (1, 1, 2, 3, 5, 8, 13, 21, 34, 55 words) then
|
||||||
|
// decreases back down (55, 34, 21, 13, 8, 5, 3, 2, 1, 1 words), creating an oscillating pattern.
|
||||||
|
// Colors from the intent-based palette are applied in sequence, with each color's duration
|
||||||
|
// determined by the current Fibonacci value. This creates a "breathing" rhythm where colors
|
||||||
|
// appear briefly, expand to longer durations, then contract back to brief appearances before
|
||||||
|
// cycling to the next color.
|
||||||
|
//
|
||||||
|
// Key differentiators from existing solutions:
|
||||||
|
// - Uses bidirectional Fibonacci sequence (ascending then descending) rather than fixed intervals
|
||||||
|
// - Creates natural rhythmic variation in color exposure duration
|
||||||
|
// - Ensures all palette colors appear throughout entire display timeline, not in isolated sections
|
||||||
|
// - Combines intent-based color selection with mathematical temporal distribution
|
||||||
|
// - Produces harmonic color rhythm synchronized with natural numerical progression
|
||||||
|
//
|
||||||
|
// Within our Rhema Wave generation system, this color distribution works in conjunction with
|
||||||
|
// the spatially-scrambled character patterns. While the scrambling creates spatial frequency
|
||||||
|
// signatures, the Fibonacci-oscillating color pattern creates temporal frequency modulation.
|
||||||
|
// The colors themselves are selected according to color theory principles based on the proclamation's
|
||||||
|
// psychological intent, and the oscillating Fibonacci distribution ensures these therapeutic colors
|
||||||
|
// maintain presence throughout the viewing session while creating harmonic temporal rhythm that
|
||||||
|
// reinforces the overall Rhema Wave effect.
|
||||||
|
func (c *ColorPattern) Color() sdl.Color {
|
||||||
|
// 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, 987, 1597, 2584, 4181, 6765, 10946, 17711, 28657, 46368, 75025, 121393, 196418
|
||||||
|
// This is a failsafe in case this pattern is used but colors are not supplied. Without this check, no text will display due to not having a color
|
||||||
|
// and becoming translucent.
|
||||||
|
if len(c.Colors) == 0 {
|
||||||
|
return colors.White // fallback to white
|
||||||
|
}
|
||||||
|
|
||||||
|
color := c.Colors[c.colorIndex%len(c.Colors)]
|
||||||
|
c.repeatsLeft--
|
||||||
|
|
||||||
|
if c.repeatsLeft <= 0 {
|
||||||
|
// Move to next color in the palette
|
||||||
|
c.colorIndex = (c.colorIndex + 1) % len(c.Colors)
|
||||||
|
|
||||||
|
// Update Fibonacci sequence for repeat count
|
||||||
|
if c.increasing {
|
||||||
|
c.a, c.b = c.b, c.a+c.b
|
||||||
|
if c.b > 55 { // Update this threahold to adjust the number of repeats
|
||||||
|
c.increasing = false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
c.a, c.b = c.b-c.a, c.a
|
||||||
|
if c.a <= 0 {
|
||||||
|
c.a, c.b = 0, 1
|
||||||
|
c.increasing = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.repeatsLeft = c.b
|
||||||
|
}
|
||||||
|
|
||||||
|
return color
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package pattern
|
||||||
|
|
||||||
|
type FibonacciPattern struct {
|
||||||
|
masterSequence []int
|
||||||
|
sequence []int
|
||||||
|
increasing bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Register("fibonacci", func() LinePattern {
|
||||||
|
return &FibonacciPattern{
|
||||||
|
masterSequence: []int{1, 2, 3, 5, 8, 13, 21},
|
||||||
|
sequence: []int{1},
|
||||||
|
increasing: true,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FibonacciPattern) Name() string { return "Fibonacci" }
|
||||||
|
|
||||||
|
// ATTENTION: TED
|
||||||
|
//
|
||||||
|
// # Fibonacci-Oscillating Spatial Distribution for Sequential Text Display
|
||||||
|
//
|
||||||
|
// This method arranges sequential text elements (words from proclamations) into display lines
|
||||||
|
// where the word count per line follows an oscillating Fibonacci sequence, creating both a
|
||||||
|
// visual wave pattern in two-dimensional space and ensuring even temporal distribution of
|
||||||
|
// content segments.
|
||||||
|
//
|
||||||
|
// The technology addresses the challenge of displaying repeated proclamation content in a way
|
||||||
|
// that maintains balanced exposure of all content portions while creating visually dynamic
|
||||||
|
// spatial patterns. Conventional scrolling text approaches either use fixed line lengths
|
||||||
|
// (creating uniform, non-dynamic displays) or random word counts (producing unbalanced content
|
||||||
|
// distribution where some portions appear more frequently than others). Our method ensures
|
||||||
|
// proclamation content is consumed in progressively sized segments that balance over time while
|
||||||
|
// creating a natural wave-like visual rhythm.
|
||||||
|
//
|
||||||
|
// The approach uses a bidirectional Fibonacci sequence to determine words per line. Starting
|
||||||
|
// with minimal word count (1, 1 words), the sequence expands (2, 3, 5, 8, 13, 21... words per
|
||||||
|
// line) until reaching a threshold, then contracts back down (21, 13, 8, 5, 3, 2, 1, 1 words),
|
||||||
|
// creating an oscillating wave. As lines scroll vertically, this varying horizontal word count
|
||||||
|
// creates a lateral wave pattern in the two-dimensional display space. Words are drawn sequentially
|
||||||
|
// from the proclamation text, and the varying segment sizes (1 word, then 2, then 3, then 5, etc.)
|
||||||
|
// ensure that over multiple oscillation cycles, the entire proclamation content receives balanced
|
||||||
|
// temporal exposure.
|
||||||
|
//
|
||||||
|
// Key differentiators from existing solutions:
|
||||||
|
// - Uses oscillating Fibonacci sequence for line word counts rather than fixed or random lengths
|
||||||
|
// - Creates two-dimensional wave pattern from one-dimensional text sequence
|
||||||
|
// - Ensures mathematical content distribution balance over time through progressive segmentation
|
||||||
|
// - Produces natural expansion-contraction rhythm synchronized with Fibonacci progression
|
||||||
|
// - Combines spatial wave formation with temporal content distribution in single mechanism
|
||||||
|
//
|
||||||
|
// Within our Rhema Wave generation system, this spatial distribution pattern works in concert
|
||||||
|
// with the character-level scrambling and color distribution methods. While character scrambling
|
||||||
|
// creates local spatial frequency patterns and color oscillation creates temporal color rhythm,
|
||||||
|
// the Fibonacci line length pattern generates a macro-level spatial wave that modulates the
|
||||||
|
// overall display geometry. The mathematical relationship between line lengths creates harmonic
|
||||||
|
// spatial progression, and the oscillating nature ensures proclamation content cycles through
|
||||||
|
// varying exposure durations, contributing to the layered frequency composition of the complete
|
||||||
|
// Rhema Wave output.
|
||||||
|
func (f *FibonacciPattern) GetNextLine(words []string, wordIndex *int, ctx *LineRenderContext) []string {
|
||||||
|
if ctx == nil || len(words) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
numWords := f.sequence[len(f.sequence)-1]
|
||||||
|
targetWidth := int32(ctx.ScreenWidth)
|
||||||
|
|
||||||
|
var line []string
|
||||||
|
var currentWidth int32
|
||||||
|
wordsAdded := 0
|
||||||
|
|
||||||
|
for wordsAdded < numWords && len(words) > 0 {
|
||||||
|
if int(*wordIndex) > len(words) {
|
||||||
|
*wordIndex = 0
|
||||||
|
}
|
||||||
|
word := words[*wordIndex%len(words)]
|
||||||
|
*wordIndex++
|
||||||
|
|
||||||
|
if word == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
wordWidth := estimateWordWidth(word, ctx.Font, ctx.FontScale)
|
||||||
|
if len(line) > 0 {
|
||||||
|
wordWidth += ctx.SpaceWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(line) == 0 || currentWidth+wordWidth <= int32(float32(targetWidth)*float32(.9)) {
|
||||||
|
line = append(line, word)
|
||||||
|
currentWidth += wordWidth
|
||||||
|
wordsAdded++
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: What should we do here instead?
|
||||||
|
// A line being longer than the display still needs to be handled, though, it should never happen
|
||||||
|
// if currentWidth >= ctx.ScreenWidth {
|
||||||
|
// syscall.Kill(syscall.Getpid(), syscall.SIGTRAP)
|
||||||
|
// }
|
||||||
|
|
||||||
|
// Update the sequence based on direction
|
||||||
|
if f.increasing {
|
||||||
|
if len(f.sequence) < len(f.masterSequence) {
|
||||||
|
// Grow sequence by appending next Fibonacci number
|
||||||
|
f.sequence = append(f.sequence, f.masterSequence[len(f.sequence)])
|
||||||
|
} else {
|
||||||
|
// Reached the top, now start shrinking
|
||||||
|
f.increasing = false
|
||||||
|
f.sequence = f.sequence[:len(f.sequence)-1]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if len(f.sequence) > 1 {
|
||||||
|
// Shrink the sequence
|
||||||
|
f.sequence = f.sequence[:len(f.sequence)-1]
|
||||||
|
} else {
|
||||||
|
// Hit bottom, start growing again
|
||||||
|
f.increasing = true
|
||||||
|
f.sequence = append(f.sequence, f.masterSequence[1]) // index 1 = second element = 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return line
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package pattern
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"rhemawavetech.com/rhemawave/internal/fontManager"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LinePattern interface {
|
||||||
|
GetNextLine(words []string, wordIndex *int, ctx *LineRenderContext) []string // Generates the next line in the pattern
|
||||||
|
Name() string // Used to display the name of a LinePattern
|
||||||
|
}
|
||||||
|
|
||||||
|
// PatternInfo holds the public information about a registered pattern.
|
||||||
|
type PatternInfo struct {
|
||||||
|
Key string // The machine-readable key (e.g., "sine")
|
||||||
|
DisplayName string // The human-readable name for the UI (e.g., "Sine Wave")
|
||||||
|
}
|
||||||
|
|
||||||
|
type LineRenderContext struct {
|
||||||
|
ScreenWidth int32
|
||||||
|
Font *fontManager.FontEntry
|
||||||
|
FontScale float32
|
||||||
|
SpaceWidth int32
|
||||||
|
}
|
||||||
|
|
||||||
|
var Registry = make(map[string]func() LinePattern)
|
||||||
|
|
||||||
|
func Register(name string, constructor func() LinePattern) {
|
||||||
|
Registry[name] = constructor
|
||||||
|
}
|
||||||
|
|
||||||
|
func Get(name string) (LinePattern, error) {
|
||||||
|
constructor, ok := Registry[name]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("failed to find line pattern %s in Registry", name)
|
||||||
|
}
|
||||||
|
return constructor(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAvailable returns a slice of info for all registered patterns.
|
||||||
|
// This is used to build a settings menu.
|
||||||
|
func ListAvailable() []PatternInfo {
|
||||||
|
var list []PatternInfo
|
||||||
|
for key, constructor := range Registry {
|
||||||
|
pattern := constructor() // Create a temporary instance to get its name
|
||||||
|
list = append(list, PatternInfo{
|
||||||
|
Key: key,
|
||||||
|
DisplayName: pattern.Name(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
// estimateWordWidth provides a basic estimate of the width of a word based on the font and scale.
|
||||||
|
func estimateWordWidth(word string, font *fontManager.FontEntry, scale float32) int32 {
|
||||||
|
if font == nil {
|
||||||
|
// Basic fallback estimate
|
||||||
|
return int32(float32(len(word)) * 10 * scale)
|
||||||
|
}
|
||||||
|
|
||||||
|
total := int32(0)
|
||||||
|
for _, ch := range word {
|
||||||
|
glyph, ok := fontManager.Get().GetGlyph(font.Name, ch)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
total += int32(float32(glyph.Advance) * scale)
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package pattern
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
|
||||||
|
"rhemawavetech.com/rhemawave/internal/fontManager"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SineWavePattern struct {
|
||||||
|
waveStepCounter int
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Register("sine", func() LinePattern { return &SineWavePattern{} })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SineWavePattern) Name() string { return "Sine Wave" }
|
||||||
|
func (s *SineWavePattern) GetNextLine(words []string, wordIndex *int, ctx *LineRenderContext) []string {
|
||||||
|
const (
|
||||||
|
totalLines = 67
|
||||||
|
frequency = 5.0 // cycles across all lines
|
||||||
|
amplitude = 0.85 // how much wave can scale
|
||||||
|
baseRatio = 0.03 // minimum line width as % of screen
|
||||||
|
sharpness = 3.0 // sharpness of the curve
|
||||||
|
)
|
||||||
|
|
||||||
|
screenWidth := int32(800)
|
||||||
|
spaceWidth := int32(10)
|
||||||
|
font := (*fontManager.FontEntry)(nil)
|
||||||
|
fontScale := float32(1.0)
|
||||||
|
|
||||||
|
if ctx != nil {
|
||||||
|
screenWidth = ctx.ScreenWidth
|
||||||
|
spaceWidth = ctx.SpaceWidth
|
||||||
|
font = ctx.Font
|
||||||
|
fontScale = ctx.FontScale
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sharpen the wave (skinnier troughs)
|
||||||
|
// raw := (math.Sin(2*math.Pi*frequency*float64(s.waveStepCounter)/totalLines) + 1.0) / 2.0
|
||||||
|
// waveVal := math.Pow(raw, sharpness)
|
||||||
|
angle := 2 * math.Pi * frequency * float64(s.waveStepCounter) / totalLines
|
||||||
|
waveVal := (math.Sin(angle) + 1.0) / 2.0
|
||||||
|
|
||||||
|
// Target width in pixels
|
||||||
|
targetWidth := int32(float64(screenWidth) * (baseRatio + amplitude*waveVal))
|
||||||
|
|
||||||
|
var line []string
|
||||||
|
var currentWidth int32
|
||||||
|
|
||||||
|
for len(words) > 0 {
|
||||||
|
word := words[*wordIndex%len(words)]
|
||||||
|
*wordIndex++
|
||||||
|
|
||||||
|
if word == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
wordWidth := estimateWordWidth(word, font, fontScale)
|
||||||
|
if len(line) > 0 {
|
||||||
|
wordWidth += spaceWidth
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(line) == 0 {
|
||||||
|
// Always include the first word
|
||||||
|
line = append(line, word)
|
||||||
|
currentWidth += wordWidth
|
||||||
|
} else {
|
||||||
|
// Only add next word if it fits
|
||||||
|
if currentWidth+wordWidth > targetWidth {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
line = append(line, word)
|
||||||
|
currentWidth += wordWidth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.waveStepCounter++
|
||||||
|
return line
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
package shaderManager
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/go-gl/gl/v3.3-core/gl"
|
||||||
|
"rhemawavetech.com/rhemawave/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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() error {
|
||||||
|
var err error
|
||||||
|
once.Do(func() {
|
||||||
|
sm := &ShaderManager{
|
||||||
|
shaders: make(map[string]*Shader),
|
||||||
|
}
|
||||||
|
if e := sm.LoadShaders(); e != nil {
|
||||||
|
err = fmt.Errorf("could not load shaders: %w", 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() error {
|
||||||
|
// Get assets directory from database
|
||||||
|
assetsDir, err := db.GetConfigValue("assets_dir")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("retrieving assets_dir from db: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
srcDir := filepath.Join(assetsDir, "shaders")
|
||||||
|
shaderFiles := make(map[string]string)
|
||||||
|
|
||||||
|
err = filepath.Walk(srcDir, 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,796 @@
|
|||||||
|
# RhemaWave Kiosk Infrastructure — Complete Maintainer Guide
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This document describes the **complete** infrastructure for building, distributing, and deploying the RhemaWave kiosk application on NixOS. It is written so that a new developer can reconstruct the entire system from scratch on a fresh machine.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. Architecture Overview
|
||||||
|
2. Repositories
|
||||||
|
3. Key Files and Secrets — What Lives Where
|
||||||
|
4. Development Machine Setup (From Scratch)
|
||||||
|
5. The RhemaWave Flake (Application)
|
||||||
|
6. The NixConfig Flake (Kiosk System)
|
||||||
|
7. Binary Cache Setup
|
||||||
|
8. Caddy Server Configuration
|
||||||
|
9. Signing Keys
|
||||||
|
10. Sops-Nix Secrets Management
|
||||||
|
11. GitHub Access
|
||||||
|
12. Building and Publishing a New Release
|
||||||
|
13. Deploying a New Kiosk (nixos-anywhere)
|
||||||
|
14. Auto-Update System
|
||||||
|
15. Testing and Debugging
|
||||||
|
16. Common Errors and Solutions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Architecture Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────┐
|
||||||
|
│ Developer Machine (P15) │
|
||||||
|
│ │
|
||||||
|
│ RhemaWave repo NixConfig repo│
|
||||||
|
│ (Go application) (NixOS cfg) │
|
||||||
|
│ │ │ │
|
||||||
|
│ nix build nix flake │
|
||||||
|
│ nix store sign update │
|
||||||
|
│ nix copy git push │
|
||||||
|
│ │ │ │
|
||||||
|
└─────────┼───────────────┼───────┘
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
┌─────────────────┐ ┌──────────┐
|
||||||
|
│ Web Server │ │ GitHub │
|
||||||
|
│ (Caddy) │ │ (private)│
|
||||||
|
│ │ │ │
|
||||||
|
│ Binary cache: │ │ NixConfig│
|
||||||
|
│ repository. │ │ flake │
|
||||||
|
│ rhemawavetech │ │ │
|
||||||
|
│ .com │ │ RhemaWave│
|
||||||
|
│ │ │ flake │
|
||||||
|
│ Static files: │ │ │
|
||||||
|
│ .narinfo + .nar │ └────┬─────┘
|
||||||
|
└────────┬────────┘ │
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
┌─────────────────────────────────┐
|
||||||
|
│ Kiosk Machines │
|
||||||
|
│ │
|
||||||
|
│ systemd timer (every 15min): │
|
||||||
|
│ 1. Fetch NixConfig from │
|
||||||
|
│ GitHub │
|
||||||
|
│ 2. Evaluate flake │
|
||||||
|
│ 3. Download RhemaWave binary │
|
||||||
|
│ from binary cache │
|
||||||
|
│ 4. nixos-rebuild switch │
|
||||||
|
│ │
|
||||||
|
│ Cage compositor → RhemaWave │
|
||||||
|
│ (fullscreen, locked, no TTY │
|
||||||
|
│ switching, auto-restart) │
|
||||||
|
└─────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Repositories
|
||||||
|
|
||||||
|
| Repo | URL | Branch | Contents |
|
||||||
|
|------|-----|--------|----------|
|
||||||
|
| RhemaWave | `github:Rhema-Wave-Technologies/RhemaWave` | `dev` | Go application source, `flake.nix` (package + NixOS module), `nixos/module.nix` |
|
||||||
|
| NixConfig | `github:Rhema-Wave-Technologies/NixConfig` | `main` | NixOS kiosk configuration, disk layout, encrypted secrets, sops config |
|
||||||
|
|
||||||
|
Both repositories are **private**. Access requires a GitHub fine-grained personal access token with read-only Contents permission on both repos.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Key Files and Secrets — What Lives Where
|
||||||
|
|
||||||
|
### On the Developer Machine
|
||||||
|
|
||||||
|
| File | Path | Purpose | Backup? |
|
||||||
|
|------|------|---------|---------|
|
||||||
|
| Binary cache private key | `~/Keys/nix/cache/rhemawave/cache-priv-key.pem` | Signs packages for the binary cache | **CRITICAL** — without this, you cannot publish new builds. Back up to encrypted offline storage. |
|
||||||
|
| Binary cache public key | `~/Keys/nix/cache/rhemawave/cache-pub-key.pem` | Distributed to all client machines for verification | Can be regenerated from private key |
|
||||||
|
| Sops age private key | `~/Keys/nix/sops/age-key.txt` | Encrypts/decrypts secrets in NixConfig | **CRITICAL** — without this, you cannot edit secrets. Back up to encrypted offline storage. |
|
||||||
|
| GitHub access token | `~/.config/nix/nix.conf` | Nix uses this to access private GitHub repos | Can be regenerated from GitHub |
|
||||||
|
| Sops age key env var | `~/.bashrc` | `SOPS_AGE_KEY_FILE=~/Keys/nix/sops/age-key.txt` | Convention only |
|
||||||
|
|
||||||
|
### On the Web Server
|
||||||
|
|
||||||
|
| File | Path | Purpose |
|
||||||
|
|------|------|---------|
|
||||||
|
| Binary cache files | `/var/lib/nix-cache/` | Static files served by Caddy |
|
||||||
|
| `nix-cache-info` | `/var/lib/nix-cache/nix-cache-info` | Metadata file for Nix clients |
|
||||||
|
| Caddy config | Caddyfile (location varies) | Serves the cache directory |
|
||||||
|
|
||||||
|
The server does **not** need any signing keys. It is a dumb static file host.
|
||||||
|
|
||||||
|
### On Each Kiosk
|
||||||
|
|
||||||
|
| File | Path | Purpose |
|
||||||
|
|------|------|---------|
|
||||||
|
| SSH host key (private) | `/etc/ssh/ssh_host_ed25519_key` | Used by sops-nix to decrypt secrets |
|
||||||
|
| SSH host key (public) | `/etc/ssh/ssh_host_ed25519_key.pub` | Converted to age key for sops `.sops.yaml` |
|
||||||
|
| Decrypted secrets | `/run/secrets/*` | Runtime-only, never persisted to disk |
|
||||||
|
|
||||||
|
### What to Back Up (Priority Order)
|
||||||
|
|
||||||
|
1. **`~/Keys/nix/cache/rhemawave/cache-priv-key.pem`** — If lost, you must generate a new key pair and update the public key in every kiosk configuration and redeploy.
|
||||||
|
2. **`~/Keys/nix/sops/age-key.txt`** — If lost, you must re-encrypt all secrets with a new key.
|
||||||
|
3. **GitHub token** — Easily regenerated, low priority.
|
||||||
|
|
||||||
|
Store backups on an encrypted USB drive or a password manager's secure notes. Never store these in any Git repository, cloud drive, or unencrypted medium.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Development Machine Setup (From Scratch)
|
||||||
|
|
||||||
|
Starting from a fresh NixOS installation on a development machine.
|
||||||
|
|
||||||
|
### 4.1 NixOS Configuration Prerequisites
|
||||||
|
|
||||||
|
In your system `configuration.nix`:
|
||||||
|
|
||||||
|
```nix
|
||||||
|
nix.settings = {
|
||||||
|
experimental-features = [ "nix-command" "flakes" ];
|
||||||
|
trusted-users = [ "root" "yourusername" ];
|
||||||
|
substituters = [
|
||||||
|
"https://cache.nixos.org"
|
||||||
|
"https://repository.rhemawavetech.com"
|
||||||
|
];
|
||||||
|
trusted-public-keys = [
|
||||||
|
"cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY="
|
||||||
|
"repository.rhemawavetech.com-1:hbaEpYz5jE9SeNgkF2jAgjMtjXzruj7lEk6Lvx9ll40="
|
||||||
|
];
|
||||||
|
};
|
||||||
|
nixpkgs.config.allowUnfree = true;
|
||||||
|
```
|
||||||
|
|
||||||
|
Rebuild: `sudo nixos-rebuild switch`
|
||||||
|
|
||||||
|
### 4.2 Add the RhemaWave Flake to Your System Config
|
||||||
|
|
||||||
|
In your system `flake.nix`, add the input and package:
|
||||||
|
|
||||||
|
```nix
|
||||||
|
inputs.rhemawave = {
|
||||||
|
url = "github:Rhema-Wave-Technologies/RhemaWave/dev";
|
||||||
|
inputs.nixpkgs.follows = "nixpkgs";
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `rhemawave` to the `outputs` function arguments, then add to a module:
|
||||||
|
|
||||||
|
```nix
|
||||||
|
{
|
||||||
|
environment.systemPackages = [
|
||||||
|
rhemawave.packages.x86_64-linux.default
|
||||||
|
];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 GitHub Access Token
|
||||||
|
|
||||||
|
Generate a fine-grained personal access token at `github.com/settings/tokens`:
|
||||||
|
- Resource owner: `Rhema-Wave-Technologies`
|
||||||
|
- Repository access: Select `NixConfig` and `RhemaWave`
|
||||||
|
- Permissions: Contents → Read-only
|
||||||
|
- Expiration: Set as appropriate
|
||||||
|
|
||||||
|
Configure Nix to use it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.config/nix
|
||||||
|
echo "access-tokens = github.com=YOUR_TOKEN_HERE" > ~/.config/nix/nix.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
Also copy to root (needed for `sudo` operations):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p /root/.config/nix
|
||||||
|
sudo cp ~/.config/nix/nix.conf /root/.config/nix/nix.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 Signing Keys
|
||||||
|
|
||||||
|
If you're taking over from an existing maintainer, obtain:
|
||||||
|
- `cache-priv-key.pem` → save to `~/Keys/nix/cache/rhemawave/cache-priv-key.pem`
|
||||||
|
- `age-key.txt` → save to `~/Keys/nix/sops/age-key.txt`
|
||||||
|
|
||||||
|
If starting fresh (new cache):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/Keys/nix/cache/rhemawave
|
||||||
|
nix key generate-secret --key-name repository.rhemawavetech.com-1 > ~/Keys/nix/cache/rhemawave/cache-priv-key.pem
|
||||||
|
nix key convert-secret-to-public < ~/Keys/nix/cache/rhemawave/cache-priv-key.pem > ~/Keys/nix/cache/rhemawave/cache-pub-key.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
If starting fresh (new sops key):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/Keys/nix/sops
|
||||||
|
nix shell nixpkgs#age -c age-keygen -o ~/Keys/nix/sops/age-key.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
Note the public key (starts with `age1...`) — it goes in `.sops.yaml`.
|
||||||
|
|
||||||
|
### 4.5 Sops Environment Variable
|
||||||
|
|
||||||
|
Add to `~/.bashrc`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo 'export SOPS_AGE_KEY_FILE=~/Keys/nix/sops/age-key.txt' >> ~/.bashrc
|
||||||
|
source ~/.bashrc
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.6 Clone the Repositories
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/Work/RhemaWave
|
||||||
|
cd ~/Work/RhemaWave
|
||||||
|
git clone git@github.com:Rhema-Wave-Technologies/RhemaWave.git
|
||||||
|
git clone git@github.com:Rhema-Wave-Technologies/NixConfig.git
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.7 Verify Everything Works
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test GitHub access
|
||||||
|
cd ~/Work/RhemaWave/NixConfig
|
||||||
|
nix flake show github:Rhema-Wave-Technologies/NixConfig
|
||||||
|
|
||||||
|
# Test RhemaWave build
|
||||||
|
cd ~/Work/RhemaWave/RhemaWave
|
||||||
|
nix build .#default
|
||||||
|
|
||||||
|
# Test binary runs
|
||||||
|
./result/bin/rhemawave
|
||||||
|
|
||||||
|
# Test secrets decryption
|
||||||
|
cd ~/Work/RhemaWave/NixConfig
|
||||||
|
SOPS_AGE_KEY_FILE=~/Keys/nix/sops/age-key.txt nix shell nixpkgs#sops -c sops secrets.yaml
|
||||||
|
# Should open in editor with plaintext values
|
||||||
|
# Close without saving
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. The RhemaWave Flake (Application)
|
||||||
|
|
||||||
|
Located at `~/Work/RhemaWave/RhemaWave/flake.nix`.
|
||||||
|
|
||||||
|
### Key Points
|
||||||
|
|
||||||
|
- Uses `buildGoModule` with `vendorHash = null` (vendor directory committed to repo)
|
||||||
|
- CGo is enabled (`env.CGO_ENABLED = "1"`)
|
||||||
|
- SDL2 mixer/ttf/image flags are set in `preBuild` via `pkg-config`
|
||||||
|
- The `share/` directory is copied to `$out/share` in `postInstall`
|
||||||
|
- License is set to `unfree` — consumers must set `allowUnfreePredicate`
|
||||||
|
- Exports `nixosModules.default` (the kiosk module) alongside per-system packages and dev shells
|
||||||
|
|
||||||
|
### Flake Outputs
|
||||||
|
|
||||||
|
| Output | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| `packages.<system>.default` | The RhemaWave binary |
|
||||||
|
| `devShells.<system>.default` | Development shell with Go + SDL2 deps |
|
||||||
|
| `nixosModules.default` | NixOS module for kiosk configuration (greetd, cage, PipeWire, polkit) |
|
||||||
|
|
||||||
|
### The NixOS Module (`nixos/module.nix`)
|
||||||
|
|
||||||
|
Imported via `import ./nixos/module.nix self` — the `self` argument gives the module access to the flake's packages. The module exposes `services.rhemawave.*` options.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. The NixConfig Flake (Kiosk System)
|
||||||
|
|
||||||
|
Located at `~/Work/RhemaWave/NixConfig/flake.nix`.
|
||||||
|
|
||||||
|
### Flake Inputs
|
||||||
|
|
||||||
|
```nix
|
||||||
|
nixpkgs → github:NixOS/nixpkgs/nixpkgs-unstable
|
||||||
|
disko → github:nix-community/disko
|
||||||
|
nixos-facter → github:numtide/nixos-facter-modules
|
||||||
|
rhemawave → github:Rhema-Wave-Technologies/RhemaWave/dev
|
||||||
|
sops-nix → github:Mic92/sops-nix
|
||||||
|
```
|
||||||
|
|
||||||
|
All inputs follow `nixpkgs` to ensure a single nixpkgs version.
|
||||||
|
|
||||||
|
### System Configuration Modules
|
||||||
|
|
||||||
|
The NixOS system (`rhemawavekiosk`) loads these modules:
|
||||||
|
1. `disko.nixosModules.disko` — disk partitioning
|
||||||
|
2. `rhemawave.nixosModules.default` — RhemaWave kiosk module
|
||||||
|
3. `sops-nix.nixosModules.sops` — secrets management
|
||||||
|
4. `./configuration.nix` — main system config
|
||||||
|
5. Hardware facter report
|
||||||
|
6. Inline module adding `rhemawave.packages.x86_64-linux.default` to `environment.systemPackages`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Binary Cache Setup
|
||||||
|
|
||||||
|
### What It Is
|
||||||
|
|
||||||
|
A Nix binary cache is a static HTTP file server with a specific directory structure. When a NixOS machine needs a package, it checks each configured cache by making HTTP GET requests for `.narinfo` files. If found and the signature is valid, it downloads the pre-built `.nar.xz` archive instead of building from source.
|
||||||
|
|
||||||
|
### Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
/var/lib/nix-cache/
|
||||||
|
├── nix-cache-info # Cache metadata
|
||||||
|
├── fv5iinvsldz9l2s8ikm0mw8ykhriad1g.narinfo # Package metadata + signature
|
||||||
|
├── nar/
|
||||||
|
│ └── 1j7d9cif4xjq7i5rw5dmivxyninh7z1cm7zvdknsp8vjd9rjmzkc.nar.xz # Compressed package
|
||||||
|
└── ... (more .narinfo and .nar.xz files)
|
||||||
|
```
|
||||||
|
|
||||||
|
### nix-cache-info
|
||||||
|
|
||||||
|
```
|
||||||
|
StoreDir: /nix/store
|
||||||
|
WantMassQuery: 1
|
||||||
|
Priority: 40
|
||||||
|
```
|
||||||
|
|
||||||
|
This file must exist at the root. `Priority: 40` means this cache is checked after the official NixOS cache (priority 30).
|
||||||
|
|
||||||
|
### Creating the Cache (One-Time)
|
||||||
|
|
||||||
|
If setting up a new cache server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# On the server
|
||||||
|
sudo mkdir -p /var/lib/nix-cache
|
||||||
|
cat > /var/lib/nix-cache/nix-cache-info <<EOF
|
||||||
|
StoreDir: /nix/store
|
||||||
|
WantMassQuery: 1
|
||||||
|
Priority: 40
|
||||||
|
EOF
|
||||||
|
```
|
||||||
|
|
||||||
|
Then configure Caddy (see section 8).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Caddy Server Configuration
|
||||||
|
|
||||||
|
The web server at `repository.rhemawavetech.com` runs Caddy with a `file_server` directive pointing at the cache directory.
|
||||||
|
|
||||||
|
### Caddyfile
|
||||||
|
|
||||||
|
```caddyfile
|
||||||
|
repository.rhemawavetech.com {
|
||||||
|
root * /var/lib/nix-cache
|
||||||
|
file_server browse
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `browse` directive is optional (useful for debugging). Caddy handles TLS automatically via Let's Encrypt.
|
||||||
|
|
||||||
|
### Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl https://repository.rhemawavetech.com/nix-cache-info
|
||||||
|
```
|
||||||
|
|
||||||
|
Should return the three-line metadata file.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Signing Keys
|
||||||
|
|
||||||
|
### How Signing Works
|
||||||
|
|
||||||
|
1. The developer builds a package with `nix build`
|
||||||
|
2. The store path is signed with the private key: `nix store sign --key-file ...`
|
||||||
|
3. The signed package is exported to cache format: `nix copy --to file:// ...`
|
||||||
|
4. The `.narinfo` file contains a `Sig:` line with the key name and signature
|
||||||
|
5. Client machines verify the signature against the public key in `trusted-public-keys`
|
||||||
|
|
||||||
|
### Key Name
|
||||||
|
|
||||||
|
The key name `repository.rhemawavetech.com-1` is embedded in both the key files and the `.narinfo` signatures. If you generate new keys, use the same naming convention with an incremented number (e.g., `-2`).
|
||||||
|
|
||||||
|
### Generating New Keys
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nix key generate-secret --key-name repository.rhemawavetech.com-1 > cache-priv-key.pem
|
||||||
|
nix key convert-secret-to-public < cache-priv-key.pem > cache-pub-key.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
### Deriving Public Key from Private Key
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nix key convert-secret-to-public < ~/Keys/nix/cache/rhemawave/cache-priv-key.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
This outputs the public key string. It should match what's in every client's `trusted-public-keys`.
|
||||||
|
|
||||||
|
### Current Public Key
|
||||||
|
|
||||||
|
```
|
||||||
|
repository.rhemawavetech.com-1:hbaEpYz5jE9SeNgkF2jAgjMtjXzruj7lEk6Lvx9ll40=
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Sops-Nix Secrets Management
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
|
||||||
|
1. Secrets are encrypted in `secrets.yaml` using age public keys
|
||||||
|
2. `.sops.yaml` defines which keys can encrypt/decrypt
|
||||||
|
3. Each kiosk's SSH host key is converted to an age key for decryption
|
||||||
|
4. On activation, sops-nix decrypts secrets and places them at `/run/secrets/<name>`
|
||||||
|
|
||||||
|
### .sops.yaml
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
keys:
|
||||||
|
- &admin age1ptpn9zee8x7vjaaktx9dv65vysdjfmmgtj96wc3l49vd8g8huy6syk8l26
|
||||||
|
- &kiosk-dev age1nmn0r66hl24wrz73q5htt0r90vfse9dvyjwae2evqwrealfgycfsy3apmy
|
||||||
|
creation_rules:
|
||||||
|
- path_regex: secrets\.yaml$
|
||||||
|
key_groups:
|
||||||
|
- age:
|
||||||
|
- *admin
|
||||||
|
- *kiosk-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adding a New Kiosk
|
||||||
|
|
||||||
|
1. Get the kiosk's SSH host public key:
|
||||||
|
```bash
|
||||||
|
ssh root@KIOSK_IP "cat /etc/ssh/ssh_host_ed25519_key.pub"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Convert to age key:
|
||||||
|
```bash
|
||||||
|
nix shell nixpkgs#ssh-to-age -c ssh-to-age < <(ssh root@KIOSK_IP "cat /etc/ssh/ssh_host_ed25519_key.pub")
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Add the age key to `.sops.yaml` under `keys:` with a descriptive anchor (e.g., `&kiosk-prod-001`)
|
||||||
|
|
||||||
|
4. Add the anchor reference to the creation rule's age list
|
||||||
|
|
||||||
|
5. Re-encrypt secrets with the new key:
|
||||||
|
```bash
|
||||||
|
SOPS_AGE_KEY_FILE=~/Keys/nix/sops/age-key.txt nix shell nixpkgs#sops -c sops updatekeys secrets.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
6. Commit and push `.sops.yaml` and `secrets.yaml`
|
||||||
|
|
||||||
|
### Editing Secrets
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/Work/RhemaWave/NixConfig
|
||||||
|
SOPS_AGE_KEY_FILE=~/Keys/nix/sops/age-key.txt nix shell nixpkgs#sops -c sops secrets.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
The file opens in your editor with plaintext values. Edit, save, close — sops re-encrypts automatically.
|
||||||
|
|
||||||
|
### Secret Format for github-token
|
||||||
|
|
||||||
|
The `github-token` secret must contain the full nix.conf line (because it's included via `!include`):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
github-token: "access-tokens = github.com=ghp_yourtokenhere"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. GitHub Access
|
||||||
|
|
||||||
|
### Developer Machine
|
||||||
|
|
||||||
|
The token is stored in `~/.config/nix/nix.conf`:
|
||||||
|
|
||||||
|
```
|
||||||
|
access-tokens = github.com=YOUR_TOKEN_HERE
|
||||||
|
```
|
||||||
|
|
||||||
|
Also needs to be in `/root/.config/nix/nix.conf` for `sudo` operations.
|
||||||
|
|
||||||
|
Flake URLs use the `github:` scheme (not `git+https://`), which reads the `access-tokens` setting.
|
||||||
|
|
||||||
|
### Kiosk Machines
|
||||||
|
|
||||||
|
The token is managed via sops-nix and included in `nix.conf` via `!include /run/secrets/github-token`.
|
||||||
|
|
||||||
|
### Token Scope
|
||||||
|
|
||||||
|
Fine-grained personal access token:
|
||||||
|
- Resource owner: `Rhema-Wave-Technologies`
|
||||||
|
- Repositories: `NixConfig`, `RhemaWave`
|
||||||
|
- Permissions: Contents → Read-only
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Building and Publishing a New Release
|
||||||
|
|
||||||
|
Complete procedure, every command, in order. All commands run on your development machine.
|
||||||
|
|
||||||
|
### Step 1: Commit Application Changes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/Work/RhemaWave/RhemaWave
|
||||||
|
|
||||||
|
# If go.mod/go.sum changed:
|
||||||
|
nix shell nixpkgs#go -c go mod vendor
|
||||||
|
git add vendor/
|
||||||
|
|
||||||
|
git add -A
|
||||||
|
git commit -m "your changes"
|
||||||
|
git push origin dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nix build .#default
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Sign
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nix store sign --key-file ~/Keys/nix/cache/rhemawave/cache-priv-key.pem $(nix path-info ./result)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Export to Cache Format
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm -rf /tmp/nix-cache
|
||||||
|
nix copy --to file:///tmp/nix-cache ./result --secret-key-files ~/Keys/nix/cache/rhemawave/cache-priv-key.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 5: Verify Signature
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat /tmp/nix-cache/*.narinfo | grep Sig
|
||||||
|
```
|
||||||
|
|
||||||
|
Must show `Sig: repository.rhemawavetech.com-1:...`
|
||||||
|
|
||||||
|
### Step 6: Upload to Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rsync -av /tmp/nix-cache/ YOUR_USER@your-server:/var/lib/nix-cache/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 7: Bump NixConfig
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/Work/RhemaWave/NixConfig
|
||||||
|
nix flake update rhemawave
|
||||||
|
git add flake.lock
|
||||||
|
git commit -m "bump rhemawave"
|
||||||
|
git push
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 8: Wait
|
||||||
|
|
||||||
|
Kiosks pick up the update within 15 minutes via the auto-update timer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Deploying a New Kiosk (nixos-anywhere)
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Target machine booted from NixOS installer USB
|
||||||
|
- SSH access to the target as root
|
||||||
|
- Target's IP address known
|
||||||
|
|
||||||
|
### Procedure
|
||||||
|
|
||||||
|
From your development machine:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/Work/RhemaWave/NixConfig
|
||||||
|
|
||||||
|
# Deploy
|
||||||
|
nix run github:nix-community/nixos-anywhere -- --flake .#rhemawavekiosk root@TARGET_IP
|
||||||
|
```
|
||||||
|
|
||||||
|
This:
|
||||||
|
1. kexec's into the NixOS installer on the target
|
||||||
|
2. Partitions the disk according to `disk-config.nix`
|
||||||
|
3. Installs NixOS with the full kiosk configuration
|
||||||
|
4. Reboots
|
||||||
|
|
||||||
|
**Note:** Uses the local flake (`.#`) so it can access local files and secrets. The `--flake` path must be the NixConfig directory.
|
||||||
|
|
||||||
|
### After Deployment
|
||||||
|
|
||||||
|
1. Get the kiosk's age key (see section 10, "Adding a New Kiosk")
|
||||||
|
2. Add it to `.sops.yaml` and re-encrypt secrets
|
||||||
|
3. Push changes
|
||||||
|
4. The kiosk will pick up the updated secrets on its next auto-update cycle
|
||||||
|
|
||||||
|
### If nixos-rebuild --target-host Fails with Bootloader Errors
|
||||||
|
|
||||||
|
Use the manual three-step deploy instead:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nix build .#nixosConfigurations.rhemawavekiosk.config.system.build.toplevel
|
||||||
|
nix copy --to ssh://root@TARGET_IP ./result
|
||||||
|
ssh root@TARGET_IP "$(readlink -f ./result)/bin/switch-to-configuration switch"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Auto-Update System
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
|
||||||
|
Each kiosk runs a systemd timer (`kiosk-auto-update.timer`) that fires every 15 minutes. The associated service runs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
nixos-rebuild switch --flake github:Rhema-Wave-Technologies/NixConfig#rhemawavekiosk
|
||||||
|
```
|
||||||
|
|
||||||
|
This:
|
||||||
|
1. Fetches the latest NixConfig flake from GitHub (using the encrypted GitHub token)
|
||||||
|
2. Evaluates the configuration (including the pinned RhemaWave version from `flake.lock`)
|
||||||
|
3. Checks the binary cache for pre-built packages
|
||||||
|
4. Downloads any new packages
|
||||||
|
5. Switches to the new configuration
|
||||||
|
|
||||||
|
### What Triggers an Update
|
||||||
|
|
||||||
|
An update is triggered when either:
|
||||||
|
- `flake.lock` changes (new RhemaWave version pinned)
|
||||||
|
- `configuration.nix` or other NixConfig files change
|
||||||
|
- Any flake input is updated
|
||||||
|
|
||||||
|
If nothing has changed, `nixos-rebuild` is a no-op.
|
||||||
|
|
||||||
|
### Monitoring
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check timer status
|
||||||
|
ssh root@KIOSK_IP "systemctl status kiosk-auto-update.timer"
|
||||||
|
|
||||||
|
# Check last update result
|
||||||
|
ssh root@KIOSK_IP "systemctl status kiosk-auto-update.service"
|
||||||
|
|
||||||
|
# Full update logs
|
||||||
|
ssh root@KIOSK_IP "journalctl -u kiosk-auto-update -n 100"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Force Immediate Update
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh root@KIOSK_IP "systemctl start kiosk-auto-update"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Testing and Debugging
|
||||||
|
|
||||||
|
### SSH into a Running Kiosk
|
||||||
|
|
||||||
|
Even with the kiosk locked down (cage compositor, single TTY), SSH always works:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh root@KIOSK_IP
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check What's Running
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh root@KIOSK_IP "systemctl status greetd"
|
||||||
|
ssh root@KIOSK_IP "systemctl status kiosk-auto-update.timer"
|
||||||
|
ssh root@KIOSK_IP "ls -la /run/secrets/"
|
||||||
|
ssh root@KIOSK_IP "cat /run/secrets/wifi-psk" # verify decryption
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check Binary Cache Connectivity
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh root@KIOSK_IP "curl -s https://repository.rhemawavetech.com/nix-cache-info"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check GitHub Connectivity
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh root@KIOSK_IP "cat /etc/nix/nix.conf | grep access-tokens"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Restart the Kiosk Application
|
||||||
|
|
||||||
|
If the application crashes or you want to restart it without a full reboot:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh root@KIOSK_IP "systemctl restart greetd"
|
||||||
|
```
|
||||||
|
|
||||||
|
greetd will restart, auto-login the kiosk user, and re-launch rhemawave in cage.
|
||||||
|
|
||||||
|
### View Application Logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ssh root@KIOSK_IP "journalctl -u greetd -n 100"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test a Configuration Change Without Pushing
|
||||||
|
|
||||||
|
Build and deploy directly from your development machine:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/Work/RhemaWave/NixConfig
|
||||||
|
nix build .#nixosConfigurations.rhemawavekiosk.config.system.build.toplevel
|
||||||
|
nix copy --to ssh://root@KIOSK_IP ./result
|
||||||
|
ssh root@KIOSK_IP "$(readlink -f ./result)/bin/switch-to-configuration switch"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 16. Common Errors and Solutions
|
||||||
|
|
||||||
|
### `env attribute set cannot contain any attributes passed to derivation`
|
||||||
|
|
||||||
|
**Cause:** `CGO_ENABLED` set as both a top-level attribute and in `env`.
|
||||||
|
**Fix:** Use only `env.CGO_ENABLED = "1";` in the `buildGoModule` block.
|
||||||
|
|
||||||
|
### `vendor/modules.txt` inconsistency
|
||||||
|
|
||||||
|
**Cause:** `go.mod` changed but `vendor/` wasn't updated.
|
||||||
|
**Fix:** Run `nix shell nixpkgs#go -c go mod vendor`, then `git add vendor/` and commit.
|
||||||
|
|
||||||
|
### `unfree license, refusing to evaluate`
|
||||||
|
|
||||||
|
**Cause:** Missing `allowUnfreePredicate` in the nixpkgs config.
|
||||||
|
**Fix:** Add `config.allowUnfreePredicate = pkg: (nixpkgs.lib.getName pkg) == "rhemawave";` to the `pkgs` import.
|
||||||
|
|
||||||
|
### `warning: ignoring the client-specified setting 'secret-key-files'`
|
||||||
|
|
||||||
|
**Cause:** Your user isn't in `trusted-users`.
|
||||||
|
**Fix:** Add your username to `nix.settings.trusted-users` and rebuild your NixOS system.
|
||||||
|
|
||||||
|
### `invalid character in Base64 string`
|
||||||
|
|
||||||
|
**Cause:** The `trusted-public-keys` value is malformed — likely doubled key name or invisible characters.
|
||||||
|
**Fix:** Regenerate the public key: `nix key convert-secret-to-public < cache-priv-key.pem` and paste it fresh into the config.
|
||||||
|
|
||||||
|
### `attribute 'nixosModules' missing`
|
||||||
|
|
||||||
|
**Cause:** The `flake.lock` points to an old RhemaWave commit that doesn't export `nixosModules`.
|
||||||
|
**Fix:** Run `nix flake update rhemawave` in the NixConfig directory.
|
||||||
|
|
||||||
|
### `path '/home/...' is forbidden in pure evaluation mode`
|
||||||
|
|
||||||
|
**Cause:** Using absolute paths (like `builtins.readFile /home/...`) in a flake. Flakes evaluate in pure mode.
|
||||||
|
**Fix:** Use relative paths (`./file.txt`) or manage secrets with sops-nix.
|
||||||
|
|
||||||
|
### `access to absolute path '/home' is forbidden`
|
||||||
|
|
||||||
|
**Cause:** Same as above — `readFile` with an absolute path.
|
||||||
|
**Fix:** Use sops-nix for secrets or deploy with the local flake (`.#`) instead of `github:`.
|
||||||
|
|
||||||
|
### Bootloader errors during remote `nixos-rebuild --target-host`
|
||||||
|
|
||||||
|
**Cause:** GRUB can't detect the disk layout properly over SSH.
|
||||||
|
**Fix:** Use the three-step manual deploy: `nix build` → `nix copy --to ssh://` → `switch-to-configuration switch`.
|
||||||
|
|
||||||
|
### SSH host key changed warning
|
||||||
|
|
||||||
|
**Cause:** After reinstalling a kiosk, its SSH host key changes.
|
||||||
|
**Fix:** `ssh-keygen -R KIOSK_IP` to remove the old key, then reconnect.
|
||||||
|
|
||||||
|
### Auto-update not working on kiosk
|
||||||
|
|
||||||
|
**Check:**
|
||||||
|
1. Timer is active: `systemctl status kiosk-auto-update.timer`
|
||||||
|
2. Service logs: `journalctl -u kiosk-auto-update -n 50`
|
||||||
|
3. GitHub token is decrypted: `cat /etc/nix/nix.conf | grep access-tokens`
|
||||||
|
4. Network connectivity: `curl https://github.com`
|
||||||
|
5. Cache connectivity: `curl https://repository.rhemawavetech.com/nix-cache-info`
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
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;
|
||||||
|
default = self.packages.${pkgs.system}.default;
|
||||||
|
description = "The RhemaWave package to use.";
|
||||||
|
};
|
||||||
|
|
||||||
|
# There should not be a "kioskUser" defined in the program
|
||||||
|
# That should be defined by the system, so everything kioskUser related
|
||||||
|
# has been commented out.
|
||||||
|
|
||||||
|
user = lib.mkOption {
|
||||||
|
type = lib.types.str;
|
||||||
|
default = "kiosk";
|
||||||
|
description = "User account that runs the kiosk session.";
|
||||||
|
};
|
||||||
|
|
||||||
|
# extraGroups = lib.mkOption {
|
||||||
|
# type = lib.types.listOf lib.types.str;
|
||||||
|
# default = [];
|
||||||
|
# description = "Additional groups for the kiosk user.";
|
||||||
|
# };
|
||||||
|
|
||||||
|
autoVolume = lib.mkOption {
|
||||||
|
type = lib.types.bool;
|
||||||
|
default = true;
|
||||||
|
description = "Set all PipeWire sinks to full volume before launching.";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
config = lib.mkIf cfg.enable {
|
||||||
|
# # --- Kiosk user ---
|
||||||
|
# users.users.${cfg.kioskUser} = {
|
||||||
|
# isNormalUser = true;
|
||||||
|
# group = cfg.kioskUser;
|
||||||
|
# extraGroups = [ "audio" "video" "input" "networkmanager" ] ++ cfg.extraGroups;
|
||||||
|
# };
|
||||||
|
# users.groups.${cfg.kioskUser} = {};
|
||||||
|
|
||||||
|
# --- Display: agetty + cage ---
|
||||||
|
|
||||||
|
|
||||||
|
# --- Environment ---
|
||||||
|
environment.variables.SDL_VIDEODRIVER = "wayland";
|
||||||
|
|
||||||
|
# --- Networking ---
|
||||||
|
networking.networkmanager.enable = true;
|
||||||
|
|
||||||
|
# --- Audio: PipeWire + WirePlumber ---
|
||||||
|
services.pipewire = {
|
||||||
|
enable = true;
|
||||||
|
alsa.enable = true;
|
||||||
|
pulse.enable = true;
|
||||||
|
wireplumber.enable = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
# --- Polkit rules ---
|
||||||
|
security.polkit.extraConfig = ''
|
||||||
|
polkit.addRule(function(action, subject) {
|
||||||
|
// Allow restarting getty@tty1
|
||||||
|
if (action.id == "org.freedesktop.systemd1.manage-units") {
|
||||||
|
var unit = action.lookup("unit");
|
||||||
|
if (unit == "getty@tty1.service") {
|
||||||
|
return polkit.Result.YES;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
'';
|
||||||
|
|
||||||
|
# --- Make the package available system-wide ---
|
||||||
|
environment.systemPackages = [ cfg.package ];
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// Allow kiosk user to run apt update, apt install for rhemawave packages,
|
||||||
|
// and restart getty for application reloading
|
||||||
|
polkit.addRule(function(action, subject) {
|
||||||
|
if (action.id == "org.freedesktop.policykit.exec" &&
|
||||||
|
subject.user == "kiosk") {
|
||||||
|
|
||||||
|
var program = action.lookup("program");
|
||||||
|
var cmdline = action.lookup("command_line");
|
||||||
|
|
||||||
|
// Allow dpkg --configure -a
|
||||||
|
if (program == "/usr/bin/dpkg") {
|
||||||
|
if (cmdline && cmdline.indexOf("--configure") >= 0) {
|
||||||
|
return polkit.Result.YES;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow apt update
|
||||||
|
if (program == "/usr/bin/apt") {
|
||||||
|
if (cmdline && cmdline.indexOf("update") >= 0) {
|
||||||
|
return polkit.Result.YES;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow: apt install --only-upgrade rhemawave[-dev]
|
||||||
|
if (cmdline &&
|
||||||
|
cmdline.indexOf("install") >= 0 &&
|
||||||
|
cmdline.indexOf("--only-upgrade") >= 0 &&
|
||||||
|
cmdline.indexOf("rhemawave") >= 0) {
|
||||||
|
return polkit.Result.YES;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow systemctl restart getty@tty1.service
|
||||||
|
if (program == "/usr/bin/systemctl") {
|
||||||
|
if (cmdline &&
|
||||||
|
cmdline.indexOf("restart") >= 0 &&
|
||||||
|
cmdline.indexOf("getty@tty1.service") >= 0) {
|
||||||
|
return polkit.Result.YES;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow sh for Tailscale install script
|
||||||
|
if (program == "/usr/bin/sh" || program == "/bin/sh") {
|
||||||
|
if (cmdline && cmdline.indexOf("tailscale.com/install.sh") >= 0) {
|
||||||
|
return polkit.Result.YES;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow tailscale set --operator
|
||||||
|
if (program == "/usr/bin/tailscale") {
|
||||||
|
if (cmdline && cmdline.indexOf("set") >= 0 && cmdline.indexOf("--operator") >= 0) {
|
||||||
|
return polkit.Result.YES;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[Service]
|
||||||
|
ExecStart=
|
||||||
|
ExecStart=-/sbin/agetty --autologin kiosk --noclear %I $TERM
|
||||||
Executable
+24
@@ -0,0 +1,24 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Post-installation script for RhemaWave
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Create kiosk group if it doesn't exist
|
||||||
|
if ! getent group kiosk > /dev/null 2>&1; then
|
||||||
|
echo "Creating kiosk group..."
|
||||||
|
groupadd --system kiosk
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create kiosk user if it doesn't exist
|
||||||
|
if ! getent passwd kiosk > /dev/null 2>&1; then
|
||||||
|
echo "Creating kiosk user..."
|
||||||
|
useradd --system --gid kiosk --create-home --home-dir /home/kiosk \
|
||||||
|
--shell /bin/bash --comment "RhemaWave Kiosk User" kiosk
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Set proper group ownership for writable directories
|
||||||
|
chgrp -R kiosk /usr/local/share/rhemawave
|
||||||
|
|
||||||
|
# Ensure kiosk user can write to the data directory
|
||||||
|
chmod g+w /usr/local/share/rhemawave
|
||||||
|
|
||||||
|
echo "✓ RhemaWave post-install complete"
|
||||||
Executable
+16
@@ -0,0 +1,16 @@
|
|||||||
|
# RhemaWave kiosk auto-launch on tty1
|
||||||
|
if [ -z "$WAYLAND_DISPLAY" ] && [ "$(tty)" = "/dev/tty1" ]; then
|
||||||
|
export XDG_RUNTIME_DIR=/run/user/$(id -u)
|
||||||
|
export SDL_VIDEODRIVER=wayland
|
||||||
|
|
||||||
|
# Check for updates before launching
|
||||||
|
/usr/bin/python3 /usr/local/bin/rhemawave-updater.py || true
|
||||||
|
|
||||||
|
# Set all sinks to full volume
|
||||||
|
for sink in $(wpctl status | grep -oP '^\s+\K\d+(?=\.)' | head -10); do
|
||||||
|
wpctl set-volume "$sink" 1.0 2>/dev/null
|
||||||
|
done
|
||||||
|
|
||||||
|
exec cage -- /usr/local/bin/rhemawave
|
||||||
|
fi
|
||||||
|
|
||||||
Executable
+564
@@ -0,0 +1,564 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
rhemawave-updater.py
|
||||||
|
|
||||||
|
Auto-updater for RhemaWave kiosk application.
|
||||||
|
Uses apt to check for and install updates from the configured repository.
|
||||||
|
|
||||||
|
USAGE:
|
||||||
|
Run as kiosk user:
|
||||||
|
python3 /usr/local/bin/rhemawave-updater.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import subprocess
|
||||||
|
import json
|
||||||
|
import socket
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# Configuration - templated by build script
|
||||||
|
PACKAGE_NAME = "__CHANNEL__"
|
||||||
|
HEADSCALE_URL = "https://headscale.rhemawavetech.com"
|
||||||
|
HEADSCALE_AUTHKEY = "803130d5b2e38bffa9fa5b6921a3d6403100e2e30fea1bd5"
|
||||||
|
|
||||||
|
# Paths
|
||||||
|
LOG_FILE = Path("/home/kiosk/rhemawave-updater.log")
|
||||||
|
LICENSE_KEY_PATH = Path.home() / ".local/share/rhemawave/license_key"
|
||||||
|
|
||||||
|
|
||||||
|
class UpdaterLogger:
|
||||||
|
"""Simple logger that writes to both stdout and file"""
|
||||||
|
|
||||||
|
def __init__(self, log_file):
|
||||||
|
self.log_file = log_file
|
||||||
|
log_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
log_file.touch(exist_ok=True)
|
||||||
|
|
||||||
|
def log(self, message):
|
||||||
|
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
formatted = f"[{timestamp}] {message}"
|
||||||
|
print(formatted)
|
||||||
|
try:
|
||||||
|
with open(self.log_file, 'a') as f:
|
||||||
|
f.write(formatted + '\n')
|
||||||
|
except IOError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def error(self, message):
|
||||||
|
self.log(f"ERROR: {message}")
|
||||||
|
|
||||||
|
def debug(self, message):
|
||||||
|
self.log(f"DEBUG: {message}")
|
||||||
|
|
||||||
|
def success(self, message):
|
||||||
|
self.log(f"✓ {message}")
|
||||||
|
|
||||||
|
|
||||||
|
logger = UpdaterLogger(LOG_FILE)
|
||||||
|
|
||||||
|
|
||||||
|
def check_network(timeout=2):
|
||||||
|
"""Quick check for internet connectivity."""
|
||||||
|
logger.log("Checking network connectivity...")
|
||||||
|
try:
|
||||||
|
socket.create_connection(("1.1.1.1", 53), timeout=timeout)
|
||||||
|
logger.success("Network check passed (1.1.1.1:53 reachable)")
|
||||||
|
return True
|
||||||
|
except OSError as e:
|
||||||
|
logger.error(f"Network check failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def run_command(cmd, description, use_pkexec=False):
|
||||||
|
"""Run a command and return success status"""
|
||||||
|
if use_pkexec:
|
||||||
|
cmd = ["pkexec"] + cmd
|
||||||
|
|
||||||
|
logger.log(f"{description}...")
|
||||||
|
logger.debug(f"Command: {' '.join(cmd)}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=300
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(f"Return code: {result.returncode}")
|
||||||
|
|
||||||
|
if result.stdout:
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
logger.log(f" {line}")
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
logger.error(f"{description} failed with return code {result.returncode}")
|
||||||
|
if result.stderr:
|
||||||
|
for line in result.stderr.splitlines():
|
||||||
|
logger.error(f" {line}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
logger.success(f"{description} completed successfully")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
logger.error(f"{description} timed out")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"{description} failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def configure_dpkg():
|
||||||
|
logger.log("Checking for dpkg errors")
|
||||||
|
if not run_command(
|
||||||
|
["dpkg", "--configure", "-a"],
|
||||||
|
'Check for dpkg configuration errors and resolve them before performing updates',
|
||||||
|
use_pkexec=True):
|
||||||
|
logger.error("Unable to reconfigure system, please reach out to support!")
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def is_tailscale_installed():
|
||||||
|
"""Check if Tailscale is installed."""
|
||||||
|
logger.log("Checking if Tailscale is installed...")
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["tailscale", "version"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True
|
||||||
|
)
|
||||||
|
version = result.stdout.strip()
|
||||||
|
logger.success(f"Tailscale is installed: {version}")
|
||||||
|
return True
|
||||||
|
except FileNotFoundError:
|
||||||
|
logger.log("Tailscale binary not found")
|
||||||
|
return False
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
logger.error(f"Tailscale version check failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def install_tailscale():
|
||||||
|
"""Install Tailscale."""
|
||||||
|
logger.log("Installing Tailscale...")
|
||||||
|
try:
|
||||||
|
logger.debug("Running: pkexec sh -c 'curl -fsSL https://tailscale.com/install.sh | sh'")
|
||||||
|
result = subprocess.run(
|
||||||
|
["pkexec", "sh", "-c", "curl -fsSL https://tailscale.com/install.sh | sh"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=120
|
||||||
|
)
|
||||||
|
logger.debug(f"Install return code: {result.returncode}")
|
||||||
|
if result.stdout:
|
||||||
|
for line in result.stdout.splitlines():
|
||||||
|
logger.log(f" {line}")
|
||||||
|
if result.stderr:
|
||||||
|
for line in result.stderr.splitlines():
|
||||||
|
logger.log(f" [stderr] {line}")
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
logger.error("Tailscale installation failed")
|
||||||
|
return False
|
||||||
|
|
||||||
|
logger.success("Tailscale installed successfully")
|
||||||
|
return True
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
logger.error("Tailscale installation timed out")
|
||||||
|
return False
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
logger.error(f"Tailscale installation failed: {e}")
|
||||||
|
if e.stderr:
|
||||||
|
logger.error(f"stderr: {e.stderr}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def set_tailscale_operator():
|
||||||
|
"""Set Tailscale operator to kiosk."""
|
||||||
|
logger.log("Setting Tailscale operator to 'kiosk'...")
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["pkexec", "tailscale", "set", "--operator=kiosk"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=30
|
||||||
|
)
|
||||||
|
logger.debug(f"Return code: {result.returncode}")
|
||||||
|
if result.stdout:
|
||||||
|
logger.debug(f"stdout: {result.stdout}")
|
||||||
|
if result.stderr:
|
||||||
|
logger.debug(f"stderr: {result.stderr}")
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
logger.error(f"Failed to set operator: {result.stderr}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
logger.success("Tailscale operator set to 'kiosk'")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to set Tailscale operator: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_tailscale_status():
|
||||||
|
"""Get full Tailscale status as dict."""
|
||||||
|
logger.log("Getting Tailscale status...")
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["tailscale", "status", "--json"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=10
|
||||||
|
)
|
||||||
|
logger.debug(f"Return code: {result.returncode}")
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
logger.error(f"tailscale status failed: {result.stderr}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
status = json.loads(result.stdout)
|
||||||
|
|
||||||
|
# Log key status info
|
||||||
|
backend_state = status.get("BackendState", "unknown")
|
||||||
|
logger.log(f" BackendState: {backend_state}")
|
||||||
|
|
||||||
|
self_info = status.get("Self", {})
|
||||||
|
if self_info:
|
||||||
|
logger.log(f" HostName: {self_info.get('HostName', 'unknown')}")
|
||||||
|
logger.log(f" DNSName: {self_info.get('DNSName', 'unknown')}")
|
||||||
|
logger.log(f" Online: {self_info.get('Online', 'unknown')}")
|
||||||
|
tailscale_ips = self_info.get("TailscaleIPs", [])
|
||||||
|
logger.log(f" TailscaleIPs: {tailscale_ips}")
|
||||||
|
|
||||||
|
# Check for health issues
|
||||||
|
health = status.get("Health", [])
|
||||||
|
if health:
|
||||||
|
logger.log(" Health issues:")
|
||||||
|
for issue in health:
|
||||||
|
logger.log(f" - {issue}")
|
||||||
|
|
||||||
|
return status
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.error(f"Failed to parse Tailscale status JSON: {e}")
|
||||||
|
return None
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
logger.error("Tailscale status timed out")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to get Tailscale status: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def is_tailscale_logged_in(status=None):
|
||||||
|
"""Check if Tailscale is logged in."""
|
||||||
|
if status is None:
|
||||||
|
status = get_tailscale_status()
|
||||||
|
|
||||||
|
if status is None:
|
||||||
|
logger.error("Cannot determine login status - no status available")
|
||||||
|
return False
|
||||||
|
|
||||||
|
backend_state = status.get("BackendState", "")
|
||||||
|
logged_in = backend_state == "Running"
|
||||||
|
|
||||||
|
if logged_in:
|
||||||
|
logger.success("Tailscale is logged in (BackendState=Running)")
|
||||||
|
else:
|
||||||
|
logger.log(f"Tailscale is NOT logged in (BackendState={backend_state})")
|
||||||
|
|
||||||
|
return logged_in
|
||||||
|
|
||||||
|
|
||||||
|
def tailscale_login(hostname=None):
|
||||||
|
"""Log in to Tailscale/Headscale."""
|
||||||
|
logger.log("Logging in to Headscale...")
|
||||||
|
logger.debug(f" Server: {HEADSCALE_URL}")
|
||||||
|
logger.debug(f" AuthKey: {HEADSCALE_AUTHKEY[:8]}...{HEADSCALE_AUTHKEY[-4:]}")
|
||||||
|
if hostname:
|
||||||
|
logger.debug(f" Hostname: {hostname}")
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
"tailscale", "up",
|
||||||
|
"--login-server", HEADSCALE_URL,
|
||||||
|
"--authkey", HEADSCALE_AUTHKEY,
|
||||||
|
"--force-reauth"
|
||||||
|
]
|
||||||
|
if hostname:
|
||||||
|
cmd.extend(["--hostname", hostname])
|
||||||
|
|
||||||
|
logger.debug(f"Command: {' '.join(cmd)}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
cmd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=30
|
||||||
|
)
|
||||||
|
logger.debug(f"Return code: {result.returncode}")
|
||||||
|
if result.stdout:
|
||||||
|
logger.log(f" stdout: {result.stdout}")
|
||||||
|
if result.stderr:
|
||||||
|
logger.log(f" stderr: {result.stderr}")
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
logger.error(f"Tailscale login failed with code {result.returncode}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
logger.success("Tailscale login successful")
|
||||||
|
return True
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
logger.error("Tailscale login timed out")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Tailscale login failed: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_current_tailscale_hostname(status=None):
|
||||||
|
"""Get the hostname we're currently advertising."""
|
||||||
|
if status is None:
|
||||||
|
status = get_tailscale_status()
|
||||||
|
|
||||||
|
if status is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
hostname = status.get("Self", {}).get("HostName")
|
||||||
|
logger.debug(f"Current Tailscale hostname: {hostname}")
|
||||||
|
return hostname
|
||||||
|
|
||||||
|
|
||||||
|
def get_license_key():
|
||||||
|
"""Read license key from plain text file."""
|
||||||
|
logger.log("Fetching license key...")
|
||||||
|
logger.debug(f" Path: {LICENSE_KEY_PATH}")
|
||||||
|
|
||||||
|
if not LICENSE_KEY_PATH.exists():
|
||||||
|
logger.log(" License key file does not exist")
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
license_key = LICENSE_KEY_PATH.read_text().strip()
|
||||||
|
if not license_key:
|
||||||
|
logger.log(" License key file is empty")
|
||||||
|
return None
|
||||||
|
|
||||||
|
logger.success(f"License key found: {license_key}")
|
||||||
|
return license_key
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to read license key: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_tailscale_installed():
|
||||||
|
"""Ensure Tailscale is installed, install if needed."""
|
||||||
|
logger.log("=== Ensuring Tailscale is installed ===")
|
||||||
|
|
||||||
|
if is_tailscale_installed():
|
||||||
|
return True
|
||||||
|
|
||||||
|
logger.log("Tailscale not installed, attempting installation...")
|
||||||
|
|
||||||
|
if not install_tailscale():
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Verify installation
|
||||||
|
if not is_tailscale_installed():
|
||||||
|
logger.error("Tailscale installation verification failed")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Set operator
|
||||||
|
if not set_tailscale_operator():
|
||||||
|
logger.error("Failed to set Tailscale operator, continuing anyway...")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_tailscale_connected():
|
||||||
|
"""Ensure Tailscale is logged in and connected."""
|
||||||
|
logger.log("=== Ensuring Tailscale is connected ===")
|
||||||
|
|
||||||
|
status = get_tailscale_status()
|
||||||
|
|
||||||
|
if not is_tailscale_logged_in(status):
|
||||||
|
logger.log("Tailscale not logged in, attempting login...")
|
||||||
|
|
||||||
|
# Get license key to use as hostname if available
|
||||||
|
license_key = get_license_key()
|
||||||
|
|
||||||
|
if not tailscale_login(hostname=license_key):
|
||||||
|
logger.error("Failed to log in to Tailscale")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Re-check status after login
|
||||||
|
status = get_tailscale_status()
|
||||||
|
if not is_tailscale_logged_in(status):
|
||||||
|
logger.error("Still not logged in after login attempt")
|
||||||
|
return False
|
||||||
|
|
||||||
|
logger.success("Tailscale is connected")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def sync_tailscale_hostname():
|
||||||
|
"""Update tailscale hostname to match license key if needed."""
|
||||||
|
logger.log("=== Syncing Tailscale hostname ===")
|
||||||
|
|
||||||
|
license_key = get_license_key()
|
||||||
|
|
||||||
|
if not license_key:
|
||||||
|
logger.log("No license key found, skipping hostname sync")
|
||||||
|
return
|
||||||
|
|
||||||
|
status = get_tailscale_status()
|
||||||
|
current = get_current_tailscale_hostname(status)
|
||||||
|
|
||||||
|
logger.log(f" Current hostname: {current}")
|
||||||
|
logger.log(f" Desired hostname: {license_key}")
|
||||||
|
|
||||||
|
if current == license_key:
|
||||||
|
logger.success(f"Hostname already correct: {license_key}")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.log(f"Hostname mismatch, updating: {current} -> {license_key}")
|
||||||
|
|
||||||
|
if tailscale_login(hostname=license_key):
|
||||||
|
logger.success("Hostname updated successfully")
|
||||||
|
# Verify the change
|
||||||
|
new_status = get_tailscale_status()
|
||||||
|
new_hostname = get_current_tailscale_hostname(new_status)
|
||||||
|
logger.log(f" Verified new hostname: {new_hostname}")
|
||||||
|
else:
|
||||||
|
logger.error("Failed to update hostname")
|
||||||
|
|
||||||
|
|
||||||
|
def sync_ssh_keys():
|
||||||
|
"""Fetch SSH public keys from GitHub and write to authorized_keys."""
|
||||||
|
logger.log("=== Syncing SSH keys from GitHub ===")
|
||||||
|
github_user = "nclack"
|
||||||
|
url = f"https://github.com/{github_user}.keys"
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.debug(f"Fetching keys from {url}")
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": "rhemawave-updater"})
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||||
|
keys = resp.read().decode().strip()
|
||||||
|
|
||||||
|
if not keys:
|
||||||
|
logger.error("No SSH keys returned from GitHub")
|
||||||
|
return
|
||||||
|
|
||||||
|
ssh_dir = Path.home() / ".ssh"
|
||||||
|
ssh_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||||
|
auth_keys = ssh_dir / "authorized_keys"
|
||||||
|
auth_keys.write_text(keys + "\n")
|
||||||
|
auth_keys.chmod(0o600)
|
||||||
|
|
||||||
|
key_count = len(keys.splitlines())
|
||||||
|
logger.success(f"Wrote {key_count} SSH key(s) to {auth_keys}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to sync SSH keys: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def check_for_upgrade():
|
||||||
|
"""Check if an upgrade is available for the package"""
|
||||||
|
logger.log(f"Checking for upgrades to {PACKAGE_NAME}...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["apt", "list", "--upgradable"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=30
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(f"apt list output: {result.stdout}")
|
||||||
|
|
||||||
|
if PACKAGE_NAME in result.stdout:
|
||||||
|
logger.success(f"Upgrade available for {PACKAGE_NAME}")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.log(f"{PACKAGE_NAME} is up to date")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Could not check for upgrades: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
logger.log("=========================================")
|
||||||
|
logger.log("RhemaWave Updater")
|
||||||
|
logger.log(f"Package: {PACKAGE_NAME}")
|
||||||
|
logger.log(f"Timestamp: {datetime.now().isoformat()}")
|
||||||
|
logger.log("=========================================")
|
||||||
|
|
||||||
|
# Quick network check
|
||||||
|
if not check_network():
|
||||||
|
logger.log("No internet connection, skipping update")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Sync SSH keys from GitHub
|
||||||
|
sync_ssh_keys()
|
||||||
|
|
||||||
|
# Ensure system is ready to install software
|
||||||
|
configure_dpkg()
|
||||||
|
|
||||||
|
# Ensure Tailscale is installed
|
||||||
|
if not ensure_tailscale_installed():
|
||||||
|
logger.error("Tailscale installation failed, continuing with update check...")
|
||||||
|
else:
|
||||||
|
# Ensure Tailscale is connected
|
||||||
|
if not ensure_tailscale_connected():
|
||||||
|
logger.error("Tailscale connection failed, continuing with update check...")
|
||||||
|
else:
|
||||||
|
# Sync hostname with license key
|
||||||
|
sync_tailscale_hostname()
|
||||||
|
|
||||||
|
logger.log("=========================================")
|
||||||
|
logger.log("=== Package Update Check ===")
|
||||||
|
logger.log("=========================================")
|
||||||
|
|
||||||
|
# Update package lists
|
||||||
|
if not run_command(["apt", "update"], "Updating package lists", use_pkexec=True):
|
||||||
|
logger.error("Failed to update package lists")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Check for upgrades
|
||||||
|
if not check_for_upgrade():
|
||||||
|
logger.log("No upgrade needed")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# Install upgrade
|
||||||
|
if not run_command(
|
||||||
|
["apt", "install", "--only-upgrade", "-y", PACKAGE_NAME],
|
||||||
|
f"Installing upgrade for {PACKAGE_NAME}",
|
||||||
|
use_pkexec=True
|
||||||
|
):
|
||||||
|
logger.error("Upgrade installation failed")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
logger.success("Upgrade installed successfully!")
|
||||||
|
|
||||||
|
# Restart getty to reload the application
|
||||||
|
if not run_command(
|
||||||
|
["systemctl", "restart", "getty@tty1.service"],
|
||||||
|
"Restarting application",
|
||||||
|
use_pkexec=True
|
||||||
|
):
|
||||||
|
logger.error("Failed to restart application (manual reboot may be required)")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
logger.success("Application restarted")
|
||||||
|
logger.log("=========================================")
|
||||||
|
logger.log("=== Update Complete ===")
|
||||||
|
logger.log("=========================================")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"rhemawavetech.com/rhemawave/internal/application"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
application.Run()
|
||||||
|
fmt.Printf("Stopping application")
|
||||||
|
}
|
||||||
Executable
+64
@@ -0,0 +1,64 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# RhemaWave Repository Setup Script
|
||||||
|
# Usage: curl -fsSL https://repository.rhemawavetech.com/setup.sh | sudo bash
|
||||||
|
|
||||||
|
echo "=== RhemaWave Repository Setup ==="
|
||||||
|
|
||||||
|
# Check if running as root
|
||||||
|
if [ "$EUID" -ne 0 ]; then
|
||||||
|
echo "Error: This script must be run as root"
|
||||||
|
echo "Please run: curl -fsSL https://repository.rhemawavetech.com/setup.sh | sudo bash"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if running on Ubuntu/Debian
|
||||||
|
if [ ! -f /etc/debian_version ]; then
|
||||||
|
echo "Error: This script is for Ubuntu/Debian systems only"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
REPO_URL="https://repository.rhemawavetech.com"
|
||||||
|
REPO_NAME="rhemawave"
|
||||||
|
SOURCES_LIST="/etc/apt/sources.list.d/${REPO_NAME}.list"
|
||||||
|
KEYRING_FILE="/usr/share/keyrings/${REPO_NAME}-archive-keyring.gpg"
|
||||||
|
|
||||||
|
echo "Adding RhemaWave repository..."
|
||||||
|
|
||||||
|
# Add the repository to sources.list.d
|
||||||
|
# echo "deb [signed-by=${KEYRING_FILE}] ${REPO_URL} stable main" >"${SOURCES_LIST}"
|
||||||
|
|
||||||
|
echo "deb [trusted=yes] ${REPO_URL} release main" >"${SOURCES_LIST}"
|
||||||
|
|
||||||
|
# Download and install the GPG key (you'll need to provide this)
|
||||||
|
# Uncomment and modify once you have a GPG key:
|
||||||
|
# curl -fsSL "${REPO_URL}/KEY.gpg" | gpg --dearmor -o "${KEYRING_FILE}"
|
||||||
|
|
||||||
|
echo "Updating package lists..."
|
||||||
|
apt update
|
||||||
|
|
||||||
|
echo "Upgrading system packages..."
|
||||||
|
apt upgrade -y
|
||||||
|
|
||||||
|
echo "Installing Tailscale..."
|
||||||
|
if ! command -v tailscale &> /dev/null; then
|
||||||
|
curl -fsSL https://tailscale.com/install.sh | sh
|
||||||
|
else
|
||||||
|
echo "✓ Tailscale already installed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Installing RhemaWave..."
|
||||||
|
apt install -y rhemawave
|
||||||
|
|
||||||
|
echo "Setting Tailscale operator..."
|
||||||
|
if tailscale set --operator=kiosk; then
|
||||||
|
echo "✓ Tailscale operator set to kiosk"
|
||||||
|
else
|
||||||
|
echo "⚠ Warning: Could not set Tailscale operator"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Installation Complete ==="
|
||||||
|
echo "RhemaWave has been installed successfully."
|
||||||
|
echo ""
|
||||||
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
|||||||
|
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.
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
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.
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
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.
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
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.
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
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.
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
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.
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
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.
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
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.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
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.
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
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.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
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.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
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.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
shall the mind and body now: Project its awareness, attention, belief, and
|
||||||
|
intention into all times dimensions of undivided universal field of
|
||||||
|
consciousness and identity, accept and release attunement to all events,
|
||||||
|
information faults, and information faults hidden that compromises
|
||||||
|
awareness of, or define into existence, the transformed-from state? Or,
|
||||||
|
that cause this transformative information quanta to define into existence
|
||||||
|
the transformed-to system state? Or that cause this transformation to
|
||||||
|
seem dangerous, undeserved, or impossible to achieve? Create awareness,
|
||||||
|
acceptance, attunement, and belief of all corrective events and
|
||||||
|
transformative information quanta to define into existence the
|
||||||
|
transformed-to system state?
|
||||||
|
|
||||||
|
Create the intention to collapse the quantum potential the field’s reality
|
||||||
|
quantum potential of the field’s reality quant to manifest this
|
||||||
|
transformation? Create optimal attunement and coherence to the source of
|
||||||
|
creation, GOD, innate intelligence, and the consciousness of healing and
|
||||||
|
accept the role of co-creator of all experienced reality, NOW?
|
||||||
|
|
||||||
|
I am grateful GOD lives through me and inspires me to achieve deep
|
||||||
|
relaxation, peace, and harmony in all aspects of my life.
|
||||||
|
|
||||||
|
I am thankful for my health, vitality, and motivation to eat properly,
|
||||||
|
exercise, and always maintain control of my thoughts, emotions, and
|
||||||
|
mental state.
|
||||||
|
|
||||||
|
I appreciate GOD providing strength and direction to every cell in my
|
||||||
|
body ensuring I am emotionally stable and maintain a relaxed and focused
|
||||||
|
attitude regardless of my personal situation.
|
||||||
|
|
||||||
|
I am pleased I always think positive, manage my thoughts, and live each
|
||||||
|
day in a relaxed and emotionally stable state of mind according to GOD’s
|
||||||
|
design.
|
||||||
|
|
||||||
|
I enjoy knowing GOD protects me and allows me to remain relaxed knowing
|
||||||
|
everything in my life will turn out well.
|
||||||
|
|
||||||
|
I am happy I think positively, I do not dwell on negative issues, and I
|
||||||
|
control my thoughts at all times.
|
||||||
|
|
||||||
|
I am grateful GOD’s healing hands are on me creating miracles in all
|
||||||
|
areas of my life.
|
||||||
|
|
||||||
|
I am thankful I wake up each morning yearning to eat healthy foods and
|
||||||
|
exercise in ways that ensure I live a peaceful and emotionally stable
|
||||||
|
life.
|
||||||
|
|
||||||
|
I appreciate GOD’s almighty power flowing though me, creating miracles
|
||||||
|
in all areas of my life, and correcting, curing, and developing all
|
||||||
|
healthy aspects of my mind and my body for the better.
|
||||||
|
|
||||||
|
I am pleased my mental health and emotional stability allow me to live a
|
||||||
|
relaxed and perpetually blissful life.
|
||||||
|
|
||||||
|
I am grateful to GOD for all the blessings bestowed upon me.
|
||||||
|
|
||||||
|
I am thankful that I only crave healthy foods that provide my mind and
|
||||||
|
body with the nutrition necessary for my optimum health, wellbeing, and
|
||||||
|
longevity.
|
||||||
+59
@@ -0,0 +1,59 @@
|
|||||||
|
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.
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
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.
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
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.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
#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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
#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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
#version 330 core
|
||||||
|
|
||||||
|
out vec4 FragColor;
|
||||||
|
|
||||||
|
uniform vec4 u_color;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
FragColor = u_color;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
#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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
#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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
#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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<p>Welcome to RHEMA WAVE TECHNOLOGY!</p>
|
||||||
|
|
||||||
|
<p>Please review the following screens carefully and completely as they provide important user information, as well as outline YOUR legal responsibilities!</p>
|
||||||
|
|
||||||
|
<p>First and foremost, congratulations! Congratulations to YOU for having the foresight to invest in what we believe is one of the greatest breakthroughs in the history of humanity! More importantly, we congratulate YOU for investing in YOU!</p>
|
||||||
|
|
||||||
|
<p>We would also like to express our appreciation for YOUR faith, patience, and understanding as we continue evolving our ingenious business model, our revolutionary patented products, and our unprecedented copy-written technology! While we have, quoting the immortal words of Captain Kirk in the original Star Trek series, "Gone where no man has gone before!" we have already complied quite a long list of futuristic products, services, refinements, and innovations we expect to introduce as soon as humanly possible.</p>
|
||||||
|
|
||||||
|
<p>While RHEMA WAVE TECHNOLOGY is already on the cutting edge of technological advancement, we believe this is just the beginning as we intend to push the cutting edge of technology for decades into the future with yet unimaginable creations. Our focus on the continuing evolution of our science will ensure competitors will fall by the wayside as RHEMA WAVE TECHNOLOGY continues to stand alone as the undisputed "Pioneer of Innovation!"</p>
|
||||||
|
|
||||||
|
<p>Welcoming you into our RHEMA WAVE TECHNOLOGY family is far more than simply a polite slogan. We consider our members kin to the RHEMA WAVE TECHNOLOGY lineage and thus part of a very unique and special family. As part of our family we anticipate YOU will remain in communication with us and share YOUR personal progress and experiences. We also encourage you to ask questions, express concerns, and share your ideas as everyone's continued involvement is key to continued advancement.</p>
|
||||||
|
|
||||||
|
<p>In closing, and keeping with the original Star Trek theme, we feel it is amazingly appropriate to salute you in the same manner Spock, the half-human, half-Vulcan, pointy eared, logic-driven first officer aboard the StarShip Enterprise would,</p>
|
||||||
|
|
||||||
|
<p>"Live long & Prosper!"</p>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<p>RHEMA WAVE TECHNOLOGY, EQUIPMENT, AND SOFTWARE IS NOT INTENDED, MARKETED, OR SOLD AS A MEDICAL DEVICE.</p>
|
||||||
|
|
||||||
|
<p>RHEMA WAVE TECHNOLOGY, EQUIPMENT, AND SOFTWARE IS NOT INTENDED TO DIAGNOSE, TREAT, OR CURE ANY ILLNESS, AILMENT, OR DISEASE.</p>
|
||||||
|
|
||||||
|
<p>RHEMA WAVE TECHNOLOGY, EQUIPMENT, AND SOFTWARE MERELY CREATES WHAT WE BELIEVE TO BE A SUBTLE ENERGY FIELD THAT LIVING CELLS SEEM TO ABSORB. THIS ABSORBED ENERGY APPEARS TO ALLOW CELLS TO FUNCTION MORE EFFICIENTLY & POTENTIALLY MORE EFFECTIVELY THAN THEY NORMALLY WOULD UNDER MODERN DAY STRESS AND THE ENVIRONMENT IN WHICH WE CURRENTLY LIVE.</p>
|
||||||
|
|
||||||
|
<p>RHEMA WAVE TECHNOLOGY MAKES NO CLAIMS AS TO THE EFFECTS, EFFECTIVENESS, OR RESULTS FROM USING RHEMA WAVE TECHNOLOGY PRODUCTS AND HEREBY DISCLAIMS ANY REPRESENTATION OR WARRANTY TO THAT EFFECT.</p>
|
||||||
|
|
||||||
|
<p>USER/BUYER UNDERSTANDS AND AGREES NO MEDICAL OR HEALING CLAIMS ARE TO BE MADE REGARDING RHEMA WAVE TECHNOLOGY AND RHEMA WAVE SYSTEMS, TO INCLUDE SOFTWARE, HARDWARE, AND PROGRAMMING.</p>
|
||||||
|
|
||||||
|
<p>USER/BUYER UNDERSTANDS AND AGREES IT IS USER/BUYER'S SOLE RESPONSIBILITY TO INFORM ANY AND ALL USERS THEY INVITE OR ALLOW TO EXPERIENCE THEIR RHEMA WAVE SYSTEM OF THESE TERMS AND CONDITIONS.</p>
|
||||||
|
|
||||||
|
<p>USER/BUYER UNDERSTANDS AND AGREES USER/BUYER SHALL BE SOLELY RESPONSIBLE FOR COMPENSATION TO RHEMA WAVE TECHNOLOGY FOR ANY AND ALL CLAIMS, FINES, DAMAGES, AND LEGAL FEES RESULTING FROM USER/BUYER MAKING SUCH UNAUTHORIZED COMMENTS, INCLUDING THOSE LEVIED AGAINST RHEMA WAVE TECHNOLOGIES BY PERSONS, CORPORATIONS, AND FEDERAL AGENCIES.</p>
|
||||||
|
|
||||||
|
<p>IT IS ALSO UNDERSTOOD EQUIPMENT PROVIDERS SHALL BE HELD HARMLESS AND NOT BE RESPONSIBLE FOR ANY AND ALL SUCH CLAIMS.</p>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<p>PROCLAMATIONS</p>
|
||||||
|
|
||||||
|
<p>Use keyboard arrow keys and return key to select Proclamations. There you will find several options for activating monthly access to proclamations and RHEMA WAVE SAAS (software as a service).</p>
|
||||||
|
|
||||||
|
<p>Option A: includes monthly access to SAAS, 10 proclamations of your choice, plus $1 for each additional activated proclamation.</p>
|
||||||
|
|
||||||
|
<p>Option B: includes monthly access to SAAS and 25 proclamations of your choice, plus 75 cents for each additional activated proclamation.</p>
|
||||||
|
|
||||||
|
<p>Option C: includes monthly access to SAAS and 50 proclamations of your choice, plus 50 cents for each additional activated proclamation.</p>
|
||||||
|
|
||||||
|
<p>Option D: includes monthly access to SAAS and access to all available proclamations.</p>
|
||||||
|
|
||||||
|
<p>Begin by using arrow keys and return key to select an proclamation category, then use the up and down arrow keys to scroll through all the proclamations in each category so that you are familiar with the complete library. You may want to consider making a list of the proclamations you would like to start with as this will simplify the process of selecting, activating, and scheduling your chosen proclamations.</p>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<p>Ideally we suggest using the SCHEDULER to create at least 4 separate lists of proclamations, one to start your morning, perhaps starting 30 minutes before your scheduled waking up time, one for an afternoon sessions, one for an evening "wind down" session, and a nighttime sleeping session. The SCHEDULER allows you to determine when one series of proclamations ends and the next series begins. Depending upon your particular schedule you may want to add more sessions. Upon completion of your review select desired proclamations by simply pressing the space bar. Activated proclamations appear GREEN, while inactive proclamations remain WHITE.</p>
|
||||||
|
|
||||||
|
<p>We highly suggest (and would require if we could) activating Shangri-La in ALL sessions ALL the time so it runs constantly from now to eternity (or at least until we have the time to create a few alternatives)! Shangri-La is a relaxing, full body cellular supercharger designed to boost energy throughout the entire body. In addition to Shangri-La, we highly suggest activating 3 to 5 priority proclamations that also run in all sessions (continuously 24 hours a day) until you are satisfied with your results. Once you are satisfied with your results you can simply return to the SCHEDULER and select a different proclamation to take its place. A priority proclamation is an proclamation focused on a particular area or issue and easily recognized by an accompanying asterisk (*).</p>
|
||||||
|
|
||||||
|
<p>General proclamations are those focused on desired behavioral changes and NOT marked with an asterisk (*). We suggest activating 10 to 20 different general proclamations for each scheduled session. Should you have sincere interest in certain general proclamations such as Health & Well-Being, Weight-Loss, or Reduce Stress, Anxiety, and Panic Attacks they can be included in more than one session, depending upon the severity and your desire to seek improvement. While initially this may seem like quite a few proclamations, other than Shangri-La, which runs a bit longer, most other proclamations are communicated in under 30 seconds so programming 20 proclamations, each would repeat 6 times an hour.</p>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<p>Now for the fun part! To ensure complete understanding I will explain a few more details about proclamations and make a few more recommendations.</p>
|
||||||
|
|
||||||
|
<p>While proclamations like Deep Sleep, Relaxation, and Bathe in Blessings connote peace, rest, and relaxation, proclamations like Goal Setting & Achievement, Achieving Success, Mastering Money Management, and Maximize My Productivity exude connotations of action and mental activity. Accordingly, we recommend scheduling relaxing proclamations like Relaxation, Bathe in Blessings, and Deep Sleep for evening wind down sessions and especially nighttime sleep sessions, with more aggressive proclamations ideally activated first thing in the morning and though out the day.</p>
|
||||||
|
|
||||||
|
<p>We highly suggest including proclamations like Deep Sleep, Vivid Dreams, and Heavenly Communication in your nighttime sleep session from the very beginning. Activating Deep Sleep in particular in nighttime sleep sessions is the simplest, fastest, and most efficient way to experience the effectiveness of RHEMA WAVES. Those who follow this recommendation report waking up amazed at how well-rested they feel and deep they slept. Just one undeniable personal experience such as this will do more to convince you just how amazing this technology truly is, as well as deepen your appreciation for the investment you are making in you than words ever could.</p>
|
||||||
|
|
||||||
|
<p>Now, those of you lucky enough to work from home will certainly have an advantage spending more time in RHEMA WAVES and all your selected sessions. Even though your system may be set up in your bedroom and you work in your office or dining room, the RHEMA WAVES will still have an impact outside the main location, it just won't be as effective.</p>
|
||||||
|
|
||||||
|
<p>Those of you not lucky enough to work from home should still use the SCHEDULER to create sessions. Having a morning session running while preparing for work as well as an evening session will be beneficial, as well as enjoying full sessions on weekends and whenever you spend more time at home. If you work outside the home and wish to make the most of your time in RHEMA WAVES, you can add more aggressive proclamations to your sleep sessions, but be sure to include Deep Sleep and a couple other relaxing proclamations along with them. You can also alter your general nighttime sleep session proclamations every couple of weeks, or as soon as you are satisfied with the session's effects.</p>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<p>Obviously the variety of proclamations accessible to you will be determined by your choice of proclamation subscription. Please know that, while each specific proclamation takes anywhere from several hours to several days to create, we remain diligent and continue conducting research and developing additional proclamations, so you can anticipate continued expansion of our proclamation catalog.</p>
|
||||||
|
|
||||||
|
<p>Each specific proclamation, as referenced by their titles, is meticulously designed and painstakingly created incorporating detailed information and very specific facts thoughtfully programmed to travel along our patented RHEMA WAVE ENERGY FIELD. The average human body is made up of approximately 37 trillion cells (plus or minus depending on your size and weight). Each of these cells appear capable of absorbing energy from RHEMA WAVE ENERGY FIELD and function more efficiently and effectively as a result. Results suggest we are capable of focusing a portion of the absorbed RHEMA WAVE energy on the specific area, habit, trait, or issue described in each proclamation's title.</p>
|
||||||
|
|
||||||
|
<p>Either that, or we have simply created the most consistently effective placebo generator in the history of placebo generators! All I know is, if I, or someone I care about, was suffering from a dreadful condition and that dreadful condition was alleviated, I would be exceedingly happy the dreadful condition was gone, and far less concerned with how, why, or where it went! While all this may sound farfetched, the results show otherwise!</p>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<p>Okay, all humor aside for this paragraph! Under U.S. federal law, specifically the Federal Food, Drug, and Cosmetic Act, a drug is generally defined as any substance intended for use in the diagnosis, cure, mitigation, treatment, or prevention of disease in humans or animals, or any substance other than food intended to affect the structure or function of the body. Thus, it is imperative anyone and everyone involved with RHEMA WAVE TECHNOLOGY understands that RHEMA WAVE TECHNOLOGY does not promote or provide drugs, medications, supplements, massage, manipulation, salves, ointments, injections, adjustments, surgeries, radiation, or any other therapies for that matter! Additionally, RHEMA WAVE TECHNOLOGY does not diagnose, cure, mitigate, treat, or prevent diseases in humans or animals!</p>
|
||||||
|
|
||||||
|
<p>RHEMA WAVE TECHNOLOGY merely provides an environment offering your 37 trillion cells (+/-) the opportunity to recharge and hopefully function more efficiently and effectively. With this understanding it may be helpful to imagine the words "Focus On" in front of each proclamation title, as we believe focusing energy to certain predetermined areas simply allows your body to do what only it knows how to do!</p>
|
||||||
|
|
||||||
|
<p>In order to maximize the effectiveness of your system we recommend placing it in your bedroom. This recommendation is based upon three factors:<br>
|
||||||
|
1) The conscious mind is dormant during sleep, while the subconscious mind is not; thereby alleviating any conscious resistance.<br>
|
||||||
|
2) Nighttime sleep sessions offer the largest amount of consecutive, uninterrupted, and disturbance free time our bodies have.<br>
|
||||||
|
3) Our bodies generally heal more during sleep because we are inactive and not using energy for other purposes.<br>
|
||||||
|
4) Results: those who sleep in the system tend to notice better and faster results, which is likely due to 1, 2, & 3!</p>
|
||||||
|
|
||||||
|
<p>Understandably, there are certainly reasons for placing your system in other rooms, such as a living room in order to share the experience with family members, in an office if that is where you work, or wherever you generally spend the majority of your time. Regardless of where you place your system it will energize your home beyond the walls of the room where it resides; but obviously to a lesser extent. While the RHEMA WAVES will not be as effective outside the main area, customers who set their system up in their family room have called the very next day sharing how amazed they were at how well they slept even though the system was running in another room!</p>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<p>Okay, I have a confession to make and this seems like a good place to make it. I was lucky enough to find a very successful mentor who, in a matter months, taught me more about relationships, business, success, happiness, and just plain life than I ever learned in 16 years of school and college! I say that because, while the lessons I learned from him allowed me to reach the top 1% money earners on the planet and live a lifestyle few will ever experience, none of what I learned from my mentor is taught in school or college! Both what he taught me and the results I achieved following his advice not only inspired me to pursue this unprecedented opportunity, it also provided me with the wherewithal to conduct the research, develop and test designs, conduct experiments, and ultimately create RHEMA WAVE TECHNOLOGY! The miraculous products we currently offer, advances and refinements to those products, as well as products currently under development and yet to be conceived are all the result of what I learned under his mentorship. So, in some small way, my mentor is having an impact on you too!</p>
|
||||||
|
|
||||||
|
<p>When I was first introduced to him he quickly realized my demeanor was very serious, critical, and structured. I had assumed this was how people conducted themselves in order to become successful. I was hoping he would be impressed with my performance because, in reality, I was broke, confused about how the world works, and desperately in need of guidance. The very first thing he said to me was, "If we are ever going to work together you must guarantee me one thing!" At that point in my life I was willing to do just about anything to learn what I knew I did not know and said, "Okay!" He said, "You must stop taking life so serious! And you must learn to NEVER, EVER lose your sense of humor! One thing you must understand is none of us are getting out of here alive anyway, so we might as well enjoy ourselves while we are here! Don't ever forget it!" I haven't.</p>
|
||||||
|
|
||||||
|
<p>So if you are the kind of person whose demeanor is serious, critical, and structured, and you are unwilling to change, can't find a sense of humor, and refuse to stop taking life so serious, please return your system before you venture any further! Above all, the most important thing my mentor taught me was life is much better, more fun, and more fulfilling when we avoid dealing with people like I used to be! So, if you are committed to being like who I used to be, be a sport, send your system back, let those of us looking to enjoy health, vitality, happiness, and fulfillment enjoy the benefits of RHEMA WAVE TECHNOLOGY and just go on your merry way!</p>
|
||||||
|
|
||||||
|
<p>As the Chief Developer who believed wholeheartedly in this amazing technology from the very beginning, I willingly and excitedly became the first RHEMA WAVE test subject. Almost immediately my home became my laboratory where every design, experiment, alteration, and advancement was tried and tested on me and my family. For well over a year we have been basking in RHEMA WAVES 24-hours a day, 7 days a week except for the occasional business meeting, short trips to the grocery store, or attending church! Currently we have 6 complete systems constantly running in our home simultaneously, 3 in bedrooms, 1 in the office, 1 in our kitchen/family room, and 1 in the home theater. Me, my wonderful wife, and our incredible children have been basking in, and continuously surrounded by RHEMA WAVES for well over a year with nothing but positive results to show for it! I purposely surrounded myself and my family with RHEMA WAVES for the sole purpose of proving the effectiveness and documenting NO NEGATIVE ADVERSE SIDE EFFECTS; that is how much I believe in RHEMA WAVE TECHNOLOGY!</p>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<p>Now, the question I am asked most often, and the question likely on your mind is, "How long will it take before I notice changes?" Well, it depends:</p>
|
||||||
|
|
||||||
|
<p>1) How old you are.<br>
|
||||||
|
2) How many years/months you have been dealing with the issue.<br>
|
||||||
|
3) What issues you will be striving to influence.<br>
|
||||||
|
4) How many total issues you will be striving to influence.<br>
|
||||||
|
5) How many issues you will be striving to influence simultaneously.<br>
|
||||||
|
6) The severity of the issue(s) you will be striving to influence.<br>
|
||||||
|
7) Where your system is located.<br>
|
||||||
|
8) Primary activities taking place where your system is located: sleeping, working, eating, watching TV, exercising, etc.<br>
|
||||||
|
9) Number of hours a day spent in the room where your system is located.<br>
|
||||||
|
10) Whether you are genuinely excited about spending time in RHEMA WAVES and looking forward to positive results, or skeptical, pessimistic, and doubting you will experience positive influences.</p>
|
||||||
|
|
||||||
|
<p>Each of these factors will have a profound affect on your results. While we are still unsure the extent to which a pessimistic attitude detracts from results, we do know those excited about spending time in RHEMA WAVES seem to achieve positive results sooner. Positive attitudes = Positive results! Negative attitudes = positive results too! (it just takes a bit more time!) If you are pessimistic, we suggest you including several "Attitude" and "Happiness" proclamations from the start!</p>
|
||||||
|
|
||||||
|
<p>I actually heard from one of our beta testers early on who set their system up in their office because they spend more time in the office than in the bedroom. I recommended beginning with several proclamations including "Deep Sleep." They called me the very next morning amazed at how deeply they slept, how well rested they felt, and how their mind felt very clear! This despite the fact they rolled up in a blanket and slept on the office floor!</p>
|
||||||
|
|
||||||
|
<p>We are excited for YOU and are available to answer your questions, satisfy your concerns, and entertain your ideas. We look forward to hearing from you and learning of your personal experiences as you bask in our RHEMA WAVE ENERGY FIELD! Always know that we will be basking right along with you!</p>
|
||||||
|
|
||||||
|
<p>Welcome to a whole new world,</p>
|
||||||
|
|
||||||
|
<p>RHEMA WAVE TECHNOLOGY<br>
|
||||||
|
(817) 864-1111<br>
|
||||||
|
rhemawavetechnology.com</p>
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (c) 2014 Eric Woroshow
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
//go:build required
|
||||||
|
// +build required
|
||||||
|
|
||||||
|
// Package dummy prevents go tooling from stripping the c dependencies.
|
||||||
|
package dummy
|
||||||
+290
@@ -0,0 +1,290 @@
|
|||||||
|
#ifndef __khrplatform_h_
|
||||||
|
#define __khrplatform_h_
|
||||||
|
|
||||||
|
/*
|
||||||
|
** Copyright (c) 2008-2018 The Khronos Group Inc.
|
||||||
|
**
|
||||||
|
** Permission is hereby granted, free of charge, to any person obtaining a
|
||||||
|
** copy of this software and/or associated documentation files (the
|
||||||
|
** "Materials"), to deal in the Materials without restriction, including
|
||||||
|
** without limitation the rights to use, copy, modify, merge, publish,
|
||||||
|
** distribute, sublicense, and/or sell copies of the Materials, and to
|
||||||
|
** permit persons to whom the Materials are furnished to do so, subject to
|
||||||
|
** the following conditions:
|
||||||
|
**
|
||||||
|
** The above copyright notice and this permission notice shall be included
|
||||||
|
** in all copies or substantial portions of the Materials.
|
||||||
|
**
|
||||||
|
** THE MATERIALS ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
** EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||||
|
** MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||||
|
** IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||||
|
** CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
||||||
|
** TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||||
|
** MATERIALS OR THE USE OR OTHER DEALINGS IN THE MATERIALS.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Khronos platform-specific types and definitions.
|
||||||
|
*
|
||||||
|
* The master copy of khrplatform.h is maintained in the Khronos EGL
|
||||||
|
* Registry repository at https://github.com/KhronosGroup/EGL-Registry
|
||||||
|
* The last semantic modification to khrplatform.h was at commit ID:
|
||||||
|
* 67a3e0864c2d75ea5287b9f3d2eb74a745936692
|
||||||
|
*
|
||||||
|
* Adopters may modify this file to suit their platform. Adopters are
|
||||||
|
* encouraged to submit platform specific modifications to the Khronos
|
||||||
|
* group so that they can be included in future versions of this file.
|
||||||
|
* Please submit changes by filing pull requests or issues on
|
||||||
|
* the EGL Registry repository linked above.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* See the Implementer's Guidelines for information about where this file
|
||||||
|
* should be located on your system and for more details of its use:
|
||||||
|
* http://www.khronos.org/registry/implementers_guide.pdf
|
||||||
|
*
|
||||||
|
* This file should be included as
|
||||||
|
* #include <KHR/khrplatform.h>
|
||||||
|
* by Khronos client API header files that use its types and defines.
|
||||||
|
*
|
||||||
|
* The types in khrplatform.h should only be used to define API-specific types.
|
||||||
|
*
|
||||||
|
* Types defined in khrplatform.h:
|
||||||
|
* khronos_int8_t signed 8 bit
|
||||||
|
* khronos_uint8_t unsigned 8 bit
|
||||||
|
* khronos_int16_t signed 16 bit
|
||||||
|
* khronos_uint16_t unsigned 16 bit
|
||||||
|
* khronos_int32_t signed 32 bit
|
||||||
|
* khronos_uint32_t unsigned 32 bit
|
||||||
|
* khronos_int64_t signed 64 bit
|
||||||
|
* khronos_uint64_t unsigned 64 bit
|
||||||
|
* khronos_intptr_t signed same number of bits as a pointer
|
||||||
|
* khronos_uintptr_t unsigned same number of bits as a pointer
|
||||||
|
* khronos_ssize_t signed size
|
||||||
|
* khronos_usize_t unsigned size
|
||||||
|
* khronos_float_t signed 32 bit floating point
|
||||||
|
* khronos_time_ns_t unsigned 64 bit time in nanoseconds
|
||||||
|
* khronos_utime_nanoseconds_t unsigned time interval or absolute time in
|
||||||
|
* nanoseconds
|
||||||
|
* khronos_stime_nanoseconds_t signed time interval in nanoseconds
|
||||||
|
* khronos_boolean_enum_t enumerated boolean type. This should
|
||||||
|
* only be used as a base type when a client API's boolean type is
|
||||||
|
* an enum. Client APIs which use an integer or other type for
|
||||||
|
* booleans cannot use this as the base type for their boolean.
|
||||||
|
*
|
||||||
|
* Tokens defined in khrplatform.h:
|
||||||
|
*
|
||||||
|
* KHRONOS_FALSE, KHRONOS_TRUE Enumerated boolean false/true values.
|
||||||
|
*
|
||||||
|
* KHRONOS_SUPPORT_INT64 is 1 if 64 bit integers are supported; otherwise 0.
|
||||||
|
* KHRONOS_SUPPORT_FLOAT is 1 if floats are supported; otherwise 0.
|
||||||
|
*
|
||||||
|
* Calling convention macros defined in this file:
|
||||||
|
* KHRONOS_APICALL
|
||||||
|
* KHRONOS_APIENTRY
|
||||||
|
* KHRONOS_APIATTRIBUTES
|
||||||
|
*
|
||||||
|
* These may be used in function prototypes as:
|
||||||
|
*
|
||||||
|
* KHRONOS_APICALL void KHRONOS_APIENTRY funcname(
|
||||||
|
* int arg1,
|
||||||
|
* int arg2) KHRONOS_APIATTRIBUTES;
|
||||||
|
*/
|
||||||
|
|
||||||
|
#if defined(__SCITECH_SNAP__) && !defined(KHRONOS_STATIC)
|
||||||
|
# define KHRONOS_STATIC 1
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/*-------------------------------------------------------------------------
|
||||||
|
* Definition of KHRONOS_APICALL
|
||||||
|
*-------------------------------------------------------------------------
|
||||||
|
* This precedes the return type of the function in the function prototype.
|
||||||
|
*/
|
||||||
|
#if defined(KHRONOS_STATIC)
|
||||||
|
/* If the preprocessor constant KHRONOS_STATIC is defined, make the
|
||||||
|
* header compatible with static linking. */
|
||||||
|
# define KHRONOS_APICALL
|
||||||
|
#elif defined(_WIN32)
|
||||||
|
# define KHRONOS_APICALL __declspec(dllimport)
|
||||||
|
#elif defined (__SYMBIAN32__)
|
||||||
|
# define KHRONOS_APICALL IMPORT_C
|
||||||
|
#elif defined(__ANDROID__)
|
||||||
|
# define KHRONOS_APICALL __attribute__((visibility("default")))
|
||||||
|
#else
|
||||||
|
# define KHRONOS_APICALL
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/*-------------------------------------------------------------------------
|
||||||
|
* Definition of KHRONOS_APIENTRY
|
||||||
|
*-------------------------------------------------------------------------
|
||||||
|
* This follows the return type of the function and precedes the function
|
||||||
|
* name in the function prototype.
|
||||||
|
*/
|
||||||
|
#if defined(_WIN32) && !defined(_WIN32_WCE) && !defined(__SCITECH_SNAP__)
|
||||||
|
/* Win32 but not WinCE */
|
||||||
|
# define KHRONOS_APIENTRY __stdcall
|
||||||
|
#else
|
||||||
|
# define KHRONOS_APIENTRY
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/*-------------------------------------------------------------------------
|
||||||
|
* Definition of KHRONOS_APIATTRIBUTES
|
||||||
|
*-------------------------------------------------------------------------
|
||||||
|
* This follows the closing parenthesis of the function prototype arguments.
|
||||||
|
*/
|
||||||
|
#if defined (__ARMCC_2__)
|
||||||
|
#define KHRONOS_APIATTRIBUTES __softfp
|
||||||
|
#else
|
||||||
|
#define KHRONOS_APIATTRIBUTES
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/*-------------------------------------------------------------------------
|
||||||
|
* basic type definitions
|
||||||
|
*-----------------------------------------------------------------------*/
|
||||||
|
#if (defined(__STDC_VERSION__) && __STDC_VERSION__ >= 199901L) || defined(__GNUC__) || defined(__SCO__) || defined(__USLC__)
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Using <stdint.h>
|
||||||
|
*/
|
||||||
|
#include <stdint.h>
|
||||||
|
typedef int32_t khronos_int32_t;
|
||||||
|
typedef uint32_t khronos_uint32_t;
|
||||||
|
typedef int64_t khronos_int64_t;
|
||||||
|
typedef uint64_t khronos_uint64_t;
|
||||||
|
#define KHRONOS_SUPPORT_INT64 1
|
||||||
|
#define KHRONOS_SUPPORT_FLOAT 1
|
||||||
|
|
||||||
|
#elif defined(__VMS ) || defined(__sgi)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Using <inttypes.h>
|
||||||
|
*/
|
||||||
|
#include <inttypes.h>
|
||||||
|
typedef int32_t khronos_int32_t;
|
||||||
|
typedef uint32_t khronos_uint32_t;
|
||||||
|
typedef int64_t khronos_int64_t;
|
||||||
|
typedef uint64_t khronos_uint64_t;
|
||||||
|
#define KHRONOS_SUPPORT_INT64 1
|
||||||
|
#define KHRONOS_SUPPORT_FLOAT 1
|
||||||
|
|
||||||
|
#elif defined(_WIN32) && !defined(__SCITECH_SNAP__)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Win32
|
||||||
|
*/
|
||||||
|
typedef __int32 khronos_int32_t;
|
||||||
|
typedef unsigned __int32 khronos_uint32_t;
|
||||||
|
typedef __int64 khronos_int64_t;
|
||||||
|
typedef unsigned __int64 khronos_uint64_t;
|
||||||
|
#define KHRONOS_SUPPORT_INT64 1
|
||||||
|
#define KHRONOS_SUPPORT_FLOAT 1
|
||||||
|
|
||||||
|
#elif defined(__sun__) || defined(__digital__)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Sun or Digital
|
||||||
|
*/
|
||||||
|
typedef int khronos_int32_t;
|
||||||
|
typedef unsigned int khronos_uint32_t;
|
||||||
|
#if defined(__arch64__) || defined(_LP64)
|
||||||
|
typedef long int khronos_int64_t;
|
||||||
|
typedef unsigned long int khronos_uint64_t;
|
||||||
|
#else
|
||||||
|
typedef long long int khronos_int64_t;
|
||||||
|
typedef unsigned long long int khronos_uint64_t;
|
||||||
|
#endif /* __arch64__ */
|
||||||
|
#define KHRONOS_SUPPORT_INT64 1
|
||||||
|
#define KHRONOS_SUPPORT_FLOAT 1
|
||||||
|
|
||||||
|
#elif 0
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Hypothetical platform with no float or int64 support
|
||||||
|
*/
|
||||||
|
typedef int khronos_int32_t;
|
||||||
|
typedef unsigned int khronos_uint32_t;
|
||||||
|
#define KHRONOS_SUPPORT_INT64 0
|
||||||
|
#define KHRONOS_SUPPORT_FLOAT 0
|
||||||
|
|
||||||
|
#else
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Generic fallback
|
||||||
|
*/
|
||||||
|
#include <stdint.h>
|
||||||
|
typedef int32_t khronos_int32_t;
|
||||||
|
typedef uint32_t khronos_uint32_t;
|
||||||
|
typedef int64_t khronos_int64_t;
|
||||||
|
typedef uint64_t khronos_uint64_t;
|
||||||
|
#define KHRONOS_SUPPORT_INT64 1
|
||||||
|
#define KHRONOS_SUPPORT_FLOAT 1
|
||||||
|
|
||||||
|
#endif
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Types that are (so far) the same on all platforms
|
||||||
|
*/
|
||||||
|
typedef signed char khronos_int8_t;
|
||||||
|
typedef unsigned char khronos_uint8_t;
|
||||||
|
typedef signed short int khronos_int16_t;
|
||||||
|
typedef unsigned short int khronos_uint16_t;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Types that differ between LLP64 and LP64 architectures - in LLP64,
|
||||||
|
* pointers are 64 bits, but 'long' is still 32 bits. Win64 appears
|
||||||
|
* to be the only LLP64 architecture in current use.
|
||||||
|
*/
|
||||||
|
#ifdef _WIN64
|
||||||
|
typedef signed long long int khronos_intptr_t;
|
||||||
|
typedef unsigned long long int khronos_uintptr_t;
|
||||||
|
typedef signed long long int khronos_ssize_t;
|
||||||
|
typedef unsigned long long int khronos_usize_t;
|
||||||
|
#else
|
||||||
|
typedef signed long int khronos_intptr_t;
|
||||||
|
typedef unsigned long int khronos_uintptr_t;
|
||||||
|
typedef signed long int khronos_ssize_t;
|
||||||
|
typedef unsigned long int khronos_usize_t;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if KHRONOS_SUPPORT_FLOAT
|
||||||
|
/*
|
||||||
|
* Float type
|
||||||
|
*/
|
||||||
|
typedef float khronos_float_t;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if KHRONOS_SUPPORT_INT64
|
||||||
|
/* Time types
|
||||||
|
*
|
||||||
|
* These types can be used to represent a time interval in nanoseconds or
|
||||||
|
* an absolute Unadjusted System Time. Unadjusted System Time is the number
|
||||||
|
* of nanoseconds since some arbitrary system event (e.g. since the last
|
||||||
|
* time the system booted). The Unadjusted System Time is an unsigned
|
||||||
|
* 64 bit value that wraps back to 0 every 584 years. Time intervals
|
||||||
|
* may be either signed or unsigned.
|
||||||
|
*/
|
||||||
|
typedef khronos_uint64_t khronos_utime_nanoseconds_t;
|
||||||
|
typedef khronos_int64_t khronos_stime_nanoseconds_t;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Dummy value used to pad enum types to 32 bits.
|
||||||
|
*/
|
||||||
|
#ifndef KHRONOS_MAX_ENUM
|
||||||
|
#define KHRONOS_MAX_ENUM 0x7FFFFFFF
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Enumerated boolean type
|
||||||
|
*
|
||||||
|
* Values other than zero should be considered to be true. Therefore
|
||||||
|
* comparisons should not be made against KHRONOS_TRUE.
|
||||||
|
*/
|
||||||
|
typedef enum {
|
||||||
|
KHRONOS_FALSE = 0,
|
||||||
|
KHRONOS_TRUE = 1,
|
||||||
|
KHRONOS_BOOLEAN_ENUM_FORCE_SIZE = KHRONOS_MAX_ENUM
|
||||||
|
} khronos_boolean_enum_t;
|
||||||
|
|
||||||
|
#endif /* __khrplatform_h_ */
|
||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
//go:build required
|
||||||
|
// +build required
|
||||||
|
|
||||||
|
package gl
|
||||||
|
|
||||||
|
// This file exists purely to prevent the golang toolchain from stripping
|
||||||
|
// away the c source directories and files when `go mod vendor` is used
|
||||||
|
// to populate a `vendor/` directory of a project depending on `go-gl/go`.
|
||||||
|
//
|
||||||
|
// How it works:
|
||||||
|
// - every directory which only includes c source files receives a dummy.go file.
|
||||||
|
// - every directory we want to preserve is included here as a _ import.
|
||||||
|
// - this file is given a build tag to exclude it from the regular build.
|
||||||
|
import (
|
||||||
|
// Prevent go tooling from stripping out the c source files.
|
||||||
|
_ "github.com/go-gl/gl/v3.3-core/gl/KHR"
|
||||||
|
)
|
||||||
+116
@@ -0,0 +1,116 @@
|
|||||||
|
// Code generated by glow (https://github.com/go-gl/glow). DO NOT EDIT.
|
||||||
|
|
||||||
|
package gl
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
// #include <stdlib.h>
|
||||||
|
import "C"
|
||||||
|
|
||||||
|
// Ptr takes a slice or pointer (to a singular scalar value or the first
|
||||||
|
// element of an array or slice) and returns its GL-compatible address.
|
||||||
|
//
|
||||||
|
// For example:
|
||||||
|
//
|
||||||
|
// var data []uint8
|
||||||
|
// ...
|
||||||
|
// gl.TexImage2D(gl.TEXTURE_2D, ..., gl.UNSIGNED_BYTE, gl.Ptr(&data[0]))
|
||||||
|
func Ptr(data interface{}) unsafe.Pointer {
|
||||||
|
if data == nil {
|
||||||
|
return unsafe.Pointer(nil)
|
||||||
|
}
|
||||||
|
var addr unsafe.Pointer
|
||||||
|
v := reflect.ValueOf(data)
|
||||||
|
switch v.Type().Kind() {
|
||||||
|
case reflect.Ptr:
|
||||||
|
e := v.Elem()
|
||||||
|
switch e.Kind() {
|
||||||
|
case
|
||||||
|
reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
|
||||||
|
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64,
|
||||||
|
reflect.Float32, reflect.Float64:
|
||||||
|
addr = unsafe.Pointer(e.UnsafeAddr())
|
||||||
|
default:
|
||||||
|
panic(fmt.Errorf("unsupported pointer to type %s; must be a slice or pointer to a singular scalar value or the first element of an array or slice", e.Kind()))
|
||||||
|
}
|
||||||
|
case reflect.Uintptr:
|
||||||
|
addr = unsafe.Pointer(data.(uintptr))
|
||||||
|
case reflect.Slice:
|
||||||
|
addr = unsafe.Pointer(v.Index(0).UnsafeAddr())
|
||||||
|
default:
|
||||||
|
panic(fmt.Errorf("unsupported type %s; must be a slice or pointer to a singular scalar value or the first element of an array or slice", v.Type()))
|
||||||
|
}
|
||||||
|
return addr
|
||||||
|
}
|
||||||
|
|
||||||
|
// PtrOffset takes a pointer offset and returns a GL-compatible pointer.
|
||||||
|
// Originally intended for functions such as glVertexAttribPointer that take pointer
|
||||||
|
// parameters also for offsets, since Go 1.14 this is no longer recommended.
|
||||||
|
//
|
||||||
|
// Use a corresponding offset-compatible variant of the function instead.
|
||||||
|
// For example, for gl.VertexAttribPointer() there is gl.VertexAttribPointerWithOffset().
|
||||||
|
//
|
||||||
|
// See https://github.com/go-gl/gl#go-114-and-checkptr for more details on the checkptr detector.
|
||||||
|
// See https://github.com/go-gl/glow#overloads, about adding new overloads.
|
||||||
|
//
|
||||||
|
// Deprecated: Use more appropriate overload function instead
|
||||||
|
func PtrOffset(offset int) unsafe.Pointer {
|
||||||
|
return unsafe.Pointer(uintptr(offset))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Str takes a null-terminated Go string and returns its GL-compatible address.
|
||||||
|
// This function reaches into Go string storage in an unsafe way so the caller
|
||||||
|
// must ensure the string is not garbage collected.
|
||||||
|
func Str(str string) *uint8 {
|
||||||
|
if !strings.HasSuffix(str, "\x00") {
|
||||||
|
panic("str argument missing null terminator: " + str)
|
||||||
|
}
|
||||||
|
header := (*reflect.StringHeader)(unsafe.Pointer(&str))
|
||||||
|
return (*uint8)(unsafe.Pointer(header.Data))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GoStr takes a null-terminated string returned by OpenGL and constructs a
|
||||||
|
// corresponding Go string.
|
||||||
|
func GoStr(cstr *uint8) string {
|
||||||
|
return C.GoString((*C.char)(unsafe.Pointer(cstr)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strs takes a list of Go strings (with or without null-termination) and
|
||||||
|
// returns their C counterpart.
|
||||||
|
//
|
||||||
|
// The returned free function must be called once you are done using the strings
|
||||||
|
// in order to free the memory.
|
||||||
|
//
|
||||||
|
// If no strings are provided as a parameter this function will panic.
|
||||||
|
func Strs(strs ...string) (cstrs **uint8, free func()) {
|
||||||
|
if len(strs) == 0 {
|
||||||
|
panic("Strs: expected at least 1 string")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allocate a contiguous array large enough to hold all the strings' contents.
|
||||||
|
n := 0
|
||||||
|
for i := range strs {
|
||||||
|
n += len(strs[i])
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
n = 1 // avoid allocating zero bytes in case all strings are empty.
|
||||||
|
}
|
||||||
|
data := C.malloc(C.size_t(n))
|
||||||
|
|
||||||
|
// Copy all the strings into data.
|
||||||
|
dataSlice := (*[1 << 30]byte)(data)[:n]
|
||||||
|
css := make([]*uint8, len(strs)) // Populated with pointers to each string.
|
||||||
|
offset := 0
|
||||||
|
for i := range strs {
|
||||||
|
copy(dataSlice[offset:offset+len(strs[i])], strs[i][:]) // Copy strs[i] into proper data location.
|
||||||
|
css[i] = (*uint8)(unsafe.Pointer(&dataSlice[offset])) // Set a pointer to it.
|
||||||
|
offset += len(strs[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
return (**uint8)(&css[0]), func() { C.free(data) }
|
||||||
|
}
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
// Code generated by glow (https://github.com/go-gl/glow). DO NOT EDIT.
|
||||||
|
|
||||||
|
package gl
|
||||||
|
|
||||||
|
import "C"
|
||||||
|
import "unsafe"
|
||||||
|
|
||||||
|
type DebugProc func(
|
||||||
|
source uint32,
|
||||||
|
gltype uint32,
|
||||||
|
id uint32,
|
||||||
|
severity uint32,
|
||||||
|
length int32,
|
||||||
|
message string,
|
||||||
|
userParam unsafe.Pointer)
|
||||||
|
|
||||||
|
var userDebugCallback DebugProc
|
||||||
|
|
||||||
|
//export glowDebugCallback_glcore33
|
||||||
|
func glowDebugCallback_glcore33(
|
||||||
|
source uint32,
|
||||||
|
gltype uint32,
|
||||||
|
id uint32,
|
||||||
|
severity uint32,
|
||||||
|
length int32,
|
||||||
|
message *uint8,
|
||||||
|
userParam unsafe.Pointer) {
|
||||||
|
if userDebugCallback != nil {
|
||||||
|
userDebugCallback(source, gltype, id, severity, length, GoStr(message), userParam)
|
||||||
|
}
|
||||||
|
}
|
||||||
+15867
File diff suppressed because it is too large
Load Diff
+75
@@ -0,0 +1,75 @@
|
|||||||
|
// Code generated by glow (https://github.com/go-gl/glow). DO NOT EDIT.
|
||||||
|
|
||||||
|
// This file implements GlowGetProcAddress for every supported platform. The
|
||||||
|
// correct version is chosen automatically based on build tags:
|
||||||
|
//
|
||||||
|
// windows: WGL
|
||||||
|
// darwin: CGL
|
||||||
|
// linux freebsd netbsd openbsd: GLX
|
||||||
|
//
|
||||||
|
// Use of EGL instead of the platform's default (listed above) is made possible
|
||||||
|
// via the "egl" build tag.
|
||||||
|
//
|
||||||
|
// It is also possible to install your own function outside this package for
|
||||||
|
// retrieving OpenGL function pointers, to do this see InitWithProcAddrFunc.
|
||||||
|
|
||||||
|
package gl
|
||||||
|
|
||||||
|
/*
|
||||||
|
#cgo windows CFLAGS: -DTAG_WINDOWS
|
||||||
|
#cgo !gles2,windows LDFLAGS: -lopengl32
|
||||||
|
#cgo gles2,windows LDFLAGS: -lGLESv2
|
||||||
|
#cgo darwin CFLAGS: -DTAG_DARWIN
|
||||||
|
#cgo !gles2,darwin LDFLAGS: -framework OpenGL
|
||||||
|
#cgo gles2,darwin LDFLAGS: -framework OpenGLES
|
||||||
|
#cgo linux freebsd netbsd openbsd CFLAGS: -DTAG_POSIX
|
||||||
|
#cgo !egl,linux !egl,freebsd !egl,netbsd !egl,openbsd pkg-config: gl
|
||||||
|
#cgo egl,linux egl,freebsd egl,netbsd egl,openbsd egl,windows CFLAGS: -DTAG_EGL
|
||||||
|
#cgo egl,linux egl,freebsd egl,netbsd egl,openbsd pkg-config: egl
|
||||||
|
#cgo egl,windows LDFLAGS: -lEGL
|
||||||
|
#cgo egl,darwin LDFLAGS: -lEGL
|
||||||
|
// Check the EGL tag first as it takes priority over the platform's default
|
||||||
|
// configuration of WGL/GLX/CGL.
|
||||||
|
#if defined(TAG_EGL)
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <EGL/egl.h>
|
||||||
|
static void* GlowGetProcAddress(const char* name) {
|
||||||
|
return eglGetProcAddress(name);
|
||||||
|
}
|
||||||
|
#elif defined(TAG_WINDOWS)
|
||||||
|
#define WIN32_LEAN_AND_MEAN 1
|
||||||
|
#include <windows.h>
|
||||||
|
#include <stdlib.h>
|
||||||
|
static HMODULE ogl32dll = NULL;
|
||||||
|
static void* GlowGetProcAddress(const char* name) {
|
||||||
|
void* pf = wglGetProcAddress((LPCSTR) name);
|
||||||
|
if (pf) {
|
||||||
|
return pf;
|
||||||
|
}
|
||||||
|
if (ogl32dll == NULL) {
|
||||||
|
ogl32dll = LoadLibraryA("opengl32.dll");
|
||||||
|
}
|
||||||
|
return GetProcAddress(ogl32dll, (LPCSTR) name);
|
||||||
|
}
|
||||||
|
#elif defined(TAG_DARWIN)
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <dlfcn.h>
|
||||||
|
static void* GlowGetProcAddress(const char* name) {
|
||||||
|
return dlsym(RTLD_DEFAULT, name);
|
||||||
|
}
|
||||||
|
#elif defined(TAG_POSIX)
|
||||||
|
#include <stdlib.h>
|
||||||
|
#include <GL/glx.h>
|
||||||
|
static void* GlowGetProcAddress(const char* name) {
|
||||||
|
return glXGetProcAddress((const GLubyte *) name);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
*/
|
||||||
|
import "C"
|
||||||
|
import "unsafe"
|
||||||
|
|
||||||
|
func getProcAddress(namea string) unsafe.Pointer {
|
||||||
|
cname := C.CString(namea)
|
||||||
|
defer C.free(unsafe.Pointer(cname))
|
||||||
|
return C.GlowGetProcAddress(cname)
|
||||||
|
}
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
# This is the official list of go-gl/mathgl authors for copyright purposes.
|
||||||
|
# This file is distinct from the CONTRIBUTORS files.
|
||||||
|
# See the latter for an explanation.
|
||||||
|
|
||||||
|
# Names should be added to this file as
|
||||||
|
# Name or Organization <email address>
|
||||||
|
# The email address is not required for organizations.
|
||||||
|
|
||||||
|
# Please keep the list sorted.
|
||||||
|
|
||||||
|
Alex Vasi <eee@someuser.com>
|
||||||
|
Dmitri Shuralyov <shurcooL@gmail.com>
|
||||||
|
GlenKelley <???>
|
||||||
|
Zoe Juozapaitis <jragonmiris@gmail.com>
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
# This is the official list of people who can contribute
|
||||||
|
# (and typically have contributed) code to the mathgl
|
||||||
|
# repository.
|
||||||
|
#
|
||||||
|
# The AUTHORS file lists the copyright holders; this file
|
||||||
|
# lists people. For example, Google employees would be listed here
|
||||||
|
# but not in AUTHORS, because Google would hold the copyright.
|
||||||
|
#
|
||||||
|
# When adding J Random Contributor's name to this file,
|
||||||
|
# either J's name or J's organization's name should be
|
||||||
|
# added to the AUTHORS file.
|
||||||
|
#
|
||||||
|
# Names should be added to this file like so:
|
||||||
|
# Name <email address>
|
||||||
|
#
|
||||||
|
# Please keep the list sorted.
|
||||||
|
|
||||||
|
Alex Vasi <eee@someuser.com>
|
||||||
|
Dmitri Shuralyov <shurcooL@gmail.com>
|
||||||
|
GlenKelley <???>
|
||||||
|
Zoe Juozapaitis <jragonmiris@gmail.com>
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
Copyright ©2013 The go-gl Authors. All rights reserved.
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions are met:
|
||||||
|
* Redistributions of source code must retain the above copyright
|
||||||
|
notice, this list of conditions and the following disclaimer.
|
||||||
|
* Redistributions in binary form must reproduce the above copyright
|
||||||
|
notice, this list of conditions and the following disclaimer in the
|
||||||
|
documentation and/or other materials provided with the distribution.
|
||||||
|
* Neither the name of the gonum project nor the names of its authors and
|
||||||
|
contributors may be used to endorse or promote products derived from this
|
||||||
|
software without specific prior written permission.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||||
|
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||||
|
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||||
|
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||||
|
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||||
|
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||||
|
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||||
|
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||||
|
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||||
|
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
+94
@@ -0,0 +1,94 @@
|
|||||||
|
// Copyright 2014 The go-gl Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a BSD-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package mgl32
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CartesianToSpherical converts 3-dimensional cartesian coordinates (x,y,z) to spherical
|
||||||
|
// coordinates with radius r, inclination theta, and azimuth phi.
|
||||||
|
//
|
||||||
|
// All angles are in radians.
|
||||||
|
func CartesianToSpherical(coord Vec3) (r, theta, phi float32) {
|
||||||
|
r = coord.Len()
|
||||||
|
theta = float32(math.Acos(float64(coord[2] / r)))
|
||||||
|
phi = float32(math.Atan2(float64(coord[1]), float64(coord[0])))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// CartesianToCylindical converts 3-dimensional cartesian coordinates (x,y,z) to
|
||||||
|
// cylindrical coordinates with radial distance r, azimuth phi, and height z.
|
||||||
|
//
|
||||||
|
// All angles are in radians.
|
||||||
|
func CartesianToCylindical(coord Vec3) (rho, phi, z float32) {
|
||||||
|
rho = float32(math.Hypot(float64(coord[0]), float64(coord[1])))
|
||||||
|
|
||||||
|
phi = float32(math.Atan2(float64(coord[1]), float64(coord[0])))
|
||||||
|
|
||||||
|
z = coord[2]
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// SphericalToCartesian converts spherical coordinates with radius r, inclination theta,
|
||||||
|
// and azimuth phi to cartesian coordinates (x,y,z).
|
||||||
|
//
|
||||||
|
// Angles are in radians.
|
||||||
|
func SphericalToCartesian(r, theta, phi float32) Vec3 {
|
||||||
|
st, ct := math.Sincos(float64(theta))
|
||||||
|
sp, cp := math.Sincos(float64(phi))
|
||||||
|
|
||||||
|
return Vec3{r * float32(st*cp), r * float32(st*sp), r * float32(ct)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SphericalToCylindrical converts spherical coordinates with radius r,
|
||||||
|
// inclination theta, and azimuth phi to cylindrical coordinates with radial
|
||||||
|
// distance r, azimuth phi, and height z.
|
||||||
|
//
|
||||||
|
// Angles are in radians
|
||||||
|
func SphericalToCylindrical(r, theta, phi float32) (rho, phi2, z float32) {
|
||||||
|
s, c := math.Sincos(float64(theta))
|
||||||
|
|
||||||
|
rho = r * float32(s)
|
||||||
|
z = r * float32(c)
|
||||||
|
phi2 = phi
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// CylindircalToSpherical converts cylindrical coordinates with radial distance
|
||||||
|
// r, azimuth phi, and height z to spherical coordinates with radius r,
|
||||||
|
// inclination theta, and azimuth phi.
|
||||||
|
//
|
||||||
|
// Angles are in radians
|
||||||
|
func CylindircalToSpherical(rho, phi, z float32) (r, theta, phi2 float32) {
|
||||||
|
r = float32(math.Hypot(float64(rho), float64(z)))
|
||||||
|
phi2 = phi
|
||||||
|
theta = float32(math.Atan2(float64(rho), float64(z)))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// CylindricalToCartesian converts cylindrical coordinates with radial distance
|
||||||
|
// r, azimuth phi, and height z to cartesian coordinates (x,y,z)
|
||||||
|
//
|
||||||
|
// Angles are in radians.
|
||||||
|
func CylindricalToCartesian(rho, phi, z float32) Vec3 {
|
||||||
|
s, c := math.Sincos(float64(phi))
|
||||||
|
|
||||||
|
return Vec3{rho * float32(c), rho * float32(s), z}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DegToRad converts degrees to radians
|
||||||
|
func DegToRad(angle float32) float32 {
|
||||||
|
return angle * float32(math.Pi) / 180
|
||||||
|
}
|
||||||
|
|
||||||
|
// RadToDeg converts radians to degrees
|
||||||
|
func RadToDeg(angle float32) float32 {
|
||||||
|
return angle * 180 / float32(math.Pi)
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user