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/flake.nix b/flake.nix index 0e59671..d0e641a 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,9 @@ postInstall = '' # Create the destination mkdir -p $out/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; { 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/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{}, + ) +} 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 -} diff --git a/internal/download/Download.go b/internal/download/Download.go index 5e704c6..2d3c467 100644 --- a/internal/download/Download.go +++ b/internal/download/Download.go @@ -20,12 +20,46 @@ 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/" + logger.Info("Starting downloads") + + 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) @@ -33,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, @@ -45,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), @@ -54,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) } @@ -87,9 +124,12 @@ 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 { + 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, @@ -102,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, @@ -113,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), @@ -127,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, @@ -141,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, @@ -148,6 +192,8 @@ func DownloadAudioFiles(progressChan chan<- DownloadStatus, filenames []string, return } + logger.Info("Finished downloading %s", filename) + // Update progress totalDownloaded += int64(n) progressChan <- DownloadStatus{ @@ -158,13 +204,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 @@ -173,4 +212,5 @@ func DownloadAudioFiles(progressChan chan<- DownloadStatus, filenames []string, Cur: totalDownloaded, Msg: "All audio downloads completed!", } + logger.Info("All audio downloads complete!") } diff --git a/internal/network/network.go b/internal/network/network.go index 08a1c99..5a8589c 100644 --- a/internal/network/network.go +++ b/internal/network/network.go @@ -5,14 +5,15 @@ package network import ( "errors" "fmt" - "log" "net" "sort" "strings" + "sync" "time" "github.com/godbus/dbus/v5" "github.com/google/uuid" + "rhemawavetech.com/rhemawave/internal/logger" ) // --- D-Bus NetworkManager Constants --- @@ -28,6 +29,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. @@ -75,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. @@ -89,31 +107,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: %v", call.Err) return fmt.Errorf("GetDevices D-Bus call failed: %w", call.Err) } if err := call.Store(&devicePaths); err != nil { @@ -125,10 +156,14 @@ 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 { + logger.Warning("[NET-032] Skipping device %s because DeviceType was invalid", path) continue } - deviceType, _ := typeVariant.Value().(uint32) var newDevice Device switch deviceType { @@ -141,10 +176,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 { + 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 { + 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 { + 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 { + logger.Warning("[NET-036] Skipping device %s because State property was invalid", path) + continue + } + m.Devices = append(m.Devices, newDevice) } return nil @@ -152,8 +206,34 @@ func (m *Manager) FindDevices() error { // CheckInternetConnection performs a check for an active internet connection. func (m *Manager) CheckInternetConnection() (*ConnectionStatus, error) { - if err := m.FindDevices(); err != nil { - return &ConnectionStatus{StatusError, "Failed to scan devices"}, err + logger.Debug("[NET-040] Checking internet connectivity") + + // 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) @@ -182,13 +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 @@ -196,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 @@ -230,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 @@ -245,22 +345,24 @@ func (m *Manager) StartScan(resultsChan chan<- []AccessPoint, errorChan chan<- e errorChan <- err return } + resultsChan <- processedAPs } // 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 } - ssidBytes, _ := props["Ssid"].Value().([]byte) - if len(ssidBytes) == 0 { + ssidBytes, ok := props["Ssid"].Value().([]byte) + if !ok || len(ssidBytes) == 0 { continue } @@ -272,12 +374,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" } @@ -309,9 +429,45 @@ 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") } + m.mu.RLock() + currentState := wifiDevice.State + for _, d := range m.Devices { + if d.Path == wifiDevice.Path { + currentState = d.State + break + } + } + m.mu.RUnlock() + + 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{ "connection": { @@ -349,34 +505,45 @@ 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 { - 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 } // 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 + logger.Debug("[NET-080] Beginning internet reachability test") + 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. 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 { @@ -392,6 +559,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 diff --git a/internal/page/home.go b/internal/page/home.go index 08b6cb6..b6253e2 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 = "cd79a6f" type Home struct { Base 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) diff --git a/internal/page/wifiSetupPage.go b/internal/page/wifiSetupPage.go index 92747c1..ce5f13a 100644 --- a/internal/page/wifiSetupPage.go +++ b/internal/page/wifiSetupPage.go @@ -91,17 +91,29 @@ func (p *WiFiSetupPage) Init(ctx *PageCtx) error { // Find Wi-Fi device if err := p.networkManager.FindDevices(); err != nil { + // 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) } - for _, dev := range p.networkManager.Devices { - if dev.Type == network.TypeWifi { - d := dev - p.wifiDevice = &d + + // 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) @@ -120,7 +132,6 @@ func (p *WiFiSetupPage) Init(ctx *PageCtx) error { // Start scanning and status monitoring p.startScan() - go p.checkConnectionStatusLoop() return nil } @@ -146,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 @@ -153,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 } @@ -161,17 +178,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 +191,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 +332,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 +347,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 +393,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 +413,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 +559,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 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