Merge pull request 'dev' (#26) from dev into main

Reviewed-on: https://codeberg.org/RhemaWave-Technologies/RhemaWave/pulls/26
This commit is contained in:
NeilClack
2026-04-24 00:32:49 +02:00
21 changed files with 276 additions and 1396 deletions
-24
View File
@@ -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:*)"
]
}
}
+18
View File
@@ -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": ""
}
},
]
}
-21
View File
@@ -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
+28
View File
@@ -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
---
-5
View File
@@ -1,5 +0,0 @@
#!/bin/bash
rm -rf ~/.local/share/rhemawave/
echo "Data directory deleted, application reset."
-67
View File
@@ -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
-189
View File
@@ -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
```
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
} }
+4 -4
View File
@@ -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()
+7 -8
View File
@@ -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
+8 -8
View File
@@ -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()
+3 -3
View File
@@ -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")
+1 -1
View File
@@ -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)
+17 -6
View File
@@ -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)
} }
+35 -23
View File
@@ -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 {
+26 -20
View File
@@ -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
View File
@@ -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
} }
-796
View File
@@ -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`
-64
View File
@@ -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 ""