Merge pull request 'Audio & Networking Hotfixes' (#41) from dev into main

Reviewed-on: https://codeberg.org/RhemaWave-Technologies/RhemaWave/pulls/41
This commit is contained in:
NeilClack
2026-06-25 17:01:21 +02:00
13 changed files with 505 additions and 218 deletions
-88
View File
@@ -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
+90
View File
@@ -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
-9
View File
@@ -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 {
@@ -75,10 +70,6 @@
# 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; {
+1
View File
@@ -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 {
+112
View File
@@ -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{},
)
}
-49
View File
@@ -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
}
+50 -10
View File
@@ -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!")
}
+197 -29
View File
@@ -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
+1 -1
View File
@@ -12,7 +12,7 @@ import (
"rhemawavetech.com/rhemawave/internal/shaderManager"
)
var GitVersion = "1.0.0-beta"
var GitVersion = "cd79a6f"
type Home struct {
Base
+6
View File
@@ -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)
+44 -31
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
* Networking Issues
- The networking package needs a LOT more logging... Nothing gets output when things crash.
-