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:
@@ -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
@@ -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
|
||||
@@ -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; {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{},
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"rhemawavetech.com/rhemawave/internal/shaderManager"
|
||||
)
|
||||
|
||||
var GitVersion = "1.0.0-beta"
|
||||
var GitVersion = "cd79a6f"
|
||||
|
||||
type Home struct {
|
||||
Base
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user