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.SyncProclamations(apiURL, DB)
|
||||
sync.SyncCategories(apiURL, DB)
|
||||
sync.SyncProclamations(DB)
|
||||
sync.SyncCategories(DB)
|
||||
|
||||
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.
|
||||
// If something is already playing, it stops and replaces it.
|
||||
func Play(path string, loopForever bool, cfg *db.AppConfig) error {
|
||||
// Check if audio is enabled or disabled
|
||||
if !cfg.AudioEnabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Lock the mutex
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
// Check if mixer has been initialized and that a device is open
|
||||
if !inited || !deviceOpen {
|
||||
if !deviceOpen {
|
||||
SwitchDevice("") // Re-switch to the default device
|
||||
Play(path, loopForever, cfg) // Re-run the function
|
||||
}
|
||||
logger.Error("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
|
||||
}
|
||||
|
||||
// Load the music file
|
||||
m, err := mix.LoadMUS(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("LoadMUS(%q): %w", path, err)
|
||||
}
|
||||
// Set current music to the loaded music file
|
||||
currentMusic = m
|
||||
currentPath = path
|
||||
if loopForever {
|
||||
@@ -381,13 +390,13 @@ func SwitchDevice(newName string) error {
|
||||
}
|
||||
|
||||
// Log device switch attempt
|
||||
oldDevice := deviceName
|
||||
// Automatically defaults to "system default"
|
||||
oldDevice := CurrentDevice() // Which device is to be closed
|
||||
if oldDevice == "" {
|
||||
oldDevice = "system default"
|
||||
}
|
||||
newDeviceLog := newName
|
||||
if newDeviceLog == "" {
|
||||
newDeviceLog = "system default"
|
||||
if newName == "" { // Which device is being switched to?
|
||||
newName = "system default"
|
||||
}
|
||||
|
||||
// Capture current state
|
||||
@@ -412,11 +421,15 @@ func SwitchDevice(newName string) error {
|
||||
mix.CloseAudio()
|
||||
deviceOpen = false
|
||||
}
|
||||
|
||||
// Attempt to open the newName device
|
||||
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 {
|
||||
} 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
|
||||
}
|
||||
|
||||
@@ -442,6 +455,8 @@ func SwitchDevice(newName string) error {
|
||||
}
|
||||
}
|
||||
|
||||
logger.Info("Audio device switched to %s", newName)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
+66
-141
@@ -5,27 +5,24 @@ import (
|
||||
|
||||
"github.com/go-gl/gl/v3.3-core/gl"
|
||||
"github.com/veandco/go-sdl2/sdl"
|
||||
"gorm.io/gorm"
|
||||
"rhemawavetech.com/rhemawave/internal/colors"
|
||||
"rhemawavetech.com/rhemawave/internal/db"
|
||||
"rhemawavetech.com/rhemawave/internal/fontManager"
|
||||
"rhemawavetech.com/rhemawave/internal/mixer"
|
||||
"rhemawavetech.com/rhemawave/internal/logger"
|
||||
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
||||
)
|
||||
|
||||
type AudioDevices struct {
|
||||
Base
|
||||
font *fontManager.FontEntry
|
||||
textShader *shaderManager.Shader
|
||||
solidShader *shaderManager.Shader
|
||||
title StringItem
|
||||
prompt StringItem
|
||||
devices []StringItem
|
||||
highlightIndex int
|
||||
selectedIndex int
|
||||
verticalMargin int32
|
||||
CfgPtr *db.AppConfig
|
||||
dbPtr *gorm.DB
|
||||
font *fontManager.FontEntry
|
||||
textShader *shaderManager.Shader
|
||||
solidShader *shaderManager.Shader
|
||||
title StringItem
|
||||
prompt StringItem
|
||||
AudioStatusToggle StringItem
|
||||
verticalMargin int32
|
||||
ItemScale float32
|
||||
CfgPtr *db.AppConfig
|
||||
}
|
||||
|
||||
func (p *AudioDevices) Init(ctx *PageCtx) error {
|
||||
@@ -57,91 +54,78 @@ func (p *AudioDevices) Init(ctx *PageCtx) error {
|
||||
}
|
||||
|
||||
// Get font size from database
|
||||
baseFontSize := p.Base.Context.CfgPtr.StandardFontSize
|
||||
baseFontSize := p.Context.CfgPtr.StandardFontSize
|
||||
|
||||
// Calculate scales
|
||||
titleScale := float32(64) / float32(baseFontSize)
|
||||
itemScale := float32(32) / float32(baseFontSize)
|
||||
p.ItemScale = float32(32) / float32(baseFontSize)
|
||||
|
||||
// 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 {
|
||||
return fmt.Errorf("creating title: %w", err)
|
||||
}
|
||||
p.title.Scale(titleScale)
|
||||
|
||||
// Create prompt
|
||||
p.prompt, err = NewStringItem("Use Arrow Keys to select, Enter to confirm", p.font, colors.White)
|
||||
p.prompt, err = NewStringItem("Switch On/Off with Enter. Setting saves immediately.", p.font, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating prompt: %w", err)
|
||||
}
|
||||
p.prompt.Scale(itemScale)
|
||||
p.prompt.Scale(p.ItemScale)
|
||||
|
||||
// Get list of audio output devices
|
||||
devices := mixer.ListDevices()
|
||||
if len(devices) == 0 {
|
||||
// Create a default entry so the page doesn't break
|
||||
devices = []string{"System Default"}
|
||||
if err = p.CreateAudioStatusItem(); err != nil {
|
||||
logger.Error("failed to create AudioStatusItem in AudioDevices.Init()")
|
||||
}
|
||||
|
||||
// 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()
|
||||
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() {
|
||||
// Position title at bottom
|
||||
// Position title at the top
|
||||
p.title.X = p.ScreenCenterX - p.title.W/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.Y = p.title.Y - p.verticalMargin*2 - p.prompt.H
|
||||
}
|
||||
|
||||
// Position device items stacked upward from prompt
|
||||
for i := range p.devices {
|
||||
p.devices[i].X = p.ScreenCenterX - p.devices[i].W/2
|
||||
if i == 0 {
|
||||
p.devices[i].Y = p.prompt.Y - p.verticalMargin*2 - p.devices[i].H
|
||||
} else {
|
||||
p.devices[i].Y = p.devices[i-1].Y - p.devices[i].H - 10
|
||||
}
|
||||
}
|
||||
func (p *AudioDevices) ToggleAudio() {
|
||||
// Toggle current setting
|
||||
p.Context.CfgPtr.AudioEnabled = !p.Context.CfgPtr.AudioEnabled
|
||||
|
||||
// Update the database
|
||||
p.Context.DbPtr.Save(p.Context.CfgPtr)
|
||||
|
||||
// Update the configuration
|
||||
p.Context.RefreshConfig()
|
||||
|
||||
// Update AudioEnabled UI element
|
||||
p.CreateAudioStatusItem()
|
||||
}
|
||||
|
||||
func (p *AudioDevices) HandleEvent(event *sdl.Event) error {
|
||||
@@ -151,86 +135,30 @@ func (p *AudioDevices) HandleEvent(event *sdl.Event) error {
|
||||
}
|
||||
|
||||
switch e.Keysym.Sym {
|
||||
case sdl.K_UP:
|
||||
if p.highlightIndex > 0 {
|
||||
p.highlightIndex--
|
||||
}
|
||||
|
||||
case sdl.K_DOWN:
|
||||
if p.highlightIndex < len(p.devices)-1 {
|
||||
p.highlightIndex++
|
||||
}
|
||||
|
||||
case sdl.K_RETURN:
|
||||
selection := p.devices[p.highlightIndex].content
|
||||
|
||||
// Handle audio disable
|
||||
if selection == "Turn Audio Off" || selection == "Turn Audio On" {
|
||||
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()
|
||||
p.ToggleAudio()
|
||||
case sdl.K_BACKSPACE:
|
||||
p.Base.Context.NavigateTo(SETTINGS)
|
||||
p.Context.NavigateTo(SETTINGS)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *AudioDevices) Render() error {
|
||||
// Always render title and prompt
|
||||
p.RenderTexture(p.textShader, p.title.ID, p.title.Position(), p.title.RawDimensions(), p.title.Color)
|
||||
p.RenderTexture(p.textShader, p.prompt.ID, p.prompt.Position(), p.prompt.RawDimensions(), p.prompt.Color)
|
||||
|
||||
// No devices to display
|
||||
if len(p.devices) == 0 {
|
||||
return nil
|
||||
if err := p.RenderTexture(p.textShader, p.title.ID, p.title.Position(), p.title.RawDimensions(), p.title.Color); err != nil {
|
||||
logger.Error("unable to render title texture: %v", err)
|
||||
return fmt.Errorf("unable to render title texture: %w", err)
|
||||
}
|
||||
|
||||
// Render highlight box behind highlighted item
|
||||
p.RenderSolidColorQuad(
|
||||
p.solidShader,
|
||||
p.devices[p.highlightIndex].Position(),
|
||||
p.devices[p.highlightIndex].RawDimensions(),
|
||||
colors.White,
|
||||
)
|
||||
if err := p.RenderTexture(p.textShader, p.prompt.ID, p.prompt.Position(), p.prompt.RawDimensions(), p.prompt.Color); err != nil {
|
||||
logger.Error("unable to render prompt texture: %v", err)
|
||||
return fmt.Errorf("unable to render prompt texture: %w", err)
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -239,8 +167,5 @@ func (p *AudioDevices) Render() error {
|
||||
func (p *AudioDevices) Destroy() error {
|
||||
gl.DeleteTextures(1, &p.title.ID)
|
||||
gl.DeleteTextures(1, &p.prompt.ID)
|
||||
for _, item := range p.devices {
|
||||
gl.DeleteTextures(1, &item.ID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -154,9 +154,9 @@ func (p *EmailAddressPage) HandleEvent(event *sdl.Event) error {
|
||||
sdl.StopTextInput()
|
||||
|
||||
// Save email address to database
|
||||
p.Base.Context.CfgPtr.EmailAddress = p.inputText
|
||||
p.Base.Context.DbPtr.Save(p.Base.Context.CfgPtr)
|
||||
p.Base.Context.RefreshConfig()
|
||||
p.Context.CfgPtr.EmailAddress = p.inputText
|
||||
p.Context.DbPtr.Save(p.Context.CfgPtr)
|
||||
p.Context.RefreshConfig()
|
||||
|
||||
if p.isFirstTimeSetup {
|
||||
// First-time setup: navigate to License Key page
|
||||
@@ -253,7 +253,7 @@ func (p *EmailAddressPage) Update(dt float32) error {
|
||||
// Render performs page drawing logic.
|
||||
func (p *EmailAddressPage) Render() error {
|
||||
// Get base font size from database
|
||||
baseFontSize := p.Base.Context.CfgPtr.StandardFontSize
|
||||
baseFontSize := p.Context.CfgPtr.StandardFontSize
|
||||
|
||||
sm := shaderManager.Get()
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
||||
)
|
||||
|
||||
var GitVersion = "v1.0.0"
|
||||
var GitVersion = "1.0"
|
||||
|
||||
type Home struct {
|
||||
Base
|
||||
@@ -30,7 +30,6 @@ type Home struct {
|
||||
|
||||
// Init performs basic page initialization and creation of static assets.
|
||||
func (p *Home) Init(ctx *PageCtx) error {
|
||||
|
||||
title := "Rhema Wave"
|
||||
version := GitVersion
|
||||
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
|
||||
if p.Base.Context.CfgPtr.EmailAddress != "" || p.Base.Context.CfgPtr.LicenseKey != "" {
|
||||
if p.Context.CfgPtr.EmailAddress != "" || p.Context.CfgPtr.LicenseKey != "" {
|
||||
p.hasLicenseInfo = true
|
||||
|
||||
// Create heading
|
||||
@@ -84,8 +83,8 @@ func (p *Home) Init(ctx *PageCtx) error {
|
||||
}
|
||||
|
||||
// Create email display if exists
|
||||
if p.Base.Context.CfgPtr.EmailAddress != "" {
|
||||
emailText := fmt.Sprintf("Email: %s", p.Base.Context.CfgPtr.EmailAddress)
|
||||
if p.Context.CfgPtr.EmailAddress != "" {
|
||||
emailText := fmt.Sprintf("Email: %s", p.Context.CfgPtr.EmailAddress)
|
||||
p.emailDisplay, err = NewStringItem(emailText, font, colors.White)
|
||||
if err != nil {
|
||||
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
|
||||
if p.Base.Context.CfgPtr.LicenseKey != "" {
|
||||
licenseKeyText := fmt.Sprintf("License Key: %s", p.Base.Context.CfgPtr.LicenseKey)
|
||||
if p.Context.CfgPtr.LicenseKey != "" {
|
||||
licenseKeyText := fmt.Sprintf("License Key: %s", p.Context.CfgPtr.LicenseKey)
|
||||
p.licenseKeyDisplay, err = NewStringItem(licenseKeyText, font, colors.White)
|
||||
if err != nil {
|
||||
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
|
||||
baseFontSize := p.Base.Context.CfgPtr.StandardFontSize
|
||||
baseFontSize := p.Context.CfgPtr.StandardFontSize
|
||||
|
||||
p.baseFontSize = baseFontSize
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ func (p *LicenseKeyPage) Init(ctx *PageCtx) error {
|
||||
p.titleTextureHeight = titleHeight
|
||||
|
||||
// Load existing license key from database
|
||||
licenseKey := p.Base.Context.CfgPtr.LicenseKey
|
||||
licenseKey := p.Context.CfgPtr.LicenseKey
|
||||
|
||||
if 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
|
||||
sdl.StopTextInput()
|
||||
p.Base.Context.NavigateTo(SETTINGS)
|
||||
p.Context.NavigateTo(SETTINGS)
|
||||
case sdl.K_RETURN:
|
||||
// Validate license key - check empty first, then length, then characters
|
||||
cleaned := strings.ReplaceAll(p.inputText, "-", "")
|
||||
@@ -188,9 +188,9 @@ func (p *LicenseKeyPage) HandleEvent(event *sdl.Event) error {
|
||||
sdl.StopTextInput()
|
||||
|
||||
// Save license key to database
|
||||
p.Base.Context.CfgPtr.LicenseKey = p.inputText
|
||||
p.Base.Context.DbPtr.Save(p.Base.Context.CfgPtr)
|
||||
p.Base.Context.RefreshConfig()
|
||||
p.Context.CfgPtr.LicenseKey = p.inputText
|
||||
p.Context.DbPtr.Save(p.Context.CfgPtr)
|
||||
p.Context.RefreshConfig()
|
||||
|
||||
// Write license key to plain text file for the updater
|
||||
if homeDir, err := os.UserHomeDir(); err == nil {
|
||||
@@ -202,10 +202,10 @@ func (p *LicenseKeyPage) HandleEvent(event *sdl.Event) error {
|
||||
|
||||
if p.isFirstTimeSetup {
|
||||
// First-time setup: rewind to Welcome page
|
||||
p.Base.Context.NavigateTo(HOME)
|
||||
p.Context.NavigateTo(HOME)
|
||||
} else {
|
||||
// Coming from settings: rewind back to settings
|
||||
p.Base.Context.NavigateTo(SETTINGS)
|
||||
p.Context.NavigateTo(SETTINGS)
|
||||
}
|
||||
case sdl.K_BACKSPACE:
|
||||
if len(p.inputText) > 0 {
|
||||
@@ -303,7 +303,7 @@ func (p *LicenseKeyPage) Update(dt float32) error {
|
||||
// Render performs page drawing logic.
|
||||
func (p *LicenseKeyPage) Render() error {
|
||||
// Get base font size from database
|
||||
baseFontSize := p.Base.Context.CfgPtr.StandardFontSize
|
||||
baseFontSize := p.Context.CfgPtr.StandardFontSize
|
||||
|
||||
sm := shaderManager.Get()
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ func (p *LogViewerPage) Init(ctx *PageCtx) error {
|
||||
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)
|
||||
return fmt.Errorf("%s", result.Error)
|
||||
}
|
||||
@@ -115,7 +115,7 @@ func (p *LogViewerPage) HandleEvent(event *sdl.Event) error {
|
||||
if e.Type == sdl.KEYDOWN {
|
||||
switch e.Keysym.Sym {
|
||||
case sdl.K_BACKSPACE:
|
||||
p.Base.Context.NavigateTo(SETTINGS)
|
||||
p.Context.NavigateTo(SETTINGS)
|
||||
case sdl.K_UP:
|
||||
p.scrollOffset -= p.lineHeight
|
||||
if p.scrollOffset < 0 {
|
||||
@@ -159,7 +159,7 @@ func (p *LogViewerPage) Update(dt float32) error {
|
||||
}
|
||||
|
||||
func (p *LogViewerPage) Render() error {
|
||||
baseFontSize := p.Base.Context.CfgPtr.StandardFontSize
|
||||
baseFontSize := p.Context.CfgPtr.StandardFontSize
|
||||
|
||||
sm := shaderManager.Get()
|
||||
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)
|
||||
}
|
||||
|
||||
baseFontSize := float32(p.Base.Context.CfgPtr.StandardFontSize)
|
||||
baseFontSize := float32(p.Context.CfgPtr.StandardFontSize)
|
||||
p.message.Scale(float32(48) / baseFontSize)
|
||||
p.instructions.Scale(float32(28) / baseFontSize)
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"rhemawavetech.com/rhemawave/internal/fontManager"
|
||||
"rhemawavetech.com/rhemawave/internal/logger"
|
||||
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
||||
"rhemawavetech.com/rhemawave/internal/sync"
|
||||
)
|
||||
|
||||
// Scrollbar Visuals
|
||||
@@ -78,10 +79,12 @@ func (p *ProclamationOptions) Init(ctx *PageCtx) error {
|
||||
p.allItems = []*FlatItem{}
|
||||
p.scrollOffset = 0
|
||||
p.isSearchActive = false
|
||||
|
||||
p.scrollTrackWidth = 30
|
||||
p.scrollTrackX = p.ScreenWidth - p.scrollTrackWidth
|
||||
|
||||
// Syncronize proclamations first
|
||||
sync.SyncProclamations(ctx.DbPtr)
|
||||
|
||||
fm := fontManager.Get()
|
||||
font, _ := fm.GetFont("Roboto-Regular")
|
||||
p.font = font
|
||||
@@ -105,19 +108,26 @@ func (p *ProclamationOptions) Init(ctx *PageCtx) error {
|
||||
|
||||
// Load current selections
|
||||
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 {
|
||||
if s.ID == defaultProclamationID {
|
||||
logger.Debug("Found default proclamation in selected list: %s", s.ID)
|
||||
}
|
||||
p.activeMap[s.ID] = true
|
||||
}
|
||||
|
||||
// Load ALL proclamations into a lookup map for SearchText population
|
||||
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)
|
||||
return err
|
||||
}
|
||||
|
||||
procContentMap := make(map[string]string, len(allProcs))
|
||||
for _, pr := range allProcs {
|
||||
if pr.ID == defaultProclamationID {
|
||||
logger.Debug("Found default proclamation in allProcs list: %s", pr.ID)
|
||||
}
|
||||
procContentMap[pr.ID] = pr.Content
|
||||
}
|
||||
p.allProclamations = []*FlatItem{}
|
||||
@@ -242,7 +252,7 @@ func (p *ProclamationOptions) expand(index int) {
|
||||
|
||||
// Load proclamations
|
||||
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 {
|
||||
var fi *FlatItem
|
||||
// Reuse existing proclamation from allProclamations if available
|
||||
@@ -484,8 +494,9 @@ func (p *ProclamationOptions) HandleEvent(event *sdl.Event) error {
|
||||
p.acceptChanges()
|
||||
case sdl.K_SPACE:
|
||||
item := p.allItems[p.highlightIndex]
|
||||
if !item.IsCategory {
|
||||
p.activeMap[item.ID] = !p.activeMap[item.ID]
|
||||
if !item.IsCategory { // If the item is NOT a Category
|
||||
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 {
|
||||
p.toggleExpansion(p.highlightIndex)
|
||||
}
|
||||
|
||||
@@ -74,6 +74,12 @@ import (
|
||||
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultProclamationID string = "f3d42354-3197-45e4-9b08-be447f14386d"
|
||||
proclamationPlayCount int = 10
|
||||
defaultProclamationPlayCount int = 10
|
||||
)
|
||||
|
||||
type Line struct {
|
||||
words []StringItem
|
||||
width int32
|
||||
@@ -231,7 +237,7 @@ func (p *ScrollerPage) Init(ctx *PageCtx) error {
|
||||
|
||||
// Get line pattern from database
|
||||
var err error
|
||||
linePatternName := p.Base.Context.CfgPtr.LinePattern
|
||||
linePatternName := p.Context.CfgPtr.LinePattern
|
||||
|
||||
p.activePattern, err = linePattern.Get(linePatternName)
|
||||
if err != nil {
|
||||
@@ -239,7 +245,7 @@ func (p *ScrollerPage) Init(ctx *PageCtx) error {
|
||||
}
|
||||
|
||||
// Initialize color pattern
|
||||
var baseColors = []sdl.Color{
|
||||
baseColors := []sdl.Color{
|
||||
colors.Tan,
|
||||
colors.PeachPuff,
|
||||
colors.LightCyan,
|
||||
@@ -252,7 +258,7 @@ func (p *ScrollerPage) Init(ctx *PageCtx) error {
|
||||
|
||||
// Get font sizes from database (optimize: load once, cache as int32)
|
||||
// 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
|
||||
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
|
||||
audioDeviceName := p.Base.Context.CfgPtr.SelectedAudioDevice
|
||||
audioDeviceName := p.Context.CfgPtr.SelectedAudioDevice
|
||||
|
||||
// Switch audio devices if needed
|
||||
if mixer.CurrentDevice() != audioDeviceName {
|
||||
@@ -302,12 +308,12 @@ func (p *ScrollerPage) Init(ctx *PageCtx) error {
|
||||
|
||||
// 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")
|
||||
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
|
||||
}
|
||||
|
||||
@@ -330,13 +336,28 @@ func (p *ScrollerPage) loadProclamations() error {
|
||||
return err
|
||||
}
|
||||
|
||||
var defaultProclamation db.Proclamation
|
||||
err = p.Context.DbPtr.Where("ID = ?", defaultProclamationID).Find(&defaultProclamation).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Build word list from selected, available proclamations
|
||||
defaultWords := strings.Fields(defaultProclamation.Content)
|
||||
|
||||
for _, proclamation := range proclamations {
|
||||
if proclamation.Selected {
|
||||
// Split content into words and add to list
|
||||
words := strings.Fields(proclamation.Content)
|
||||
p.words = append(p.words, words...)
|
||||
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 {
|
||||
@@ -414,15 +435,6 @@ func (p *ScrollerPage) HandleEvent(event *sdl.Event) error {
|
||||
if !p.autoVelocity {
|
||||
p.velocity -= p.increment
|
||||
}
|
||||
case sdl.K_a:
|
||||
p.autoVelocity = !p.autoVelocity
|
||||
if p.autoVelocity {
|
||||
// Reset to initial state when enabling
|
||||
p.velocity = p.velocityFloor
|
||||
p.velocityDirection = 1
|
||||
p.velocityState = 0
|
||||
p.velocityTimer = 0.0
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -594,7 +606,7 @@ func (p *ScrollerPage) Destroy() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
mixer.Stop(1000, p.Base.Context.CfgPtr)
|
||||
mixer.Stop(1000, p.Context.CfgPtr)
|
||||
|
||||
// GL cleanup — delete all text textures
|
||||
if p.lines != nil {
|
||||
|
||||
@@ -78,16 +78,7 @@ func (p *Settings) Init(ctx *PageCtx) error {
|
||||
label: proclamationsLabel,
|
||||
})
|
||||
|
||||
wifiLabel, err := NewStringItem("WiFi Setup", font, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating wifi label: %w", err)
|
||||
}
|
||||
p.options = append(p.options, Option{
|
||||
target: WIFI,
|
||||
label: wifiLabel,
|
||||
})
|
||||
|
||||
audioLabel, err := NewStringItem("Audio Device", font, colors.White)
|
||||
audioLabel, err := NewStringItem("Audio", font, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating audio label: %w", err)
|
||||
}
|
||||
@@ -114,6 +105,15 @@ func (p *Settings) Init(ctx *PageCtx) error {
|
||||
label: licenseKeyLabel,
|
||||
})
|
||||
|
||||
wifiLabel, err := NewStringItem("WiFi Setup", font, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating wifi label: %w", err)
|
||||
}
|
||||
p.options = append(p.options, Option{
|
||||
target: WIFI,
|
||||
label: wifiLabel,
|
||||
})
|
||||
|
||||
logsLabel, err := NewStringItem("Logs", font, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating logs label: %w", err)
|
||||
@@ -123,14 +123,8 @@ func (p *Settings) Init(ctx *PageCtx) error {
|
||||
label: logsLabel,
|
||||
})
|
||||
|
||||
updateLabel, err := NewStringItem("Check for Updates", font, colors.White)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating update label: %w", err)
|
||||
}
|
||||
p.options = append(p.options, Option{
|
||||
target: CHECK_UPDATES,
|
||||
label: updateLabel,
|
||||
})
|
||||
// TODO: Add entry for "Tutorial"
|
||||
// TODO: Add entry for "About"
|
||||
|
||||
// Get base font size from database
|
||||
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
|
||||
|
||||
// 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
|
||||
|
||||
// If this value starts at 0, nothing is further left than that...
|
||||
var furthestLeft int32 = p.ScreenWidth // < more to the left, > more to the right, = same
|
||||
|
||||
// Position options stacked upward from prompt
|
||||
for i, option := range p.options {
|
||||
p.options[i].label.X = p.ScreenCenterX - option.label.W/2
|
||||
p.options[i].label.X = p.ScreenCenterX - option.label.W/2 // Calculate horizontal center posision
|
||||
if p.options[i].label.X < furthestLeft { // If the left most pixel is further left than furthestLeft, indicating a longer line:
|
||||
// Then update furthestLeft
|
||||
furthestLeft = p.options[i].label.X
|
||||
}
|
||||
if i == 0 {
|
||||
p.options[i].label.Y = int32(float32(p.prompt.Y)-(float32(p.verticalMargin)*4)) - option.label.H
|
||||
} else {
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+42
-10
@@ -1,7 +1,6 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -14,10 +13,10 @@ var htmlRegex = regexp.MustCompile(`<[^>]*>`)
|
||||
|
||||
// SyncProclamations fetches data from the API, cleans it, and updates the local DB.
|
||||
// It returns the slice of proclamations so the UI can update immediately.
|
||||
func SyncProclamations(apiURL string, database *gorm.DB) ([]db.Proclamation, error) {
|
||||
fullURL := fmt.Sprintf("%s/proclamations", apiURL)
|
||||
func SyncProclamations(database *gorm.DB) ([]db.Proclamation, error) {
|
||||
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)
|
||||
if err != nil {
|
||||
// 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)
|
||||
return nil, err
|
||||
}
|
||||
logger.Debug("Request Granted")
|
||||
|
||||
// 2. Data Transformation (Cleaning)
|
||||
for i := range proclamations {
|
||||
proclamations[i].Content = htmlRegex.ReplaceAllString(proclamations[i].Content, "")
|
||||
}
|
||||
|
||||
// 3. Persist to Database
|
||||
// Using Transaction ensures we don't end up with partial data if something fails
|
||||
var storedProclamationSettings []db.Proclamation
|
||||
database.Select("ID", "Selected").Find(&storedProclamationSettings)
|
||||
// [ID]Selected
|
||||
stash := make(map[string]bool)
|
||||
for _, row := range storedProclamationSettings {
|
||||
stash[row.ID] = row.Selected
|
||||
}
|
||||
|
||||
for i := range proclamations {
|
||||
proclamations[i].Selected = stash[proclamations[i].ID]
|
||||
}
|
||||
|
||||
err = database.Transaction(func(tx *gorm.DB) error {
|
||||
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
|
||||
}
|
||||
// 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
|
||||
})
|
||||
|
||||
logger.Debug("Proclamations stored to database")
|
||||
|
||||
if err != nil {
|
||||
logger.Error("Failed to save synced proclamations: %v", err)
|
||||
return nil, err
|
||||
@@ -49,19 +71,29 @@ func SyncProclamations(apiURL string, database *gorm.DB) ([]db.Proclamation, err
|
||||
return proclamations, nil
|
||||
}
|
||||
|
||||
func SyncCategories(apiURL string, database *gorm.DB) ([]db.ProclamationCategory, error) {
|
||||
fullURL := fmt.Sprintf("%s/categories", apiURL)
|
||||
func SyncCategories(database *gorm.DB) ([]db.ProclamationCategory, error) {
|
||||
fullURL := "https://portal.rhemawavetech.com/api/v1/categories"
|
||||
categories, err := netreq.GetJSON[db.ProclamationCategory](fullURL)
|
||||
if err != nil {
|
||||
logger.Error("Category sync failed, skipping update: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = database.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Where("1 = 1").Delete(&db.ProclamationCategory{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Save(&categories).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
logger.Error("failed to sync proclamation categories: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logger.Info("Successfully synced proclamation categories: %d", len(categories))
|
||||
return categories, nil
|
||||
}
|
||||
|
||||
@@ -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