From fc5c1dfe6da3b65d822c8e3c1bb0ae99cf1e7f17 Mon Sep 17 00:00:00 2001 From: NeilClack Date: Fri, 26 Jun 2026 18:40:56 -0500 Subject: [PATCH] Update network manager to utilize gonetworkmanager for an easier life... --- go.mod | 10 +- go.sum | 5 +- internal/application/application.go | 15 +- internal/network/network.go | 937 +++++++++++-------------- internal/page/page.go | 52 ++ internal/page/wifiSetupPage.go | 1004 +++++++++++---------------- 6 files changed, 896 insertions(+), 1127 deletions(-) diff --git a/go.mod b/go.mod index cbd77b7..225109e 100644 --- a/go.mod +++ b/go.mod @@ -8,11 +8,11 @@ require github.com/veandco/go-sdl2 v0.4.40 require github.com/go-gl/mathgl v1.2.0 -require ( - github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 - github.com/godbus/dbus/v5 v5.1.0 - github.com/google/uuid v1.6.0 -) +require github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 + +require github.com/Wifx/gonetworkmanager/v2 v2.2.0 + +require github.com/godbus/dbus/v5 v5.1.0 // indirect require ( github.com/jinzhu/inflection v1.0.0 // indirect diff --git a/go.sum b/go.sum index 8b36dab..070ce89 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,12 @@ +github.com/Wifx/gonetworkmanager/v2 v2.2.0 h1:kPstgsQtY8CmDOOFZd81ytM9Gi3f6ImzPCKF7nNhQ2U= +github.com/Wifx/gonetworkmanager/v2 v2.2.0/go.mod h1:fMDb//SHsKWxyDUAwXvCqurV3npbIyyaQWenGpZ/uXg= github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA= github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw= github.com/go-gl/mathgl v1.2.0 h1:v2eOj/y1B2afDxF6URV1qCYmo1KW08lAMtTbOn3KXCY= github.com/go-gl/mathgl v1.2.0/go.mod h1:pf9+b5J3LFP7iZ4XXaVzZrCle0Q/vNpB/vDe5+3ulRE= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= diff --git a/internal/application/application.go b/internal/application/application.go index f985138..2c10d62 100644 --- a/internal/application/application.go +++ b/internal/application/application.go @@ -204,25 +204,16 @@ func (app *Application) checkState() { // Check if there is an internet connection, and if not, force the WiFi page if !networkIsUp { if lastNetworkCheckTime.IsZero() || time.Since(lastNetworkCheckTime) >= time.Second { - networkManager, err := network.New() - if err != nil { - logger.Error("error obtaining a network manager %v", err) - return - } lastNetworkCheckTime = time.Now() - status, err := networkManager.CheckInternetConnection() - if err != nil { - logger.Error("error checking for internet connection %v", err) - return - } - if status.StatusCode != network.StatusWifiConnectedInternetUp && - status.StatusCode != network.StatusEthernetConnectedInternetUp { + + if !network.HasInternet() { if app.state != NEEDS_INTERNET { app.PageRouter.NavigateTo(page.WIFI) app.state = NEEDS_INTERNET } return } + networkIsUp = true // internet confirmed, don't check again } else { return // throttled, and we don't yet know if network is up — can't proceed diff --git a/internal/network/network.go b/internal/network/network.go index 5a8589c..629aa1c 100644 --- a/internal/network/network.go +++ b/internal/network/network.go @@ -1,603 +1,500 @@ -// Package network provides a high-level utility for managing network -// connections via the system's D-Bus and NetworkManager. package network import ( - "errors" + "context" "fmt" "net" - "sort" - "strings" - "sync" + "net/http" "time" - "github.com/godbus/dbus/v5" - "github.com/google/uuid" + gonm "github.com/Wifx/gonetworkmanager/v2" "rhemawavetech.com/rhemawave/internal/logger" ) -// --- D-Bus NetworkManager Constants --- -const ( - nmService = "org.freedesktop.NetworkManager" - nmPath = "/org/freedesktop/NetworkManager" - nmInterface = "org.freedesktop.NetworkManager" - nmDeviceInterface = "org.freedesktop.NetworkManager.Device" - nmWirelessDeviceInterface = "org.freedesktop.NetworkManager.Device.Wireless" - nmAPInterface = "org.freedesktop.NetworkManager.AccessPoint" +// ─── Public types ───────────────────────────────────────────────────────────── - nmDeviceTypeEthernet uint32 = 1 - 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. -var ErrScanAborted = errors.New("network scan aborted by caller") - -// Status defines the type for various connection status codes. -type Status int +type SecurityType string const ( - StatusError Status = iota - StatusNoDevicesFound - StatusWifiAvailableNotConnected - StatusWifiConnectedNoInternet - StatusWifiConnectedInternetUp - StatusEthernetNotConnected - StatusEthernetConnectedNoInternet - StatusEthernetConnectedInternetUp + SecurityOpen SecurityType = "Open" + SecurityWEP SecurityType = "WEP" + SecurityWPA SecurityType = "WPA" + SecurityWPA2 SecurityType = "WPA2" + SecurityWPA3 SecurityType = "WPA3" ) -// ConnectionStatus holds the detailed result of a connectivity check. -type ConnectionStatus struct { - StatusCode Status - Message string +type Network struct { + SSID string + SignalStrength uint8 + Security SecurityType + Saved bool + Connected bool } -// DeviceType defines the kind of network device. -type DeviceType int +// ─── Package init ───────────────────────────────────────────────────────────── -const ( - TypeUnknown DeviceType = iota - TypeEthernet - TypeWifi -) +var nm gonm.NetworkManager -// Device holds information about a single network device. -type Device struct { - Path dbus.ObjectPath - InterfaceName string - Type DeviceType - State uint32 -} - -// Manager handles all D-Bus communication with NetworkManager. -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. -type AccessPoint struct { - ObjectPath dbus.ObjectPath - SSID string - Strength byte - IsProtected bool - IsActive bool - KeyMgmt string -} - -// 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() +func init() { + var err error + nm, err = gonm.NewNetworkManager() 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) + fmt.Printf("network: failed to connect to NetworkManager: %v\n", 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 -} +// ─── ScanForNetworks ────────────────────────────────────────────────────────── -// 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 { - return fmt.Errorf("could not parse device list: %w", err) +func ScanForNetworks(ctx context.Context) (<-chan []Network, error) { + dev, err := wifiDevice() + if err != nil { + return nil, err } - m.Devices = []Device{} - for _, path := range devicePaths { - deviceObj := m.conn.Object(nmService, path) - typeVariant, err := deviceObj.GetProperty(nmDeviceInterface + ".DeviceType") - if err != nil { - 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 - } + ch := make(chan []Network, 1) - var newDevice Device - switch deviceType { - case nmDeviceTypeEthernet: - newDevice.Type = TypeEthernet - case nmDeviceTypeWifi: - newDevice.Type = TypeWifi - default: - continue - } + go func() { + defer close(ch) - newDevice.Path = path + ticker := time.NewTicker(15 * time.Second) + defer ticker.Stop() - // 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 - } + scan := func() { + if err := dev.RequestScan(); err != nil { + logger.Warning("network: scan request failed: %v", err) + return + } + time.Sleep(2 * time.Second) - // 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 -} - -// CheckInternetConnection performs a check for an active internet connection. -func (m *Manager) CheckInternetConnection() (*ConnectionStatus, error) { - 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") + nets, err := buildNetworkList(dev) if err != nil { - logger.Warning("[NET-041] Could not refresh state for device %s: %v", m.Devices[i].Path, err) - continue + logger.Warning("network: building network list failed: %v", err) + return } - if state, ok := stateVariant.Value().(uint32); ok { - m.Devices[i].State = state + + select { + case ch <- nets: + case <-ctx.Done(): } } - m.mu.Unlock() - } - // Check Ethernet first (Priority) - for _, device := range m.Devices { - if device.Type == TypeEthernet && device.State == nmDeviceStateActivated { - if pingTest() { - return &ConnectionStatus{StatusEthernetConnectedInternetUp, "Ethernet: Online"}, nil + scan() + + for { + select { + case <-ticker.C: + scan() + case <-ctx.Done(): + return } - return &ConnectionStatus{StatusEthernetConnectedNoInternet, "Ethernet: No Internet"}, nil } - } + }() - // Check Wifi - for _, device := range m.Devices { - if device.Type == TypeWifi && device.State == nmDeviceStateActivated { - if pingTest() { - return &ConnectionStatus{StatusWifiConnectedInternetUp, "Wi-Fi: Online"}, nil - } - // Even if ping fails, the state IS activated (connected to AP) - return &ConnectionStatus{StatusWifiConnectedNoInternet, "Wi-Fi: Connected, checking internet..."}, nil - } - } - - return &ConnectionStatus{StatusWifiAvailableNotConnected, "Ready to connect"}, nil + return ch, nil } -// 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 - } - - // Ensure Wi-Fi is enabled - nmObj := m.conn.Object(nmService, nmPath) - - prop, err := nmObj.GetProperty(nmInterface + ".WirelessEnabled") +// ─── ConnectToNetwork ───────────────────────────────────────────────────────── +func ConnectToNetwork(ssid, password string) error { + dev, err := wifiDevice() if err != nil { - errorChan <- fmt.Errorf("could not read WirelessEnabled property: %w", err) - return + return err } - 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 - } - - select { - case <-time.After(3 * time.Second): - case <-stopChan: - errorChan <- ErrScanAborted - return - } - - 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 - } - - processedAPs, err := m.scanAccessPoints(apPaths) + ap, err := findAccessPoint(dev, ssid) if err != nil { - errorChan <- err - return + return fmt.Errorf("network: access point %q not found: %w", ssid, err) } - resultsChan <- processedAPs + security := detectSecurity(ap) + settings, err := buildConnectionSettings(ssid, password, security) + if err != nil { + return err + } + + logger.Info("network: connecting to %q (%s)", ssid, security) + + _, err = nm.AddAndActivateConnection(settings, dev) + if err != nil { + return fmt.Errorf("network: failed to connect to %q: %w", ssid, err) + } + + return waitForConnection(ssid, 15*time.Second) } -// 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) +// ─── ForgetNetwork ──────────────────────────────────────────────────────────── + +func ForgetNetwork(ssid string) error { + s, err := gonm.NewSettings() + if err != nil { + return fmt.Errorf("network: cannot access NM settings: %w", err) + } + + connections, err := s.ListConnections() + if err != nil { + return fmt.Errorf("network: cannot list connections: %w", err) + } + + found := false + for _, conn := range connections { + settings, err := conn.GetSettings() if err != nil { - logger.Warning("[NET-061] Failed to read access point properties for %s: %v", apPath, err) + continue + } + wireless, ok := settings["802-11-wireless"] + if !ok { + continue + } + rawSSID, ok := wireless["ssid"] + if !ok { + continue + } + if string(rawSSID.([]byte)) != ssid { continue } - ssidBytes, ok := props["Ssid"].Value().([]byte) - if !ok || len(ssidBytes) == 0 { - continue - } - - ssidStr := string(ssidBytes) - strength, _ := props["Strength"].Value().(byte) - flags, _ := props["Flags"].Value().(uint32) - wpaFlags, _ := props["WpaFlags"].Value().(uint32) - rsnFlags, _ := props["RsnFlags"].Value().(uint32) - - isProtected := (flags&APFlagsPrivacy != 0) || (wpaFlags != 0) || (rsnFlags != 0) - - // Determine key management type - var keyMgmt string - 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 { - // 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" - } - - currentAP := AccessPoint{ - ObjectPath: apPath, - SSID: ssidStr, - Strength: strength, - IsProtected: isProtected, - KeyMgmt: keyMgmt, - } - if existingAP, ok := tempNetworkMap[ssidStr]; !ok || strength > existingAP.Strength { - tempNetworkMap[ssidStr] = currentAP + found = true + logger.Info("network: forgetting %q", ssid) + if err := conn.Delete(); err != nil { + return fmt.Errorf("network: failed to delete connection %q: %w", ssid, err) } } - networkList := make([]AccessPoint, 0, len(tempNetworkMap)) - for _, ap := range tempNetworkMap { - networkList = append(networkList, ap) + if !found { + return fmt.Errorf("network: no saved connection found for %q", ssid) } - sort.Slice(networkList, func(i, j int) bool { - if networkList[i].Strength != networkList[j].Strength { - return networkList[i].Strength > networkList[j].Strength - } - return strings.ToLower(networkList[i].SSID) < strings.ToLower(networkList[j].SSID) - }) - return networkList, nil -} - -// 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": { - "type": dbus.MakeVariant("802-11-wireless"), - "uuid": dbus.MakeVariant(uuid.NewString()), - "id": dbus.MakeVariant(ap.SSID), - "interface-name": dbus.MakeVariant(wifiDevice.InterfaceName), - "permissions": dbus.MakeVariant([]string{}), // Shared connection - "autoconnect": dbus.MakeVariant(true), - }, - "802-11-wireless": { - "ssid": dbus.MakeVariant([]byte(ap.SSID)), - "mode": dbus.MakeVariant("infrastructure"), - }, - "ipv4": {"method": dbus.MakeVariant("auto")}, - "ipv6": {"method": dbus.MakeVariant("auto")}, - } - - if ap.IsProtected { - settings["802-11-wireless-security"] = map[string]dbus.Variant{ - "key-mgmt": dbus.MakeVariant(ap.KeyMgmt), - "auth-alg": dbus.MakeVariant("open"), - "psk": dbus.MakeVariant(passphrase), - } - // 0 means "The secret is not saved or is managed by an external agent" - // In a kiosk, we want NM to just handle it. - settings["802-11-wireless-security"]["psk-flags"] = dbus.MakeVariant(uint32(0)) - } - - var connectionPath dbus.ObjectPath - var activeConnectionPath dbus.ObjectPath - - // Capture the two return values from AddAndActivateConnection - err := m.nmObject.Call(nmInterface+".AddAndActivateConnection", 0, settings, wifiDevice.Path, ap.ObjectPath).Store(&connectionPath, &activeConnectionPath) - - if err != nil { - // THIS IS THE LOG YOU NEED TO SEE IN SSH - logger.Error("[NET-071] NetworkManager rejected the connection request: %v", err) - return fmt.Errorf("AddAndActivateConnection failed: %w", err) - } - - logger.Info("[NET-072] Connection created successfully. Connection=%s Active=%s", connectionPath, activeConnectionPath) - - // 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 { - 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 +// ─── CurrentNetwork ─────────────────────────────────────────────────────────── + +func CurrentNetwork() (string, error) { + dev, err := wifiDevice() + if err != nil { + return "", err } - timeout := 2 * time.Second + + ap, err := dev.GetPropertyActiveAccessPoint() + if err != nil || ap == nil { + return "", nil + } + + ssid, err := ap.GetPropertySSID() + if err != nil { + return "", err + } + + return ssid, nil +} + +// ─── ListSavedNetworks ──────────────────────────────────────────────────────── + +func ListSavedNetworks() ([]string, error) { + s, err := gonm.NewSettings() + if err != nil { + return nil, fmt.Errorf("network: cannot access NM settings: %w", err) + } + + connections, err := s.ListConnections() + if err != nil { + return nil, fmt.Errorf("network: cannot list connections: %w", err) + } + + var ssids []string + for _, conn := range connections { + settings, err := conn.GetSettings() + if err != nil { + continue + } + wireless, ok := settings["802-11-wireless"] + if !ok { + continue + } + rawSSID, ok := wireless["ssid"] + if !ok { + continue + } + ssids = append(ssids, string(rawSSID.([]byte))) + } + + return ssids, nil +} + +// ─── IsConnected ────────────────────────────────────────────────────────────── + +func IsConnected() (bool, error) { + if nm == nil { + return false, fmt.Errorf("network: NetworkManager not available") + } + + state, err := nm.GetPropertyState() + if err != nil { + return false, fmt.Errorf("network: cannot get NM state: %w", err) + } + + return state >= gonm.NmStateConnectedLocal, nil +} + +// ─── HasInternet ────────────────────────────────────────────────────────────── + +func HasInternet() bool { + targets := []string{ + "1.1.1.1:53", + "1.0.0.1:53", + "8.8.8.8:53", + "9.9.9.9:53", + "8.8.4.4:53", + "google.com:443", + "cloudflare.com:443", + } + + results := make(chan bool, len(targets)) for _, target := range targets { - conn, err := net.DialTimeout("tcp", target, timeout) - if err == nil { - conn.Close() + go func(addr string) { + conn, err := net.DialTimeout("tcp", addr, 2*time.Second) + if err == nil { + conn.Close() + results <- true + } else { + results <- false + } + }(target) + } + + for range targets { + if <-results { 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 { - return nil, call.Err +// ─── WatchState ─────────────────────────────────────────────────────────────── + +func WatchState(ctx context.Context) (<-chan bool, error) { + if nm == nil { + return nil, fmt.Errorf("network: NetworkManager not available") } - if err := call.Store(&props); err != nil { - return nil, err - } - return props, nil -} -// WaitForInternet blocks until CheckInternetConnection reports Internet up, -// 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 - var upSince time.Time + ch := make(chan bool, 1) - // Reasonable poll cadence; NM typically completes within a few seconds. - const poll = 500 * time.Millisecond + go func() { + defer close(ch) - for time.Now().Before(deadline) { - st, err := m.CheckInternetConnection() - if err != nil { - lastErr = err - lastStatus = &ConnectionStatus{StatusError, "NetworkManager not ready"} - upSince = time.Time{} // reset stability - } else { - lastStatus = st - switch st.StatusCode { - case StatusEthernetConnectedInternetUp, StatusWifiConnectedInternetUp: - if upSince.IsZero() { - upSince = time.Now() + ticker := time.NewTicker(3 * time.Second) + defer ticker.Stop() + + var last *bool + + for { + select { + case <-ticker.C: + connected, err := IsConnected() + if err != nil { + logger.Warning("network: WatchState poll failed: %v", err) + continue } - if time.Since(upSince) >= stableFor { - return st, nil // stable "up" + if last == nil || *last != connected { + last = &connected + select { + case ch <- connected: + case <-ctx.Done(): + return + } } - default: - // not up; keep waiting - upSince = time.Time{} + case <-ctx.Done(): + return } } - time.Sleep(poll) + }() + + return ch, nil +} + +// ─── Internal helpers ───────────────────────────────────────────────────────── + +func wifiDevice() (gonm.DeviceWireless, error) { + if nm == nil { + return nil, fmt.Errorf("network: NetworkManager not available") } - // Timed out; return the last observed status and error (if any). - if lastStatus == nil { - lastStatus = &ConnectionStatus{StatusError, "Timed out waiting for connectivity"} + devices, err := nm.GetPropertyAllDevices() + if err != nil { + return nil, fmt.Errorf("network: cannot list devices: %w", err) } - if lastErr != nil { - return lastStatus, fmt.Errorf("timeout waiting for internet: %w", lastErr) + + for _, dev := range devices { + devType, err := dev.GetPropertyDeviceType() + if err != nil { + continue + } + if devType == gonm.NmDeviceTypeWifi { + return gonm.NewDeviceWireless(dev.GetPath()) + } } - return lastStatus, nil + + return nil, fmt.Errorf("network: no WiFi device found") } + +func findAccessPoint(dev gonm.DeviceWireless, ssid string) (gonm.AccessPoint, error) { + aps, err := dev.GetPropertyAccessPoints() + if err != nil { + return nil, err + } + + for _, ap := range aps { + apSSID, err := ap.GetPropertySSID() + if err != nil { + continue + } + if apSSID == ssid { + return ap, nil + } + } + + return nil, fmt.Errorf("not found") +} + +func detectSecurity(ap gonm.AccessPoint) SecurityType { + flags, _ := ap.GetPropertyFlags() + wpaFlags, _ := ap.GetPropertyWPAFlags() + rsnFlags, _ := ap.GetPropertyRSNFlags() + + if rsnFlags != 0 { + if rsnFlags&0x100 != 0 { + return SecurityWPA3 + } + return SecurityWPA2 + } + if wpaFlags != 0 { + return SecurityWPA + } + if flags&0x1 != 0 { + return SecurityWEP + } + + return SecurityOpen +} + +func buildConnectionSettings(ssid, password string, security SecurityType) (map[string]map[string]interface{}, error) { + settings := map[string]map[string]interface{}{ + "connection": { + "id": ssid, + "type": "802-11-wireless", + }, + "802-11-wireless": { + "ssid": []byte(ssid), + "mode": "infrastructure", + }, + "ipv4": {"method": "auto"}, + "ipv6": {"method": "auto"}, + } + + switch security { + case SecurityOpen: + // nothing + + case SecurityWEP: + if password == "" { + return nil, fmt.Errorf("network: WEP network requires a password") + } + settings["802-11-wireless-security"] = map[string]interface{}{ + "key-mgmt": "none", + "wep-key0": password, + "wep-key-type": uint32(1), + } + + case SecurityWPA, SecurityWPA2: + if password == "" { + return nil, fmt.Errorf("network: WPA/WPA2 network requires a password") + } + settings["802-11-wireless-security"] = map[string]interface{}{ + "key-mgmt": "wpa-psk", + "psk": password, + } + + case SecurityWPA3: + if password == "" { + return nil, fmt.Errorf("network: WPA3 network requires a password") + } + settings["802-11-wireless-security"] = map[string]interface{}{ + "key-mgmt": "sae", + "psk": password, + } + } + + return settings, nil +} + +func buildNetworkList(dev gonm.DeviceWireless) ([]Network, error) { + aps, err := dev.GetPropertyAccessPoints() + if err != nil { + return nil, err + } + + activeAP, _ := dev.GetPropertyActiveAccessPoint() + var activeSSID string + if activeAP != nil { + activeSSID, _ = activeAP.GetPropertySSID() + } + + saved, _ := ListSavedNetworks() + savedSet := make(map[string]bool, len(saved)) + for _, s := range saved { + savedSet[s] = true + } + + var networks []Network + seen := make(map[string]bool) + + for _, ap := range aps { + ssid, err := ap.GetPropertySSID() + if err != nil || ssid == "" { + continue + } + if seen[ssid] { + continue + } + seen[ssid] = true + + strength, _ := ap.GetPropertyStrength() + + networks = append(networks, Network{ + SSID: ssid, + SignalStrength: strength, + Security: detectSecurity(ap), + Saved: savedSet[ssid], + Connected: ssid == activeSSID, + }) + } + + return networks, nil +} + +func waitForConnection(ssid string, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + current, err := CurrentNetwork() + if err == nil && current == ssid { + return nil + } + time.Sleep(500 * time.Millisecond) + } + return fmt.Errorf("network: timed out waiting for connection to %q", ssid) +} + +var internetCheckURL = "https://clients3.google.com/generate_204" + +func httpCheck() bool { + client := http.Client{Timeout: 3 * time.Second} + resp, err := client.Get(internetCheckURL) + if err != nil { + return false + } + resp.Body.Close() + return resp.StatusCode == 204 +} + +var _ = httpCheck diff --git a/internal/page/page.go b/internal/page/page.go index b3c0e65..b3dfb81 100644 --- a/internal/page/page.go +++ b/internal/page/page.go @@ -7,6 +7,7 @@ import ( "github.com/go-gl/gl/v3.3-core/gl" "github.com/go-gl/mathgl/mgl32" "github.com/veandco/go-sdl2/sdl" + "rhemawavetech.com/rhemawave/internal/logger" "rhemawavetech.com/rhemawave/internal/shaderManager" ) @@ -184,3 +185,54 @@ func (p *Base) RenderSolidColorQuad(shader *shaderManager.Shader, position, size func (p *Base) HandleEvent(event sdl.Event) error { return nil } func (p *Base) Update(dt float32) error { return nil } func (p *Base) Render() error { return nil } + +// --- Helper Functions --- +// keycodeToChar converts an SDL keycode to a rune, respecting SHIFT state. +// Returns 0 for non-printable keys. +func keycodeToChar(sym sdl.Keycode, mod uint16) rune { + shifted := mod&sdl.KMOD_SHIFT != 0 + + // Letters + if sym >= sdl.K_a && sym <= sdl.K_z { + ch := rune(sym) + if shifted { + ch -= 'a' - 'A' + } + logger.Info("Wifi Password entered: %v", ch) + return ch + } + + // Digits and their shifted symbols + if sym >= sdl.K_0 && sym <= sdl.K_9 { + if shifted { + shiftDigits := []rune{')', '!', '@', '#', '$', '%', '^', '&', '*', '('} + return shiftDigits[sym-sdl.K_0] + } + return rune(sym) + } + + // Common punctuation / symbols + type charPair struct{ normal, shifted rune } + punctuation := map[sdl.Keycode]charPair{ + sdl.K_SPACE: {' ', ' '}, + sdl.K_MINUS: {'-', '_'}, + sdl.K_EQUALS: {'=', '+'}, + sdl.K_LEFTBRACKET: {'[', '{'}, + sdl.K_RIGHTBRACKET: {']', '}'}, + sdl.K_BACKSLASH: {'\\', '|'}, + sdl.K_SEMICOLON: {';', ':'}, + sdl.K_QUOTE: {'\'', '"'}, + sdl.K_BACKQUOTE: {'`', '~'}, + sdl.K_COMMA: {',', '<'}, + sdl.K_PERIOD: {'.', '>'}, + sdl.K_SLASH: {'/', '?'}, + } + if pair, ok := punctuation[sym]; ok { + if shifted { + return pair.shifted + } + return pair.normal + } + + return 0 +} diff --git a/internal/page/wifiSetupPage.go b/internal/page/wifiSetupPage.go index ce5f13a..a74118f 100644 --- a/internal/page/wifiSetupPage.go +++ b/internal/page/wifiSetupPage.go @@ -1,12 +1,11 @@ package page import ( + "context" "fmt" - "time" "github.com/go-gl/gl/v3.3-core/gl" "github.com/go-gl/mathgl/mgl32" - "github.com/godbus/dbus/v5" "github.com/veandco/go-sdl2/sdl" "rhemawavetech.com/rhemawave/internal/colors" "rhemawavetech.com/rhemawave/internal/fontManager" @@ -16,19 +15,30 @@ import ( "rhemawavetech.com/rhemawave/internal/shaderManager" ) -// accessPointUIItem holds both the network data and its rendered texture. -type accessPointUIItem struct { - AP network.AccessPoint +// ─── Types ──────────────────────────────────────────────────────────────────── + +type networkUIItem struct { + Net network.Network Item StringItem } -// WiFiSetupPage manages the discovery and connection to Wi-Fi networks. +type wifiStatus int + +const ( + wifiStatusIdle wifiStatus = iota + wifiStatusScanning + wifiStatusConnecting + wifiStatusConnected + wifiStatusError +) + +// ─── Page ───────────────────────────────────────────────────────────────────── + type WiFiSetupPage struct { Base - networkManager *network.Manager applicationFont *fontManager.FontEntry - // Page-specific UI elements + // UI elements titleItem StringItem scanningStatusItem StringItem connectionStatusItem StringItem @@ -36,669 +46,461 @@ type WiFiSetupPage struct { passwordInputItem StringItem passwordErrorItem StringItem - // Network list and selection - accessPoints []accessPointUIItem + // Network list + networks []networkUIItem selectedIndex int - scrollOffset int - // State management - isScanning bool - isEnteringPassword bool - isConnecting bool - connectionError string - passwordInput string - selectedAP *network.AccessPoint - wifiDevice *network.Device - connectionStatus *network.ConnectionStatus - activeSSID string // Tracks the CONFIRMED active network - pendingSSID string // Tracks the network we are TRYING to connect to - connectionAttemptTime time.Time - connectionTimeout time.Duration + // State + status wifiStatus + statusMessage string + connectError string - // Text input deduplication - textInputSupported bool + // Password entry + isEnteringPassword bool + passwordInput string + selectedNet *network.Network - // Async management - resultsChan chan []network.AccessPoint - statusChan chan *network.ConnectionStatus - errorChan chan error - stopScan chan struct{} + // Async + ctx context.Context + cancel context.CancelFunc + networksCh <-chan []network.Network + connectResultCh chan error } -// Init sets up the page, initializes the network manager, and starts a scan. -func (p *WiFiSetupPage) Init(ctx *PageCtx) error { +// ─── Init ───────────────────────────────────────────────────────────────────── +func (p *WiFiSetupPage) Init(ctx *PageCtx) error { if err := p.Base.Init(ctx); err != nil { return fmt.Errorf("failed base page initialization: %w", err) } - // Get application font from database - applicationFontName := "Roboto-Regular" - fm := fontManager.Get() - font, ok := fm.GetFont(applicationFontName) + font, ok := fm.GetFont("Roboto-Regular") if !ok { - return fmt.Errorf("could not find application font: %s", applicationFontName) + return fmt.Errorf("could not find application font: Roboto-Regular") } p.applicationFont = font - // Initialize network manager - nm, err := network.New() - if err != nil { - return fmt.Errorf("failed to initialize network manager: %w", err) - } - p.networkManager = nm + var err 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) - } - - // 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 := colors.White - p.titleItem, err = NewStringItem("Wi-Fi Setup", p.applicationFont, white) + p.titleItem, err = NewStringItem("Wi-Fi Setup", p.applicationFont, colors.White) if err != nil { return fmt.Errorf("failed to create title item: %w", err) } - p.scanningStatusItem, err = NewStringItem("Scanning for networks...", p.applicationFont, white) + p.scanningStatusItem, err = NewStringItem("Scanning for networks...", p.applicationFont, colors.White) if err != nil { return fmt.Errorf("failed to create scanning status item: %w", err) } - // Initialize channels and timeout - p.resultsChan = make(chan []network.AccessPoint) - p.statusChan = make(chan *network.ConnectionStatus, 1) - p.errorChan = make(chan error) - p.stopScan = make(chan struct{}) - p.connectionTimeout = 30 * time.Second + p.connectResultCh = make(chan error, 1) + p.ctx, p.cancel = context.WithCancel(context.Background()) - // Start scanning and status monitoring p.startScan() return nil } -// checkConnectionStatusLoop periodically checks the network status. -func (p *WiFiSetupPage) checkConnectionStatusLoop() { - ticker := time.NewTicker(2 * time.Second) // Check more frequently for better UX - defer ticker.Stop() +// ─── Scan ───────────────────────────────────────────────────────────────────── - status, _ := p.networkManager.CheckInternetConnection() - p.statusChan <- status - - for { - select { - case <-ticker.C: - status, _ := p.networkManager.CheckInternetConnection() - p.statusChan <- status - case <-p.stopScan: - return - } - } -} - -// 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.status = wifiStatusScanning + p.statusMessage = "Scanning for networks..." p.selectedIndex = 0 - p.scrollOffset = 0 - p.accessPoints = nil + p.networks = nil - if p.wifiDevice != nil { - go p.networkManager.StartScan(p.resultsChan, p.errorChan, p.stopScan) - go p.checkConnectionStatusLoop() - } else { - p.isScanning = false + var err error + p.networksCh, err = network.ScanForNetworks(p.ctx) + if err != nil { + logger.Error("wifi: failed to start scan: %v", err) + p.status = wifiStatusError + p.statusMessage = "Failed to start network scan. No Wi-Fi adapter found." } } -// HandleEvent processes user input. +// ─── Input ──────────────────────────────────────────────────────────────────── + func (p *WiFiSetupPage) HandleEvent(event *sdl.Event) error { switch e := (*event).(type) { + case *sdl.TextInputEvent: + // SDL fires TextInputEvent for every printable character typed. + // This is the correct SDL2 way — no manual keycode mapping needed. if p.isEnteringPassword { - p.passwordInput += e.GetText() // Always append text - p.textInputSupported = true // Mark as supported - p.updatePasswordTexture() + p.passwordInput += e.GetText() + p.rebuildPasswordTexture() } case *sdl.KeyboardEvent: if e.Type != sdl.KEYDOWN { return nil } - if p.isEnteringPassword { - p.handlePasswordInput(e.Keysym.Sym, e.Keysym.Mod) + p.handlePasswordKey(e.Keysym.Sym) } else { - p.handleNetworkSelection(e.Keysym.Sym) + p.handleNavKey(e.Keysym.Sym) } } return nil } -// handlePasswordInput manages keyboard events during password entry. -// Update the signature to include mod -// handlePasswordInput manages keyboard events during password entry. -func (p *WiFiSetupPage) handlePasswordInput(sym sdl.Keycode, mod uint16) { +func (p *WiFiSetupPage) handlePasswordKey(sym sdl.Keycode) { switch sym { - case sdl.K_RETURN: - p.exitPasswordMode() - - p.pendingSSID = p.selectedAP.SSID - p.isConnecting = true - p.connectionError = "" - p.connectionAttemptTime = time.Now() - - err := p.networkManager.Connect(*p.wifiDevice, *p.selectedAP, p.passwordInput) - if err != nil { - p.isConnecting = false - p.pendingSSID = "" - p.connectionError = "Failed to initiate connection" - } - - p.isEnteringPassword = false + p.submitPassword() case sdl.K_BACKSPACE: if len(p.passwordInput) > 0 { - p.passwordInput = p.passwordInput[:len(p.passwordInput)-1] - logger.Info("WiFi Password backspace pressed") - p.updatePasswordTexture() + // Trim one rune safely + runes := []rune(p.passwordInput) + p.passwordInput = string(runes[:len(runes)-1]) + p.rebuildPasswordTexture() } case sdl.K_ESCAPE: - logger.Info("WiFi Password entry cancelled") - p.isEnteringPassword = false - p.pendingSSID = "" - p.connectionError = "" + logger.Info("wifi: password entry cancelled") p.exitPasswordMode() - - default: - // FALLBACK: If the kiosk environment doesn't fire TextInputEvents, - // manually convert keycodes to characters. - if !p.textInputSupported { - if ch := keycodeToChar(sym, mod); ch != 0 { - p.passwordInput += string(ch) - logger.Info("Manual character fallback: %c", ch) - p.updatePasswordTexture() - } - } + p.connectError = "" + p.selectedNet = nil } } -// enterPasswordMode disables global keybinds and starts SDL text input. -func (p *WiFiSetupPage) enterPasswordMode() { - keybinds.Enabled = false - - p.passwordInput = "" - - logger.Info("WiFi Password entry started") - - sdl.StartTextInput() -} - -// exitPasswordMode re-enables global keybinds and stops SDL text input. -func (p *WiFiSetupPage) exitPasswordMode() { - logger.Info("WiFi Password entry finished") - - keybinds.Enabled = true - sdl.StopTextInput() -} - -// keycodeToChar converts an SDL keycode to a rune, respecting SHIFT state. -// Returns 0 for non-printable keys. -func keycodeToChar(sym sdl.Keycode, mod uint16) rune { - shifted := mod&sdl.KMOD_SHIFT != 0 - - // Letters - if sym >= sdl.K_a && sym <= sdl.K_z { - ch := rune(sym) - if shifted { - ch -= 'a' - 'A' - } - logger.Info("Wifi Password entered: %v", ch) - return ch - } - - // Digits and their shifted symbols - if sym >= sdl.K_0 && sym <= sdl.K_9 { - if shifted { - shiftDigits := []rune{')', '!', '@', '#', '$', '%', '^', '&', '*', '('} - return shiftDigits[sym-sdl.K_0] - } - return rune(sym) - } - - // Common punctuation / symbols - type charPair struct{ normal, shifted rune } - punctuation := map[sdl.Keycode]charPair{ - sdl.K_SPACE: {' ', ' '}, - sdl.K_MINUS: {'-', '_'}, - sdl.K_EQUALS: {'=', '+'}, - sdl.K_LEFTBRACKET: {'[', '{'}, - sdl.K_RIGHTBRACKET: {']', '}'}, - sdl.K_BACKSLASH: {'\\', '|'}, - sdl.K_SEMICOLON: {';', ':'}, - sdl.K_QUOTE: {'\'', '"'}, - sdl.K_BACKQUOTE: {'`', '~'}, - sdl.K_COMMA: {',', '<'}, - sdl.K_PERIOD: {'.', '>'}, - sdl.K_SLASH: {'/', '?'}, - } - if pair, ok := punctuation[sym]; ok { - if shifted { - return pair.shifted - } - return pair.normal - } - - return 0 -} - -// handleNetworkSelection manages keyboard events for the network list. -func (p *WiFiSetupPage) handleNetworkSelection(sym sdl.Keycode) { +func (p *WiFiSetupPage) handleNavKey(sym sdl.Keycode) { switch sym { case sdl.K_UP: if p.selectedIndex > 0 { p.selectedIndex-- } + case sdl.K_DOWN: - if p.selectedIndex < len(p.accessPoints)-1 { + if p.selectedIndex < len(p.networks)-1 { p.selectedIndex++ } + 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 // Set for ALL networks (protected or not) - p.connectionError = "" - if p.selectedAP.IsProtected { - p.isEnteringPassword = true - p.passwordInput = "" - p.updatePasswordTexture() - p.updatePasswordPromptTexture() - p.enterPasswordMode() - } else { - p.isConnecting = true - p.connectionAttemptTime = time.Now() - err := p.networkManager.Connect(*p.wifiDevice, *p.selectedAP, "") - if err != nil { - p.isConnecting = false - p.pendingSSID = "" - p.connectionError = fmt.Sprintf("Failed to connect: %s", err.Error()) // Include actual error - } - } + if len(p.networks) == 0 || p.selectedIndex >= len(p.networks) { + return } + selected := p.networks[p.selectedIndex].Net + p.selectedNet = &selected + p.connectError = "" + + if selected.Security != network.SecurityOpen { + p.passwordInput = "" + p.enterPasswordMode() + } else { + p.connect("") + } + case sdl.K_r: - if !p.isScanning { + if p.status != wifiStatusScanning && p.status != wifiStatusConnecting { p.startScan() } + + case sdl.K_ESCAPE: + p.selectedNet = nil + p.connectError = "" } } -// Update checks for async results and updates UI state. +func (p *WiFiSetupPage) enterPasswordMode() { + keybinds.Enabled = false + p.isEnteringPassword = true + p.rebuildPasswordPromptTexture() + p.rebuildPasswordTexture() + sdl.StartTextInput() + logger.Info("wifi: password entry started for %q", p.selectedNet.SSID) +} + +func (p *WiFiSetupPage) exitPasswordMode() { + keybinds.Enabled = true + p.isEnteringPassword = false + sdl.StopTextInput() + logger.Info("wifi: password entry finished") +} + +func (p *WiFiSetupPage) submitPassword() { + p.exitPasswordMode() + p.connect(p.passwordInput) +} + +// ─── Connect ────────────────────────────────────────────────────────────────── + +func (p *WiFiSetupPage) connect(password string) { + if p.selectedNet == nil { + return + } + + ssid := p.selectedNet.SSID + p.status = wifiStatusConnecting + p.statusMessage = fmt.Sprintf("Connecting to \"%s\"...", ssid) + logger.Info("wifi: connecting to %q", ssid) + + // ConnectToNetwork blocks (up to 15s), so run it off the main goroutine. + go func() { + err := network.ConnectToNetwork(ssid, password) + p.connectResultCh <- err + }() +} + +// ─── Update ─────────────────────────────────────────────────────────────────── + func (p *WiFiSetupPage) Update(dt float32) error { select { - case results := <-p.resultsChan: - p.isScanning = false - p.buildNetworkUIList(results) - case status := <-p.statusChan: - p.connectionStatus = status - p.handleConnectionStatusUpdate(status) - p.updateConnectionStatusItem() - case err := <-p.errorChan: - p.isScanning = false - fmt.Printf("error scannign for networks %v", err) + + case nets, ok := <-p.networksCh: + if ok { + p.rebuildNetworkList(nets) + if p.status == wifiStatusScanning { + p.status = wifiStatusIdle + p.statusMessage = "" + } + } + + case err := <-p.connectResultCh: + if err != nil { + logger.Error("wifi: connection failed: %v", err) + p.status = wifiStatusError + p.connectError = fmt.Sprintf("Could not connect: %s", err.Error()) + p.statusMessage = "" + + // Drop back into password entry so the user can try again. + if p.selectedNet != nil && p.selectedNet.Security != network.SecurityOpen { + p.passwordInput = "" + p.rebuildPasswordErrorTexture() + p.enterPasswordMode() + } + } else { + ssid := "" + if p.selectedNet != nil { + ssid = p.selectedNet.SSID + } + logger.Info("wifi: connected to %q", ssid) + p.status = wifiStatusConnected + p.connectError = "" + p.statusMessage = fmt.Sprintf("Connected to \"%s\" — press F1 to continue", ssid) + // Trigger a fresh scan so the list shows the connected state. + p.startScan() + } + default: } - // Check for connection timeout - if p.isConnecting && time.Since(p.connectionAttemptTime) > p.connectionTimeout { - p.isConnecting = false - p.connectionError = "Connection timed out - Incorrect password?" - if p.selectedAP != nil && p.selectedAP.IsProtected { - p.isEnteringPassword = true - p.passwordInput = "" - p.updatePasswordTexture() - p.updatePasswordPromptTexture() - p.updatePasswordErrorTexture() - p.enterPasswordMode() - } - } + p.rebuildStatusTexture() return nil } -// handleConnectionStatusUpdate CHECKS0 if connection succeeded or failed -func (p *WiFiSetupPage) handleConnectionStatusUpdate(status *network.ConnectionStatus) { - if !p.isConnecting || p.pendingSSID == "" { - return - } +// ─── Texture builders ───────────────────────────────────────────────────────── - // 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 isConnectedToPending { - activeSSID := p.getActiveSSID() - 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 = "" - p.connectionError = "" - p.refreshNetworkColors() - return +func (p *WiFiSetupPage) rebuildNetworkList(nets []network.Network) { + for _, item := range p.networks { + if item.Item.ID != 0 { + gl.DeleteTextures(1, &item.Item.ID) } } - // Handle failure: If we've been trying for too long, show error - if status.StatusCode == network.StatusWifiAvailableNotConnected { - if time.Since(p.connectionAttemptTime) > 20*time.Second { - 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() - } + p.networks = make([]networkUIItem, 0, len(nets)) + + for _, n := range nets { + label := n.SSID + switch { + case n.Connected: + label += " ✓" + case n.Saved: + label += " ★" } - } -} - -// getAiveSSID queries NetworkManager for the currently active WiFi SSID -func (p *WiFiSetupPage) getActiveSSID() string { - if p.networkManager == nil || p.wifiDevice == nil { - return "" - } - - conn := p.networkManager.Conn() - - devObj := conn.Object("org.freedesktop.NetworkManager", p.wifiDevice.Path) - activeApProp, err := devObj.GetProperty("org.freedesktop.NetworkManager.Device.Wireless.ActiveAccessPoint") - if err != nil { - return "" - } - - activeApPath, ok := activeApProp.Value().(dbus.ObjectPath) - if !ok || activeApPath == "/" || activeApPath == "" { - return "" - } - - apObj := conn.Object("org.freedesktop.NetworkManager", activeApPath) - ssidProp, err := apObj.GetProperty("org.freedesktop.NetworkManager.AccessPoint.Ssid") - if err != nil { - return "" - } - - ssidBytes, ok := ssidProp.Value().([]byte) - if !ok { - return "" - } - - logger.Info("Active SSID: %s", string(ssidBytes)) - - return string(ssidBytes) -} - -// refreshNetworkColors updates the colors of network items based on active status -func (p *WiFiSetupPage) refreshNetworkColors() { - activeSSID := p.getActiveSSID() - p.activeSSID = activeSSID - - for i := range p.accessPoints { - if p.accessPoints[i].AP.SSID == activeSSID { - p.accessPoints[i].Item.Color = colors.LawnGreen - } else { - p.accessPoints[i].Item.Color = colors.White - } - } -} - -// updateConnectionStatusItem creates the colored status message item. -func (p *WiFiSetupPage) updateConnectionStatusItem() { - if p.connectionStatusItem.ID != 0 { - gl.DeleteTextures(1, &p.connectionStatusItem.ID) - } - - // Show connecting message if in progress - if p.isConnecting { - message := fmt.Sprintf("Connecting to %s...", p.pendingSSID) - item, err := NewStringItem(message, p.applicationFont, colors.Yellow) - if err == nil { - p.connectionStatusItem = item - } - return - } - - // Show connection error if present - if p.connectionError != "" { - item, err := NewStringItem(p.connectionError, p.applicationFont, colors.Red) - if err == nil { - p.connectionStatusItem = item - } - return - } - - if p.connectionStatus == nil { - return - } - - switch p.connectionStatus.StatusCode { - case network.StatusWifiConnectedInternetUp: - message := fmt.Sprintf("Connected to %s - Internet accessible | F1 to exit", p.activeSSID) - item, err := NewStringItem(message, p.applicationFont, colors.LawnGreen) - if err == nil { - p.connectionStatusItem = item - } - p.refreshNetworkColors() - - case network.StatusWifiAvailableNotConnected: - message := "WiFi available - Not connected | Select network or press R to rescan" - item, err := NewStringItem(message, p.applicationFont, colors.Yellow) - if err == nil { - p.connectionStatusItem = item + if n.Security != network.SecurityOpen { + label += " [" + string(n.Security) + "]" } - case network.StatusEthernetConnectedInternetUp: - message := "Ethernet connected - Internet accessible | F1 to exit" - item, err := NewStringItem(message, p.applicationFont, colors.LawnGreen) - if err == nil { - p.connectionStatusItem = item + c := colors.White + if n.Connected { + c = colors.LawnGreen } - case network.StatusEthernetNotConnected: - message := "No network connection | Select WiFi network or press R to rescan" - item, err := NewStringItem(message, p.applicationFont, colors.Red) - if err == nil { - p.connectionStatusItem = item - } - - case network.StatusNoDevicesFound: - message := "No network devices found" - item, err := NewStringItem(message, p.applicationFont, colors.Red) - if err == nil { - p.connectionStatusItem = item - } - - case network.StatusError: - message := "Network status check failed" - item, err := NewStringItem(message, p.applicationFont, colors.Red) - if err == nil { - p.connectionStatusItem = item - } - - default: - item, err := NewStringItem(p.connectionStatus.Message, p.applicationFont, colors.White) - if err == nil { - p.connectionStatusItem = item - } - } -} - -// 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 - - // Set IsActive flag based on the actual active SSID - for i := range aps { - aps[i].IsActive = (aps[i].SSID == p.activeSSID) - } - - p.accessPoints = make([]accessPointUIItem, len(aps)) - white := sdl.Color{R: 255, G: 255, B: 255, A: 255} - - for i, ap := range aps { - ssid := ap.SSID - if ap.IsProtected { - ssid += " [L]" - } - color := white - if ap.IsActive { - color = colors.LawnGreen - } - item, err := NewStringItem(ssid, p.applicationFont, color) + item, err := NewStringItem(label, p.applicationFont, c) if err != nil { + logger.Warning("wifi: failed to create item for %q: %v", n.SSID, err) continue } - p.accessPoints[i] = accessPointUIItem{AP: ap, Item: item} + p.networks = append(p.networks, networkUIItem{Net: n, Item: item}) } } -// updatePasswordTexture regenerates the texture for the password input field. -func (p *WiFiSetupPage) updatePasswordTexture() { +func (p *WiFiSetupPage) rebuildStatusTexture() { + if p.connectionStatusItem.ID != 0 { + gl.DeleteTextures(1, &p.connectionStatusItem.ID) + p.connectionStatusItem.ID = 0 + } + + var msg string + var c sdl.Color + + switch p.status { + case wifiStatusScanning: + msg = "Scanning for nearby networks, please wait..." + c = colors.White + case wifiStatusConnecting: + msg = p.statusMessage + c = colors.Yellow + case wifiStatusConnected: + msg = p.statusMessage + c = colors.LawnGreen + case wifiStatusError: + msg = p.connectError + if msg == "" { + msg = "Something went wrong. Press R to scan again." + } + c = colors.Red + default: + if len(p.networks) == 0 { + msg = "No networks found. Press R to scan again." + c = colors.Yellow + } else { + msg = "Select a network to connect — press R to rescan" + c = colors.White + } + } + + if msg == "" { + return + } + + item, err := NewStringItem(msg, p.applicationFont, c) + if err == nil { + p.connectionStatusItem = item + } +} + +func (p *WiFiSetupPage) rebuildPasswordTexture() { if p.passwordInputItem.ID != 0 { gl.DeleteTextures(1, &p.passwordInputItem.ID) } - maskedPassword := p.passwordInput - if maskedPassword == "" { - maskedPassword = " " + text := p.passwordInput + if text == "" { + text = " " // prevent empty texture } - item, err := NewStringItem(maskedPassword, p.applicationFont, sdl.Color{R: 255, G: 255, B: 255, A: 255}) + item, err := NewStringItem(text, p.applicationFont, colors.White) if err == nil { p.passwordInputItem = item } } -// updatePasswordPromptTexture creates the texture for the password prompt label. -func (p *WiFiSetupPage) updatePasswordPromptTexture() { +func (p *WiFiSetupPage) rebuildPasswordPromptTexture() { if p.passwordPromptItem.ID != 0 { gl.DeleteTextures(1, &p.passwordPromptItem.ID) } - prompt := fmt.Sprintf("Password for %s:", p.selectedAP.SSID) - item, err := NewStringItem(prompt, p.applicationFont, sdl.Color{R: 255, G: 255, B: 255, A: 255}) + if p.selectedNet == nil { + return + } + prompt := fmt.Sprintf("Enter password for \"%s\":", p.selectedNet.SSID) + item, err := NewStringItem(prompt, p.applicationFont, colors.White) if err == nil { p.passwordPromptItem = item } } -// updatePasswordErrorTexture creates the texture for password error message. -func (p *WiFiSetupPage) updatePasswordErrorTexture() { +func (p *WiFiSetupPage) rebuildPasswordErrorTexture() { if p.passwordErrorItem.ID != 0 { gl.DeleteTextures(1, &p.passwordErrorItem.ID) + p.passwordErrorItem.ID = 0 } - if p.connectionError != "" { - item, err := NewStringItem(p.connectionError, p.applicationFont, colors.DarkRed) - if err == nil { - p.passwordErrorItem = item - } + if p.connectError == "" { + return + } + item, err := NewStringItem(p.connectError, p.applicationFont, colors.Red) + if err == nil { + p.passwordErrorItem = item } } -// Render draws the entire page. -func (p *WiFiSetupPage) Render() error { - // Get base font size from database - baseFontSize := p.Context.CfgPtr.StandardFontSize +// ─── Render ─────────────────────────────────────────────────────────────────── - // Colors & Config - bgColor := sdl.Color{R: 0, G: 0, B: 0, A: 255} - selectionColor := sdl.Color{R: 68, G: 71, B: 90, A: 255} - white := sdl.Color{R: 255, G: 255, B: 255, A: 255} - verticalMargin := float32(20) +func (p *WiFiSetupPage) Render() error { + baseFontSize := p.Context.CfgPtr.StandardFontSize solidShader, _ := shaderManager.Get().Get("solid_color") textShader, _ := shaderManager.Get().Get("text") - // Background - p.Base.RenderSolidColorQuad(solidShader, mgl32.Vec2{0, 0}, mgl32.Vec2{float32(p.ScreenWidth), float32(p.ScreenHeight)}, bgColor) + bgColor := sdl.Color{R: 0, G: 0, B: 0, A: 255} + selColor := sdl.Color{R: 68, G: 71, B: 90, A: 255} + white := colors.White + vertMargin := float32(20) - // Title + // Background + p.Base.RenderSolidColorQuad(solidShader, + mgl32.Vec2{0, 0}, + mgl32.Vec2{float32(p.ScreenWidth), float32(p.ScreenHeight)}, + bgColor, + ) + + // Title — 48pt titleScale := float32(48) / float32(baseFontSize) titleW := float32(p.titleItem.W) * titleScale titleH := float32(p.titleItem.H) * titleScale - titleX := float32(p.ScreenCenterX) - (titleW / 2) + titleX := float32(p.ScreenCenterX) - titleW/2 titleY := float32(p.ScreenHeight) - titleH - 40 - p.Base.RenderTexture(textShader, p.titleItem.ID, mgl32.Vec2{titleX, titleY}, mgl32.Vec2{titleW, titleH}, white) + p.Base.RenderTexture(textShader, p.titleItem.ID, + mgl32.Vec2{titleX, titleY}, + mgl32.Vec2{titleW, titleH}, + white, + ) - // Status message - statusY := titleY - titleH - verticalMargin - if p.isScanning { - statusScale := float32(24) / float32(baseFontSize) - statusW := float32(p.scanningStatusItem.W) * statusScale - statusH := float32(p.scanningStatusItem.H) * statusScale - statusX := float32(p.ScreenCenterX) - (statusW / 2) - p.Base.RenderTexture(textShader, p.scanningStatusItem.ID, mgl32.Vec2{statusX, statusY}, mgl32.Vec2{statusW, statusH}, white) + // Status message — 30pt (was 24pt, now 1.25x) + statusY := titleY - titleH - vertMargin + if p.status == wifiStatusScanning { + scale := float32(30) / float32(baseFontSize) + w := float32(p.scanningStatusItem.W) * scale + h := float32(p.scanningStatusItem.H) * scale + x := float32(p.ScreenCenterX) - w/2 + p.Base.RenderTexture(textShader, p.scanningStatusItem.ID, + mgl32.Vec2{x, statusY}, + mgl32.Vec2{w, h}, + white, + ) } else if p.connectionStatusItem.ID != 0 { - statusScale := float32(24) / float32(baseFontSize) - statusW := float32(p.connectionStatusItem.W) * statusScale - statusH := float32(p.connectionStatusItem.H) * statusScale - statusX := float32(p.ScreenCenterX) - (statusW / 2) - p.Base.RenderTexture(textShader, p.connectionStatusItem.ID, mgl32.Vec2{statusX, statusY}, mgl32.Vec2{statusW, statusH}, p.connectionStatusItem.Color) + scale := float32(30) / float32(baseFontSize) + w := float32(p.connectionStatusItem.W) * scale + h := float32(p.connectionStatusItem.H) * scale + x := float32(p.ScreenCenterX) - w/2 + p.Base.RenderTexture(textShader, p.connectionStatusItem.ID, + mgl32.Vec2{x, statusY}, + mgl32.Vec2{w, h}, + p.connectionStatusItem.Color, + ) } // Network list - listY := statusY - verticalMargin - if len(p.accessPoints) > 0 { - itemHeight := int32(40) - listPadding := int32(10) - for i, apItem := range p.accessPoints { - itemYpos := listY - float32(int32(i+1)*itemHeight) - itemXpos := float32(p.ScreenCenterX - 350) - itemW := float32(700) - if i == p.selectedIndex { - p.Base.RenderSolidColorQuad(solidShader, mgl32.Vec2{itemXpos, itemYpos}, mgl32.Vec2{itemW, float32(itemHeight)}, selectionColor) - } + listY := statusY - vertMargin + itemH := int32(40) + listPad := int32(10) + listW := float32(700) + listX := float32(p.ScreenCenterX) - listW/2 - textScale := float32(24) / float32(baseFontSize) - textW := float32(apItem.Item.W) * textScale - textH := float32(apItem.Item.H) * textScale - textY := itemYpos + (float32(itemHeight) / 2) - (textH / 2) - textX := itemXpos + float32(listPadding) - p.Base.RenderTexture(textShader, apItem.Item.ID, mgl32.Vec2{textX, textY}, mgl32.Vec2{textW, textH}, apItem.Item.Color) + for i, item := range p.networks { + rowY := listY - float32(int32(i+1)*itemH) + + if i == p.selectedIndex { + p.Base.RenderSolidColorQuad(solidShader, + mgl32.Vec2{listX, rowY}, + mgl32.Vec2{listW, float32(itemH)}, + selColor, + ) } + + scale := float32(24) / float32(baseFontSize) + tw := float32(item.Item.W) * scale + th := float32(item.Item.H) * scale + tx := listX + float32(listPad) + ty := rowY + (float32(itemH)/2 - th/2) + p.Base.RenderTexture(textShader, item.Item.ID, + mgl32.Vec2{tx, ty}, + mgl32.Vec2{tw, th}, + item.Item.Color, + ) } - // Password dialog overlay + // Password dialog if p.isEnteringPassword { if err := p.renderPasswordDialog(solidShader, textShader, float32(baseFontSize)); err != nil { return err @@ -708,106 +510,132 @@ func (p *WiFiSetupPage) Render() error { return nil } -// renderPasswordDialog draws the password entry modal. func (p *WiFiSetupPage) renderPasswordDialog(solidShader, textShader *shaderManager.Shader, baseFontSize float32) error { - overlayColor := sdl.Color{R: 0, G: 0, B: 0, A: 150} + overlayColor := sdl.Color{R: 0, G: 0, B: 0, A: 180} dialogBgColor := sdl.Color{R: 40, G: 42, B: 54, A: 255} boxBorderColor := sdl.Color{R: 120, G: 120, B: 120, A: 255} boxBgColor := sdl.Color{R: 30, G: 30, B: 30, A: 255} - white := sdl.Color{R: 255, G: 255, B: 255, A: 255} + white := colors.White - dialogW, dialogH := int32(600), int32(250) - if p.connectionError != "" { - dialogH = int32(300) // Make dialog taller if there's an error + dialogW := int32(600) + dialogH := int32(250) + if p.connectError != "" { + dialogH = 310 } - dialogX, dialogY := p.ScreenCenterX-(dialogW/2), p.ScreenCenterY-(dialogH/2) + dialogX := p.ScreenCenterX - dialogW/2 + dialogY := p.ScreenCenterY - dialogH/2 - // Overlay and dialog background - p.Base.RenderSolidColorQuad(solidShader, mgl32.Vec2{0, 0}, mgl32.Vec2{float32(p.ScreenWidth), float32(p.ScreenHeight)}, overlayColor) - p.Base.RenderSolidColorQuad(solidShader, mgl32.Vec2{float32(dialogX), float32(dialogY)}, mgl32.Vec2{float32(dialogW), float32(dialogH)}, dialogBgColor) + // Overlay + dialog box + p.Base.RenderSolidColorQuad(solidShader, + mgl32.Vec2{0, 0}, + mgl32.Vec2{float32(p.ScreenWidth), float32(p.ScreenHeight)}, + overlayColor, + ) + p.Base.RenderSolidColorQuad(solidShader, + mgl32.Vec2{float32(dialogX), float32(dialogY)}, + mgl32.Vec2{float32(dialogW), float32(dialogH)}, + dialogBgColor, + ) currentY := float32(dialogY+dialogH) - 30 - // Password error (if exists) - shown at the top in red + // Error message if p.passwordErrorItem.ID != 0 { - errorScale := float32(20) / baseFontSize - errorW := float32(p.passwordErrorItem.W) * errorScale - errorH := float32(p.passwordErrorItem.H) * errorScale - errorX := float32(p.ScreenCenterX) - (errorW / 2) - currentY -= errorH - p.Base.RenderTexture(textShader, p.passwordErrorItem.ID, mgl32.Vec2{errorX, currentY}, mgl32.Vec2{errorW, errorH}, colors.DarkRed) + scale := float32(20) / baseFontSize + w := float32(p.passwordErrorItem.W) * scale + h := float32(p.passwordErrorItem.H) * scale + x := float32(p.ScreenCenterX) - w/2 + currentY -= h + p.Base.RenderTexture(textShader, p.passwordErrorItem.ID, + mgl32.Vec2{x, currentY}, + mgl32.Vec2{w, h}, + colors.Red, + ) currentY -= 20 } - // Password prompt + // Prompt if p.passwordPromptItem.ID != 0 { - promptScale := float32(24) / baseFontSize - promptW := float32(p.passwordPromptItem.W) * promptScale - promptH := float32(p.passwordPromptItem.H) * promptScale - promptX := float32(p.ScreenCenterX) - (promptW / 2) - currentY -= promptH - p.Base.RenderTexture(textShader, p.passwordPromptItem.ID, mgl32.Vec2{promptX, currentY}, mgl32.Vec2{promptW, promptH}, white) + scale := float32(24) / baseFontSize + w := float32(p.passwordPromptItem.W) * scale + h := float32(p.passwordPromptItem.H) * scale + x := float32(p.ScreenCenterX) - w/2 + currentY -= h + p.Base.RenderTexture(textShader, p.passwordPromptItem.ID, + mgl32.Vec2{x, currentY}, + mgl32.Vec2{w, h}, + white, + ) currentY -= 30 } // Input box - padding, border := int32(4), int32(3) + padding := int32(4) + border := int32(3) boxW := int32(500) - targetFontSize := int32(36) - boxH := targetFontSize + (padding * 2) - borderBoxW, borderBoxH := boxW+(border*2), boxH+(border*2) - borderBoxX := p.ScreenCenterX - (borderBoxW / 2) - borderBoxY := int32(currentY) - boxH - (padding * 2) - boxX, boxY := p.ScreenCenterX-(boxW/2), int32(currentY)-boxH-padding + fontSize := int32(36) + boxH := fontSize + padding*2 + bboxW := boxW + border*2 + bboxH := boxH + border*2 + bboxX := p.ScreenCenterX - bboxW/2 + bboxY := int32(currentY) - boxH - padding*2 + boxX := p.ScreenCenterX - boxW/2 + boxY := int32(currentY) - boxH - padding - p.Base.RenderSolidColorQuad(solidShader, mgl32.Vec2{float32(borderBoxX), float32(borderBoxY)}, mgl32.Vec2{float32(borderBoxW), float32(borderBoxH)}, boxBorderColor) - p.Base.RenderSolidColorQuad(solidShader, mgl32.Vec2{float32(boxX), float32(boxY)}, mgl32.Vec2{float32(boxW), float32(boxH)}, boxBgColor) + p.Base.RenderSolidColorQuad(solidShader, + mgl32.Vec2{float32(bboxX), float32(bboxY)}, + mgl32.Vec2{float32(bboxW), float32(bboxH)}, + boxBorderColor, + ) + p.Base.RenderSolidColorQuad(solidShader, + mgl32.Vec2{float32(boxX), float32(boxY)}, + mgl32.Vec2{float32(boxW), float32(boxH)}, + boxBgColor, + ) - // Password input text + // Password text (plain, unmasked) if p.passwordInputItem.ID != 0 { - inputScale := float32(targetFontSize) / baseFontSize - inputW := float32(p.passwordInputItem.W) * inputScale - inputH := float32(p.passwordInputItem.H) * inputScale - inputX := float32(boxX + padding) - inputY := float32(boxY + padding) - p.Base.RenderTexture(textShader, p.passwordInputItem.ID, mgl32.Vec2{inputX, inputY}, mgl32.Vec2{inputW, inputH}, white) + scale := float32(fontSize) / baseFontSize + w := float32(p.passwordInputItem.W) * scale + h := float32(p.passwordInputItem.H) * scale + p.Base.RenderTexture(textShader, p.passwordInputItem.ID, + mgl32.Vec2{float32(boxX + padding), float32(boxY + padding)}, + mgl32.Vec2{w, h}, + white, + ) } return nil } -// Destroy cleans up all resources used by the page. +// ─── Destroy ────────────────────────────────────────────────────────────────── + func (p *WiFiSetupPage) Destroy() error { - // Re-enable global keybinds in case we're destroyed during password entry. keybinds.Enabled = true + sdl.StopTextInput() - if p.stopScan != nil { - close(p.stopScan) - p.stopScan = nil + // Cancel the scan context — stops ScanForNetworks goroutine. + if p.cancel != nil { + p.cancel() } - p.networkManager.Close() - - gl.DeleteTextures(1, &p.titleItem.ID) - gl.DeleteTextures(1, &p.scanningStatusItem.ID) - if p.connectionStatusItem.ID != 0 { - gl.DeleteTextures(1, &p.connectionStatusItem.ID) - } - if p.passwordPromptItem.ID != 0 { - gl.DeleteTextures(1, &p.passwordPromptItem.ID) - } - if p.passwordInputItem.ID != 0 { - gl.DeleteTextures(1, &p.passwordInputItem.ID) - } - if p.passwordErrorItem.ID != 0 { - gl.DeleteTextures(1, &p.passwordErrorItem.ID) - } - - for _, apItem := range p.accessPoints { - if apItem.Item.ID != 0 { - gl.DeleteTextures(1, &apItem.Item.ID) + deleteTexture := func(id *uint32) { + if *id != 0 { + gl.DeleteTextures(1, id) + *id = 0 } } + deleteTexture(&p.titleItem.ID) + deleteTexture(&p.scanningStatusItem.ID) + deleteTexture(&p.connectionStatusItem.ID) + deleteTexture(&p.passwordPromptItem.ID) + deleteTexture(&p.passwordInputItem.ID) + deleteTexture(&p.passwordErrorItem.ID) + + for _, item := range p.networks { + deleteTexture(&item.Item.ID) + } + return p.Base.Destroy() }