From 0a9a33b87b90b2c94414fe837773efe9ecb68f9f Mon Sep 17 00:00:00 2001 From: NeilClack Date: Wed, 24 Jun 2026 14:17:06 -0500 Subject: [PATCH 01/12] Fix download URL mismatch --- README.md | 88 ---------------------------------- README.org | 90 +++++++++++++++++++++++++++++++++++ internal/download/Download.go | 43 +++++++++++++---- 3 files changed, 125 insertions(+), 96 deletions(-) delete mode 100644 README.md create mode 100644 README.org diff --git a/README.md b/README.md deleted file mode 100644 index 2a7fb76..0000000 --- a/README.md +++ /dev/null @@ -1,88 +0,0 @@ -# RhemaWave - -A Go-based fullscreen SDL2/OpenGL application for Linux kiosk systems. Displays proclamations with audio playback and animated visual patterns. - -## Development - -Development uses Nix for a reproducible build environment. - -```bash -nix develop # enter dev shell -go build -o bin/rhemawave rhemawave.go # build -./bin/rhemawave # run -./dev_scripts/reset.sh # reset database (if needed) -``` - -### Prerequisites - -- Nix with flakes enabled -- A display server (X11/Wayland) and audio device for running the application - -## Architecture - -- **Page-based UI** — SDL2/OpenGL 3.3 fullscreen rendering with a page stack for navigation -- **Proclamation player** — scrolling text display with synchronized audio playback -- **Startup validation** — WiFi, TOS acceptance, email, license key checks -- **Singleton managers** — ShaderManager, FontManager, Logger (initialized once via `sync.Once`) - -### Key Directories - -``` -bin/ # build output (gitignored) -share/rhemawave/ # assets (fonts, shaders, proclamations, TOS) -internal/ # Go source packages -packaging/ # deployment configs (kiosk, updater, polkit) -dev_scripts/ # developer utilities -``` - -### Runtime Data - -Stored in `~/.local/share/rhemawave/`: - -- `data.db` — SQLite database (config, proclamations, logs) -- `log.txt` — application log -- `license_key` — license key file - -### Development Rules - -These rules must be followed during the development of RhemaWave to retain a -maintainable application. - -1. The Application package is soley responsible for managing the database handler, and the - configuration cache. Internal packages may update the configuration within the database, and - then signal the application to refresh the cache via a callback function `refreshCache()`. - -## Deployment - -Deployed to kiosk hardware via NixOS. - -## Notes - -### Deployment Pipeline - -A manual walkthough of deploying Radiant Wave updates. -For reference later when writing automated actions. - -1. Create a new branch for issue being worked on -2. Perform work -3. Merge to dev when done -4. Build dev with `nix build` to verify no errors -5. Checkout KioskConfig dev -6. Update rhemawave-dev with `nix flake update rhemawave-dev` -7. Build rhemawavekiosk-dev virtual machine -8. Test virtual machine for problems -9. Build dev-iso -10. Install dev-iso to a machine and check for problems -11. Checkout `main` -12. Merge `dev` -13. `nix flake update rhemawave` -14. Build virtual machine -15. Build iso image -16. Build commercial virtual machine -17. Build commercial iso image -18. Build Raspberry Pi img -19. Approve all virtual machines and iso images -20. Approve all machines update on their own -21. Rename all iso image to "radiantwavekiosk-iso-{system}-{MM-DD-YYYY}-{radiantwave-git-hash}.iso" -22. Upload iso images to file server -23. Move old iso images to `archives/` on the fileserver diff --git a/README.org b/README.org new file mode 100644 index 0000000..c2a383d --- /dev/null +++ b/README.org @@ -0,0 +1,90 @@ +#+TITLE: Radiantwave & KioskConfig Development & Release Pipeline +#+AUTHOR: Neil C. +#+DATE: 2026-06-23 +#+OPTIONS: toc:nil num:nil +#+TAGS: development nixos radiantwave kiosk automation + +* Radiant Wave + +* Full Pipeline Workflow (Detailed) + +** Development Phase +1. Create a new branch for the issue +2. Implement the changes for the issue + +** Integration to =dev= +3. Open a pull request to =dev= +4. CI: Run =nix build= to verify no errors +5. CI: Run linting and unit tests +6. CI: Auto-update =rhemawave-dev= flake input +7. CI: Build all =dev= VMs in parallel (Kiosk, Commercial, RPi) +8. Merge the pull request to =dev= after CI passes + +** Testing Phase +9. Run all VMs (Kiosk, Commercial, RPi) and test all functionality of the application on each VM +10. Install =dev-iso= to a test machine and verify installation and application functionality + +** Release to =main= +11. Open a pull request from =dev= to =main= +12. CI: Build all =main= VMs in parallel (Kiosk, Commercial, RPi) +13. CI: Build all ISO images in parallel +14. CI: Auto-rename ISO images to format =radiantwave-{system}-{YYYY-MM-DD}-{git-hash}.iso= +15. CI: Upload ISO images to file server +16. CI: Move old ISO images to =archives/= on file server +17. Merge the pull request to =main= after CI passes +18. Tag the release (optional, for traceability) + +* Automation Opportunities + +This section describes which parts of the workflow can be automated via CI/CD, and how. + +** Group 1: Development Validation (Steps 3-8) +- =What to automate=: PR triggering, =nix build=, linting, unit tests, flake updates, dev VM builds +- =How=: GitHub Actions workflow triggered on PR to =dev= +- =Why=: Catch integration errors before manual testing; ensure =dev= is always buildable + +** Group 2: Testing (Steps 9-10) +- =What to automate=: VM functionality testing, ISO installation verification +- =How=: Scripted test suites, automated ISO installation in VMs +- =Why=: Reduce manual toil; increase test coverage and consistency + +** Group 3: Release Pipeline (Steps 11-16) +- =What to automate=: Main VM/ISO builds, ISO renaming, file server uploads, archiving +- =How=: GitHub Actions workflow triggered on PR to =main= +- =Why=: Ensure consistent releases; eliminate human error in naming/uploading + +* Preflight Checklist + +- [ ] Create a new branch for the issue +- [ ] Implement the changes for the issue +- [ ] Open a pull request to =dev= +- [ ] Merge the pull request to =dev= after CI passes +- [ ] Run all VMs (Kiosk, Commercial, RPi) and test all functionality of the application on each VM +- [ ] Install =dev-iso= to a test machine and verify installation and application functionality +- [ ] Open a pull request from =dev= to =main= +- [ ] Merge the pull request to =main= after CI passes +- [ ] Tag the release (optional, for traceability) + +* Automation Task List + +** Group 1: Development Validation +- [ ] Set up GitHub Actions workflow for PRs to =dev= + - [ ] Configure =nix build= step + - [ ] Add linting and unit test steps + - [ ] Add flake auto-update step + - [ ] Configure parallel VM builds (Kiosk, Commercial, RPi) +- [ ] Verify workflow blocks PR merge on failure + +** Group 2: Testing +- [ ] Create automated test scripts for VM functionality +- [ ] Set up automated ISO installation testing +- [ ] Integrate with CI for optional automated testing + +** Group 3: Release Pipeline +- [ ] Set up GitHub Actions workflow for PRs to =main= + - [ ] Configure parallel VM builds + - [ ] Configure parallel ISO builds + - [ ] Add ISO auto-renaming step (using =git rev-parse HEAD= and =date +%Y-%m-%d=) + - [ ] Add file server upload step (=rsync= or similar) + - [ ] Add archive old ISOs step +- [ ] Verify workflow blocks PR merge on failure \ No newline at end of file diff --git a/internal/download/Download.go b/internal/download/Download.go index 5e704c6..144d55d 100644 --- a/internal/download/Download.go +++ b/internal/download/Download.go @@ -20,12 +20,44 @@ type DownloadStatus struct { Err error // Error, if any } +// ensureDir checks if a dire +func ensureDir(path string) error { + info, err := os.Stat(path) + if err == nil { + if !info.IsDir() { + return fmt.Errorf("path exists but is not a directory: %s", path) + } + fmt.Printf("Directory already exists: %s\n", path) + return nil + } + + if !os.IsNotExist(err) { + return fmt.Errorf("error checking path: %w", err) + } + + // 0760 = rwxrw---- (owner: rwx, group: rw-, others: ---) + if err := os.MkdirAll(path, 0760); err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + + logger.Info("Directory created: %s\n", path) + return nil +} + // DownloadAudioFiles downloads multiple audio files and sends progress updates // to the provided channel. The channel is closed when all downloads are complete. func DownloadAudioFiles(progressChan chan<- DownloadStatus, filenames []string, destDir string) { defer close(progressChan) - urlBase := "https://files.rhemawavetech.com/audio/" + if err := ensureDir(destDir); err != nil { + logger.Error("failed to ensureDir %s %v", destDir, err) + progressChan <- DownloadStatus{ + Msg: fmt.Sprintf("Failed to ensure directory exists: %v", err), + Err: err, + } + } + + urlBase := "https://files.radiantwavetech.com/audio/" // First, calculate the total size of files that need to be downloaded totalSize := int64(0) @@ -87,6 +119,8 @@ func DownloadAudioFiles(progressChan chan<- DownloadStatus, filenames []string, url := urlBase + filename destPath := filepath.Join(destDir, filename) + // Ensure the destination directory exists + // Create the destination file out, err := os.Create(destPath) if err != nil { @@ -158,13 +192,6 @@ func DownloadAudioFiles(progressChan chan<- DownloadStatus, filenames []string, } out.Close() - - // Send completion for this file - progressChan <- DownloadStatus{ - Max: totalSize, - Cur: totalDownloaded, - Msg: "Finished Downloading", - } } // Send final status From b018a79ecf0a073cee97efc0e2f51c73bf24abd0 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Wed, 24 Jun 2026 14:22:48 -0500 Subject: [PATCH 02/12] Create a fallback for wen no audio is selected --- internal/page/scrollerPage.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/page/scrollerPage.go b/internal/page/scrollerPage.go index 6f128e5..6ec7a9f 100644 --- a/internal/page/scrollerPage.go +++ b/internal/page/scrollerPage.go @@ -287,6 +287,12 @@ func (p *ScrollerPage) Init(ctx *PageCtx) error { var selectedAudio db.AudioFile if err := p.Context.DbPtr.Where("Selected = ?", true).First(&selectedAudio).Error; err != nil { logger.Error("error querying database for selected audio: %v", err) + selectedAudio = db.AudioFile{ + Name: "OriginalAudio", + Path: filepath.Join(p.Context.CfgPtr.AssetsDir, "audio", "OriginalAudio.wav"), + Selected: true, + Order: 0, + } } logger.Info("Playing %s", selectedAudio.Path) From 970b8ebf09afd46b653a38b7b735cc29335fd30c Mon Sep 17 00:00:00 2001 From: NeilClack Date: Wed, 24 Jun 2026 14:50:13 -0500 Subject: [PATCH 03/12] Add verbose logging to download.DownloadAudioFiles --- internal/download/Download.go | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/internal/download/Download.go b/internal/download/Download.go index 144d55d..b035285 100644 --- a/internal/download/Download.go +++ b/internal/download/Download.go @@ -49,6 +49,8 @@ func ensureDir(path string) error { func DownloadAudioFiles(progressChan chan<- DownloadStatus, filenames []string, destDir string) { defer close(progressChan) + logger.Info("Starting downloads") + if err := ensureDir(destDir); err != nil { logger.Error("failed to ensureDir %s %v", destDir, err) progressChan <- DownloadStatus{ @@ -65,11 +67,13 @@ func DownloadAudioFiles(progressChan chan<- DownloadStatus, filenames []string, totalDownloaded := int64(0) for _, filename := range filenames { + logger.Info("Downloading %s", filename) destPath := filepath.Join(destDir, filename) // Check if the file already exists url := urlBase + filename resp, err := http.Head(url) if err != nil { + logger.Warning("Failed to get size for %s: %v", filename, err) progressChan <- DownloadStatus{ Msg: fmt.Sprintf("Failed to get size for %s: %v", filename, err), Err: err, @@ -77,6 +81,7 @@ func DownloadAudioFiles(progressChan chan<- DownloadStatus, filenames []string, return } if resp.StatusCode != http.StatusOK { + logger.Error("Failed to get size for %s: status %s", filename, resp.Status) progressChan <- DownloadStatus{ Msg: fmt.Sprintf("Failed to get size for %s: status %s", filename, resp.Status), Err: fmt.Errorf("bad status: %s", resp.Status), @@ -86,9 +91,9 @@ func DownloadAudioFiles(progressChan chan<- DownloadStatus, filenames []string, fileStat, err := os.Stat(destPath) // If the file is incomplete, or does not exist if os.IsNotExist(err) || fileStat.Size() != resp.ContentLength { - // Oh, and log a warning if the file is not complete + // log a warning if the file is not complete if !os.IsNotExist(err) { - logger.Warning("%s incomplete/corrupt. Adding to download queue.", filename) + logger.Warning("%s corrupt. Adding to download queue.", filename) } else { logger.Info("%s not found. Adding to download queue.", filename) } @@ -124,6 +129,7 @@ func DownloadAudioFiles(progressChan chan<- DownloadStatus, filenames []string, // Create the destination file out, err := os.Create(destPath) if err != nil { + logger.Error("Failed to create file %s: %v", destPath, err) progressChan <- DownloadStatus{ Msg: fmt.Sprintf("Failed to create file %s: %v", destPath, err), Err: err, @@ -136,6 +142,7 @@ func DownloadAudioFiles(progressChan chan<- DownloadStatus, filenames []string, if err != nil { out.Close() os.Remove(destPath) + logger.Error("Failed to download %s: %v", filename, err) progressChan <- DownloadStatus{ Msg: fmt.Sprintf("Failed to download %s: %v", filename, err), Err: err, @@ -147,6 +154,7 @@ func DownloadAudioFiles(progressChan chan<- DownloadStatus, filenames []string, if resp.StatusCode != http.StatusOK { out.Close() os.Remove(destPath) + logger.Error("Failed to download %s: status %s", filename, resp.Status) progressChan <- DownloadStatus{ Msg: fmt.Sprintf("Failed to download %s: status %s", filename, resp.Status), Err: fmt.Errorf("bad status: %s", resp.Status), @@ -161,6 +169,7 @@ func DownloadAudioFiles(progressChan chan<- DownloadStatus, filenames []string, if err != nil && err != io.EOF { out.Close() os.Remove(destPath) + logger.Error("Failed to read %s: %v", filename, err) progressChan <- DownloadStatus{ Msg: fmt.Sprintf("Failed to read %s: %v", filename, err), Err: err, @@ -175,6 +184,7 @@ func DownloadAudioFiles(progressChan chan<- DownloadStatus, filenames []string, if _, err := out.Write(buf[:n]); err != nil { out.Close() os.Remove(destPath) + logger.Error("Failed to write %s: %v", filename, err) progressChan <- DownloadStatus{ Msg: fmt.Sprintf("Failed to write %s: %v", filename, err), Err: err, @@ -200,4 +210,5 @@ func DownloadAudioFiles(progressChan chan<- DownloadStatus, filenames []string, Cur: totalDownloaded, Msg: "All audio downloads completed!", } + logger.Info("All audio downloads complete!") } From cc2887d2a06d8946394001a11fb29a712aef11f7 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Wed, 24 Jun 2026 14:57:42 -0500 Subject: [PATCH 04/12] Update version text and add more download logs --- internal/download/Download.go | 2 ++ internal/page/home.go | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/download/Download.go b/internal/download/Download.go index b035285..2d3c467 100644 --- a/internal/download/Download.go +++ b/internal/download/Download.go @@ -192,6 +192,8 @@ func DownloadAudioFiles(progressChan chan<- DownloadStatus, filenames []string, return } + logger.Info("Finished downloading %s", filename) + // Update progress totalDownloaded += int64(n) progressChan <- DownloadStatus{ diff --git a/internal/page/home.go b/internal/page/home.go index 08b6cb6..38f04e1 100644 --- a/internal/page/home.go +++ b/internal/page/home.go @@ -12,7 +12,7 @@ import ( "rhemawavetech.com/rhemawave/internal/shaderManager" ) -var GitVersion = "1.0.0-beta" +var GitVersion = "b018a79" type Home struct { Base From d903deabea05782309e848a846958b988492f520 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Wed, 24 Jun 2026 15:06:45 -0500 Subject: [PATCH 05/12] Remove audio.wav and audio directory creation --- flake.nix | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/flake.nix b/flake.nix index 0e59671..8a0efa5 100644 --- a/flake.nix +++ b/flake.nix @@ -41,11 +41,6 @@ pkg-config ]; - audioFile = pkgs.fetchurl { - url = "https://files.rhemawavetech.com/audio.wav"; - sha256 = "sha256-5Dikxx9RKOoPfY1ukIgMoVQ7tzpMirp+XPj6OphfEW0="; - }; - in { packages.default = pkgs.buildGoModule { @@ -72,13 +67,15 @@ postInstall = '' # Create the destination mkdir -p $out/share/rhemawave + + # Update ownership + chown -R kiosk:kiosk /home/kiosk/.local/share/rhemawave + + # Update permissions + chmod -R 770 /home/kiosk/.local/share/rhemawave # Copy everything from your local share/rhemawave/* into the store cp -r share/rhemawave/* $out/share/rhemawave/ - - # Add the fetched audio file specifically - mkdir -p $out/share/rhemawave/audio - cp ${audioFile} $out/share/rhemawave/audio/audio.wav ''; meta = with pkgs.lib; { From 2d2069fd634762d742dc8ff6ab145e56786069fa Mon Sep 17 00:00:00 2001 From: NeilClack Date: Wed, 24 Jun 2026 15:11:58 -0500 Subject: [PATCH 06/12] Remove ownership commands --- flake.nix | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/flake.nix b/flake.nix index 8a0efa5..d0e641a 100644 --- a/flake.nix +++ b/flake.nix @@ -67,13 +67,7 @@ postInstall = '' # Create the destination mkdir -p $out/share/rhemawave - - # Update ownership - chown -R kiosk:kiosk /home/kiosk/.local/share/rhemawave - - # Update permissions - chmod -R 770 /home/kiosk/.local/share/rhemawave - + # Copy everything from your local share/rhemawave/* into the store cp -r share/rhemawave/* $out/share/rhemawave/ ''; From 953d0d8bc93dc510d0abbb4bf689c950bfc58da5 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Wed, 24 Jun 2026 18:29:59 -0500 Subject: [PATCH 07/12] Rewrite entire network package and update wifi page --- internal/network/network.go | 107 +++++++++++++++++++++++++++------ internal/page/wifiSetupPage.go | 57 ++++++++---------- 2 files changed, 114 insertions(+), 50 deletions(-) diff --git a/internal/network/network.go b/internal/network/network.go index 08a1c99..c31128a 100644 --- a/internal/network/network.go +++ b/internal/network/network.go @@ -28,6 +28,22 @@ const ( nmDeviceTypeWifi uint32 = 2 nmDeviceStateActivated uint32 = 100 APFlagsPrivacy uint32 = 0x00000001 // Equivalent to NM_802_11_AP_FLAGS_PRIVACY + + // Device states (from NM spec) + nmDeviceStateUnknown uint32 = 0 + nmDeviceStateUnmanaged uint32 = 10 + nmDeviceStateUnavailable uint32 = 20 + nmDeviceStateDisconnected uint32 = 30 + nmDeviceStateFailed uint32 = 120 + + // AP Security Flags (from NM_802_11_AP_SEC_*) + APFlagsWpaPsk uint32 = 0x00000100 // WPA-Personal + APFlagsWpaEap uint32 = 0x00000200 // WPA-Enterprise + APFlagsRsnPsk uint32 = 0x00000100 // WPA2-Personal (same flag as WPA-PSK) + APFlagsRsnSae uint32 = 0x00000400 // WPA3-Personal (SAE) + APFlagsRsnEap uint32 = 0x00000200 // WPA2-Enterprise (same flag as WPA-EAP) + APFlagsRsnOwe uint32 = 0x00000800 // OWE (Opportunistic Wireless Encryption) + APFlagsRsnEapSuiteB uint32 = 0x00001000 // WPA3-Enterprise ) // ErrScanAborted is returned when a scan is cancelled via the stop channel. @@ -128,7 +144,11 @@ func (m *Manager) FindDevices() error { log.Printf("network: Skipping device %s, could not get DeviceType: %v", path, err) continue } - deviceType, _ := typeVariant.Value().(uint32) + deviceType, ok := typeVariant.Value().(uint32) + if !ok { + log.Printf("network: Skipping device %s, invalid DeviceType", path) + continue + } var newDevice Device switch deviceType { @@ -141,10 +161,29 @@ func (m *Manager) FindDevices() error { } newDevice.Path = path - ifaceVariant, _ := deviceObj.GetProperty(nmDeviceInterface + ".Interface") - newDevice.InterfaceName, _ = ifaceVariant.Value().(string) - stateVariant, _ := deviceObj.GetProperty(nmDeviceInterface + ".State") - newDevice.State, _ = stateVariant.Value().(uint32) + + // Safely get Interface property + ifaceVariant, err := deviceObj.GetProperty(nmDeviceInterface + ".Interface") + if err != nil { + log.Printf("network: Skipping device %s, could not get Interface: %v", path, err) + continue + } + if newDevice.InterfaceName, ok = ifaceVariant.Value().(string); !ok { + log.Printf("network: Skipping device %s, invalid Interface", path) + continue + } + + // Safely get State property + stateVariant, err := deviceObj.GetProperty(nmDeviceInterface + ".State") + if err != nil { + log.Printf("network: Skipping device %s, could not get State: %v", path, err) + continue + } + if newDevice.State, ok = stateVariant.Value().(uint32); !ok { + log.Printf("network: Skipping device %s, invalid State", path) + continue + } + m.Devices = append(m.Devices, newDevice) } return nil @@ -259,8 +298,8 @@ func (m *Manager) scanAccessPoints(apPaths []dbus.ObjectPath) ([]AccessPoint, er continue } - ssidBytes, _ := props["Ssid"].Value().([]byte) - if len(ssidBytes) == 0 { + ssidBytes, ok := props["Ssid"].Value().([]byte) + if !ok || len(ssidBytes) == 0 { continue } @@ -272,12 +311,30 @@ func (m *Manager) scanAccessPoints(apPaths []dbus.ObjectPath) ([]AccessPoint, er isProtected := (flags&APFlagsPrivacy != 0) || (wpaFlags != 0) || (rsnFlags != 0) - // Determine the key management type + // Determine key management type var keyMgmt string - if wpaFlags != 0 { - keyMgmt = "wpa-psk" + if !isProtected { + keyMgmt = "none" + } else if wpaFlags != 0 { + // WPA (not WPA2/WPA3) + if wpaFlags&APFlagsWpaPsk != 0 { + keyMgmt = "wpa-psk" + } else if wpaFlags&APFlagsWpaEap != 0 { + keyMgmt = "wpa-eap" + } else { + keyMgmt = "wpa-psk" // Fallback + } } else if rsnFlags != 0 { - keyMgmt = "wpa-psk" + // WPA2 or WPA3 (RSN) + if rsnFlags&APFlagsRsnSae != 0 { + keyMgmt = "sae" // WPA3-Personal + } else if rsnFlags&APFlagsRsnEapSuiteB != 0 { + keyMgmt = "eap-suite-b-192" // WPA3-Enterprise + } else if rsnFlags&APFlagsRsnEap != 0 { + keyMgmt = "wpa-eap" // WPA2-Enterprise + } else { + keyMgmt = "wpa-psk" // WPA2-Personal (default for RSN) + } } else { keyMgmt = "none" } @@ -313,6 +370,12 @@ func (m *Manager) Connect(wifiDevice Device, ap AccessPoint, passphrase string) return fmt.Errorf("cannot connect: provided device is not a Wi-Fi device") } + if wifiDevice.State == nmDeviceStateFailed || + wifiDevice.State == nmDeviceStateUnmanaged || + wifiDevice.State == nmDeviceStateUnavailable { + return fmt.Errorf("cannot connect: device is in an error state (state=%d)", wifiDevice.State) + } + settings := map[string]map[string]dbus.Variant{ "connection": { "type": dbus.MakeVariant("802-11-wireless"), @@ -365,14 +428,22 @@ func (m *Manager) Connect(wifiDevice Device, ap AccessPoint, passphrase string) // pingTest with a short timeout to prevent the UI loop from hanging func pingTest() bool { - // Use a dialer with a timeout instead of LookupHost which can hang - timeout := 2 * time.Second - conn, err := net.DialTimeout("tcp", "8.8.8.8:53", timeout) - if err != nil { - return false + targets := []string{ + "1.1.1.1:53", // Cloudflare DNS + "8.8.8.8:53", // Google DNS + "9.9.9.9:53", // Quad9 DNS + "149.112.112.112:53", // Quad9 Alternate } - conn.Close() - return true + timeout := 2 * time.Second + + for _, target := range targets { + conn, err := net.DialTimeout("tcp", target, timeout) + if err == nil { + conn.Close() + return true + } + } + return false } // getAllProperties is a helper to fetch all properties for a D-Bus object interface. diff --git a/internal/page/wifiSetupPage.go b/internal/page/wifiSetupPage.go index 92747c1..3da1405 100644 --- a/internal/page/wifiSetupPage.go +++ b/internal/page/wifiSetupPage.go @@ -91,13 +91,10 @@ func (p *WiFiSetupPage) Init(ctx *PageCtx) error { // Find Wi-Fi device if err := p.networkManager.FindDevices(); err != nil { - } - for _, dev := range p.networkManager.Devices { - if dev.Type == network.TypeWifi { - d := dev - p.wifiDevice = &d - break - } + // Clean up before returning error + p.networkManager.Close() + p.networkManager = nil + return fmt.Errorf("failed to find network devices: %w. Try rebooting the computer. If this is your 3rd reboot, call for help", err) } // Create UI elements @@ -161,17 +158,11 @@ func (p *WiFiSetupPage) startScan() { // HandleEvent processes user input. func (p *WiFiSetupPage) HandleEvent(event *sdl.Event) error { switch e := (*event).(type) { - case *sdl.TextInputEvent: if p.isEnteringPassword { - // If we get a TextInputEvent, the system supports high-level input. - // Mark it supported so we don't double-type from KeyboardEvents. - if !p.textInputSupported { - p.textInputSupported = true - } else { - p.passwordInput += e.GetText() - p.updatePasswordTexture() - } + p.passwordInput += e.GetText() // Always append text + p.textInputSupported = true // Mark as supported + p.updatePasswordTexture() } case *sdl.KeyboardEvent: @@ -180,13 +171,11 @@ func (p *WiFiSetupPage) HandleEvent(event *sdl.Event) error { } if p.isEnteringPassword { - // Pass the Modifiers (Shift/Caps) to the handler p.handlePasswordInput(e.Keysym.Sym, e.Keysym.Mod) } else { p.handleNetworkSelection(e.Keysym.Sym) } } - return nil } @@ -323,7 +312,7 @@ func (p *WiFiSetupPage) handleNetworkSelection(sym sdl.Keycode) { case sdl.K_RETURN: if len(p.accessPoints) > 0 && p.selectedIndex < len(p.accessPoints) { p.selectedAP = &p.accessPoints[p.selectedIndex].AP - p.pendingSSID = p.selectedAP.SSID + p.pendingSSID = p.selectedAP.SSID // Set for ALL networks (protected or not) p.connectionError = "" if p.selectedAP.IsProtected { p.isEnteringPassword = true @@ -338,7 +327,7 @@ func (p *WiFiSetupPage) handleNetworkSelection(sym sdl.Keycode) { if err != nil { p.isConnecting = false p.pendingSSID = "" - p.connectionError = "Failed to initiate connection" + p.connectionError = fmt.Sprintf("Failed to connect: %s", err.Error()) // Include actual error } } } @@ -384,18 +373,17 @@ func (p *WiFiSetupPage) Update(dt float32) error { // handleConnectionStatusUpdate CHECKS0 if connection succeeded or failed func (p *WiFiSetupPage) handleConnectionStatusUpdate(status *network.ConnectionStatus) { - if !p.isConnecting { + if !p.isConnecting || p.pendingSSID == "" { return } - // milestone 1: We are associated with an AP (even if internet is still pending) - isConnected := status.StatusCode == network.StatusWifiConnectedInternetUp || + // Only consider it successful if we're connected to the EXACT network we tried to join + isConnectedToPending := status.StatusCode == network.StatusWifiConnectedInternetUp || status.StatusCode == network.StatusWifiConnectedNoInternet - if isConnected { + if isConnectedToPending { activeSSID := p.getActiveSSID() - // If we see our SSID, OR if we're connected to anything while trying, call it a win. - if activeSSID == p.pendingSSID || activeSSID != "" { + if activeSSID == p.pendingSSID { // ONLY accept if it's the network we tried logger.Info("UI synced: Connected to %s", activeSSID) p.isConnecting = false p.pendingSSID = "" @@ -405,14 +393,12 @@ func (p *WiFiSetupPage) handleConnectionStatusUpdate(status *network.ConnectionS } } - // milestone 2: Handle the "Not Connected" state during the handshake + // Handle failure: If we've been trying for too long, show error if status.StatusCode == network.StatusWifiAvailableNotConnected { - // INCREASE THIS: 5 seconds is way too aggressive. - // Give it at least 20 seconds before assuming the password was wrong. if time.Since(p.connectionAttemptTime) > 20*time.Second { - if time.Since(p.connectionAttemptTime) > 15*time.Second { - p.isConnecting = false - p.connectionError = "Connection failed - Check password or signal" + p.isConnecting = false + p.connectionError = "Connection failed - Check password or signal" + if p.selectedAP != nil && p.selectedAP.IsProtected { p.isEnteringPassword = true p.enterPasswordMode() p.updatePasswordErrorTexture() @@ -553,6 +539,13 @@ func (p *WiFiSetupPage) updateConnectionStatusItem() { // buildNetworkUIList creates the renderable textures for the list of discovered networks. func (p *WiFiSetupPage) buildNetworkUIList(aps []network.AccessPoint) { + // Delete old textures to prevent memory leaks + for _, apItem := range p.accessPoints { + if apItem.Item.ID != 0 { + gl.DeleteTextures(1, &apItem.Item.ID) + } + } + // Get the currently active Wi-Fi SSID activeSSID := p.getActiveSSID() p.activeSSID = activeSSID From a7f551ccca410098e73481fd799e596b6d6c8ed9 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Wed, 24 Jun 2026 21:32:14 -0500 Subject: [PATCH 08/12] Add extensive logging to networking package --- internal/network/network.go | 50 ++++++++++++++++++++++++++----------- 1 file changed, 35 insertions(+), 15 deletions(-) diff --git a/internal/network/network.go b/internal/network/network.go index c31128a..0a1ad4a 100644 --- a/internal/network/network.go +++ b/internal/network/network.go @@ -5,7 +5,6 @@ package network import ( "errors" "fmt" - "log" "net" "sort" "strings" @@ -13,6 +12,7 @@ import ( "github.com/godbus/dbus/v5" "github.com/google/uuid" + "rhemawavetech.com/rhemawave/internal/logger" ) // --- D-Bus NetworkManager Constants --- @@ -30,11 +30,11 @@ const ( APFlagsPrivacy uint32 = 0x00000001 // Equivalent to NM_802_11_AP_FLAGS_PRIVACY // Device states (from NM spec) - nmDeviceStateUnknown uint32 = 0 - nmDeviceStateUnmanaged uint32 = 10 - nmDeviceStateUnavailable uint32 = 20 - nmDeviceStateDisconnected uint32 = 30 - nmDeviceStateFailed uint32 = 120 + // nmDeviceStateUnknown uint32 = 0 + nmDeviceStateUnmanaged uint32 = 10 + nmDeviceStateUnavailable uint32 = 20 + // nmDeviceStateDisconnected uint32 = 30 + nmDeviceStateFailed uint32 = 120 // AP Security Flags (from NM_802_11_AP_SEC_*) APFlagsWpaPsk uint32 = 0x00000100 // WPA-Personal @@ -105,31 +105,44 @@ type AccessPoint struct { // New creates a new network Manager and connects to the system D-Bus. func New() (*Manager, error) { + logger.Info("[NET-001] Initializing NetworkManager client") + logger.Debug("[NET-002] Connecting to system D-Bus") conn, err := dbus.ConnectSystemBus() if err != nil { + logger.Error("failed to connect to system bus: %v", err) return nil, fmt.Errorf("failed to connect to system bus: %w", err) } nmObject := conn.Object(nmService, nmPath) + logger.Debug("network manager created") return &Manager{conn: conn, nmObject: nmObject}, nil } // Conn returns the underlying dbus.Conn for advanced queries. func (m *Manager) Conn() *dbus.Conn { + logger.Debug("[NET-010] Returning underlying D-Bus connection") + logger.Debug("Conn() returning m.conn") return m.conn } // Close disconnects from the D-Bus. func (m *Manager) Close() { + logger.Info("[NET-020] Closing NetworkManager client") + logger.Debug("Disconnecting from the D-Bus") if m.conn != nil { m.conn.Close() + logger.Debug("Disconnected") } + logger.Debug("Nothing to disconnect!") } // FindDevices scans the system for all available network devices (Ethernet and Wi-Fi). func (m *Manager) FindDevices() error { + logger.Info("[NET-030] Starting network device discovery") + logger.Debug("Searching for network devices") var devicePaths []dbus.ObjectPath call := m.nmObject.Call(nmInterface+".GetDevices", 0) if call.Err != nil { + logger.Error("GetDevices D-Bus call failed: %w", call.Err) return fmt.Errorf("GetDevices D-Bus call failed: %w", call.Err) } if err := call.Store(&devicePaths); err != nil { @@ -141,12 +154,12 @@ func (m *Manager) FindDevices() error { deviceObj := m.conn.Object(nmService, path) typeVariant, err := deviceObj.GetProperty(nmDeviceInterface + ".DeviceType") if err != nil { - log.Printf("network: Skipping device %s, could not get DeviceType: %v", path, err) + logger.Warning("[NET-031] Skipping device %s because DeviceType could not be read: %v", path, err) continue } deviceType, ok := typeVariant.Value().(uint32) if !ok { - log.Printf("network: Skipping device %s, invalid DeviceType", path) + logger.Warning("[NET-032] Skipping device %s because DeviceType was invalid", path) continue } @@ -165,22 +178,22 @@ func (m *Manager) FindDevices() error { // Safely get Interface property ifaceVariant, err := deviceObj.GetProperty(nmDeviceInterface + ".Interface") if err != nil { - log.Printf("network: Skipping device %s, could not get Interface: %v", path, err) + logger.Warning("[NET-033] Skipping device %s because Interface property could not be read: %v", path, err) continue } if newDevice.InterfaceName, ok = ifaceVariant.Value().(string); !ok { - log.Printf("network: Skipping device %s, invalid Interface", path) + logger.Warning("[NET-034] Skipping device %s because Interface property was invalid", path) continue } // Safely get State property stateVariant, err := deviceObj.GetProperty(nmDeviceInterface + ".State") if err != nil { - log.Printf("network: Skipping device %s, could not get State: %v", path, err) + logger.Warning("[NET-035] Skipping device %s because State property could not be read: %v", path, err) continue } if newDevice.State, ok = stateVariant.Value().(uint32); !ok { - log.Printf("network: Skipping device %s, invalid State", path) + logger.Warning("[NET-036] Skipping device %s because State property was invalid", path) continue } @@ -191,6 +204,7 @@ func (m *Manager) FindDevices() error { // CheckInternetConnection performs a check for an active internet connection. func (m *Manager) CheckInternetConnection() (*ConnectionStatus, error) { + logger.Debug("[NET-040] Checking internet connectivity") if err := m.FindDevices(); err != nil { return &ConnectionStatus{StatusError, "Failed to scan devices"}, err } @@ -221,6 +235,7 @@ func (m *Manager) CheckInternetConnection() (*ConnectionStatus, error) { // StartScan performs a Wi-Fi scan asynchronously. The optional stopChan can be used to abort. func (m *Manager) StartScan(resultsChan chan<- []AccessPoint, errorChan chan<- error, stopChan <-chan struct{}) { + logger.Info("[NET-050] Starting Wi‑Fi scan") var wifiDevice *Device for i := range m.Devices { if m.Devices[i].Type == TypeWifi { @@ -289,12 +304,13 @@ func (m *Manager) StartScan(resultsChan chan<- []AccessPoint, errorChan chan<- e // scanAccessPoints filters and sorts the raw list of D-Bus AP paths. func (m *Manager) scanAccessPoints(apPaths []dbus.ObjectPath) ([]AccessPoint, error) { + logger.Debug("[NET-060] Processing %d access points", len(apPaths)) tempNetworkMap := make(map[string]AccessPoint) for _, apPath := range apPaths { apObj := m.conn.Object(nmService, apPath) props, err := m.getAllProperties(apObj, nmAPInterface) if err != nil { - log.Printf("network: Failed to get properties for AP %s: %v", apPath, err) + logger.Warning("[NET-061] Failed to read access point properties for %s: %v", apPath, err) continue } @@ -366,6 +382,7 @@ func (m *Manager) scanAccessPoints(apPaths []dbus.ObjectPath) ([]AccessPoint, er // Connect attempts to connect a Wi-Fi device to a given access point. func (m *Manager) Connect(wifiDevice Device, ap AccessPoint, passphrase string) error { + logger.Info("[NET-070] Connection requested for SSID %q using adapter %q", ap.SSID, wifiDevice.InterfaceName) if wifiDevice.Type != TypeWifi { return fmt.Errorf("cannot connect: provided device is not a Wi-Fi device") } @@ -412,11 +429,11 @@ func (m *Manager) Connect(wifiDevice Device, ap AccessPoint, passphrase string) if err != nil { // THIS IS THE LOG YOU NEED TO SEE IN SSH - log.Printf("NETWORK MANAGER FAIL: %v", err) + logger.Error("[NET-071] NetworkManager rejected the connection request: %v", err) return fmt.Errorf("AddAndActivateConnection failed: %w", err) } - log.Printf("Connection created: %s, Active path: %s", connectionPath, activeConnectionPath) + logger.Info("[NET-072] Connection created successfully. Connection=%s Active=%s", connectionPath, activeConnectionPath) // Wait for the connection to be fully established if _, err := m.WaitForInternet(30*time.Second, 5*time.Second); err != nil { @@ -428,6 +445,7 @@ func (m *Manager) Connect(wifiDevice Device, ap AccessPoint, passphrase string) // pingTest with a short timeout to prevent the UI loop from hanging func pingTest() bool { + logger.Debug("[NET-080] Beginning internet reachability test") targets := []string{ "1.1.1.1:53", // Cloudflare DNS "8.8.8.8:53", // Google DNS @@ -448,6 +466,7 @@ func pingTest() bool { // getAllProperties is a helper to fetch all properties for a D-Bus object interface. func (m *Manager) getAllProperties(obj dbus.BusObject, iface string) (map[string]dbus.Variant, error) { + logger.Debug("[NET-090] Fetching all D‑Bus properties for interface %s", iface) var props map[string]dbus.Variant call := obj.Call("org.freedesktop.DBus.Properties.GetAll", 0, iface) if call.Err != nil { @@ -463,6 +482,7 @@ func (m *Manager) getAllProperties(obj dbus.BusObject, iface string) (map[string // or until maxWait elapses. When it first sees "up", it requires that state // to be stable for at least 'stableFor' before returning success to avoid flapping. func (m *Manager) WaitForInternet(maxWait, stableFor time.Duration) (*ConnectionStatus, error) { + logger.Info("[NET-100] Waiting for internet connectivity (maxWait=%s stableFor=%s)", maxWait, stableFor) deadline := time.Now().Add(maxWait) var lastErr error var lastStatus *ConnectionStatus From a4eff17b77a10784ff342f4c66a41a4f15edcb09 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Wed, 24 Jun 2026 22:41:48 -0500 Subject: [PATCH 09/12] fix(network): fix wifi page never scanning, never exiting, and UI freezes on connect WiFiSetupPage never showed networks and could not be exited once entered. Several compounding bugs were responsible across network.go and page.go. Primary bugs fixed: - WiFiSetupPage.Init() never assigned p.wifiDevice after FindDevices(), so startScan() always hit the nil guard and exited without launching a scan goroutine. No networks ever appeared in the list. A value copy of the Wi-Fi device is now taken from m.Devices immediately after FindDevices() returns, with an explicit error if no adapter is found. - CheckInternetConnection() called FindDevices() on every tick, rebuilding m.Devices every 2 seconds. This caused [NET-030] log spam, invalidated any pointers held into the slice, and meant checkState()'s fresh Manager instances (which have an empty Devices slice) would never detect internet after Fix 2 was applied. CheckInternetConnection() now calls FindDevices() only when m.Devices is empty (fresh Manager), and does a cheaper per-device State property refresh when devices are already known (long-lived Manager). This satisfies both callers correctly. - Connect() called WaitForInternet(30s, 5s) synchronously before returning, blocking the main UI thread for up to 35 seconds. It also returned an error if internet wasn't stable in time, even though AddAndActivateConnection had succeeded, causing the page to show "Failed to initiate connection" while NM was still negotiating in the background. WaitForInternet removed from Connect(); the page's existing checkConnectionStatusLoop and handleConnectionStatusUpdate already handle post-connection state tracking. Additional bugs fixed: - startScan() launched a new StartScan goroutine on every R-press without signalling the previous one to stop. Concurrent goroutines raced on resultsChan and accumulated indefinitely. stopScan is now closed and replaced before each scan, and checkConnectionStatusLoop is restarted with the new channel. - Connect() always generated a new UUID and called AddAndActivateConnection without checking for an existing profile. Repeated connection attempts (e.g. wrong password then correct) accumulated duplicate stale profiles in NetworkManager. Existing profiles matching the SSID are now deleted via Settings.Connection.Delete before creating a new one. - m.Devices was read and written concurrently from StartScan and CheckInternetConnection goroutines without synchronisation. A sync.RWMutex is added to Manager; state refreshes take a write lock, StartScan device lookup takes a read lock. - Connect() checked wifiDevice.State using the caller's value copy taken at Init() time, which never updated. The current state is now read from m.Devices under lock at the point of connection. - auth-alg: "open" was unconditionally set in the 802-11-wireless-security settings block. This field is only meaningful for WPA/WPA2-Personal and should be omitted for SAE (WPA3) and EAP variants. It is now only set when KeyMgmt is "wpa-psk". --- internal/application/application.go | 1 + internal/network/network.go | 99 +++++++++++++++++++++++++---- internal/page/wifiSetupPage.go | 24 ++++++- notes.org | 3 + 4 files changed, 114 insertions(+), 13 deletions(-) create mode 100644 notes.org diff --git a/internal/application/application.go b/internal/application/application.go index cee6e72..f985138 100644 --- a/internal/application/application.go +++ b/internal/application/application.go @@ -97,6 +97,7 @@ func Run() { os.Remove(testFile) logger.AddFile(filepath.Join(dataDir, "log.txt")) + logger.SetLevel = logger.LevelDebug DB, err = db.Connect(filepath.Join(dataDir, "database.sqlite")) if err != nil { diff --git a/internal/network/network.go b/internal/network/network.go index 0a1ad4a..5a8589c 100644 --- a/internal/network/network.go +++ b/internal/network/network.go @@ -8,6 +8,7 @@ import ( "net" "sort" "strings" + "sync" "time" "github.com/godbus/dbus/v5" @@ -91,6 +92,7 @@ type Manager struct { conn *dbus.Conn nmObject dbus.BusObject Devices []Device + mu sync.RWMutex // protects Devices } // AccessPoint holds essential information about a discovered Wi-Fi network. @@ -142,7 +144,7 @@ func (m *Manager) FindDevices() error { var devicePaths []dbus.ObjectPath call := m.nmObject.Call(nmInterface+".GetDevices", 0) if call.Err != nil { - logger.Error("GetDevices D-Bus call failed: %w", call.Err) + logger.Error("GetDevices D-Bus call failed: %v", call.Err) return fmt.Errorf("GetDevices D-Bus call failed: %w", call.Err) } if err := call.Store(&devicePaths); err != nil { @@ -205,8 +207,33 @@ func (m *Manager) FindDevices() error { // CheckInternetConnection performs a check for an active internet connection. func (m *Manager) CheckInternetConnection() (*ConnectionStatus, error) { logger.Debug("[NET-040] Checking internet connectivity") - if err := m.FindDevices(); err != nil { - return &ConnectionStatus{StatusError, "Failed to scan devices"}, err + + // Previously called FindDevices() here, which rebuilt m.Devices every 2s, + // causing [NET-030] log spam and invalidating any pointers held by callers. + // Device enumeration is done once in Init(); we only need fresh State values. + // If this is a fresh Manager with no devices yet (e.g. called from + // checkState which creates a new Manager each tick), do a full device + // discovery. If devices are already known (e.g. the WiFiSetupPage's + // long-lived Manager), only refresh State to avoid the [NET-030] spam + // and avoid rebuilding the slice which would invalidate held pointers. + if len(m.Devices) == 0 { + if err := m.FindDevices(); err != nil { + return &ConnectionStatus{StatusError, "Failed to scan devices"}, err + } + } else { + m.mu.Lock() + for i := range m.Devices { + deviceObj := m.conn.Object(nmService, m.Devices[i].Path) + stateVariant, err := deviceObj.GetProperty(nmDeviceInterface + ".State") + if err != nil { + logger.Warning("[NET-041] Could not refresh state for device %s: %v", m.Devices[i].Path, err) + continue + } + if state, ok := stateVariant.Value().(uint32); ok { + m.Devices[i].State = state + } + } + m.mu.Unlock() } // Check Ethernet first (Priority) @@ -235,14 +262,22 @@ func (m *Manager) CheckInternetConnection() (*ConnectionStatus, error) { // StartScan performs a Wi-Fi scan asynchronously. The optional stopChan can be used to abort. func (m *Manager) StartScan(resultsChan chan<- []AccessPoint, errorChan chan<- error, stopChan <-chan struct{}) { + logger.Info("[NET-050] Starting Wi‑Fi scan") + + m.mu.RLock() + var wifiDevice *Device + for i := range m.Devices { if m.Devices[i].Type == TypeWifi { wifiDevice = &m.Devices[i] break } } + + m.mu.RUnlock() + if wifiDevice == nil { errorChan <- fmt.Errorf("no Wi-Fi device available for scanning") return @@ -250,27 +285,35 @@ func (m *Manager) StartScan(resultsChan chan<- []AccessPoint, errorChan chan<- e // Ensure Wi-Fi is enabled nmObj := m.conn.Object(nmService, nmPath) + prop, err := nmObj.GetProperty(nmInterface + ".WirelessEnabled") + if err != nil { errorChan <- fmt.Errorf("could not read WirelessEnabled property: %w", err) return } + if !prop.Value().(bool) { + call := nmObj.Call("org.freedesktop.DBus.Properties.Set", 0, nmInterface, "WirelessEnabled", dbus.MakeVariant(true), ) + if call.Err != nil { errorChan <- fmt.Errorf("could not enable Wi-Fi: %w", call.Err) return } + time.Sleep(2 * time.Second) // Give NetworkManager time to bring up the device } // Request a scan wifiDeviceWireless := m.conn.Object(nmService, wifiDevice.Path) + call := wifiDeviceWireless.Call(nmWirelessDeviceInterface+".RequestScan", 0, make(map[string]dbus.Variant)) + if call.Err != nil { errorChan <- fmt.Errorf("scan request failed: %w", call.Err) return @@ -284,11 +327,14 @@ func (m *Manager) StartScan(resultsChan chan<- []AccessPoint, errorChan chan<- e } var apPaths []dbus.ObjectPath + call = wifiDeviceWireless.Call(nmWirelessDeviceInterface+".GetAllAccessPoints", 0) + if call.Err != nil { errorChan <- fmt.Errorf("could not retrieve network list: %w", call.Err) return } + if err := call.Store(&apPaths); err != nil { errorChan <- fmt.Errorf("could not parse network list: %w", err) return @@ -299,6 +345,7 @@ func (m *Manager) StartScan(resultsChan chan<- []AccessPoint, errorChan chan<- e errorChan <- err return } + resultsChan <- processedAPs } @@ -386,11 +433,40 @@ func (m *Manager) Connect(wifiDevice Device, ap AccessPoint, passphrase string) if wifiDevice.Type != TypeWifi { return fmt.Errorf("cannot connect: provided device is not a Wi-Fi device") } + m.mu.RLock() + currentState := wifiDevice.State + for _, d := range m.Devices { + if d.Path == wifiDevice.Path { + currentState = d.State + break + } + } + m.mu.RUnlock() - if wifiDevice.State == nmDeviceStateFailed || - wifiDevice.State == nmDeviceStateUnmanaged || - wifiDevice.State == nmDeviceStateUnavailable { - return fmt.Errorf("cannot connect: device is in an error state (state=%d)", wifiDevice.State) + if currentState == nmDeviceStateFailed || + currentState == nmDeviceStateUnmanaged || + currentState == nmDeviceStateUnavailable { + return fmt.Errorf("cannot connect: device is in an error state (state=%d)", currentState) + } + + // Delete any existing NM connection profile for this SSID to avoid + // accumulating duplicates across connection attempts. + settingsObj := m.conn.Object(nmService, "/org/freedesktop/NetworkManager/Settings") + var existingPaths []dbus.ObjectPath + if call := settingsObj.Call("org.freedesktop.NetworkManager.Settings.ListConnections", 0); call.Err == nil { + _ = call.Store(&existingPaths) + for _, cp := range existingPaths { + cObj := m.conn.Object(nmService, cp) + var csettings map[string]map[string]dbus.Variant + if c2 := cObj.Call("org.freedesktop.NetworkManager.Settings.Connection.GetSettings", 0); c2.Err == nil { + if err := c2.Store(&csettings); err == nil { + if id, ok := csettings["connection"]["id"].Value().(string); ok && id == ap.SSID { + logger.Info("[NET-073] Deleting existing profile for %q before reconnect", ap.SSID) + _ = cObj.Call("org.freedesktop.NetworkManager.Settings.Connection.Delete", 0) + } + } + } + } } settings := map[string]map[string]dbus.Variant{ @@ -435,10 +511,11 @@ func (m *Manager) Connect(wifiDevice Device, ap AccessPoint, passphrase string) logger.Info("[NET-072] Connection created successfully. Connection=%s Active=%s", connectionPath, activeConnectionPath) - // Wait for the connection to be fully established - if _, err := m.WaitForInternet(30*time.Second, 5*time.Second); err != nil { - return fmt.Errorf("failed to establish internet connection: %w", err) - } + // Removed WaitForInternet() call here. It blocked the caller's thread + // for up to 35s and returned an error even on successful NM initiation + // if internet wasn't stable in time. The page's checkConnectionStatusLoop + // and handleConnectionStatusUpdate handle post-connection state tracking. + logger.Info("[NET-072] Connection initiated. Connection=%s Active=%s", connectionPath, activeConnectionPath) return nil } diff --git a/internal/page/wifiSetupPage.go b/internal/page/wifiSetupPage.go index 3da1405..ce5f13a 100644 --- a/internal/page/wifiSetupPage.go +++ b/internal/page/wifiSetupPage.go @@ -97,8 +97,23 @@ func (p *WiFiSetupPage) Init(ctx *PageCtx) error { return fmt.Errorf("failed to find network devices: %w. Try rebooting the computer. If this is your 3rd reboot, call for help", err) } + // FindDevices() rebuilds m.Devices each call, so store a VALUE copy, + // not a pointer into the slice — it would be invalidated on next call. + for i := range p.networkManager.Devices { + if p.networkManager.Devices[i].Type == network.TypeWifi { + device := p.networkManager.Devices[i] + p.wifiDevice = &device + break + } + } + if p.wifiDevice == nil { + p.networkManager.Close() + p.networkManager = nil + return fmt.Errorf("no Wi-Fi adapter found. Try rebooting the computer. If this is your 3rd reboot, call for help") + } + // Create UI elements - white := sdl.Color{R: 255, G: 255, B: 255, A: 255} + white := colors.White p.titleItem, err = NewStringItem("Wi-Fi Setup", p.applicationFont, white) if err != nil { return fmt.Errorf("failed to create title item: %w", err) @@ -117,7 +132,6 @@ func (p *WiFiSetupPage) Init(ctx *PageCtx) error { // Start scanning and status monitoring p.startScan() - go p.checkConnectionStatusLoop() return nil } @@ -143,6 +157,11 @@ func (p *WiFiSetupPage) checkConnectionStatusLoop() { // startScan triggers a new asynchronous network scan. func (p *WiFiSetupPage) startScan() { + if p.stopScan != nil { + close(p.stopScan) + } + p.stopScan = make(chan struct{}) + p.isScanning = true p.selectedIndex = 0 p.scrollOffset = 0 @@ -150,6 +169,7 @@ func (p *WiFiSetupPage) startScan() { if p.wifiDevice != nil { go p.networkManager.StartScan(p.resultsChan, p.errorChan, p.stopScan) + go p.checkConnectionStatusLoop() } else { p.isScanning = false } diff --git a/notes.org b/notes.org new file mode 100644 index 0000000..7df70a0 --- /dev/null +++ b/notes.org @@ -0,0 +1,3 @@ +* Networking Issues +- The networking package needs a LOT more logging... Nothing gets output when things crash. +- \ No newline at end of file From 57500308b00d763dd8509453cbb6c6fe7545ba10 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Wed, 24 Jun 2026 23:18:32 -0500 Subject: [PATCH 10/12] Handle locked database cases --- internal/db/DB.go | 112 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 internal/db/DB.go diff --git a/internal/db/DB.go b/internal/db/DB.go new file mode 100644 index 0000000..e290f79 --- /dev/null +++ b/internal/db/DB.go @@ -0,0 +1,112 @@ +// Package db provides a database connection and functions for interacting with the database. +// The DB object should be held and maintained by the application, not by this package. +// The application should be responsible for connecting to, migrating, and initial population +// of the database. This package should only provide the schema definitions and helper functions. +// Schema definitions should clone API response structures as closely as possible. + +package db + +import ( + "fmt" + "os" + "time" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +const ( + connectMaxAttempts = 5 + connectRetryDelay = 500 * time.Millisecond +) + +// Connect opens the SQLite database at dbpath with WAL mode and a hardened +// lock-recovery strategy suitable for kiosk use where the process may have +// crashed and left stale lock artefacts behind. +func Connect(dbpath string) (*gorm.DB, error) { + + // ── Stale lock recovery ─────────────────────────────────────────────── + // SQLite WAL mode leaves a .db-wal and .db-shm alongside the main file. + // If the previous process crashed, the OS releases file locks but the + // artefacts remain. SQLite recovers them automatically on the next open, + // UNLESS an OS-level advisory lock is still held (rare but possible on + // Linux with certain crash patterns). Removing them before opening is + // safe: SQLite treats absent WAL/SHM files as a clean slate and will + // recreate them. We only do this if the main DB file already exists + // (i.e. this is not a first-run) to avoid masking legitimate errors. + if _, err := os.Stat(dbpath); err == nil { + for _, ext := range []string{"-wal", "-shm"} { + artefact := dbpath + ext + if _, err := os.Stat(artefact); err == nil { + if removeErr := os.Remove(artefact); removeErr != nil { + // Non-fatal: log and continue — SQLite may still recover. + fmt.Printf("[DB] warning: could not remove stale %s: %v\n", ext, removeErr) + } else { + fmt.Printf("[DB] removed stale lock artefact: %s\n", artefact) + } + } + } + } + + // ── Connection with retry ───────────────────────────────────────────── + // Even after removing WAL/SHM, the main DB file itself may still be + // briefly locked if another process is shutting down. Retry with + // backoff before giving up. + var ( + db *gorm.DB + err error + ) + for attempt := 1; attempt <= connectMaxAttempts; attempt++ { + db, err = gorm.Open(sqlite.Open(dbpath), &gorm.Config{}) + if err == nil { + break + } + if attempt == connectMaxAttempts { + return nil, fmt.Errorf("database still locked after %d attempts: %w", connectMaxAttempts, err) + } + fmt.Printf("[DB] open attempt %d/%d failed (%v), retrying in %s\n", + attempt, connectMaxAttempts, err, connectRetryDelay) + time.Sleep(connectRetryDelay) + } + + // ── Connection pool ─────────────────────────────────────────────────── + // SQLite does not support concurrent writers. Setting MaxOpenConns(1) + // forces gorm to serialise all queries through a single connection, + // preventing "database is locked" errors from competing goroutines + // within this process. This is the single most important setting for + // SQLite in a multi-goroutine application. + sqlDB, err := db.DB() + if err != nil { + return nil, fmt.Errorf("could not get underlying sql.DB: %w", err) + } + sqlDB.SetMaxOpenConns(1) + + // ── Pragmas ─────────────────────────────────────────────────────────── + pragmas := []string{ + "PRAGMA journal_mode=WAL", + "PRAGMA synchronous=FULL", + "PRAGMA foreign_keys=ON", + // 5 s gives the serialised connection time to finish a long write + // before returning SQLITE_BUSY to the application layer. + "PRAGMA busy_timeout=5000", + } + for _, pragma := range pragmas { + if _, err := sqlDB.Exec(pragma); err != nil { + return nil, fmt.Errorf("failed to set %q: %w", pragma, err) + } + } + + return db, nil +} + +// Migrate the database schema. +func Migrate(db *gorm.DB) error { + return db.AutoMigrate( + &AppConfig{}, + &Log{}, + &Proclamation{}, + &ProclamationCategory{}, + &SolfeggioFrequency{}, + &AudioFile{}, + ) +} From cd79a6ffa1f9e386b7cef0341bedbfcece7dd89c Mon Sep 17 00:00:00 2001 From: NeilClack Date: Wed, 24 Jun 2026 23:18:52 -0500 Subject: [PATCH 11/12] Rename package files for consistency --- internal/db/{schema.go => Schema.go} | 0 internal/db/newDb.go | 49 ---------------------------- 2 files changed, 49 deletions(-) rename internal/db/{schema.go => Schema.go} (100%) delete mode 100644 internal/db/newDb.go diff --git a/internal/db/schema.go b/internal/db/Schema.go similarity index 100% rename from internal/db/schema.go rename to internal/db/Schema.go diff --git a/internal/db/newDb.go b/internal/db/newDb.go deleted file mode 100644 index fd77866..0000000 --- a/internal/db/newDb.go +++ /dev/null @@ -1,49 +0,0 @@ -// Package db provides a database connection and functions for interacting with the database. -// The DB object should be held and maintained by the application, not by this package. -// The application should be responsible for connecting to, migrating, and initial population -// of the database. This package should only provide the schema definitions and helper functions. -// Schema definitions should clone API response structures as closely as possible. - -package db - -import ( - "gorm.io/driver/sqlite" - "gorm.io/gorm" -) - -// Connect to the database and return the gorm.DB object. -func Connect(dbpath string) (*gorm.DB, error) { - db, err := gorm.Open(sqlite.Open(dbpath), &gorm.Config{}) - if err != nil { - return nil, err - } - - // Set pragmas - sqlDB, err := db.DB() - if err != nil { - return nil, err - } - pragmas := []string{ - "PRAGMA journal_mode=WAL", - "PRAGMA synchronous=FULL", - "PRAGMA foreign_keys=ON", - "PRAGMA busy_timeout=5000", - } - - for _, pragma := range pragmas { - if _, err := sqlDB.Exec(pragma); err != nil { - return nil, err - } - } - - return db, nil -} - -// Migrate the database schema -func Migrate(db *gorm.DB) error { - // Pass all models to be migrated to this function call. - if err := db.AutoMigrate(&AppConfig{}, &Log{}, &Proclamation{}, &ProclamationCategory{}, &SolfeggioFrequency{}, &AudioFile{}); err != nil { - return err - } - return nil -} From f8e75194e8458a9203713174feca9313212f8da7 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Wed, 24 Jun 2026 23:20:57 -0500 Subject: [PATCH 12/12] Update version hash for visual verification --- internal/page/home.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/page/home.go b/internal/page/home.go index 38f04e1..b6253e2 100644 --- a/internal/page/home.go +++ b/internal/page/home.go @@ -12,7 +12,7 @@ import ( "rhemawavetech.com/rhemawave/internal/shaderManager" ) -var GitVersion = "b018a79" +var GitVersion = "cd79a6f" type Home struct { Base