diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 28bc551..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -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:*)" - ] - } -} diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..2a88165 --- /dev/null +++ b/.vscode/launch.json @@ -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": "" + } + }, + ] +} \ No newline at end of file diff --git a/MANUAL.md b/MANUAL.md deleted file mode 100644 index 3f5a586..0000000 --- a/MANUAL.md +++ /dev/null @@ -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 diff --git a/changelog.md b/changelog.md new file mode 100644 index 0000000..8124dd0 --- /dev/null +++ b/changelog.md @@ -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 + +--- + diff --git a/dev_scripts/reset.sh b/dev_scripts/reset.sh deleted file mode 100755 index 17c420a..0000000 --- a/dev_scripts/reset.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash - -rm -rf ~/.local/share/rhemawave/ - -echo "Data directory deleted, application reset." diff --git a/docs/greetd-autologin-setup.md b/docs/greetd-autologin-setup.md deleted file mode 100644 index 4ee2d08..0000000 --- a/docs/greetd-autologin-setup.md +++ /dev/null @@ -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 diff --git a/docs/kiosk-setup-guide.md b/docs/kiosk-setup-guide.md deleted file mode 100644 index 9e1b49c..0000000 --- a/docs/kiosk-setup-guide.md +++ /dev/null @@ -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 < -``` - -### 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 -``` diff --git a/internal/application/application.go b/internal/application/application.go index a725d94..e77e332 100644 --- a/internal/application/application.go +++ b/internal/application/application.go @@ -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() } diff --git a/internal/mixer/mixer.go b/internal/mixer/mixer.go index 54880cc..f143680 100644 --- a/internal/mixer/mixer.go +++ b/internal/mixer/mixer.go @@ -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 } diff --git a/internal/page/audioDevices.go b/internal/page/audioDevices.go index 1bb7c3b..1fa117e 100644 --- a/internal/page/audioDevices.go +++ b/internal/page/audioDevices.go @@ -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 } diff --git a/internal/page/emailAddressPage.go b/internal/page/emailAddressPage.go index c27e20b..3465c09 100644 --- a/internal/page/emailAddressPage.go +++ b/internal/page/emailAddressPage.go @@ -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() diff --git a/internal/page/home.go b/internal/page/home.go index 8122c0f..535dcd0 100644 --- a/internal/page/home.go +++ b/internal/page/home.go @@ -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 diff --git a/internal/page/licenseKeyPage.go b/internal/page/licenseKeyPage.go index 8bf1061..d971d47 100644 --- a/internal/page/licenseKeyPage.go +++ b/internal/page/licenseKeyPage.go @@ -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() diff --git a/internal/page/logViewerPage.go b/internal/page/logViewerPage.go index 7470d21..aa735d5 100644 --- a/internal/page/logViewerPage.go +++ b/internal/page/logViewerPage.go @@ -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") diff --git a/internal/page/message.go b/internal/page/message.go index f05bc7d..43a81d0 100644 --- a/internal/page/message.go +++ b/internal/page/message.go @@ -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) diff --git a/internal/page/proclamationOptions.go b/internal/page/proclamationOptions.go index 6719c0b..61343ff 100644 --- a/internal/page/proclamationOptions.go +++ b/internal/page/proclamationOptions.go @@ -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) } diff --git a/internal/page/scrollerPage.go b/internal/page/scrollerPage.go index 0b39652..5476bb6 100644 --- a/internal/page/scrollerPage.go +++ b/internal/page/scrollerPage.go @@ -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 { diff --git a/internal/page/settingsPage.go b/internal/page/settingsPage.go index 7d0d6e3..23cee0a 100644 --- a/internal/page/settingsPage.go +++ b/internal/page/settingsPage.go @@ -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 } diff --git a/internal/sync/sync.go b/internal/sync/sync.go index d9b5fd0..7b09a4a 100644 --- a/internal/sync/sync.go +++ b/internal/sync/sync.go @@ -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 } diff --git a/maintainer-guide.md b/maintainer-guide.md deleted file mode 100644 index 42685a1..0000000 --- a/maintainer-guide.md +++ /dev/null @@ -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..default` | The RhemaWave binary | -| `devShells..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 < 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/` - -### .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` diff --git a/scripts/setup.sh b/scripts/setup.sh deleted file mode 100755 index d2ec981..0000000 --- a/scripts/setup.sh +++ /dev/null @@ -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 "" \ No newline at end of file