Merge pull request 'dev' (#26) from dev into main
Reviewed-on: https://codeberg.org/RhemaWave-Technologies/RhemaWave/pulls/26
This commit is contained in:
@@ -1,24 +0,0 @@
|
|||||||
{
|
|
||||||
"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:*)"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Vendored
+18
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
// Use IntelliSense to learn about possible attributes.
|
||||||
|
// Hover to view descriptions of existing attributes.
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "Launch file",
|
||||||
|
"type": "go",
|
||||||
|
"request": "launch",
|
||||||
|
"mode": "debug",
|
||||||
|
"program": "rhemawave.go",
|
||||||
|
"env": {
|
||||||
|
"DEBUGINFOD_URLS": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
# 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,28 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added proclamation sync when entering "proclamations" settings page
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Changed audio devices page to be strictly an audio on/off toggle
|
||||||
|
- Changed settings menu items to be left-aligned with eachother
|
||||||
|
- Changed settings entry organization
|
||||||
|
|
||||||
|
### Deprecated
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
|
||||||
|
- Removed list of audio devices from audio device page
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed audio device not being initialized on first entry to F2 screen
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
rm -rf ~/.local/share/rhemawave/
|
|
||||||
|
|
||||||
echo "Data directory deleted, application reset."
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -1,189 +0,0 @@
|
|||||||
# 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
|
|
||||||
```
|
|
||||||
@@ -154,8 +154,8 @@ func Run() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Sync the proclamations from the API to the Database
|
// Sync the proclamations from the API to the Database
|
||||||
sync.SyncProclamations(apiURL, DB)
|
sync.SyncProclamations(DB)
|
||||||
sync.SyncCategories(apiURL, DB)
|
sync.SyncCategories(DB)
|
||||||
|
|
||||||
app.runEventLoop()
|
app.runEventLoop()
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-6
@@ -103,14 +103,21 @@ func openDeviceLocked(name string) error {
|
|||||||
// Play loads a WAV (as music) and plays it. If loopForever is true, loops forever.
|
// 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.
|
// If something is already playing, it stops and replaces it.
|
||||||
func Play(path string, loopForever bool, cfg *db.AppConfig) error {
|
func Play(path string, loopForever bool, cfg *db.AppConfig) error {
|
||||||
|
// Check if audio is enabled or disabled
|
||||||
if !cfg.AudioEnabled {
|
if !cfg.AudioEnabled {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lock the mutex
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
defer mu.Unlock()
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
// Check if mixer has been initialized and that a device is open
|
||||||
if !inited || !deviceOpen {
|
if !inited || !deviceOpen {
|
||||||
|
if !deviceOpen {
|
||||||
|
SwitchDevice("") // Re-switch to the default device
|
||||||
|
Play(path, loopForever, cfg) // Re-run the function
|
||||||
|
}
|
||||||
logger.Error("audio mixer not initialized")
|
logger.Error("audio mixer not initialized")
|
||||||
return errors.New("audio mixer not initialized")
|
return errors.New("audio mixer not initialized")
|
||||||
}
|
}
|
||||||
@@ -126,10 +133,12 @@ func Play(path string, loopForever bool, cfg *db.AppConfig) error {
|
|||||||
currentMusic = nil
|
currentMusic = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load the music file
|
||||||
m, err := mix.LoadMUS(path)
|
m, err := mix.LoadMUS(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("LoadMUS(%q): %w", path, err)
|
return fmt.Errorf("LoadMUS(%q): %w", path, err)
|
||||||
}
|
}
|
||||||
|
// Set current music to the loaded music file
|
||||||
currentMusic = m
|
currentMusic = m
|
||||||
currentPath = path
|
currentPath = path
|
||||||
if loopForever {
|
if loopForever {
|
||||||
@@ -381,13 +390,13 @@ func SwitchDevice(newName string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Log device switch attempt
|
// Log device switch attempt
|
||||||
oldDevice := deviceName
|
// Automatically defaults to "system default"
|
||||||
|
oldDevice := CurrentDevice() // Which device is to be closed
|
||||||
if oldDevice == "" {
|
if oldDevice == "" {
|
||||||
oldDevice = "system default"
|
oldDevice = "system default"
|
||||||
}
|
}
|
||||||
newDeviceLog := newName
|
if newName == "" { // Which device is being switched to?
|
||||||
if newDeviceLog == "" {
|
newName = "system default"
|
||||||
newDeviceLog = "system default"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Capture current state
|
// Capture current state
|
||||||
@@ -412,11 +421,15 @@ func SwitchDevice(newName string) error {
|
|||||||
mix.CloseAudio()
|
mix.CloseAudio()
|
||||||
deviceOpen = false
|
deviceOpen = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Attempt to open the newName device
|
||||||
if err := openDeviceLocked(newName); err != nil {
|
if err := openDeviceLocked(newName); err != nil {
|
||||||
// Best effort: try to reopen the previous device (or default) so we don't leave audio dead
|
// If error switching to new device, switch back to the old device
|
||||||
if restoreErr := openDeviceLocked(oldDevice); restoreErr != nil {
|
if restoreErr := openDeviceLocked(oldDevice); restoreErr != nil {
|
||||||
} else {
|
// We should not hit an error when switching back, but if we do...
|
||||||
|
logger.Error("Failed to switch to device: %s", oldDevice)
|
||||||
}
|
}
|
||||||
|
// Could not switch to the new device, return the error
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -442,6 +455,8 @@ func SwitchDevice(newName string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.Info("Audio device switched to %s", newName)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+59
-134
@@ -5,11 +5,10 @@ import (
|
|||||||
|
|
||||||
"github.com/go-gl/gl/v3.3-core/gl"
|
"github.com/go-gl/gl/v3.3-core/gl"
|
||||||
"github.com/veandco/go-sdl2/sdl"
|
"github.com/veandco/go-sdl2/sdl"
|
||||||
"gorm.io/gorm"
|
|
||||||
"rhemawavetech.com/rhemawave/internal/colors"
|
"rhemawavetech.com/rhemawave/internal/colors"
|
||||||
"rhemawavetech.com/rhemawave/internal/db"
|
"rhemawavetech.com/rhemawave/internal/db"
|
||||||
"rhemawavetech.com/rhemawave/internal/fontManager"
|
"rhemawavetech.com/rhemawave/internal/fontManager"
|
||||||
"rhemawavetech.com/rhemawave/internal/mixer"
|
"rhemawavetech.com/rhemawave/internal/logger"
|
||||||
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -20,12 +19,10 @@ type AudioDevices struct {
|
|||||||
solidShader *shaderManager.Shader
|
solidShader *shaderManager.Shader
|
||||||
title StringItem
|
title StringItem
|
||||||
prompt StringItem
|
prompt StringItem
|
||||||
devices []StringItem
|
AudioStatusToggle StringItem
|
||||||
highlightIndex int
|
|
||||||
selectedIndex int
|
|
||||||
verticalMargin int32
|
verticalMargin int32
|
||||||
|
ItemScale float32
|
||||||
CfgPtr *db.AppConfig
|
CfgPtr *db.AppConfig
|
||||||
dbPtr *gorm.DB
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *AudioDevices) Init(ctx *PageCtx) error {
|
func (p *AudioDevices) Init(ctx *PageCtx) error {
|
||||||
@@ -57,91 +54,78 @@ func (p *AudioDevices) Init(ctx *PageCtx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get font size from database
|
// Get font size from database
|
||||||
baseFontSize := p.Base.Context.CfgPtr.StandardFontSize
|
baseFontSize := p.Context.CfgPtr.StandardFontSize
|
||||||
|
|
||||||
// Calculate scales
|
// Calculate scales
|
||||||
titleScale := float32(64) / float32(baseFontSize)
|
titleScale := float32(64) / float32(baseFontSize)
|
||||||
itemScale := float32(32) / float32(baseFontSize)
|
p.ItemScale = float32(32) / float32(baseFontSize)
|
||||||
|
|
||||||
// Create title
|
// Create title
|
||||||
p.title, err = NewStringItem("Select an Audio Output Device", p.font, colors.White)
|
p.title, err = NewStringItem("Toggle Audio", p.font, colors.White)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("creating title: %w", err)
|
return fmt.Errorf("creating title: %w", err)
|
||||||
}
|
}
|
||||||
p.title.Scale(titleScale)
|
p.title.Scale(titleScale)
|
||||||
|
|
||||||
// Create prompt
|
// Create prompt
|
||||||
p.prompt, err = NewStringItem("Use Arrow Keys to select, Enter to confirm", p.font, colors.White)
|
p.prompt, err = NewStringItem("Switch On/Off with Enter. Setting saves immediately.", p.font, colors.White)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("creating prompt: %w", err)
|
return fmt.Errorf("creating prompt: %w", err)
|
||||||
}
|
}
|
||||||
p.prompt.Scale(itemScale)
|
p.prompt.Scale(p.ItemScale)
|
||||||
|
|
||||||
// Get list of audio output devices
|
if err = p.CreateAudioStatusItem(); err != nil {
|
||||||
devices := mixer.ListDevices()
|
logger.Error("failed to create AudioStatusItem in AudioDevices.Init()")
|
||||||
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 := p.Base.Context.CfgPtr.SelectedAudioDevice
|
|
||||||
|
|
||||||
// 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 := p.Base.Context.CfgPtr.AudioEnabled
|
|
||||||
|
|
||||||
audioStatus := "Turn Audio On"
|
|
||||||
if on {
|
|
||||||
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()
|
p.layout()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (p *AudioDevices) CreateAudioStatusItem() error {
|
||||||
|
on := p.Context.CfgPtr.AudioEnabled
|
||||||
|
statusString := "Turn Audio Off" // The audio is ON
|
||||||
|
color := colors.Green
|
||||||
|
if !on {
|
||||||
|
statusString = "Turn Audio on"
|
||||||
|
color = colors.Red
|
||||||
|
}
|
||||||
|
audioStatusStringItem, err := NewStringItem(statusString, p.font, color)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create '%s' string item: %v", statusString, err)
|
||||||
|
}
|
||||||
|
fmt.Printf("AudioStatusToggle (%d)", audioStatusStringItem.ID)
|
||||||
|
audioStatusStringItem.Scale(p.ItemScale)
|
||||||
|
p.AudioStatusToggle = audioStatusStringItem
|
||||||
|
|
||||||
|
// Position the audio toggle
|
||||||
|
p.AudioStatusToggle.X = p.ScreenCenterX - p.AudioStatusToggle.W/2 // Horizontal Position
|
||||||
|
p.AudioStatusToggle.Y = p.ScreenCenterY + (p.AudioStatusToggle.H / 2) // Vertical Position
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (p *AudioDevices) layout() {
|
func (p *AudioDevices) layout() {
|
||||||
// Position title at bottom
|
// Position title at the top
|
||||||
p.title.X = p.ScreenCenterX - p.title.W/2
|
p.title.X = p.ScreenCenterX - p.title.W/2
|
||||||
p.title.Y = p.ScreenHeight - p.title.H - p.verticalMargin*2
|
p.title.Y = p.ScreenHeight - p.title.H - p.verticalMargin*2
|
||||||
|
|
||||||
// Position prompt above title
|
// Position prompt below the title
|
||||||
p.prompt.X = p.ScreenCenterX - p.prompt.W/2
|
p.prompt.X = p.ScreenCenterX - p.prompt.W/2
|
||||||
p.prompt.Y = p.title.Y - p.verticalMargin*2 - p.prompt.H
|
p.prompt.Y = p.title.Y - p.verticalMargin*2 - p.prompt.H
|
||||||
|
}
|
||||||
|
|
||||||
// Position device items stacked upward from prompt
|
func (p *AudioDevices) ToggleAudio() {
|
||||||
for i := range p.devices {
|
// Toggle current setting
|
||||||
p.devices[i].X = p.ScreenCenterX - p.devices[i].W/2
|
p.Context.CfgPtr.AudioEnabled = !p.Context.CfgPtr.AudioEnabled
|
||||||
if i == 0 {
|
|
||||||
p.devices[i].Y = p.prompt.Y - p.verticalMargin*2 - p.devices[i].H
|
// Update the database
|
||||||
} else {
|
p.Context.DbPtr.Save(p.Context.CfgPtr)
|
||||||
p.devices[i].Y = p.devices[i-1].Y - p.devices[i].H - 10
|
|
||||||
}
|
// Update the configuration
|
||||||
}
|
p.Context.RefreshConfig()
|
||||||
|
|
||||||
|
// Update AudioEnabled UI element
|
||||||
|
p.CreateAudioStatusItem()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *AudioDevices) HandleEvent(event *sdl.Event) error {
|
func (p *AudioDevices) HandleEvent(event *sdl.Event) error {
|
||||||
@@ -151,86 +135,30 @@ func (p *AudioDevices) HandleEvent(event *sdl.Event) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch e.Keysym.Sym {
|
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:
|
case sdl.K_RETURN:
|
||||||
selection := p.devices[p.highlightIndex].content
|
p.ToggleAudio()
|
||||||
|
|
||||||
// Handle audio disable
|
|
||||||
if selection == "Turn Audio Off" || selection == "Turn Audio On" {
|
|
||||||
newStatus := !p.Base.Context.CfgPtr.AudioEnabled
|
|
||||||
updatedConfig := p.Base.Context.CfgPtr
|
|
||||||
updatedConfig.AudioEnabled = newStatus
|
|
||||||
p.Base.Context.DbPtr.Save(&updatedConfig)
|
|
||||||
p.Base.Context.RefreshConfig()
|
|
||||||
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
|
|
||||||
p.Base.Context.CfgPtr.SelectedAudioDevice = deviceName
|
|
||||||
p.Base.Context.CfgPtr.AudioEnabled = true
|
|
||||||
p.Base.Context.DbPtr.Save(p.Base.Context.CfgPtr)
|
|
||||||
p.Base.Context.RefreshConfig()
|
|
||||||
case sdl.K_BACKSPACE:
|
case sdl.K_BACKSPACE:
|
||||||
p.Base.Context.NavigateTo(SETTINGS)
|
p.Context.NavigateTo(SETTINGS)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *AudioDevices) Render() error {
|
func (p *AudioDevices) Render() error {
|
||||||
// Always render title and prompt
|
if err := p.RenderTexture(p.textShader, p.title.ID, p.title.Position(), p.title.RawDimensions(), p.title.Color); err != nil {
|
||||||
p.RenderTexture(p.textShader, p.title.ID, p.title.Position(), p.title.RawDimensions(), p.title.Color)
|
logger.Error("unable to render title texture: %v", err)
|
||||||
p.RenderTexture(p.textShader, p.prompt.ID, p.prompt.Position(), p.prompt.RawDimensions(), p.prompt.Color)
|
return fmt.Errorf("unable to render title texture: %w", err)
|
||||||
|
|
||||||
// No devices to display
|
|
||||||
if len(p.devices) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render highlight box behind highlighted item
|
if err := p.RenderTexture(p.textShader, p.prompt.ID, p.prompt.Position(), p.prompt.RawDimensions(), p.prompt.Color); err != nil {
|
||||||
p.RenderSolidColorQuad(
|
logger.Error("unable to render prompt texture: %v", err)
|
||||||
p.solidShader,
|
return fmt.Errorf("unable to render prompt texture: %w", err)
|
||||||
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)
|
|
||||||
|
if err := p.RenderTexture(p.textShader, p.AudioStatusToggle.ID, p.AudioStatusToggle.Position(), p.AudioStatusToggle.RawDimensions(), p.AudioStatusToggle.Color); err != nil {
|
||||||
|
logger.Error("unable to render audioStatusToggle texture: %v", err)
|
||||||
|
return fmt.Errorf("unable to render audioStatusToggle texture: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -239,8 +167,5 @@ func (p *AudioDevices) Render() error {
|
|||||||
func (p *AudioDevices) Destroy() error {
|
func (p *AudioDevices) Destroy() error {
|
||||||
gl.DeleteTextures(1, &p.title.ID)
|
gl.DeleteTextures(1, &p.title.ID)
|
||||||
gl.DeleteTextures(1, &p.prompt.ID)
|
gl.DeleteTextures(1, &p.prompt.ID)
|
||||||
for _, item := range p.devices {
|
|
||||||
gl.DeleteTextures(1, &item.ID)
|
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,9 +154,9 @@ func (p *EmailAddressPage) HandleEvent(event *sdl.Event) error {
|
|||||||
sdl.StopTextInput()
|
sdl.StopTextInput()
|
||||||
|
|
||||||
// Save email address to database
|
// Save email address to database
|
||||||
p.Base.Context.CfgPtr.EmailAddress = p.inputText
|
p.Context.CfgPtr.EmailAddress = p.inputText
|
||||||
p.Base.Context.DbPtr.Save(p.Base.Context.CfgPtr)
|
p.Context.DbPtr.Save(p.Context.CfgPtr)
|
||||||
p.Base.Context.RefreshConfig()
|
p.Context.RefreshConfig()
|
||||||
|
|
||||||
if p.isFirstTimeSetup {
|
if p.isFirstTimeSetup {
|
||||||
// First-time setup: navigate to License Key page
|
// First-time setup: navigate to License Key page
|
||||||
@@ -253,7 +253,7 @@ func (p *EmailAddressPage) Update(dt float32) error {
|
|||||||
// Render performs page drawing logic.
|
// Render performs page drawing logic.
|
||||||
func (p *EmailAddressPage) Render() error {
|
func (p *EmailAddressPage) Render() error {
|
||||||
// Get base font size from database
|
// Get base font size from database
|
||||||
baseFontSize := p.Base.Context.CfgPtr.StandardFontSize
|
baseFontSize := p.Context.CfgPtr.StandardFontSize
|
||||||
|
|
||||||
sm := shaderManager.Get()
|
sm := shaderManager.Get()
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
||||||
)
|
)
|
||||||
|
|
||||||
var GitVersion = "v1.0.0"
|
var GitVersion = "1.0"
|
||||||
|
|
||||||
type Home struct {
|
type Home struct {
|
||||||
Base
|
Base
|
||||||
@@ -30,7 +30,6 @@ type Home struct {
|
|||||||
|
|
||||||
// Init performs basic page initialization and creation of static assets.
|
// Init performs basic page initialization and creation of static assets.
|
||||||
func (p *Home) Init(ctx *PageCtx) error {
|
func (p *Home) Init(ctx *PageCtx) error {
|
||||||
|
|
||||||
title := "Rhema Wave"
|
title := "Rhema Wave"
|
||||||
version := GitVersion
|
version := GitVersion
|
||||||
prompt := "Press F2 to Start, or F3 for Settings"
|
prompt := "Press F2 to Start, or F3 for Settings"
|
||||||
@@ -74,7 +73,7 @@ func (p *Home) Init(ctx *PageCtx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create license info textures if data exists
|
// Create license info textures if data exists
|
||||||
if p.Base.Context.CfgPtr.EmailAddress != "" || p.Base.Context.CfgPtr.LicenseKey != "" {
|
if p.Context.CfgPtr.EmailAddress != "" || p.Context.CfgPtr.LicenseKey != "" {
|
||||||
p.hasLicenseInfo = true
|
p.hasLicenseInfo = true
|
||||||
|
|
||||||
// Create heading
|
// Create heading
|
||||||
@@ -84,8 +83,8 @@ func (p *Home) Init(ctx *PageCtx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create email display if exists
|
// Create email display if exists
|
||||||
if p.Base.Context.CfgPtr.EmailAddress != "" {
|
if p.Context.CfgPtr.EmailAddress != "" {
|
||||||
emailText := fmt.Sprintf("Email: %s", p.Base.Context.CfgPtr.EmailAddress)
|
emailText := fmt.Sprintf("Email: %s", p.Context.CfgPtr.EmailAddress)
|
||||||
p.emailDisplay, err = NewStringItem(emailText, font, colors.White)
|
p.emailDisplay, err = NewStringItem(emailText, font, colors.White)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("unable to create email display texture: %w", err)
|
return fmt.Errorf("unable to create email display texture: %w", err)
|
||||||
@@ -93,8 +92,8 @@ func (p *Home) Init(ctx *PageCtx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create license key display if exists
|
// Create license key display if exists
|
||||||
if p.Base.Context.CfgPtr.LicenseKey != "" {
|
if p.Context.CfgPtr.LicenseKey != "" {
|
||||||
licenseKeyText := fmt.Sprintf("License Key: %s", p.Base.Context.CfgPtr.LicenseKey)
|
licenseKeyText := fmt.Sprintf("License Key: %s", p.Context.CfgPtr.LicenseKey)
|
||||||
p.licenseKeyDisplay, err = NewStringItem(licenseKeyText, font, colors.White)
|
p.licenseKeyDisplay, err = NewStringItem(licenseKeyText, font, colors.White)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("unable to create license key display texture: %w", err)
|
return fmt.Errorf("unable to create license key display texture: %w", err)
|
||||||
@@ -103,7 +102,7 @@ func (p *Home) Init(ctx *PageCtx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get base font size from database
|
// Get base font size from database
|
||||||
baseFontSize := p.Base.Context.CfgPtr.StandardFontSize
|
baseFontSize := p.Context.CfgPtr.StandardFontSize
|
||||||
|
|
||||||
p.baseFontSize = baseFontSize
|
p.baseFontSize = baseFontSize
|
||||||
|
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ func (p *LicenseKeyPage) Init(ctx *PageCtx) error {
|
|||||||
p.titleTextureHeight = titleHeight
|
p.titleTextureHeight = titleHeight
|
||||||
|
|
||||||
// Load existing license key from database
|
// Load existing license key from database
|
||||||
licenseKey := p.Base.Context.CfgPtr.LicenseKey
|
licenseKey := p.Context.CfgPtr.LicenseKey
|
||||||
|
|
||||||
if licenseKey != "" {
|
if licenseKey != "" {
|
||||||
p.inputText = licenseKey
|
p.inputText = licenseKey
|
||||||
@@ -165,7 +165,7 @@ func (p *LicenseKeyPage) HandleEvent(event *sdl.Event) error {
|
|||||||
}
|
}
|
||||||
// Allow escape back to settings if coming from settings
|
// Allow escape back to settings if coming from settings
|
||||||
sdl.StopTextInput()
|
sdl.StopTextInput()
|
||||||
p.Base.Context.NavigateTo(SETTINGS)
|
p.Context.NavigateTo(SETTINGS)
|
||||||
case sdl.K_RETURN:
|
case sdl.K_RETURN:
|
||||||
// Validate license key - check empty first, then length, then characters
|
// Validate license key - check empty first, then length, then characters
|
||||||
cleaned := strings.ReplaceAll(p.inputText, "-", "")
|
cleaned := strings.ReplaceAll(p.inputText, "-", "")
|
||||||
@@ -188,9 +188,9 @@ func (p *LicenseKeyPage) HandleEvent(event *sdl.Event) error {
|
|||||||
sdl.StopTextInput()
|
sdl.StopTextInput()
|
||||||
|
|
||||||
// Save license key to database
|
// Save license key to database
|
||||||
p.Base.Context.CfgPtr.LicenseKey = p.inputText
|
p.Context.CfgPtr.LicenseKey = p.inputText
|
||||||
p.Base.Context.DbPtr.Save(p.Base.Context.CfgPtr)
|
p.Context.DbPtr.Save(p.Context.CfgPtr)
|
||||||
p.Base.Context.RefreshConfig()
|
p.Context.RefreshConfig()
|
||||||
|
|
||||||
// Write license key to plain text file for the updater
|
// Write license key to plain text file for the updater
|
||||||
if homeDir, err := os.UserHomeDir(); err == nil {
|
if homeDir, err := os.UserHomeDir(); err == nil {
|
||||||
@@ -202,10 +202,10 @@ func (p *LicenseKeyPage) HandleEvent(event *sdl.Event) error {
|
|||||||
|
|
||||||
if p.isFirstTimeSetup {
|
if p.isFirstTimeSetup {
|
||||||
// First-time setup: rewind to Welcome page
|
// First-time setup: rewind to Welcome page
|
||||||
p.Base.Context.NavigateTo(HOME)
|
p.Context.NavigateTo(HOME)
|
||||||
} else {
|
} else {
|
||||||
// Coming from settings: rewind back to settings
|
// Coming from settings: rewind back to settings
|
||||||
p.Base.Context.NavigateTo(SETTINGS)
|
p.Context.NavigateTo(SETTINGS)
|
||||||
}
|
}
|
||||||
case sdl.K_BACKSPACE:
|
case sdl.K_BACKSPACE:
|
||||||
if len(p.inputText) > 0 {
|
if len(p.inputText) > 0 {
|
||||||
@@ -303,7 +303,7 @@ func (p *LicenseKeyPage) Update(dt float32) error {
|
|||||||
// Render performs page drawing logic.
|
// Render performs page drawing logic.
|
||||||
func (p *LicenseKeyPage) Render() error {
|
func (p *LicenseKeyPage) Render() error {
|
||||||
// Get base font size from database
|
// Get base font size from database
|
||||||
baseFontSize := p.Base.Context.CfgPtr.StandardFontSize
|
baseFontSize := p.Context.CfgPtr.StandardFontSize
|
||||||
|
|
||||||
sm := shaderManager.Get()
|
sm := shaderManager.Get()
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ func (p *LogViewerPage) Init(ctx *PageCtx) error {
|
|||||||
return fmt.Errorf("failed base page initialization: %w", err)
|
return fmt.Errorf("failed base page initialization: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if result := p.Base.Context.DbPtr.Order("timestamp desc").Limit(250).Find(&logs); result.Error != nil {
|
if result := p.Context.DbPtr.Order("timestamp desc").Limit(250).Find(&logs); result.Error != nil {
|
||||||
logger.Error("error querying database for logs: %s", result.Error)
|
logger.Error("error querying database for logs: %s", result.Error)
|
||||||
return fmt.Errorf("%s", result.Error)
|
return fmt.Errorf("%s", result.Error)
|
||||||
}
|
}
|
||||||
@@ -115,7 +115,7 @@ func (p *LogViewerPage) HandleEvent(event *sdl.Event) error {
|
|||||||
if e.Type == sdl.KEYDOWN {
|
if e.Type == sdl.KEYDOWN {
|
||||||
switch e.Keysym.Sym {
|
switch e.Keysym.Sym {
|
||||||
case sdl.K_BACKSPACE:
|
case sdl.K_BACKSPACE:
|
||||||
p.Base.Context.NavigateTo(SETTINGS)
|
p.Context.NavigateTo(SETTINGS)
|
||||||
case sdl.K_UP:
|
case sdl.K_UP:
|
||||||
p.scrollOffset -= p.lineHeight
|
p.scrollOffset -= p.lineHeight
|
||||||
if p.scrollOffset < 0 {
|
if p.scrollOffset < 0 {
|
||||||
@@ -159,7 +159,7 @@ func (p *LogViewerPage) Update(dt float32) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (p *LogViewerPage) Render() error {
|
func (p *LogViewerPage) Render() error {
|
||||||
baseFontSize := p.Base.Context.CfgPtr.StandardFontSize
|
baseFontSize := p.Context.CfgPtr.StandardFontSize
|
||||||
|
|
||||||
sm := shaderManager.Get()
|
sm := shaderManager.Get()
|
||||||
textShader, _ := sm.Get("text")
|
textShader, _ := sm.Get("text")
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ func (p *MessagePage) Init(ctx *PageCtx) error {
|
|||||||
return fmt.Errorf("unable to create instructions string texture: %w", err)
|
return fmt.Errorf("unable to create instructions string texture: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
baseFontSize := float32(p.Base.Context.CfgPtr.StandardFontSize)
|
baseFontSize := float32(p.Context.CfgPtr.StandardFontSize)
|
||||||
p.message.Scale(float32(48) / baseFontSize)
|
p.message.Scale(float32(48) / baseFontSize)
|
||||||
p.instructions.Scale(float32(28) / baseFontSize)
|
p.instructions.Scale(float32(28) / baseFontSize)
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"rhemawavetech.com/rhemawave/internal/fontManager"
|
"rhemawavetech.com/rhemawave/internal/fontManager"
|
||||||
"rhemawavetech.com/rhemawave/internal/logger"
|
"rhemawavetech.com/rhemawave/internal/logger"
|
||||||
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
||||||
|
"rhemawavetech.com/rhemawave/internal/sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Scrollbar Visuals
|
// Scrollbar Visuals
|
||||||
@@ -78,10 +79,12 @@ func (p *ProclamationOptions) Init(ctx *PageCtx) error {
|
|||||||
p.allItems = []*FlatItem{}
|
p.allItems = []*FlatItem{}
|
||||||
p.scrollOffset = 0
|
p.scrollOffset = 0
|
||||||
p.isSearchActive = false
|
p.isSearchActive = false
|
||||||
|
|
||||||
p.scrollTrackWidth = 30
|
p.scrollTrackWidth = 30
|
||||||
p.scrollTrackX = p.ScreenWidth - p.scrollTrackWidth
|
p.scrollTrackX = p.ScreenWidth - p.scrollTrackWidth
|
||||||
|
|
||||||
|
// Syncronize proclamations first
|
||||||
|
sync.SyncProclamations(ctx.DbPtr)
|
||||||
|
|
||||||
fm := fontManager.Get()
|
fm := fontManager.Get()
|
||||||
font, _ := fm.GetFont("Roboto-Regular")
|
font, _ := fm.GetFont("Roboto-Regular")
|
||||||
p.font = font
|
p.font = font
|
||||||
@@ -105,19 +108,26 @@ func (p *ProclamationOptions) Init(ctx *PageCtx) error {
|
|||||||
|
|
||||||
// Load current selections
|
// Load current selections
|
||||||
var selected []db.Proclamation
|
var selected []db.Proclamation
|
||||||
p.Context.DbPtr.Where("selected = ?", true).Find(&selected)
|
p.Context.DbPtr.Where("selected = ?", true).Where("id != ?", defaultProclamationID).Find(&selected)
|
||||||
for _, s := range selected {
|
for _, s := range selected {
|
||||||
|
if s.ID == defaultProclamationID {
|
||||||
|
logger.Debug("Found default proclamation in selected list: %s", s.ID)
|
||||||
|
}
|
||||||
p.activeMap[s.ID] = true
|
p.activeMap[s.ID] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load ALL proclamations into a lookup map for SearchText population
|
// Load ALL proclamations into a lookup map for SearchText population
|
||||||
var allProcs []db.Proclamation
|
var allProcs []db.Proclamation
|
||||||
if err := p.Context.DbPtr.Find(&allProcs).Error; err != nil {
|
if err := p.Context.DbPtr.Where("id != ?", defaultProclamationID).Find(&allProcs).Error; err != nil {
|
||||||
logger.Error("Failed to fetch proclamations: %s", err)
|
logger.Error("Failed to fetch proclamations: %s", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
procContentMap := make(map[string]string, len(allProcs))
|
procContentMap := make(map[string]string, len(allProcs))
|
||||||
for _, pr := range allProcs {
|
for _, pr := range allProcs {
|
||||||
|
if pr.ID == defaultProclamationID {
|
||||||
|
logger.Debug("Found default proclamation in allProcs list: %s", pr.ID)
|
||||||
|
}
|
||||||
procContentMap[pr.ID] = pr.Content
|
procContentMap[pr.ID] = pr.Content
|
||||||
}
|
}
|
||||||
p.allProclamations = []*FlatItem{}
|
p.allProclamations = []*FlatItem{}
|
||||||
@@ -242,7 +252,7 @@ func (p *ProclamationOptions) expand(index int) {
|
|||||||
|
|
||||||
// Load proclamations
|
// Load proclamations
|
||||||
var procs []db.Proclamation
|
var procs []db.Proclamation
|
||||||
p.Context.DbPtr.Where("category_id = ?", parentID).Find(&procs)
|
p.Context.DbPtr.Where("category_id = ?", parentID).Where("ID != ?", defaultProclamationID).Find(&procs)
|
||||||
for _, pr := range procs {
|
for _, pr := range procs {
|
||||||
var fi *FlatItem
|
var fi *FlatItem
|
||||||
// Reuse existing proclamation from allProclamations if available
|
// Reuse existing proclamation from allProclamations if available
|
||||||
@@ -484,8 +494,9 @@ func (p *ProclamationOptions) HandleEvent(event *sdl.Event) error {
|
|||||||
p.acceptChanges()
|
p.acceptChanges()
|
||||||
case sdl.K_SPACE:
|
case sdl.K_SPACE:
|
||||||
item := p.allItems[p.highlightIndex]
|
item := p.allItems[p.highlightIndex]
|
||||||
if !item.IsCategory {
|
if !item.IsCategory { // If the item is NOT a Category
|
||||||
p.activeMap[item.ID] = !p.activeMap[item.ID]
|
p.activeMap[item.ID] = !p.activeMap[item.ID] // Toggle it from the 'selected' list
|
||||||
|
logger.Debug("Select/Deselect Proclamation: %s %s", item.Label, item.ID)
|
||||||
} else if !item.IsSubCategory {
|
} else if !item.IsSubCategory {
|
||||||
p.toggleExpansion(p.highlightIndex)
|
p.toggleExpansion(p.highlightIndex)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,12 @@ import (
|
|||||||
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultProclamationID string = "f3d42354-3197-45e4-9b08-be447f14386d"
|
||||||
|
proclamationPlayCount int = 10
|
||||||
|
defaultProclamationPlayCount int = 10
|
||||||
|
)
|
||||||
|
|
||||||
type Line struct {
|
type Line struct {
|
||||||
words []StringItem
|
words []StringItem
|
||||||
width int32
|
width int32
|
||||||
@@ -231,7 +237,7 @@ func (p *ScrollerPage) Init(ctx *PageCtx) error {
|
|||||||
|
|
||||||
// Get line pattern from database
|
// Get line pattern from database
|
||||||
var err error
|
var err error
|
||||||
linePatternName := p.Base.Context.CfgPtr.LinePattern
|
linePatternName := p.Context.CfgPtr.LinePattern
|
||||||
|
|
||||||
p.activePattern, err = linePattern.Get(linePatternName)
|
p.activePattern, err = linePattern.Get(linePatternName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -239,7 +245,7 @@ func (p *ScrollerPage) Init(ctx *PageCtx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Initialize color pattern
|
// Initialize color pattern
|
||||||
var baseColors = []sdl.Color{
|
baseColors := []sdl.Color{
|
||||||
colors.Tan,
|
colors.Tan,
|
||||||
colors.PeachPuff,
|
colors.PeachPuff,
|
||||||
colors.LightCyan,
|
colors.LightCyan,
|
||||||
@@ -252,7 +258,7 @@ func (p *ScrollerPage) Init(ctx *PageCtx) error {
|
|||||||
|
|
||||||
// Get font sizes from database (optimize: load once, cache as int32)
|
// Get font sizes from database (optimize: load once, cache as int32)
|
||||||
// Match the working page's logic exactly:
|
// Match the working page's logic exactly:
|
||||||
p.fontScale = float32(32) / float32(p.Base.Context.CfgPtr.StandardFontSize)
|
p.fontScale = float32(32) / float32(p.Context.CfgPtr.StandardFontSize)
|
||||||
|
|
||||||
// Font setup
|
// Font setup
|
||||||
nLines := int(float32(p.ScreenHeight) / 32.0) // 32 is your target visual size
|
nLines := int(float32(p.ScreenHeight) / 32.0) // 32 is your target visual size
|
||||||
@@ -291,7 +297,7 @@ func (p *ScrollerPage) Init(ctx *PageCtx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Audio setup
|
// Audio setup
|
||||||
audioDeviceName := p.Base.Context.CfgPtr.SelectedAudioDevice
|
audioDeviceName := p.Context.CfgPtr.SelectedAudioDevice
|
||||||
|
|
||||||
// Switch audio devices if needed
|
// Switch audio devices if needed
|
||||||
if mixer.CurrentDevice() != audioDeviceName {
|
if mixer.CurrentDevice() != audioDeviceName {
|
||||||
@@ -302,12 +308,12 @@ func (p *ScrollerPage) Init(ctx *PageCtx) error {
|
|||||||
|
|
||||||
// Start audio playback
|
// Start audio playback
|
||||||
|
|
||||||
// selectedAudio := p.Base.Context.CfgPtr.SelectedAudio
|
// selectedAudio := p.Context.CfgPtr.SelectedAudio
|
||||||
|
|
||||||
assetsDir := p.Base.Context.CfgPtr.AssetsDir
|
assetsDir := p.Context.CfgPtr.AssetsDir
|
||||||
audioFile := filepath.Join(assetsDir, "audio/audio.wav")
|
audioFile := filepath.Join(assetsDir, "audio/audio.wav")
|
||||||
logger.Info("Attempting to play %s", audioFile)
|
logger.Info("Attempting to play %s", audioFile)
|
||||||
if err := mixer.Play(audioFile, true, p.Base.Context.CfgPtr); err != nil {
|
if err := mixer.Play(audioFile, true, p.Context.CfgPtr); err != nil {
|
||||||
logger.Error("Mixer returned an error: %v", err) // ADD THIS
|
logger.Error("Mixer returned an error: %v", err) // ADD THIS
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,13 +336,28 @@ func (p *ScrollerPage) loadProclamations() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build word list from selected, available proclamations
|
var defaultProclamation db.Proclamation
|
||||||
for _, proclamation := range proclamations {
|
err = p.Context.DbPtr.Where("ID = ?", defaultProclamationID).Find(&defaultProclamation).Error
|
||||||
if proclamation.Selected {
|
if err != nil {
|
||||||
// Split content into words and add to list
|
return err
|
||||||
words := strings.Fields(proclamation.Content)
|
|
||||||
p.words = append(p.words, words...)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build word list from selected, available proclamations
|
||||||
|
defaultWords := strings.Fields(defaultProclamation.Content)
|
||||||
|
|
||||||
|
for _, proclamation := range proclamations {
|
||||||
|
words := strings.Fields(proclamation.Content)
|
||||||
|
|
||||||
|
// Pre-allocate: 10x proclamation words + 1x default words
|
||||||
|
batch := make([]string, 0, len(words)*10+len(defaultWords))
|
||||||
|
for range 10 {
|
||||||
|
logger.Debug("Adding %s", proclamation.Name)
|
||||||
|
batch = append(batch, words...)
|
||||||
|
}
|
||||||
|
logger.Debug("Adding %s", defaultProclamation.Name)
|
||||||
|
batch = append(batch, defaultWords...)
|
||||||
|
|
||||||
|
p.words = append(p.words, batch...)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(p.words) == 0 {
|
if len(p.words) == 0 {
|
||||||
@@ -414,15 +435,6 @@ func (p *ScrollerPage) HandleEvent(event *sdl.Event) error {
|
|||||||
if !p.autoVelocity {
|
if !p.autoVelocity {
|
||||||
p.velocity -= p.increment
|
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
|
||||||
}
|
}
|
||||||
@@ -594,7 +606,7 @@ func (p *ScrollerPage) Destroy() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
mixer.Stop(1000, p.Base.Context.CfgPtr)
|
mixer.Stop(1000, p.Context.CfgPtr)
|
||||||
|
|
||||||
// GL cleanup — delete all text textures
|
// GL cleanup — delete all text textures
|
||||||
if p.lines != nil {
|
if p.lines != nil {
|
||||||
|
|||||||
@@ -78,16 +78,7 @@ func (p *Settings) Init(ctx *PageCtx) error {
|
|||||||
label: proclamationsLabel,
|
label: proclamationsLabel,
|
||||||
})
|
})
|
||||||
|
|
||||||
wifiLabel, err := NewStringItem("WiFi Setup", font, colors.White)
|
audioLabel, err := NewStringItem("Audio", font, colors.White)
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("creating wifi label: %w", err)
|
|
||||||
}
|
|
||||||
p.options = append(p.options, Option{
|
|
||||||
target: WIFI,
|
|
||||||
label: wifiLabel,
|
|
||||||
})
|
|
||||||
|
|
||||||
audioLabel, err := NewStringItem("Audio Device", font, colors.White)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("creating audio label: %w", err)
|
return fmt.Errorf("creating audio label: %w", err)
|
||||||
}
|
}
|
||||||
@@ -114,6 +105,15 @@ func (p *Settings) Init(ctx *PageCtx) error {
|
|||||||
label: licenseKeyLabel,
|
label: licenseKeyLabel,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
wifiLabel, err := NewStringItem("WiFi Setup", font, colors.White)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("creating wifi label: %w", err)
|
||||||
|
}
|
||||||
|
p.options = append(p.options, Option{
|
||||||
|
target: WIFI,
|
||||||
|
label: wifiLabel,
|
||||||
|
})
|
||||||
|
|
||||||
logsLabel, err := NewStringItem("Logs", font, colors.White)
|
logsLabel, err := NewStringItem("Logs", font, colors.White)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("creating logs label: %w", err)
|
return fmt.Errorf("creating logs label: %w", err)
|
||||||
@@ -123,14 +123,8 @@ func (p *Settings) Init(ctx *PageCtx) error {
|
|||||||
label: logsLabel,
|
label: logsLabel,
|
||||||
})
|
})
|
||||||
|
|
||||||
updateLabel, err := NewStringItem("Check for Updates", font, colors.White)
|
// TODO: Add entry for "Tutorial"
|
||||||
if err != nil {
|
// TODO: Add entry for "About"
|
||||||
return fmt.Errorf("creating update label: %w", err)
|
|
||||||
}
|
|
||||||
p.options = append(p.options, Option{
|
|
||||||
target: CHECK_UPDATES,
|
|
||||||
label: updateLabel,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Get base font size from database
|
// Get base font size from database
|
||||||
baseFontSize := p.Context.CfgPtr.StandardFontSize
|
baseFontSize := p.Context.CfgPtr.StandardFontSize
|
||||||
@@ -161,12 +155,19 @@ func (p *Settings) Init(ctx *PageCtx) error {
|
|||||||
p.title.Y = p.ScreenHeight - p.title.H - p.verticalMargin*2
|
p.title.Y = p.ScreenHeight - p.title.H - p.verticalMargin*2
|
||||||
|
|
||||||
// Position prompt above title
|
// Position prompt above title
|
||||||
p.prompt.X = p.ScreenCenterX - p.prompt.W/2
|
p.prompt.X = p.ScreenCenterX - int32(float32(p.prompt.W)/2)
|
||||||
p.prompt.Y = p.title.Y - p.verticalMargin*2 - p.prompt.H
|
p.prompt.Y = p.title.Y - p.verticalMargin*2 - p.prompt.H
|
||||||
|
|
||||||
|
// If this value starts at 0, nothing is further left than that...
|
||||||
|
var furthestLeft int32 = p.ScreenWidth // < more to the left, > more to the right, = same
|
||||||
|
|
||||||
// Position options stacked upward from prompt
|
// Position options stacked upward from prompt
|
||||||
for i, option := range p.options {
|
for i, option := range p.options {
|
||||||
p.options[i].label.X = p.ScreenCenterX - option.label.W/2
|
p.options[i].label.X = p.ScreenCenterX - option.label.W/2 // Calculate horizontal center posision
|
||||||
|
if p.options[i].label.X < furthestLeft { // If the left most pixel is further left than furthestLeft, indicating a longer line:
|
||||||
|
// Then update furthestLeft
|
||||||
|
furthestLeft = p.options[i].label.X
|
||||||
|
}
|
||||||
if i == 0 {
|
if i == 0 {
|
||||||
p.options[i].label.Y = int32(float32(p.prompt.Y)-(float32(p.verticalMargin)*4)) - option.label.H
|
p.options[i].label.Y = int32(float32(p.prompt.Y)-(float32(p.verticalMargin)*4)) - option.label.H
|
||||||
} else {
|
} else {
|
||||||
@@ -174,6 +175,11 @@ func (p *Settings) Init(ctx *PageCtx) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Now, re-iterate over the options and justify everything to furthestLeft
|
||||||
|
for i := range p.options {
|
||||||
|
p.options[i].label.X = furthestLeft
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+42
-10
@@ -1,7 +1,6 @@
|
|||||||
package sync
|
package sync
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"regexp"
|
"regexp"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -14,10 +13,10 @@ var htmlRegex = regexp.MustCompile(`<[^>]*>`)
|
|||||||
|
|
||||||
// SyncProclamations fetches data from the API, cleans it, and updates the local DB.
|
// SyncProclamations fetches data from the API, cleans it, and updates the local DB.
|
||||||
// It returns the slice of proclamations so the UI can update immediately.
|
// It returns the slice of proclamations so the UI can update immediately.
|
||||||
func SyncProclamations(apiURL string, database *gorm.DB) ([]db.Proclamation, error) {
|
func SyncProclamations(database *gorm.DB) ([]db.Proclamation, error) {
|
||||||
fullURL := fmt.Sprintf("%s/proclamations", apiURL)
|
fullURL := "https://portal.rhemawavetech.com/api/v1/proclamations"
|
||||||
|
logger.Debug("Syncronizing proclamations. URL: %s", fullURL)
|
||||||
|
|
||||||
// 1. Fetch from API
|
|
||||||
proclamations, err := netreq.GetJSON[db.Proclamation](fullURL)
|
proclamations, err := netreq.GetJSON[db.Proclamation](fullURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// If we're offline or API is down, we log it but don't crash.
|
// If we're offline or API is down, we log it but don't crash.
|
||||||
@@ -25,21 +24,44 @@ func SyncProclamations(apiURL string, database *gorm.DB) ([]db.Proclamation, err
|
|||||||
logger.Error("Proclamation sync failed, skipping update: %v", err)
|
logger.Error("Proclamation sync failed, skipping update: %v", err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
logger.Debug("Request Granted")
|
||||||
|
|
||||||
// 2. Data Transformation (Cleaning)
|
|
||||||
for i := range proclamations {
|
for i := range proclamations {
|
||||||
proclamations[i].Content = htmlRegex.ReplaceAllString(proclamations[i].Content, "")
|
proclamations[i].Content = htmlRegex.ReplaceAllString(proclamations[i].Content, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Persist to Database
|
var storedProclamationSettings []db.Proclamation
|
||||||
// Using Transaction ensures we don't end up with partial data if something fails
|
database.Select("ID", "Selected").Find(&storedProclamationSettings)
|
||||||
|
// [ID]Selected
|
||||||
|
stash := make(map[string]bool)
|
||||||
|
for _, row := range storedProclamationSettings {
|
||||||
|
stash[row.ID] = row.Selected
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range proclamations {
|
||||||
|
proclamations[i].Selected = stash[proclamations[i].ID]
|
||||||
|
}
|
||||||
|
|
||||||
err = database.Transaction(func(tx *gorm.DB) error {
|
err = database.Transaction(func(tx *gorm.DB) error {
|
||||||
if err := tx.Omit("selected").Save(&proclamations).Error; err != nil {
|
// Clear the database
|
||||||
|
// Where "1 = 1" because batch Delete() requires a condition
|
||||||
|
// 'Where "1 = 1"' is like saying "while true be true" and tricks GORM into deleting everything
|
||||||
|
if err := tx.Where("1 = 1").Delete(db.Proclamation{}).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// Then re-populate the database
|
||||||
|
if err := tx.Save(&proclamations).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Old sync code
|
||||||
|
// if err := tx.Omit("selected").Save(&proclamations).Error; err != nil {
|
||||||
|
// return err
|
||||||
|
// }
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
logger.Debug("Proclamations stored to database")
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("Failed to save synced proclamations: %v", err)
|
logger.Error("Failed to save synced proclamations: %v", err)
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -49,19 +71,29 @@ func SyncProclamations(apiURL string, database *gorm.DB) ([]db.Proclamation, err
|
|||||||
return proclamations, nil
|
return proclamations, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func SyncCategories(apiURL string, database *gorm.DB) ([]db.ProclamationCategory, error) {
|
func SyncCategories(database *gorm.DB) ([]db.ProclamationCategory, error) {
|
||||||
fullURL := fmt.Sprintf("%s/categories", apiURL)
|
fullURL := "https://portal.rhemawavetech.com/api/v1/categories"
|
||||||
categories, err := netreq.GetJSON[db.ProclamationCategory](fullURL)
|
categories, err := netreq.GetJSON[db.ProclamationCategory](fullURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("Category sync failed, skipping update: %v", err)
|
logger.Error("Category sync failed, skipping update: %v", err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = database.Transaction(func(tx *gorm.DB) error {
|
err = database.Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Where("1 = 1").Delete(&db.ProclamationCategory{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if err := tx.Save(&categories).Error; err != nil {
|
if err := tx.Save(&categories).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("failed to sync proclamation categories: %v", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
logger.Info("Successfully synced proclamation categories: %d", len(categories))
|
logger.Info("Successfully synced proclamation categories: %d", len(categories))
|
||||||
return categories, nil
|
return categories, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,796 +0,0 @@
|
|||||||
# 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`
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
#!/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 ""
|
|
||||||
Reference in New Issue
Block a user