diff --git a/core/internal/errdefs/errdefs.go b/core/internal/errdefs/errdefs.go index 7cf3faf81..636f20f11 100644 --- a/core/internal/errdefs/errdefs.go +++ b/core/internal/errdefs/errdefs.go @@ -41,6 +41,10 @@ const ( ErrWifiDisabled = "wifi-disabled" ErrAlreadyConnected = "already-connected" ErrConnectionFailed = "connection-failed" + + ErrHotspotIPConfigFailed = "hotspot-ip-config-failed" + ErrHotspotSupplicantFailed = "hotspot-supplicant-failed" + ErrHotspotFailed = "hotspot-failed" ) var ( diff --git a/core/internal/server/network/API.md b/core/internal/server/network/API.md index e70ce8740..82f4afea9 100644 --- a/core/internal/server/network/API.md +++ b/core/internal/server/network/API.md @@ -6,6 +6,99 @@ The network manager API provides methods for managing WiFi connections, monitori ## API Methods +### network.hotspot.configure + +Create or update the DMS-managed hotspot profile. Hotspot support is capability-gated: clients should require API v28+, `hotspotSupported: true`, and `hotspotAvailable: true` from network state before showing hotspot controls. + +For this implementation, only hotspot-capable backends such as NetworkManager should accept this action. Unsupported backends return an error such as `hotspot not supported by active network backend`. + +Configuration changes are rejected while the DMS hotspot is active or activating; stop it before updating the profile. + +**Request:** +```json +{ + "method": "network.hotspot.configure", + "params": { + "ssid": "Dank Hotspot", + "password": "optional-password", + "device": "wlan0", + "band": "bg" + } +} +``` + +**Parameters:** +- `ssid` (string, required): Hotspot SSID to advertise. +- `password` (string, optional): WPA-PSK password. Omit for an open hotspot when the backend allows it. +- `device` (string, optional): Wi-Fi interface name to use, for example `wlan0`. When omitted, the backend picks an AP-capable radio at start time, preferring one that is already hosting the hotspot, then an idle radio, and only as a last resort a radio carrying an active connection (which NetworkManager will disconnect). Network state exposes `apCapable` on each `wifiDevices` entry so clients can predict this choice. +- `band` (string, optional): Requested NetworkManager band: `bg` for 2.4GHz or `a` for 5GHz. + +**Response:** +```json +{ + "success": true, + "message": "hotspot configured" +} +``` + +### network.hotspot.start + +Start the previously configured DMS-managed hotspot profile. This action does not accept or require SSID/password parameters; call `network.hotspot.configure` first when changing hotspot settings. + +A successful response only means the activation was requested; the outcome is reported asynchronously through network state updates. While activation is in flight, `hotspotActivating` is `true`; on success `hotspotEnabled` becomes `true`; on failure `hotspotActivating` returns to `false` and `hotspotLastError` carries one of `hotspot-ip-config-failed` (IP sharing setup failed, commonly a missing `dnsmasq`, which NetworkManager's shared IPv4 method requires), `hotspot-supplicant-failed` (the Wi-Fi driver could not start AP mode), or `hotspot-failed`. `hotspotLastError` is cleared on the next successful start. + +**Request:** +```json +{ + "method": "network.hotspot.start" +} +``` + +**Response:** +```json +{ + "success": true, + "message": "hotspot started" +} +``` + +### network.hotspot.stop + +Stop the active DMS-managed hotspot connection. It must not stop arbitrary user-created hotspot profiles. + +**Request:** +```json +{ + "method": "network.hotspot.stop" +} +``` + +**Response:** +```json +{ + "success": true, + "message": "hotspot stopped" +} +``` + +### network.hotspot.getSecrets + +Retrieve the stored password of the DMS-managed hotspot profile, for prefilling edit forms. Returns an empty string for an open hotspot. Network state exposes `hotspotSecured` so clients can tell an open hotspot apart from a secured one without fetching the secret. + +**Request:** +```json +{ + "method": "network.hotspot.getSecrets" +} +``` + +**Response:** +```json +{ + "password": "the-stored-psk" +} +``` + ### network.wifi.connect Initiate a WiFi connection. @@ -127,6 +220,16 @@ State updates are sent whenever network configuration changes: - `wifiIP`: Assigned IP address (empty until DHCP completes) - `savedWifiNetworks` (API v26+): Saved WiFi profiles exposed at SSID granularity. If a backend has multiple profiles for the same SSID, DMS merges them into one SSID-level entry. Clients talking to older servers should derive saved visible networks from `wifiNetworks` entries where `saved` is true. - `savedWifiNetworks[].outOfRange` (API v26+): Whether the saved profile is not currently visible in scan results. Fallback entries derived from `wifiNetworks` should be treated as visible (`outOfRange: false`). +- `hotspotSupported` (API v28+): Whether the active backend implements hotspot actions. +- `hotspotAvailable` (API v28+): Whether hotspot support is usable on this backend/device set. For NetworkManager this means at least one AP-capable managed Wi-Fi device exists, independent of Wi-Fi radio enabled state. +- `hotspotConfigured` (API v28+): Whether the DMS-managed hotspot profile exists. +- `hotspotEnabled` (API v28+): Whether the DMS-managed hotspot profile is currently active. +- `hotspotActivating` (API v28+): Whether hotspot activation is currently in progress. +- `hotspotSecured` (API v28+): Whether the configured hotspot uses password-based security. +- `hotspotSSID` (API v28+): Configured DMS hotspot SSID. +- `hotspotDevice` (API v28+): Optional configured Wi-Fi device for the DMS hotspot. +- `hotspotBand` (API v28+): Optional configured hotspot band (`bg` or `a`). +- `hotspotLastError` (API v28+): Machine-readable error from the most recent failed hotspot activation. Cleared when the next start succeeds. - `lastError`: Error message from last failed connection attempt ### network.credentials Service Events diff --git a/core/internal/server/network/backend.go b/core/internal/server/network/backend.go index f000dc48f..19ec78836 100644 --- a/core/internal/server/network/backend.go +++ b/core/internal/server/network/backend.go @@ -51,6 +51,13 @@ type Backend interface { CancelCredentials(token string) error } +type HotspotBackend interface { + ConfigureHotspot(req HotspotRequest) error + StartHotspot() error + StopHotspot() error + GetHotspotSecrets() (string, error) +} + type BackendState struct { Backend string NetworkStatus NetworkStatus @@ -69,6 +76,15 @@ type BackendState struct { WiFiNetworks []WiFiNetwork SavedWiFiNetworks []WiFiNetwork WiFiDevices []WiFiDevice + HotspotAvailable bool + HotspotConfigured bool + HotspotEnabled bool + HotspotActivating bool + HotspotSecured bool + HotspotSSID string + HotspotDevice string + HotspotBand string + HotspotLastError string WiredConnections []WiredConnection VPNProfiles []VPNProfile VPNActive []VPNActive diff --git a/core/internal/server/network/backend_networkmanager.go b/core/internal/server/network/backend_networkmanager.go index d4fcae2d4..f0d583162 100644 --- a/core/internal/server/network/backend_networkmanager.go +++ b/core/internal/server/network/backend_networkmanager.go @@ -78,6 +78,8 @@ type NetworkManagerBackend struct { lastFailedTime int64 failedMutex sync.RWMutex + hotspotPendingDevice string + pendingVPNSave *pendingVPNCredentials pendingVPNSaveMu sync.Mutex cachedVPNCreds *cachedVPNCredentials @@ -249,12 +251,18 @@ func (b *NetworkManagerBackend) Initialize() error { log.Warnf("Failed to get initial saved WiFi networks: %v", err) } + if err := b.updateHotspotState(); err != nil { + log.Warnf("Failed to get initial hotspot state: %v", err) + } + if wifiEnabled { if _, err := b.updateWiFiNetworks(); err != nil { log.Warnf("Failed to get initial networks: %v", err) } - b.updateAllWiFiDevices() } + // Device metadata (names, AP capability) is needed even while the radio is + // disabled, e.g. by the hotspot device selector. + b.updateAllWiFiDevices() b.updateAllEthernetDevices() diff --git a/core/internal/server/network/backend_networkmanager_hotspot.go b/core/internal/server/network/backend_networkmanager_hotspot.go new file mode 100644 index 000000000..bc85d2238 --- /dev/null +++ b/core/internal/server/network/backend_networkmanager_hotspot.go @@ -0,0 +1,706 @@ +package network + +import ( + "fmt" + "sort" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs" + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/Wifx/gonetworkmanager/v2" +) + +const ( + nmWiFiDeviceCapAP uint32 = 0x00000040 + nmWiFiDeviceCapFreqValid uint32 = 0x00000100 + nmWiFiDeviceCapFreq2GHz uint32 = 0x00000200 + nmWiFiDeviceCapFreq5GHz uint32 = 0x00000400 + + dmsHotspotConnectionID = "DankMaterialShell Hotspot" + dmsHotspotStableID = "dms-hotspot" +) + +var _ HotspotBackend = (*NetworkManagerBackend)(nil) + +// ConfigureHotspot validates only DMS-owned constraints: SSID presence, band +// names, device capability, and the not-active guard. SSID byte-length and +// WPA-PSK password policy are deliberately left to NetworkManager, whose +// AddConnection/Update errors are returned to the caller; duplicating its +// evolving rules here would only let them drift. +func (b *NetworkManagerBackend) ConfigureHotspot(req HotspotRequest) error { + if req.SSID == "" { + return fmt.Errorf("hotspot SSID cannot be empty") + } + if err := validateHotspotBandName(req.Band); err != nil { + return err + } + + b.stateMutex.RLock() + hotspotActive := b.state.HotspotEnabled || b.state.HotspotActivating + b.stateMutex.RUnlock() + if hotspotActive { + return fmt.Errorf("stop the hotspot before changing its configuration") + } + + if req.Device != "" { + if _, err := b.getAPCapableWiFiDevice(req.Device, req.Band); err != nil { + return err + } + } else if err := b.validateAutoHotspotBand(req.Band); err != nil { + return err + } + + settingsMgr, err := b.networkManagerSettings() + if err != nil { + return err + } + + existing, existingSettings, err := b.findDMSHotspotConnection() + if err != nil { + return err + } + + settings := buildHotspotSettings(req, existingSettings) + if existing != nil { + if err := existing.Update(settings); err != nil { + return fmt.Errorf("failed to update hotspot profile: %w", err) + } + } else if _, err := settingsMgr.AddConnection(settings); err != nil { + return fmt.Errorf("failed to create hotspot profile: %w", err) + } + + if err := b.updateHotspotState(); err != nil { + return err + } + + return nil +} + +func (b *NetworkManagerBackend) StartHotspot() error { + conn, settings, err := b.findDMSHotspotConnection() + if err != nil { + return err + } + if conn == nil { + return fmt.Errorf("hotspot is not configured") + } + + devInfo, err := b.getAPCapableWiFiDevice(hotspotDeviceFromSettings(settings), hotspotBandFromSettings(settings)) + if err != nil { + return err + } + + deviceName := "" + if devInfo.device != nil { + deviceName, _ = devInfo.device.GetPropertyInterface() + } + + nm := b.nmConn.(gonetworkmanager.NetworkManager) + if _, err := nm.ActivateConnection(conn, devInfo.device, nil); err != nil { + return fmt.Errorf("failed to start hotspot: %w", err) + } + + b.stateMutex.Lock() + b.state.HotspotActivating = true + b.state.HotspotLastError = "" + b.hotspotPendingDevice = deviceName + b.stateMutex.Unlock() + + if err := b.updateHotspotState(); err != nil { + return err + } + + return nil +} + +func (b *NetworkManagerBackend) StopHotspot() error { + b.stateMutex.Lock() + b.state.HotspotActivating = false + b.state.HotspotLastError = "" + b.hotspotPendingDevice = "" + b.stateMutex.Unlock() + + active, err := b.findActiveDMSHotspotConnection() + if err != nil { + return err + } + if active == nil { + return nil + } + + nm := b.nmConn.(gonetworkmanager.NetworkManager) + if err := nm.DeactivateConnection(active); err != nil { + return fmt.Errorf("failed to stop hotspot: %w", err) + } + + if err := b.updateHotspotState(); err != nil { + return err + } + + return nil +} + +func (b *NetworkManagerBackend) GetHotspotSecrets() (string, error) { + conn, settings, err := b.findDMSHotspotConnection() + if err != nil { + return "", err + } + if conn == nil { + return "", fmt.Errorf("hotspot is not configured") + } + if !hotspotSecuredFromSettings(settings) { + return "", nil + } + + secrets, err := conn.GetSecrets("802-11-wireless-security") + if err != nil { + return "", fmt.Errorf("failed to read hotspot password: %w", err) + } + + if security, ok := secrets["802-11-wireless-security"]; ok { + if psk, ok := security["psk"].(string); ok { + return psk, nil + } + } + + return "", nil +} + +func (b *NetworkManagerBackend) networkManagerSettings() (gonetworkmanager.Settings, error) { + s := b.settings + if s == nil { + var err error + s, err = gonetworkmanager.NewSettings() + if err != nil { + return nil, fmt.Errorf("failed to get settings: %w", err) + } + b.settings = s + } + + settingsMgr, ok := s.(gonetworkmanager.Settings) + if !ok { + return nil, fmt.Errorf("invalid NetworkManager settings handle") + } + return settingsMgr, nil +} + +func buildHotspotSettings(req HotspotRequest, existing gonetworkmanager.ConnectionSettings) gonetworkmanager.ConnectionSettings { + connection := map[string]any{ + "id": dmsHotspotConnectionID, + "type": "802-11-wireless", + "autoconnect": false, + "stable-id": dmsHotspotStableID, + } + if req.Device != "" { + connection["interface-name"] = req.Device + } + if existingConnection, ok := existing["connection"]; ok { + if uuid, ok := existingConnection["uuid"].(string); ok && uuid != "" { + connection["uuid"] = uuid + } + } + + wifi := map[string]any{ + "mode": "ap", + "ssid": []byte(req.SSID), + } + if req.Band != "" { + wifi["band"] = req.Band + } + + settings := gonetworkmanager.ConnectionSettings{ + "connection": connection, + "802-11-wireless": wifi, + "ipv4": {"method": "shared"}, + "ipv6": {"method": "ignore"}, + } + + if req.Password != "" { + wifi["security"] = "802-11-wireless-security" + settings["802-11-wireless-security"] = map[string]any{ + "key-mgmt": "wpa-psk", + "psk": req.Password, + "psk-flags": uint32(0), + } + } + + return settings +} + +func (b *NetworkManagerBackend) findDMSHotspotConnection() (gonetworkmanager.Connection, gonetworkmanager.ConnectionSettings, error) { + settingsMgr, err := b.networkManagerSettings() + if err != nil { + return nil, nil, err + } + + connections, err := settingsMgr.ListConnections() + if err != nil { + return nil, nil, fmt.Errorf("failed to list connections: %w", err) + } + + for _, conn := range connections { + connSettings, err := conn.GetSettings() + if err != nil { + continue + } + if isDMSHotspotConnection(connSettings) { + return conn, connSettings, nil + } + } + + return nil, nil, nil +} + +func (b *NetworkManagerBackend) findActiveDMSHotspotConnection() (gonetworkmanager.ActiveConnection, error) { + nm := b.nmConn.(gonetworkmanager.NetworkManager) + activeConns, err := nm.GetPropertyActiveConnections() + if err != nil { + return nil, fmt.Errorf("failed to get active connections: %w", err) + } + + for _, active := range activeConns { + connType, err := active.GetPropertyType() + if err != nil || connType != "802-11-wireless" { + continue + } + + conn, err := active.GetPropertyConnection() + if err != nil || conn == nil { + continue + } + + settings, err := conn.GetSettings() + if err != nil { + continue + } + if isDMSHotspotConnection(settings) { + return active, nil + } + } + + return nil, nil +} + +func isDMSHotspotConnection(settings gonetworkmanager.ConnectionSettings) bool { + connMeta, _, ok := wifiConnectionSettings(settings) + if !ok || !isAPModeWiFiConnection(settings) { + return false + } + + stableID, _ := connMeta["stable-id"].(string) + return stableID == dmsHotspotStableID +} + +func wifiConnectionSettings(settings gonetworkmanager.ConnectionSettings) (map[string]any, map[string]any, bool) { + connMeta, ok := settings["connection"] + if !ok { + return nil, nil, false + } + connType, _ := connMeta["type"].(string) + if connType != "802-11-wireless" { + return nil, nil, false + } + + wifiSettings, ok := settings["802-11-wireless"] + if !ok { + return nil, nil, false + } + + return connMeta, wifiSettings, true +} + +func isAPModeWiFiConnection(settings gonetworkmanager.ConnectionSettings) bool { + _, wifiSettings, ok := wifiConnectionSettings(settings) + if !ok { + return false + } + mode, _ := wifiSettings["mode"].(string) + return mode == "ap" +} + +func isClientWiFiConnection(settings gonetworkmanager.ConnectionSettings) bool { + _, _, ok := wifiConnectionSettings(settings) + return ok && !isAPModeWiFiConnection(settings) +} + +// activeDMSHotspotDevicePaths returns only the devices hosting the DMS-owned +// hotspot, unlike activeAPModeWiFiDevicePaths which matches any AP-mode +// connection (as the client-state isolation requires). +func (b *NetworkManagerBackend) activeDMSHotspotDevicePaths() map[string]bool { + paths := make(map[string]bool) + active, err := b.findActiveDMSHotspotConnection() + if err != nil || active == nil { + return paths + } + + devices, err := active.GetPropertyDevices() + if err != nil { + return paths + } + for _, dev := range devices { + if dev != nil { + paths[string(dev.GetPath())] = true + } + } + + return paths +} + +func (b *NetworkManagerBackend) activeAPModeWiFiDevicePaths() map[string]bool { + paths := make(map[string]bool) + nm := b.nmConn.(gonetworkmanager.NetworkManager) + activeConns, err := nm.GetPropertyActiveConnections() + if err != nil { + return paths + } + + for _, active := range activeConns { + connType, err := active.GetPropertyType() + if err != nil || connType != "802-11-wireless" { + continue + } + + conn, err := active.GetPropertyConnection() + if err != nil || conn == nil { + continue + } + + settings, err := conn.GetSettings() + if err != nil || !isAPModeWiFiConnection(settings) { + continue + } + + devices, err := active.GetPropertyDevices() + if err != nil { + continue + } + for _, dev := range devices { + if dev != nil { + paths[string(dev.GetPath())] = true + } + } + } + + return paths +} + +func (b *NetworkManagerBackend) getAPCapableWiFiDevice(deviceName string, band string) (*wifiDeviceInfo, error) { + if err := validateHotspotBandName(band); err != nil { + return nil, err + } + + if deviceName != "" { + devInfo, ok := b.wifiDeviceByIface(deviceName) + if !ok { + return nil, fmt.Errorf("WiFi device not found: %s", deviceName) + } + if err := validateAPCapableWiFiDevice(devInfo, deviceName, band); err != nil { + return nil, err + } + return devInfo, nil + } + + wifiDevices := b.wifiDevicesSnapshot() + deviceNames := make([]string, 0, len(wifiDevices)) + for name := range wifiDevices { + deviceNames = append(deviceNames, name) + } + sort.Strings(deviceNames) + + dmsHotspotDevicePaths := b.activeDMSHotspotDevicePaths() + + // Rank 0: already hosting the DMS hotspot (keep it where it is). Radios + // hosting foreign AP-mode connections must not get this preference and + // rank as busy through their Activated state instead. + // Rank 1: genuinely disconnected, so starting the AP disturbs nothing. + // Rank 2: client activation in progress; grabbing it kills the attempt. + // Rank 3: carrying an active connection; only used as a last resort. + // Rank 4: unavailable, failed, or unknown; activation is unlikely to succeed. + rankDevice := func(devInfo *wifiDeviceInfo) int { + if len(dmsHotspotDevicePaths) > 0 && dmsHotspotDevicePaths[string(devInfo.device.GetPath())] { + return 0 + } + state, err := devInfo.device.GetPropertyState() + if err != nil { + return 4 + } + switch { + case state == gonetworkmanager.NmDeviceStateDisconnected: + return 1 + case state == gonetworkmanager.NmDeviceStateActivated: + return 3 + case state > gonetworkmanager.NmDeviceStateDisconnected && state < gonetworkmanager.NmDeviceStateActivated: + return 2 + default: + return 4 + } + } + + var lastBandErr error + var best *wifiDeviceInfo + bestRank := 5 + for _, name := range deviceNames { + devInfo := wifiDevices[name] + ok, err := isAPCapableWiFiDevice(devInfo) + if err != nil || !ok { + continue + } + if err := validateHotspotBand(devInfo, band); err != nil { + lastBandErr = err + continue + } + if rank := rankDevice(devInfo); rank < bestRank { + best = devInfo + bestRank = rank + } + } + + if best != nil { + return best, nil + } + if lastBandErr != nil { + return nil, lastBandErr + } + return nil, fmt.Errorf("no hotspot-capable WiFi device available") +} + +func validateAPCapableWiFiDevice(devInfo *wifiDeviceInfo, deviceName string, band string) error { + ok, err := isAPCapableWiFiDevice(devInfo) + if err != nil { + return err + } + if !ok { + return fmt.Errorf("WiFi device is not hotspot-capable: %s", deviceName) + } + return validateHotspotBand(devInfo, band) +} + +func isAPCapableWiFiDevice(devInfo *wifiDeviceInfo) (bool, error) { + if devInfo == nil || devInfo.device == nil || devInfo.wireless == nil { + return false, nil + } + + managed, err := devInfo.device.GetPropertyManaged() + if err != nil { + return false, fmt.Errorf("failed to get WiFi device managed state: %w", err) + } + if !managed { + return false, nil + } + + caps, err := devInfo.wireless.GetPropertyWirelessCapabilities() + if err != nil { + return false, fmt.Errorf("failed to get WiFi device capabilities: %w", err) + } + + return caps&nmWiFiDeviceCapAP != 0, nil +} + +func validateHotspotBandName(band string) error { + if band != "" && band != "bg" && band != "a" { + return fmt.Errorf("unsupported hotspot band: %s", band) + } + return nil +} + +func validateHotspotBand(devInfo *wifiDeviceInfo, band string) error { + if band == "" || devInfo == nil || devInfo.wireless == nil { + return nil + } + + caps, err := devInfo.wireless.GetPropertyWirelessCapabilities() + if err != nil { + return fmt.Errorf("failed to get WiFi device capabilities: %w", err) + } + if caps&nmWiFiDeviceCapFreqValid == 0 { + return nil + } + + switch band { + case "bg": + if caps&nmWiFiDeviceCapFreq2GHz == 0 { + return fmt.Errorf("WiFi device does not support 2.4GHz hotspot band") + } + case "a": + if caps&nmWiFiDeviceCapFreq5GHz == 0 { + return fmt.Errorf("WiFi device does not support 5GHz hotspot band") + } + } + + return nil +} + +func (b *NetworkManagerBackend) validateAutoHotspotBand(band string) error { + if band == "" { + return nil + } + + var lastBandErr error + foundAPCapableDevice := false + for _, devInfo := range b.wifiDevicesSnapshot() { + apCapable, err := isAPCapableWiFiDevice(devInfo) + if err != nil { + // Capability could not be determined, so defer to NetworkManager. + return nil + } + if !apCapable { + continue + } + + foundAPCapableDevice = true + if err := validateHotspotBand(devInfo, band); err == nil { + return nil + } else { + lastBandErr = err + } + } + + if foundAPCapableDevice && lastBandErr != nil { + return lastBandErr + } + return nil +} + +func hotspotSSIDFromSettings(settings gonetworkmanager.ConnectionSettings) string { + wifiSettings, ok := settings["802-11-wireless"] + if !ok { + return "" + } + ssidBytes, ok := wifiSettings["ssid"].([]byte) + if !ok { + return "" + } + return string(ssidBytes) +} + +func hotspotDeviceFromSettings(settings gonetworkmanager.ConnectionSettings) string { + connMeta, ok := settings["connection"] + if !ok { + return "" + } + device, _ := connMeta["interface-name"].(string) + return device +} + +func hotspotBandFromSettings(settings gonetworkmanager.ConnectionSettings) string { + wifiSettings, ok := settings["802-11-wireless"] + if !ok { + return "" + } + band, _ := wifiSettings["band"].(string) + return band +} + +func (b *NetworkManagerBackend) updateHotspotState() error { + available := false + for _, devInfo := range b.wifiDevicesSnapshot() { + ok, err := isAPCapableWiFiDevice(devInfo) + if err != nil { + continue + } + if ok { + available = true + break + } + } + + _, settings, err := b.findDMSHotspotConnection() + if err != nil { + return err + } + + configured := settings != nil + enabled := false + activating := false + if configured { + active, err := b.findActiveDMSHotspotConnection() + if err != nil { + return err + } + if active != nil { + switch state, _ := active.GetPropertyState(); state { + case gonetworkmanager.NmActiveConnectionStateActivated: + enabled = true + case gonetworkmanager.NmActiveConnectionStateActivating: + activating = true + } + } + } + + b.stateMutex.RLock() + wasStarting := b.state.HotspotActivating + pendingDevice := b.hotspotPendingDevice + b.stateMutex.RUnlock() + + failureCode := "" + if wasStarting && !enabled && !activating { + if devInfo, ok := b.wifiDeviceByIface(pendingDevice); ok && devInfo.device != nil && b.dbusConn != nil { + reason := b.getDeviceStateReason(devInfo.device) + // A fresh activation briefly reports no reason; don't misread it as failure. + if reason == gonetworkmanager.NmDeviceStateReasonNewActivation || reason == gonetworkmanager.NmDeviceStateReasonNone { + activating = true + } else { + failureCode = classifyHotspotStateReason(reason) + log.Warnf("[updateHotspotState] Hotspot activation failed: device=%s, reason=%d (%s)", pendingDevice, reason, failureCode) + } + } else { + failureCode = errdefs.ErrHotspotFailed + log.Warnf("[updateHotspotState] Hotspot activation failed: device %q no longer available", pendingDevice) + } + } + + b.stateMutex.Lock() + b.state.HotspotAvailable = available + b.state.HotspotConfigured = configured + b.state.HotspotEnabled = enabled + b.state.HotspotSSID = hotspotSSIDFromSettings(settings) + b.state.HotspotDevice = hotspotDeviceFromSettings(settings) + b.state.HotspotBand = hotspotBandFromSettings(settings) + b.state.HotspotSecured = hotspotSecuredFromSettings(settings) + if wasStarting { + switch { + case enabled: + b.state.HotspotActivating = false + b.state.HotspotLastError = "" + b.hotspotPendingDevice = "" + case failureCode != "": + b.state.HotspotActivating = false + b.state.HotspotLastError = failureCode + b.hotspotPendingDevice = "" + } + } else { + b.state.HotspotActivating = activating + } + b.stateMutex.Unlock() + + return nil +} + +func hotspotSecuredFromSettings(settings gonetworkmanager.ConnectionSettings) bool { + if settings == nil { + return false + } + _, ok := settings["802-11-wireless-security"] + return ok +} + +// classifyHotspotStateReason uses the gonetworkmanager reason constants; the +// same-named package-local aliases in backend_networkmanager.go carry wrong +// values and must not be used here. +func classifyHotspotStateReason(reason uint32) string { + switch reason { + case gonetworkmanager.NmDeviceStateReasonIpConfigUnavailable, + gonetworkmanager.NmDeviceStateReasonDhcpStartFailed, + gonetworkmanager.NmDeviceStateReasonDhcpError, + gonetworkmanager.NmDeviceStateReasonDhcpFailed, + gonetworkmanager.NmDeviceStateReasonSharedStartFailed, + gonetworkmanager.NmDeviceStateReasonSharedFailed: + return errdefs.ErrHotspotIPConfigFailed + case gonetworkmanager.NmDeviceStateReasonSupplicantDisconnect, + gonetworkmanager.NmDeviceStateReasonSupplicantConfigFailed, + gonetworkmanager.NmDeviceStateReasonSupplicantFailed, + gonetworkmanager.NmDeviceStateReasonSupplicantTimeout: + return errdefs.ErrHotspotSupplicantFailed + default: + return errdefs.ErrHotspotFailed + } +} diff --git a/core/internal/server/network/backend_networkmanager_hotspot_test.go b/core/internal/server/network/backend_networkmanager_hotspot_test.go new file mode 100644 index 000000000..850ec19bb --- /dev/null +++ b/core/internal/server/network/backend_networkmanager_hotspot_test.go @@ -0,0 +1,918 @@ +package network + +import ( + "testing" + + mock_gonetworkmanager "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/github.com/Wifx/gonetworkmanager/v2" + "github.com/Wifx/gonetworkmanager/v2" + "github.com/godbus/dbus/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNetworkManagerBackendImplementsHotspotBackend(t *testing.T) { + var backend any = (*NetworkManagerBackend)(nil) + _, ok := backend.(HotspotBackend) + assert.True(t, ok) +} + +func TestBuildHotspotSettings(t *testing.T) { + settings := buildHotspotSettings(HotspotRequest{ + SSID: "DMS Hotspot", + Password: "hunter2-password", + Device: "wlan0", + Band: "bg", + }, gonetworkmanager.ConnectionSettings{ + "connection": { + "uuid": "existing-uuid", + }, + }) + + assert.Equal(t, dmsHotspotConnectionID, settings["connection"]["id"]) + assert.Equal(t, "802-11-wireless", settings["connection"]["type"]) + assert.Equal(t, false, settings["connection"]["autoconnect"]) + assert.Equal(t, dmsHotspotStableID, settings["connection"]["stable-id"]) + assert.Equal(t, "existing-uuid", settings["connection"]["uuid"]) + assert.Equal(t, "wlan0", settings["connection"]["interface-name"]) + + assert.Equal(t, "ap", settings["802-11-wireless"]["mode"]) + assert.Equal(t, []byte("DMS Hotspot"), settings["802-11-wireless"]["ssid"]) + assert.Equal(t, "bg", settings["802-11-wireless"]["band"]) + assert.Equal(t, "802-11-wireless-security", settings["802-11-wireless"]["security"]) + + assert.Equal(t, "wpa-psk", settings["802-11-wireless-security"]["key-mgmt"]) + assert.Equal(t, "hunter2-password", settings["802-11-wireless-security"]["psk"]) + assert.Equal(t, uint32(0), settings["802-11-wireless-security"]["psk-flags"]) + + assert.Equal(t, "shared", settings["ipv4"]["method"]) + assert.Equal(t, "ignore", settings["ipv6"]["method"]) +} + +func TestBuildHotspotSettingsOpenNetwork(t *testing.T) { + settings := buildHotspotSettings(HotspotRequest{SSID: "Open Hotspot"}, nil) + + _, hasSecurity := settings["802-11-wireless-security"] + assert.False(t, hasSecurity) + _, hasWirelessSecurityRef := settings["802-11-wireless"]["security"] + assert.False(t, hasWirelessSecurityRef) +} + +func TestIsDMSHotspotConnection(t *testing.T) { + tests := []struct { + name string + settings gonetworkmanager.ConnectionSettings + want bool + }{ + { + name: "stable-id marker", + settings: gonetworkmanager.ConnectionSettings{ + "connection": { + "type": "802-11-wireless", + "stable-id": dmsHotspotStableID, + }, + "802-11-wireless": { + "mode": "ap", + }, + }, + want: true, + }, + { + name: "stable DMS id without marker is not enough", + settings: gonetworkmanager.ConnectionSettings{ + "connection": { + "type": "802-11-wireless", + "id": dmsHotspotConnectionID, + }, + "802-11-wireless": { + "mode": "ap", + }, + }, + want: false, + }, + { + name: "same SSID but client profile", + settings: gonetworkmanager.ConnectionSettings{ + "connection": { + "type": "802-11-wireless", + "id": dmsHotspotConnectionID, + }, + "802-11-wireless": { + "mode": "infrastructure", + "ssid": []byte("DMS Hotspot"), + }, + }, + want: false, + }, + { + name: "matching SSID only is not enough", + settings: gonetworkmanager.ConnectionSettings{ + "connection": { + "type": "802-11-wireless", + "id": "User Hotspot", + }, + "802-11-wireless": { + "mode": "ap", + "ssid": []byte("DMS Hotspot"), + }, + }, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, isDMSHotspotConnection(tt.settings)) + }) + } +} + +func TestIsAPCapableWiFiDevice(t *testing.T) { + mockWiFi := mock_gonetworkmanager.NewMockDeviceWireless(t) + mockWiFi.EXPECT().GetPropertyManaged().Return(true, nil) + mockWiFi.EXPECT().GetPropertyWirelessCapabilities().Return(nmWiFiDeviceCapAP, nil) + + ok, err := isAPCapableWiFiDevice(&wifiDeviceInfo{device: mockWiFi, wireless: mockWiFi}) + require.NoError(t, err) + assert.True(t, ok) +} + +func TestIsAPCapableWiFiDeviceUnmanaged(t *testing.T) { + mockWiFi := mock_gonetworkmanager.NewMockDeviceWireless(t) + mockWiFi.EXPECT().GetPropertyManaged().Return(false, nil) + + ok, err := isAPCapableWiFiDevice(&wifiDeviceInfo{device: mockWiFi, wireless: mockWiFi}) + require.NoError(t, err) + assert.False(t, ok) +} + +func TestValidateHotspotBand(t *testing.T) { + tests := []struct { + name string + band string + caps uint32 + wantErr bool + }{ + { + name: "2.4GHz supported", + band: "bg", + caps: nmWiFiDeviceCapFreqValid | nmWiFiDeviceCapFreq2GHz, + }, + { + name: "5GHz supported", + band: "a", + caps: nmWiFiDeviceCapFreqValid | nmWiFiDeviceCapFreq5GHz, + }, + { + name: "2.4GHz unsupported", + band: "bg", + caps: nmWiFiDeviceCapFreqValid | nmWiFiDeviceCapFreq5GHz, + wantErr: true, + }, + { + name: "5GHz unsupported", + band: "a", + caps: nmWiFiDeviceCapFreqValid | nmWiFiDeviceCapFreq2GHz, + wantErr: true, + }, + { + name: "unknown frequencies defer to NetworkManager", + band: "a", + caps: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockWiFi := mock_gonetworkmanager.NewMockDeviceWireless(t) + mockWiFi.EXPECT().GetPropertyWirelessCapabilities().Return(tt.caps, nil) + + err := validateHotspotBand(&wifiDeviceInfo{wireless: mockWiFi}, tt.band) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestGetAPCapableWiFiDeviceSelectsDeviceCompatibleWithRequestedBand(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + wlan0 := mock_gonetworkmanager.NewMockDeviceWireless(t) + wlan1 := mock_gonetworkmanager.NewMockDeviceWireless(t) + + backend, err := NewNetworkManagerBackend(mockNM) + require.NoError(t, err) + backend.wifiDevices = map[string]*wifiDeviceInfo{ + "wlan0": {device: wlan0, wireless: wlan0, name: "wlan0"}, + "wlan1": {device: wlan1, wireless: wlan1, name: "wlan1"}, + } + + wlan0.EXPECT().GetPropertyManaged().Return(true, nil) + wlan0.EXPECT().GetPropertyWirelessCapabilities().Return(nmWiFiDeviceCapAP|nmWiFiDeviceCapFreqValid|nmWiFiDeviceCapFreq2GHz, nil).Twice() + wlan1.EXPECT().GetPropertyManaged().Return(true, nil) + wlan1.EXPECT().GetPropertyWirelessCapabilities().Return(nmWiFiDeviceCapAP|nmWiFiDeviceCapFreqValid|nmWiFiDeviceCapFreq5GHz, nil).Twice() + mockNM.EXPECT().GetPropertyActiveConnections().Return(nil, nil).Once() + wlan1.EXPECT().GetPropertyState().Return(gonetworkmanager.NmDeviceStateDisconnected, nil).Once() + + devInfo, err := backend.getAPCapableWiFiDevice("", "a") + require.NoError(t, err) + assert.Equal(t, "wlan1", devInfo.name) +} + +func TestGetAPCapableWiFiDeviceAutoPrefersIdleDevice(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + wlan0 := mock_gonetworkmanager.NewMockDeviceWireless(t) + wlan1 := mock_gonetworkmanager.NewMockDeviceWireless(t) + + backend, err := NewNetworkManagerBackend(mockNM) + require.NoError(t, err) + backend.wifiDevices = map[string]*wifiDeviceInfo{ + "wlan0": {device: wlan0, wireless: wlan0, name: "wlan0"}, + "wlan1": {device: wlan1, wireless: wlan1, name: "wlan1"}, + } + + wlan0.EXPECT().GetPropertyManaged().Return(true, nil) + wlan0.EXPECT().GetPropertyWirelessCapabilities().Return(nmWiFiDeviceCapAP, nil) + wlan1.EXPECT().GetPropertyManaged().Return(true, nil) + wlan1.EXPECT().GetPropertyWirelessCapabilities().Return(nmWiFiDeviceCapAP, nil) + mockNM.EXPECT().GetPropertyActiveConnections().Return(nil, nil).Once() + wlan0.EXPECT().GetPropertyState().Return(gonetworkmanager.NmDeviceStateActivated, nil).Once() + wlan1.EXPECT().GetPropertyState().Return(gonetworkmanager.NmDeviceStateDisconnected, nil).Once() + + devInfo, err := backend.getAPCapableWiFiDevice("", "") + require.NoError(t, err) + assert.Equal(t, "wlan1", devInfo.name, "auto selection should prefer the radio without an active connection") +} + +func TestGetAPCapableWiFiDeviceAutoRanksTransitionalAndUnusableStates(t *testing.T) { + tests := []struct { + name string + wlan0State gonetworkmanager.NmDeviceState + wlan1State gonetworkmanager.NmDeviceState + want string + }{ + { + name: "disconnected beats client activation in progress", + wlan0State: gonetworkmanager.NmDeviceStateConfig, + wlan1State: gonetworkmanager.NmDeviceStateDisconnected, + want: "wlan1", + }, + { + name: "active connection beats failed radio", + wlan0State: gonetworkmanager.NmDeviceStateFailed, + wlan1State: gonetworkmanager.NmDeviceStateActivated, + want: "wlan1", + }, + { + name: "active connection beats unavailable radio", + wlan0State: gonetworkmanager.NmDeviceStateUnavailable, + wlan1State: gonetworkmanager.NmDeviceStateActivated, + want: "wlan1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + wlan0 := mock_gonetworkmanager.NewMockDeviceWireless(t) + wlan1 := mock_gonetworkmanager.NewMockDeviceWireless(t) + + backend, err := NewNetworkManagerBackend(mockNM) + require.NoError(t, err) + backend.wifiDevices = map[string]*wifiDeviceInfo{ + "wlan0": {device: wlan0, wireless: wlan0, name: "wlan0"}, + "wlan1": {device: wlan1, wireless: wlan1, name: "wlan1"}, + } + + wlan0.EXPECT().GetPropertyManaged().Return(true, nil) + wlan0.EXPECT().GetPropertyWirelessCapabilities().Return(nmWiFiDeviceCapAP, nil) + wlan1.EXPECT().GetPropertyManaged().Return(true, nil) + wlan1.EXPECT().GetPropertyWirelessCapabilities().Return(nmWiFiDeviceCapAP, nil) + mockNM.EXPECT().GetPropertyActiveConnections().Return(nil, nil).Once() + wlan0.EXPECT().GetPropertyState().Return(tt.wlan0State, nil).Once() + wlan1.EXPECT().GetPropertyState().Return(tt.wlan1State, nil).Once() + + devInfo, err := backend.getAPCapableWiFiDevice("", "") + require.NoError(t, err) + assert.Equal(t, tt.want, devInfo.name) + }) + } +} + +func TestGetAPCapableWiFiDeviceAutoDoesNotEvictForeignHotspot(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + wlan0 := mock_gonetworkmanager.NewMockDeviceWireless(t) + wlan1 := mock_gonetworkmanager.NewMockDeviceWireless(t) + mockActive := mock_gonetworkmanager.NewMockActiveConnection(t) + mockConn := mock_gonetworkmanager.NewMockConnection(t) + + backend, err := NewNetworkManagerBackend(mockNM) + require.NoError(t, err) + backend.wifiDevices = map[string]*wifiDeviceInfo{ + "wlan0": {device: wlan0, wireless: wlan0, name: "wlan0"}, + "wlan1": {device: wlan1, wireless: wlan1, name: "wlan1"}, + } + + userHotspot := gonetworkmanager.ConnectionSettings{ + "connection": { + "type": "802-11-wireless", + "id": "User Hotspot", + }, + "802-11-wireless": { + "mode": "ap", + }, + } + + wlan0.EXPECT().GetPropertyManaged().Return(true, nil) + wlan0.EXPECT().GetPropertyWirelessCapabilities().Return(nmWiFiDeviceCapAP, nil) + wlan1.EXPECT().GetPropertyManaged().Return(true, nil) + wlan1.EXPECT().GetPropertyWirelessCapabilities().Return(nmWiFiDeviceCapAP, nil) + mockNM.EXPECT().GetPropertyActiveConnections().Return([]gonetworkmanager.ActiveConnection{mockActive}, nil).Once() + mockActive.EXPECT().GetPropertyType().Return("802-11-wireless", nil).Once() + mockActive.EXPECT().GetPropertyConnection().Return(mockConn, nil).Once() + mockConn.EXPECT().GetSettings().Return(userHotspot, nil).Once() + wlan0.EXPECT().GetPropertyState().Return(gonetworkmanager.NmDeviceStateActivated, nil).Once() + wlan1.EXPECT().GetPropertyState().Return(gonetworkmanager.NmDeviceStateDisconnected, nil).Once() + + devInfo, err := backend.getAPCapableWiFiDevice("", "") + require.NoError(t, err) + assert.Equal(t, "wlan1", devInfo.name, "a radio hosting a foreign hotspot must rank as busy, not preferred") +} + +func TestGetAPCapableWiFiDeviceAutoSticksWithActiveDMSHotspot(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + wlan0 := mock_gonetworkmanager.NewMockDeviceWireless(t) + wlan1 := mock_gonetworkmanager.NewMockDeviceWireless(t) + mockActive := mock_gonetworkmanager.NewMockActiveConnection(t) + mockConn := mock_gonetworkmanager.NewMockConnection(t) + + backend, err := NewNetworkManagerBackend(mockNM) + require.NoError(t, err) + backend.wifiDevices = map[string]*wifiDeviceInfo{ + "wlan0": {device: wlan0, wireless: wlan0, name: "wlan0"}, + "wlan1": {device: wlan1, wireless: wlan1, name: "wlan1"}, + } + + dmsSettings := buildHotspotSettings(HotspotRequest{SSID: "DMS Hotspot"}, nil) + + wlan0.EXPECT().GetPropertyManaged().Return(true, nil) + wlan0.EXPECT().GetPropertyWirelessCapabilities().Return(nmWiFiDeviceCapAP, nil) + wlan1.EXPECT().GetPropertyManaged().Return(true, nil) + wlan1.EXPECT().GetPropertyWirelessCapabilities().Return(nmWiFiDeviceCapAP, nil) + mockNM.EXPECT().GetPropertyActiveConnections().Return([]gonetworkmanager.ActiveConnection{mockActive}, nil).Once() + mockActive.EXPECT().GetPropertyType().Return("802-11-wireless", nil).Once() + mockActive.EXPECT().GetPropertyConnection().Return(mockConn, nil).Once() + mockConn.EXPECT().GetSettings().Return(dmsSettings, nil).Once() + mockActive.EXPECT().GetPropertyDevices().Return([]gonetworkmanager.Device{wlan0}, nil).Once() + wlan0.EXPECT().GetPath().Return(dbus.ObjectPath("/org/freedesktop/NetworkManager/Devices/1")) + wlan1.EXPECT().GetPath().Return(dbus.ObjectPath("/org/freedesktop/NetworkManager/Devices/2")) + wlan1.EXPECT().GetPropertyState().Return(gonetworkmanager.NmDeviceStateDisconnected, nil).Once() + + devInfo, err := backend.getAPCapableWiFiDevice("", "") + require.NoError(t, err) + assert.Equal(t, "wlan0", devInfo.name, "the radio already hosting the DMS hotspot keeps the preference") +} + +func TestConfigureHotspotRejectsNonAPCapableDevice(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + mockWiFi := mock_gonetworkmanager.NewMockDeviceWireless(t) + + backend, err := NewNetworkManagerBackend(mockNM) + require.NoError(t, err) + backend.wifiDevices = map[string]*wifiDeviceInfo{ + "wlan0": {device: mockWiFi, wireless: mockWiFi, name: "wlan0"}, + } + + mockWiFi.EXPECT().GetPropertyManaged().Return(true, nil) + mockWiFi.EXPECT().GetPropertyWirelessCapabilities().Return(uint32(0), nil) + + err = backend.ConfigureHotspot(HotspotRequest{SSID: "DMS Hotspot", Device: "wlan0"}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "not hotspot-capable") +} + +func TestConfigureHotspotRejectsChangesWhileActive(t *testing.T) { + tests := []struct { + name string + enabled bool + activating bool + }{ + {name: "enabled", enabled: true}, + {name: "activating", activating: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + backend, err := NewNetworkManagerBackend(mockNM) + require.NoError(t, err) + backend.state.HotspotEnabled = tt.enabled + backend.state.HotspotActivating = tt.activating + + err = backend.ConfigureHotspot(HotspotRequest{SSID: "DMS Hotspot"}) + assert.ErrorContains(t, err, "stop the hotspot") + }) + } +} + +func TestConfigureHotspotRejectsKnownIncompatibleAutoBand(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + mockWiFi := mock_gonetworkmanager.NewMockDeviceWireless(t) + + backend, err := NewNetworkManagerBackend(mockNM) + require.NoError(t, err) + backend.wifiDevices = map[string]*wifiDeviceInfo{ + "wlan0": {device: mockWiFi, wireless: mockWiFi, name: "wlan0"}, + } + + mockWiFi.EXPECT().GetPropertyManaged().Return(true, nil).Once() + mockWiFi.EXPECT().GetPropertyWirelessCapabilities().Return(nmWiFiDeviceCapAP|nmWiFiDeviceCapFreqValid|nmWiFiDeviceCapFreq2GHz, nil).Twice() + + err = backend.ConfigureHotspot(HotspotRequest{SSID: "DMS Hotspot", Band: "a"}) + assert.ErrorContains(t, err, "does not support 5GHz") +} + +func TestConfigureHotspotAllowsAutoDeviceWithoutCurrentAPCapableDevice(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + mockSettings := mock_gonetworkmanager.NewMockSettings(t) + mockConn := mock_gonetworkmanager.NewMockConnection(t) + + backend, err := NewNetworkManagerBackend(mockNM) + require.NoError(t, err) + backend.settings = mockSettings + backend.wifiDevices = map[string]*wifiDeviceInfo{} + + var stateChangeCalls int + backend.onStateChange = func() { + stateChangeCalls++ + } + + req := HotspotRequest{SSID: "DMS Hotspot", Password: "hunter2-password", Band: "a"} + expectedSettings := buildHotspotSettings(req, nil) + + mockSettings.EXPECT().ListConnections().Return([]gonetworkmanager.Connection{}, nil).Once() + mockSettings.EXPECT().AddConnection(expectedSettings).Return(mockConn, nil).Once() + mockSettings.EXPECT().ListConnections().Return([]gonetworkmanager.Connection{mockConn}, nil).Once() + mockConn.EXPECT().GetSettings().Return(expectedSettings, nil).Once() + mockNM.EXPECT().GetPropertyActiveConnections().Return([]gonetworkmanager.ActiveConnection{}, nil).Once() + + err = backend.ConfigureHotspot(req) + require.NoError(t, err) + assert.Equal(t, 0, stateChangeCalls) + + backend.stateMutex.RLock() + defer backend.stateMutex.RUnlock() + assert.False(t, backend.state.HotspotAvailable) + assert.True(t, backend.state.HotspotConfigured) + assert.False(t, backend.state.HotspotEnabled) + assert.Equal(t, "DMS Hotspot", backend.state.HotspotSSID) + assert.Empty(t, backend.state.HotspotDevice) +} + +func TestConfigureHotspotCreatesDMSProfile(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + mockSettings := mock_gonetworkmanager.NewMockSettings(t) + mockWiFi := mock_gonetworkmanager.NewMockDeviceWireless(t) + mockConn := mock_gonetworkmanager.NewMockConnection(t) + + backend, err := NewNetworkManagerBackend(mockNM) + require.NoError(t, err) + backend.settings = mockSettings + backend.wifiDevices = map[string]*wifiDeviceInfo{ + "wlan0": {device: mockWiFi, wireless: mockWiFi, name: "wlan0"}, + } + + req := HotspotRequest{SSID: "DMS Hotspot", Password: "hunter2-password", Device: "wlan0"} + expectedSettings := buildHotspotSettings(req, nil) + + mockWiFi.EXPECT().GetPropertyManaged().Return(true, nil).Twice() + mockWiFi.EXPECT().GetPropertyWirelessCapabilities().Return(nmWiFiDeviceCapAP, nil).Twice() + mockSettings.EXPECT().ListConnections().Return([]gonetworkmanager.Connection{}, nil).Once() + mockSettings.EXPECT().AddConnection(expectedSettings).Return(mockConn, nil).Once() + mockSettings.EXPECT().ListConnections().Return([]gonetworkmanager.Connection{mockConn}, nil).Once() + mockConn.EXPECT().GetSettings().Return(expectedSettings, nil).Once() + mockNM.EXPECT().GetPropertyActiveConnections().Return([]gonetworkmanager.ActiveConnection{}, nil).Once() + + err = backend.ConfigureHotspot(req) + require.NoError(t, err) + + backend.stateMutex.RLock() + defer backend.stateMutex.RUnlock() + assert.True(t, backend.state.HotspotAvailable) + assert.True(t, backend.state.HotspotConfigured) + assert.False(t, backend.state.HotspotEnabled) + assert.Equal(t, "DMS Hotspot", backend.state.HotspotSSID) + assert.Equal(t, "wlan0", backend.state.HotspotDevice) +} + +func TestGetSavedWiFiProfilesFiltersAPModeProfiles(t *testing.T) { + clientConn := mock_gonetworkmanager.NewMockConnection(t) + dmsHotspotConn := mock_gonetworkmanager.NewMockConnection(t) + userHotspotConn := mock_gonetworkmanager.NewMockConnection(t) + + clientSettings := gonetworkmanager.ConnectionSettings{ + "connection": { + "type": "802-11-wireless", + "autoconnect": true, + }, + "802-11-wireless": { + "mode": "infrastructure", + "ssid": []byte("Home WiFi"), + }, + } + dmsSettings := buildHotspotSettings(HotspotRequest{SSID: "DMS Hotspot"}, nil) + userAPSettings := gonetworkmanager.ConnectionSettings{ + "connection": { + "type": "802-11-wireless", + "id": "User AP", + }, + "802-11-wireless": { + "mode": "ap", + "ssid": []byte("User AP"), + }, + } + + clientConn.EXPECT().GetSettings().Return(clientSettings, nil).Once() + dmsHotspotConn.EXPECT().GetSettings().Return(dmsSettings, nil).Once() + userHotspotConn.EXPECT().GetSettings().Return(userAPSettings, nil).Once() + + profiles := getSavedWiFiProfiles([]gonetworkmanager.Connection{clientConn, dmsHotspotConn, userHotspotConn}) + + assert.Contains(t, profiles, "Home WiFi") + assert.NotContains(t, profiles, "DMS Hotspot") + assert.NotContains(t, profiles, "User AP") +} + +func TestUpdateWiFiNetworksFiltersAPModeAccessPoints(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + mockSettings := mock_gonetworkmanager.NewMockSettings(t) + mockWiFi := mock_gonetworkmanager.NewMockDeviceWireless(t) + mockAP := mock_gonetworkmanager.NewMockAccessPoint(t) + + backend, err := NewNetworkManagerBackend(mockNM) + require.NoError(t, err) + backend.settings = mockSettings + backend.wifiDevice = mockWiFi + backend.wifiDev = mockWiFi + + mockWiFi.EXPECT().GetAccessPoints().Return([]gonetworkmanager.AccessPoint{mockAP}, nil).Once() + mockSettings.EXPECT().ListConnections().Return([]gonetworkmanager.Connection{}, nil).Once() + mockAP.EXPECT().GetPropertySSID().Return("DMS Hotspot", nil).Once() + mockAP.EXPECT().GetPropertyMode().Return(gonetworkmanager.Nm80211ModeAp, nil).Once() + + networks, err := backend.updateWiFiNetworks() + require.NoError(t, err) + assert.Empty(t, networks) + + backend.stateMutex.RLock() + defer backend.stateMutex.RUnlock() + assert.Empty(t, backend.state.WiFiNetworks) + assert.Empty(t, backend.state.SavedWiFiNetworks) +} + +func TestFindConnectionIgnoresAPModeProfiles(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + mockSettings := mock_gonetworkmanager.NewMockSettings(t) + dmsHotspotConn := mock_gonetworkmanager.NewMockConnection(t) + clientConn := mock_gonetworkmanager.NewMockConnection(t) + + backend, err := NewNetworkManagerBackend(mockNM) + require.NoError(t, err) + backend.settings = mockSettings + + dmsSettings := buildHotspotSettings(HotspotRequest{SSID: "Shared SSID"}, nil) + clientSettings := gonetworkmanager.ConnectionSettings{ + "connection": { + "type": "802-11-wireless", + "id": "Shared SSID", + }, + "802-11-wireless": { + "mode": "infrastructure", + "ssid": []byte("Shared SSID"), + }, + } + + mockSettings.EXPECT().ListConnections().Return([]gonetworkmanager.Connection{dmsHotspotConn, clientConn}, nil).Once() + dmsHotspotConn.EXPECT().GetSettings().Return(dmsSettings, nil).Once() + clientConn.EXPECT().GetSettings().Return(clientSettings, nil).Once() + + conn, err := backend.findConnection("Shared SSID") + require.NoError(t, err) + assert.Same(t, clientConn, conn) +} + +func TestFindConnectionReturnsNotFoundForDMSHotspotOnly(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + mockSettings := mock_gonetworkmanager.NewMockSettings(t) + dmsHotspotConn := mock_gonetworkmanager.NewMockConnection(t) + + backend, err := NewNetworkManagerBackend(mockNM) + require.NoError(t, err) + backend.settings = mockSettings + + dmsSettings := buildHotspotSettings(HotspotRequest{SSID: "DMS Hotspot"}, nil) + + mockSettings.EXPECT().ListConnections().Return([]gonetworkmanager.Connection{dmsHotspotConn}, nil).Once() + dmsHotspotConn.EXPECT().GetSettings().Return(dmsSettings, nil).Once() + + _, err = backend.findConnection("DMS Hotspot") + assert.Error(t, err) +} + +func TestFindActiveDMSHotspotConnectionIgnoresUserAPProfiles(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + userConn := mock_gonetworkmanager.NewMockConnection(t) + dmsConn := mock_gonetworkmanager.NewMockConnection(t) + userActive := mock_gonetworkmanager.NewMockActiveConnection(t) + dmsActive := mock_gonetworkmanager.NewMockActiveConnection(t) + + backend, err := NewNetworkManagerBackend(mockNM) + require.NoError(t, err) + + userSettings := gonetworkmanager.ConnectionSettings{ + "connection": { + "type": "802-11-wireless", + "id": dmsHotspotConnectionID, + }, + "802-11-wireless": { + "mode": "ap", + "ssid": []byte("DMS Hotspot"), + }, + } + dmsSettings := buildHotspotSettings(HotspotRequest{SSID: "DMS Hotspot"}, nil) + + mockNM.EXPECT().GetPropertyActiveConnections().Return([]gonetworkmanager.ActiveConnection{userActive, dmsActive}, nil).Once() + userActive.EXPECT().GetPropertyType().Return("802-11-wireless", nil).Once() + userActive.EXPECT().GetPropertyConnection().Return(userConn, nil).Once() + userConn.EXPECT().GetSettings().Return(userSettings, nil).Once() + dmsActive.EXPECT().GetPropertyType().Return("802-11-wireless", nil).Once() + dmsActive.EXPECT().GetPropertyConnection().Return(dmsConn, nil).Once() + dmsConn.EXPECT().GetSettings().Return(dmsSettings, nil).Once() + + active, err := backend.findActiveDMSHotspotConnection() + require.NoError(t, err) + assert.Same(t, dmsActive, active) +} + +func TestUpdateWiFiStateSuppressesActiveAPModeConnection(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + mockWiFi := mock_gonetworkmanager.NewMockDeviceWireless(t) + mockConn := mock_gonetworkmanager.NewMockConnection(t) + mockActive := mock_gonetworkmanager.NewMockActiveConnection(t) + + backend, err := NewNetworkManagerBackend(mockNM) + require.NoError(t, err) + backend.wifiDevice = mockWiFi + backend.wifiDev = mockWiFi + + dmsSettings := buildHotspotSettings(HotspotRequest{SSID: "DMS Hotspot"}, nil) + + mockWiFi.EXPECT().GetPropertyInterface().Return("wlan0", nil).Once() + mockWiFi.EXPECT().GetPropertyState().Return(gonetworkmanager.NmDeviceStateActivated, nil).Once() + mockWiFi.EXPECT().GetPath().Return("/org/freedesktop/NetworkManager/Devices/1").Twice() + mockNM.EXPECT().GetPropertyActiveConnections().Return([]gonetworkmanager.ActiveConnection{mockActive}, nil).Once() + mockActive.EXPECT().GetPropertyType().Return("802-11-wireless", nil).Once() + mockActive.EXPECT().GetPropertyConnection().Return(mockConn, nil).Once() + mockConn.EXPECT().GetSettings().Return(dmsSettings, nil).Once() + mockActive.EXPECT().GetPropertyDevices().Return([]gonetworkmanager.Device{mockWiFi}, nil).Once() + + err = backend.updateWiFiState() + require.NoError(t, err) + + backend.stateMutex.RLock() + defer backend.stateMutex.RUnlock() + assert.Equal(t, "wlan0", backend.state.WiFiDevice) + assert.False(t, backend.state.WiFiConnected) + assert.Empty(t, backend.state.WiFiSSID) + assert.Empty(t, backend.state.WiFiIP) +} + +func TestUpdateAllWiFiDevicesSuppressesActiveAPModeConnection(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + mockSettings := mock_gonetworkmanager.NewMockSettings(t) + mockWiFi := mock_gonetworkmanager.NewMockDeviceWireless(t) + mockConn := mock_gonetworkmanager.NewMockConnection(t) + mockActive := mock_gonetworkmanager.NewMockActiveConnection(t) + + backend, err := NewNetworkManagerBackend(mockNM) + require.NoError(t, err) + backend.settings = mockSettings + backend.wifiDevices = map[string]*wifiDeviceInfo{ + "wlan0": {device: mockWiFi, wireless: mockWiFi, name: "wlan0", hwAddress: "00:11:22:33:44:55"}, + } + + dmsSettings := buildHotspotSettings(HotspotRequest{SSID: "DMS Hotspot"}, nil) + + mockSettings.EXPECT().ListConnections().Return([]gonetworkmanager.Connection{}, nil).Once() + mockWiFi.EXPECT().GetPropertyState().Return(gonetworkmanager.NmDeviceStateActivated, nil).Once() + mockWiFi.EXPECT().GetPath().Return("/org/freedesktop/NetworkManager/Devices/1").Twice() + mockNM.EXPECT().GetPropertyActiveConnections().Return([]gonetworkmanager.ActiveConnection{mockActive}, nil).Once() + mockActive.EXPECT().GetPropertyType().Return("802-11-wireless", nil).Once() + mockActive.EXPECT().GetPropertyConnection().Return(mockConn, nil).Once() + mockConn.EXPECT().GetSettings().Return(dmsSettings, nil).Once() + mockActive.EXPECT().GetPropertyDevices().Return([]gonetworkmanager.Device{mockWiFi}, nil).Once() + mockWiFi.EXPECT().GetAccessPoints().Return([]gonetworkmanager.AccessPoint{}, nil).Once() + mockWiFi.EXPECT().GetPropertyManaged().Return(true, nil).Once() + mockWiFi.EXPECT().GetPropertyWirelessCapabilities().Return(nmWiFiDeviceCapAP, nil).Once() + + backend.updateAllWiFiDevices() + + backend.stateMutex.RLock() + defer backend.stateMutex.RUnlock() + require.Len(t, backend.state.WiFiDevices, 1) + device := backend.state.WiFiDevices[0] + assert.Equal(t, "wlan0", device.Name) + assert.Equal(t, "disconnected", device.State) + assert.False(t, device.Connected) + assert.Empty(t, device.SSID) +} + +func TestUpdateHotspotStateDetectsRunningDMSHotspot(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + mockSettings := mock_gonetworkmanager.NewMockSettings(t) + mockWiFi := mock_gonetworkmanager.NewMockDeviceWireless(t) + mockConn := mock_gonetworkmanager.NewMockConnection(t) + mockActive := mock_gonetworkmanager.NewMockActiveConnection(t) + + backend, err := NewNetworkManagerBackend(mockNM) + require.NoError(t, err) + backend.settings = mockSettings + backend.wifiDevices = map[string]*wifiDeviceInfo{ + "wlan0": {device: mockWiFi, wireless: mockWiFi, name: "wlan0"}, + } + + settings := buildHotspotSettings(HotspotRequest{SSID: "DMS Hotspot", Device: "wlan0", Band: "bg"}, nil) + + mockWiFi.EXPECT().GetPropertyManaged().Return(true, nil).Once() + mockWiFi.EXPECT().GetPropertyWirelessCapabilities().Return(nmWiFiDeviceCapAP, nil).Once() + mockSettings.EXPECT().ListConnections().Return([]gonetworkmanager.Connection{mockConn}, nil).Once() + mockConn.EXPECT().GetSettings().Return(settings, nil).Twice() + mockNM.EXPECT().GetPropertyActiveConnections().Return([]gonetworkmanager.ActiveConnection{mockActive}, nil).Once() + mockActive.EXPECT().GetPropertyType().Return("802-11-wireless", nil).Once() + mockActive.EXPECT().GetPropertyConnection().Return(mockConn, nil).Once() + mockActive.EXPECT().GetPropertyState().Return(gonetworkmanager.NmActiveConnectionStateActivated, nil).Once() + + err = backend.updateHotspotState() + require.NoError(t, err) + + backend.stateMutex.RLock() + defer backend.stateMutex.RUnlock() + assert.True(t, backend.state.HotspotAvailable) + assert.True(t, backend.state.HotspotConfigured) + assert.True(t, backend.state.HotspotEnabled) + assert.Equal(t, "DMS Hotspot", backend.state.HotspotSSID) + assert.Equal(t, "wlan0", backend.state.HotspotDevice) + assert.Equal(t, "bg", backend.state.HotspotBand) +} + +func TestUpdateHotspotStateReportsActivating(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + mockSettings := mock_gonetworkmanager.NewMockSettings(t) + mockWiFi := mock_gonetworkmanager.NewMockDeviceWireless(t) + mockConn := mock_gonetworkmanager.NewMockConnection(t) + mockActive := mock_gonetworkmanager.NewMockActiveConnection(t) + + backend, err := NewNetworkManagerBackend(mockNM) + require.NoError(t, err) + backend.settings = mockSettings + backend.wifiDevices = map[string]*wifiDeviceInfo{ + "wlan0": {device: mockWiFi, wireless: mockWiFi, name: "wlan0"}, + } + + settings := buildHotspotSettings(HotspotRequest{SSID: "DMS Hotspot", Device: "wlan0", Band: "bg"}, nil) + + mockWiFi.EXPECT().GetPropertyManaged().Return(true, nil).Once() + mockWiFi.EXPECT().GetPropertyWirelessCapabilities().Return(nmWiFiDeviceCapAP, nil).Once() + mockSettings.EXPECT().ListConnections().Return([]gonetworkmanager.Connection{mockConn}, nil).Once() + mockConn.EXPECT().GetSettings().Return(settings, nil).Twice() + mockNM.EXPECT().GetPropertyActiveConnections().Return([]gonetworkmanager.ActiveConnection{mockActive}, nil).Once() + mockActive.EXPECT().GetPropertyType().Return("802-11-wireless", nil).Once() + mockActive.EXPECT().GetPropertyConnection().Return(mockConn, nil).Once() + mockActive.EXPECT().GetPropertyState().Return(gonetworkmanager.NmActiveConnectionStateActivating, nil).Once() + + err = backend.updateHotspotState() + require.NoError(t, err) + + backend.stateMutex.RLock() + defer backend.stateMutex.RUnlock() + assert.False(t, backend.state.HotspotEnabled) + assert.True(t, backend.state.HotspotActivating) + assert.Empty(t, backend.state.HotspotLastError) +} + +func TestUpdateHotspotStateReportsActivationFailure(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + mockSettings := mock_gonetworkmanager.NewMockSettings(t) + mockWiFi := mock_gonetworkmanager.NewMockDeviceWireless(t) + mockConn := mock_gonetworkmanager.NewMockConnection(t) + + backend, err := NewNetworkManagerBackend(mockNM) + require.NoError(t, err) + backend.settings = mockSettings + backend.wifiDevices = map[string]*wifiDeviceInfo{ + "wlan0": {device: mockWiFi, wireless: mockWiFi, name: "wlan0"}, + } + + backend.stateMutex.Lock() + backend.state.HotspotActivating = true + backend.hotspotPendingDevice = "wlan1" + backend.stateMutex.Unlock() + + settings := buildHotspotSettings(HotspotRequest{SSID: "DMS Hotspot", Device: "wlan0", Band: "bg"}, nil) + + mockWiFi.EXPECT().GetPropertyManaged().Return(true, nil).Once() + mockWiFi.EXPECT().GetPropertyWirelessCapabilities().Return(nmWiFiDeviceCapAP, nil).Once() + mockSettings.EXPECT().ListConnections().Return([]gonetworkmanager.Connection{mockConn}, nil).Once() + mockConn.EXPECT().GetSettings().Return(settings, nil).Once() + mockNM.EXPECT().GetPropertyActiveConnections().Return(nil, nil).Once() + + err = backend.updateHotspotState() + require.NoError(t, err) + + backend.stateMutex.RLock() + defer backend.stateMutex.RUnlock() + assert.False(t, backend.state.HotspotEnabled) + assert.False(t, backend.state.HotspotActivating) + assert.Equal(t, "hotspot-failed", backend.state.HotspotLastError) + assert.Empty(t, backend.hotspotPendingDevice) +} + +func TestClassifyHotspotStateReason(t *testing.T) { + // Raw value pinned on purpose: 5 is what NetworkManager reports for the + // missing-dnsmasq failure ('ip-config-unavailable'), and package-local + // aliases with the same names carry different, incorrect values. + assert.Equal(t, "hotspot-ip-config-failed", classifyHotspotStateReason(5)) + assert.Equal(t, "hotspot-ip-config-failed", classifyHotspotStateReason(gonetworkmanager.NmDeviceStateReasonSharedStartFailed)) + assert.Equal(t, "hotspot-ip-config-failed", classifyHotspotStateReason(gonetworkmanager.NmDeviceStateReasonDhcpFailed)) + assert.Equal(t, "hotspot-supplicant-failed", classifyHotspotStateReason(gonetworkmanager.NmDeviceStateReasonSupplicantFailed)) + assert.Equal(t, "hotspot-supplicant-failed", classifyHotspotStateReason(gonetworkmanager.NmDeviceStateReasonSupplicantDisconnect)) + assert.Equal(t, "hotspot-failed", classifyHotspotStateReason(gonetworkmanager.NmDeviceStateReasonModemNoCarrier)) +} + +func TestHotspotSecuredFromSettings(t *testing.T) { + secured := buildHotspotSettings(HotspotRequest{SSID: "DMS Hotspot", Password: "hunter2-password"}, nil) + assert.True(t, hotspotSecuredFromSettings(secured)) + + open := buildHotspotSettings(HotspotRequest{SSID: "DMS Hotspot"}, nil) + assert.False(t, hotspotSecuredFromSettings(open)) + + assert.False(t, hotspotSecuredFromSettings(nil)) +} + +func TestGetHotspotSecrets(t *testing.T) { + t.Run("secured profile returns psk", func(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + mockSettings := mock_gonetworkmanager.NewMockSettings(t) + mockConn := mock_gonetworkmanager.NewMockConnection(t) + + backend, err := NewNetworkManagerBackend(mockNM) + require.NoError(t, err) + backend.settings = mockSettings + + settings := buildHotspotSettings(HotspotRequest{SSID: "DMS Hotspot", Password: "hunter2-password"}, nil) + + mockSettings.EXPECT().ListConnections().Return([]gonetworkmanager.Connection{mockConn}, nil).Once() + mockConn.EXPECT().GetSettings().Return(settings, nil).Once() + mockConn.EXPECT().GetSecrets("802-11-wireless-security").Return(gonetworkmanager.ConnectionSettings{ + "802-11-wireless-security": {"psk": "hunter2-password"}, + }, nil).Once() + + password, err := backend.GetHotspotSecrets() + require.NoError(t, err) + assert.Equal(t, "hunter2-password", password) + }) + + t.Run("open profile skips secrets lookup", func(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + mockSettings := mock_gonetworkmanager.NewMockSettings(t) + mockConn := mock_gonetworkmanager.NewMockConnection(t) + + backend, err := NewNetworkManagerBackend(mockNM) + require.NoError(t, err) + backend.settings = mockSettings + + settings := buildHotspotSettings(HotspotRequest{SSID: "DMS Hotspot"}, nil) + + mockSettings.EXPECT().ListConnections().Return([]gonetworkmanager.Connection{mockConn}, nil).Once() + mockConn.EXPECT().GetSettings().Return(settings, nil).Once() + + password, err := backend.GetHotspotSecrets() + require.NoError(t, err) + assert.Empty(t, password) + }) + + t.Run("unconfigured returns error", func(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + mockSettings := mock_gonetworkmanager.NewMockSettings(t) + + backend, err := NewNetworkManagerBackend(mockNM) + require.NoError(t, err) + backend.settings = mockSettings + + mockSettings.EXPECT().ListConnections().Return(nil, nil).Once() + + _, err = backend.GetHotspotSecrets() + assert.Error(t, err) + }) +} diff --git a/core/internal/server/network/backend_networkmanager_signals.go b/core/internal/server/network/backend_networkmanager_signals.go index 6fe525265..d5b951b0f 100644 --- a/core/internal/server/network/backend_networkmanager_signals.go +++ b/core/internal/server/network/backend_networkmanager_signals.go @@ -261,6 +261,7 @@ func (b *NetworkManagerBackend) handleDBusSignal(sig *dbus.Signal) { if err := b.updateSavedWiFiNetworks(); err != nil { b.updateWiFiNetworks() } + b.updateHotspotState() if b.onStateChange != nil { b.onStateChange() } @@ -360,6 +361,7 @@ func (b *NetworkManagerBackend) handleNetworkManagerChange(changes map[string]db if _, exists := changes["ActiveConnections"]; exists { b.updateVPNConnectionState() b.ListActiveVPN() + b.updateHotspotState() } if b.onStateChange != nil { b.onStateChange() @@ -370,6 +372,7 @@ func (b *NetworkManagerBackend) handleNetworkManagerChange(changes map[string]db func (b *NetworkManagerBackend) handleActiveConnectionStateChange() { b.updateVPNConnectionState() b.ListActiveVPN() + b.updateHotspotState() if b.onStateChange != nil { b.onStateChange() } @@ -409,9 +412,15 @@ func (b *NetworkManagerBackend) handleDeviceChange(devicePath dbus.ObjectPath, c if managedChanged { if managedVariant, ok := changes["Managed"]; ok { - if managed, ok := managedVariant.Value().(bool); ok && managed { - b.handleDeviceAdded(devicePath) - return + if managed, ok := managedVariant.Value().(bool); ok { + if managed { + b.handleDeviceAdded(devicePath) + return + } + // Newly unmanaged devices stay tracked (matching Initialize), + // but capability-dependent state such as hotspotAvailable + // must be recomputed and broadcast. + needsUpdate = true } } } @@ -424,6 +433,7 @@ func (b *NetworkManagerBackend) handleDeviceChange(devicePath dbus.ObjectPath, c b.updateEthernetState() b.updateAllWiFiDevices() b.updateWiFiState() + b.updateHotspotState() if stateChanged { b.listEthernetConnections() b.updatePrimaryConnection() @@ -587,6 +597,7 @@ func (b *NetworkManagerBackend) handleDeviceAdded(devicePath dbus.ObjectPath) { b.updateAllWiFiDevices() b.updateWiFiState() + b.updateHotspotState() } if b.onStateChange != nil { @@ -642,6 +653,7 @@ func (b *NetworkManagerBackend) handleDeviceRemoved(devicePath dbus.ObjectPath) b.updateAllWiFiDevices() b.updateWiFiState() + b.updateHotspotState() if b.onStateChange != nil { b.onStateChange() diff --git a/core/internal/server/network/backend_networkmanager_signals_test.go b/core/internal/server/network/backend_networkmanager_signals_test.go index 6b58684ae..b991cfcee 100644 --- a/core/internal/server/network/backend_networkmanager_signals_test.go +++ b/core/internal/server/network/backend_networkmanager_signals_test.go @@ -9,11 +9,21 @@ import ( "github.com/stretchr/testify/assert" ) +// emptySettingsMock keeps tests hermetic: without it, networkManagerSettings() +// falls back to the real system D-Bus and picks up whatever profiles exist on +// the developer's machine. +func emptySettingsMock(t *testing.T) *mock_gonetworkmanager.MockSettings { + mockSettings := mock_gonetworkmanager.NewMockSettings(t) + mockSettings.EXPECT().ListConnections().Return(nil, nil).Maybe() + return mockSettings +} + func TestNetworkManagerBackend_HandleDBusSignal_NewConnection(t *testing.T) { mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) backend, err := NewNetworkManagerBackend(mockNM) assert.NoError(t, err) + backend.settings = emptySettingsMock(t) sig := &dbus.Signal{ Name: "org.freedesktop.NetworkManager.Settings.NewConnection", @@ -30,6 +40,7 @@ func TestNetworkManagerBackend_HandleDBusSignal_ConnectionRemoved(t *testing.T) backend, err := NewNetworkManagerBackend(mockNM) assert.NoError(t, err) + backend.settings = emptySettingsMock(t) sig := &dbus.Signal{ Name: "org.freedesktop.NetworkManager.Settings.ConnectionRemoved", @@ -168,6 +179,7 @@ func TestNetworkManagerBackend_HandleDeviceChange_Ip4Config(t *testing.T) { backend, err := NewNetworkManagerBackend(mockNM) assert.NoError(t, err) + backend.settings = emptySettingsMock(t) changes := map[string]dbus.Variant{ "Ip4Config": dbus.MakeVariant("/"), @@ -178,6 +190,39 @@ func TestNetworkManagerBackend_HandleDeviceChange_Ip4Config(t *testing.T) { }) } +func TestNetworkManagerBackend_HandleDeviceChange_UnmanagedRefreshesHotspotState(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + mockWiFi := mock_gonetworkmanager.NewMockDeviceWireless(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + backend.settings = emptySettingsMock(t) + backend.wifiDevices = map[string]*wifiDeviceInfo{ + "wlan0": {device: mockWiFi, wireless: mockWiFi, name: "wlan0"}, + } + backend.state.HotspotAvailable = true + + stateChangeCalls := 0 + backend.onStateChange = func() { + stateChangeCalls++ + } + + mockWiFi.EXPECT().GetPropertyState().Return(gonetworkmanager.NmDeviceStateUnavailable, nil) + mockWiFi.EXPECT().GetAccessPoints().Return([]gonetworkmanager.AccessPoint{}, nil) + mockWiFi.EXPECT().GetPropertyManaged().Return(false, nil) + + changes := map[string]dbus.Variant{ + "Managed": dbus.MakeVariant(false), + } + backend.handleDeviceChange("/org/freedesktop/NetworkManager/Devices/1", changes) + + assert.Equal(t, 1, stateChangeCalls, "unmanaged transition must notify subscribers") + + backend.stateMutex.RLock() + defer backend.stateMutex.RUnlock() + assert.False(t, backend.state.HotspotAvailable, "availability must be recomputed when the only AP-capable radio becomes unmanaged") +} + func TestNetworkManagerBackend_HandleWiFiChange_ActiveAccessPoint(t *testing.T) { mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) diff --git a/core/internal/server/network/backend_networkmanager_state.go b/core/internal/server/network/backend_networkmanager_state.go index c1361cc0f..216c18903 100644 --- a/core/internal/server/network/backend_networkmanager_state.go +++ b/core/internal/server/network/backend_networkmanager_state.go @@ -163,6 +163,12 @@ func (b *NetworkManagerBackend) updateWiFiState() error { } connected := state == gonetworkmanager.NmDeviceStateActivated + if connected { + apModeDevicePaths := b.activeAPModeWiFiDevicePaths() + if apModeDevicePaths[string(dev.GetPath())] { + connected = false + } + } failed := state == gonetworkmanager.NmDeviceStateFailed disconnected := state == gonetworkmanager.NmDeviceStateDisconnected diff --git a/core/internal/server/network/backend_networkmanager_wifi.go b/core/internal/server/network/backend_networkmanager_wifi.go index 1cfe2d3ad..e62eee6f1 100644 --- a/core/internal/server/network/backend_networkmanager_wifi.go +++ b/core/internal/server/network/backend_networkmanager_wifi.go @@ -94,25 +94,23 @@ func (b *NetworkManagerBackend) GetWiFiNetworkDetails(ssid string) (*NetworkInfo autoconnectMap := make(map[string]bool) for _, conn := range connections { connSettings, err := conn.GetSettings() - if err != nil { + if err != nil || !isClientWiFiConnection(connSettings) { continue } - if connMeta, ok := connSettings["connection"]; ok { - if connType, ok := connMeta["type"].(string); ok && connType == "802-11-wireless" { - if wifiSettings, ok := connSettings["802-11-wireless"]; ok { - if ssidBytes, ok := wifiSettings["ssid"].([]byte); ok { - savedSSID := string(ssidBytes) - savedSSIDs[savedSSID] = true - autoconnect := true - if ac, ok := connMeta["autoconnect"].(bool); ok { - autoconnect = ac - } - autoconnectMap[savedSSID] = autoconnect - } - } - } + connMeta, wifiSettings, _ := wifiConnectionSettings(connSettings) + ssidBytes, ok := wifiSettings["ssid"].([]byte) + if !ok { + continue } + + savedSSID := string(ssidBytes) + savedSSIDs[savedSSID] = true + autoconnect := true + if ac, ok := connMeta["autoconnect"].(bool); ok { + autoconnect = ac + } + autoconnectMap[savedSSID] = autoconnect } b.stateMutex.RLock() @@ -128,6 +126,11 @@ func (b *NetworkManagerBackend) GetWiFiNetworkDetails(ssid string) (*NetworkInfo continue } + mode, _ := ap.GetPropertyMode() + if mode == gonetworkmanager.Nm80211ModeAp { + continue + } + strength, _ := ap.GetPropertyStrength() flags, _ := ap.GetPropertyFlags() wpaFlags, _ := ap.GetPropertyWPAFlags() @@ -135,7 +138,6 @@ func (b *NetworkManagerBackend) GetWiFiNetworkDetails(ssid string) (*NetworkInfo freq, _ := ap.GetPropertyFrequency() maxBitrate, _ := ap.GetPropertyMaxBitrate() bssid, _ := ap.GetPropertyHWAddress() - mode, _ := ap.GetPropertyMode() secured := flags != uint32(gonetworkmanager.Nm80211APFlagsNone) || wpaFlags != uint32(gonetworkmanager.Nm80211APSecNone) || @@ -418,24 +420,11 @@ func getSavedWiFiProfiles(connections []gonetworkmanager.Connection) map[string] for _, conn := range connections { connSettings, err := conn.GetSettings() - if err != nil { + if err != nil || !isClientWiFiConnection(connSettings) { continue } - connMeta, ok := connSettings["connection"] - if !ok { - continue - } - - connType, ok := connMeta["type"].(string) - if !ok || connType != "802-11-wireless" { - continue - } - - wifiSettings, ok := connSettings["802-11-wireless"] - if !ok { - continue - } + connMeta, wifiSettings, _ := wifiConnectionSettings(connSettings) ssidBytes, ok := wifiSettings["ssid"].([]byte) if !ok || len(ssidBytes) == 0 { @@ -535,6 +524,10 @@ func (b *NetworkManagerBackend) updateWiFiNetworks() ([]WiFiNetwork, error) { if err != nil || ssid == "" { continue } + mode, _ := ap.GetPropertyMode() + if mode == gonetworkmanager.Nm80211ModeAp { + continue + } if existingIndex, exists := seenSSIDs[ssid]; exists { existing := &networks[existingIndex] @@ -556,7 +549,6 @@ func (b *NetworkManagerBackend) updateWiFiNetworks() ([]WiFiNetwork, error) { freq, _ := ap.GetPropertyFrequency() maxBitrate, _ := ap.GetPropertyMaxBitrate() bssid, _ := ap.GetPropertyHWAddress() - mode, _ := ap.GetPropertyMode() secured := flags != uint32(gonetworkmanager.Nm80211APFlagsNone) || wpaFlags != uint32(gonetworkmanager.Nm80211APSecNone) || @@ -695,21 +687,16 @@ func (b *NetworkManagerBackend) findConnection(ssid string) (gonetworkmanager.Co ssidBytes := []byte(ssid) for _, conn := range connections { connSettings, err := conn.GetSettings() - if err != nil { + if err != nil || !isClientWiFiConnection(connSettings) { continue } - if connMeta, ok := connSettings["connection"]; ok { - if connType, ok := connMeta["type"].(string); ok && connType == "802-11-wireless" { - if wifiSettings, ok := connSettings["802-11-wireless"]; ok { - if candidateSSID, ok := wifiSettings["ssid"].([]byte); ok { - if bytes.Equal(candidateSSID, ssidBytes) { - return conn, nil - } - log.Debugf("[findConnection] SSID mismatch: stored=%q, request=%q", string(candidateSSID), ssid) - } - } + _, wifiSettings, _ := wifiConnectionSettings(connSettings) + if candidateSSID, ok := wifiSettings["ssid"].([]byte); ok { + if bytes.Equal(candidateSSID, ssidBytes) { + return conn, nil } + log.Debugf("[findConnection] SSID mismatch: stored=%q, request=%q", string(candidateSSID), ssid) } } @@ -1069,9 +1056,18 @@ func (b *NetworkManagerBackend) updateAllWiFiDevices() { wifiConnected := b.state.WiFiConnected b.stateMutex.RUnlock() + var apModeDevicePaths map[string]bool for name, devInfo := range b.wifiDevicesSnapshot() { state, _ := devInfo.device.GetPropertyState() connected := state == gonetworkmanager.NmDeviceStateActivated + if connected { + if apModeDevicePaths == nil { + apModeDevicePaths = b.activeAPModeWiFiDevicePaths() + } + if apModeDevicePaths[string(devInfo.device.GetPath())] { + connected = false + } + } var ssid, bssid, ip string var signal uint8 @@ -1088,7 +1084,9 @@ func (b *NetworkManagerBackend) updateAllWiFiDevices() { stateStr := "disconnected" switch state { case gonetworkmanager.NmDeviceStateActivated: - stateStr = "connected" + if connected { + stateStr = "connected" + } case gonetworkmanager.NmDeviceStateConfig, gonetworkmanager.NmDeviceStateIpConfig: stateStr = "connecting" case gonetworkmanager.NmDeviceStatePrepare: @@ -1107,6 +1105,10 @@ func (b *NetworkManagerBackend) updateAllWiFiDevices() { if err != nil || apSSID == "" { continue } + mode, _ := ap.GetPropertyMode() + if mode == gonetworkmanager.Nm80211ModeAp { + continue + } if existingIndex, exists := seenSSIDs[apSSID]; exists { existing := &networks[existingIndex] @@ -1128,7 +1130,6 @@ func (b *NetworkManagerBackend) updateAllWiFiDevices() { freq, _ := ap.GetPropertyFrequency() maxBitrate, _ := ap.GetPropertyMaxBitrate() apBSSID, _ := ap.GetPropertyHWAddress() - mode, _ := ap.GetPropertyMode() secured := flags != uint32(gonetworkmanager.Nm80211APFlagsNone) || wpaFlags != uint32(gonetworkmanager.Nm80211APSecNone) || @@ -1208,11 +1209,14 @@ func (b *NetworkManagerBackend) updateAllWiFiDevices() { sortWiFiNetworks(networks) } + apCapable, _ := isAPCapableWiFiDevice(devInfo) + devices = append(devices, WiFiDevice{ Name: name, HwAddress: devInfo.hwAddress, State: stateStr, Connected: connected, + APCapable: apCapable, SSID: ssid, BSSID: bssid, Signal: signal, diff --git a/core/internal/server/network/handlers.go b/core/internal/server/network/handlers.go index d0b132061..f588f9c2c 100644 --- a/core/internal/server/network/handlers.go +++ b/core/internal/server/network/handlers.go @@ -79,6 +79,14 @@ func HandleRequest(conn *models.Conn, req models.Request, manager *Manager) { handleSetVPNCredentials(conn, req, manager) case "network.wifi.setAutoconnect": handleSetWiFiAutoconnect(conn, req, manager) + case "network.hotspot.configure": + handleConfigureHotspot(conn, req, manager) + case "network.hotspot.start": + handleStartHotspot(conn, req, manager) + case "network.hotspot.stop": + handleStopHotspot(conn, req, manager) + case "network.hotspot.getSecrets": + handleGetHotspotSecrets(conn, req, manager) default: models.RespondError(conn, req.ID, fmt.Sprintf("unknown method: %s", req.Method)) } @@ -529,6 +537,56 @@ func handleSetWiFiAutoconnect(conn *models.Conn, req models.Request, manager *Ma models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "autoconnect updated"}) } +func handleConfigureHotspot(conn *models.Conn, req models.Request, manager *Manager) { + ssid, err := params.String(req.Params, "ssid") + if err != nil { + models.RespondError(conn, req.ID, err.Error()) + return + } + + hotspotReq := HotspotRequest{ + SSID: ssid, + Password: params.StringOpt(req.Params, "password", ""), + Device: params.StringOpt(req.Params, "device", ""), + Band: params.StringOpt(req.Params, "band", ""), + } + + if err := manager.ConfigureHotspot(hotspotReq); err != nil { + models.RespondError(conn, req.ID, fmt.Sprintf("failed to configure hotspot: %v", err)) + return + } + + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "hotspot configured"}) +} + +func handleStartHotspot(conn *models.Conn, req models.Request, manager *Manager) { + if err := manager.StartHotspot(); err != nil { + models.RespondError(conn, req.ID, fmt.Sprintf("failed to start hotspot: %v", err)) + return + } + + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "hotspot started"}) +} + +func handleStopHotspot(conn *models.Conn, req models.Request, manager *Manager) { + if err := manager.StopHotspot(); err != nil { + models.RespondError(conn, req.ID, fmt.Sprintf("failed to stop hotspot: %v", err)) + return + } + + models.Respond(conn, req.ID, models.SuccessResult{Success: true, Message: "hotspot stopped"}) +} + +func handleGetHotspotSecrets(conn *models.Conn, req models.Request, manager *Manager) { + password, err := manager.GetHotspotSecrets() + if err != nil { + models.RespondError(conn, req.ID, fmt.Sprintf("failed to get hotspot secrets: %v", err)) + return + } + + models.Respond(conn, req.ID, map[string]string{"password": password}) +} + func handleListVPNPlugins(conn *models.Conn, req models.Request, manager *Manager) { plugins, err := manager.ListVPNPlugins() if err != nil { diff --git a/core/internal/server/network/handlers_test.go b/core/internal/server/network/handlers_test.go index 092ccf9cf..383ce51db 100644 --- a/core/internal/server/network/handlers_test.go +++ b/core/internal/server/network/handlers_test.go @@ -172,6 +172,143 @@ func TestHandleSetPreference(t *testing.T) { }) } +func TestHandleHotspotRequests(t *testing.T) { + t.Run("configure requires ssid", func(t *testing.T) { + manager := &Manager{state: &NetworkState{}} + mc := newMockNetConn() + conn := models.NewConn(mc) + req := models.Request{ + ID: 123, + Method: "network.hotspot.configure", + Params: map[string]any{}, + } + + handleConfigureHotspot(conn, req, manager) + + var resp models.Response[any] + err := json.NewDecoder(mc.writeBuf).Decode(&resp) + require.NoError(t, err) + + assert.Equal(t, 123, resp.ID) + assert.Contains(t, resp.Error, "missing or invalid 'ssid' parameter") + }) + + t.Run("configure dispatches request", func(t *testing.T) { + iwdBackend, err := NewIWDBackend() + require.NoError(t, err) + backend := &testHotspotBackend{IWDBackend: iwdBackend} + manager := NewTestManager(backend, &NetworkState{}) + mc := newMockNetConn() + conn := models.NewConn(mc) + req := models.Request{ + ID: 123, + Method: "network.hotspot.configure", + Params: map[string]any{ + "ssid": "DMS Hotspot", + "password": "hunter2-password", + "device": "wlan0", + "band": "bg", + }, + } + + HandleRequest(conn, req, manager) + + var resp models.Response[models.SuccessResult] + err = json.NewDecoder(mc.writeBuf).Decode(&resp) + require.NoError(t, err) + + assert.Equal(t, 123, resp.ID) + assert.Empty(t, resp.Error) + require.NotNil(t, resp.Result) + assert.True(t, resp.Result.Success) + assert.True(t, backend.configureCalled) + assert.Equal(t, HotspotRequest{ + SSID: "DMS Hotspot", + Password: "hunter2-password", + Device: "wlan0", + Band: "bg", + }, backend.configureReq) + }) + + t.Run("start dispatches without payload", func(t *testing.T) { + iwdBackend, err := NewIWDBackend() + require.NoError(t, err) + backend := &testHotspotBackend{IWDBackend: iwdBackend} + manager := NewTestManager(backend, &NetworkState{}) + mc := newMockNetConn() + conn := models.NewConn(mc) + req := models.Request{ID: 123, Method: "network.hotspot.start"} + + HandleRequest(conn, req, manager) + + var resp models.Response[models.SuccessResult] + err = json.NewDecoder(mc.writeBuf).Decode(&resp) + require.NoError(t, err) + + assert.Equal(t, 123, resp.ID) + assert.Empty(t, resp.Error) + assert.True(t, backend.startCalled) + }) + + t.Run("stop dispatches", func(t *testing.T) { + iwdBackend, err := NewIWDBackend() + require.NoError(t, err) + backend := &testHotspotBackend{IWDBackend: iwdBackend} + manager := NewTestManager(backend, &NetworkState{}) + mc := newMockNetConn() + conn := models.NewConn(mc) + req := models.Request{ID: 123, Method: "network.hotspot.stop"} + + HandleRequest(conn, req, manager) + + var resp models.Response[models.SuccessResult] + err = json.NewDecoder(mc.writeBuf).Decode(&resp) + require.NoError(t, err) + + assert.Equal(t, 123, resp.ID) + assert.Empty(t, resp.Error) + assert.True(t, backend.stopCalled) + }) + + t.Run("getSecrets dispatches", func(t *testing.T) { + iwdBackend, err := NewIWDBackend() + require.NoError(t, err) + backend := &testHotspotBackend{IWDBackend: iwdBackend, secrets: "hunter2-password"} + manager := NewTestManager(backend, &NetworkState{}) + mc := newMockNetConn() + conn := models.NewConn(mc) + req := models.Request{ID: 123, Method: "network.hotspot.getSecrets"} + + HandleRequest(conn, req, manager) + + var resp models.Response[map[string]string] + err = json.NewDecoder(mc.writeBuf).Decode(&resp) + require.NoError(t, err) + + assert.Equal(t, 123, resp.ID) + assert.Empty(t, resp.Error) + assert.True(t, backend.getSecretsCalled) + require.NotNil(t, resp.Result) + assert.Equal(t, "hunter2-password", (*resp.Result)["password"]) + }) + + t.Run("unsupported backend returns error", func(t *testing.T) { + manager := &Manager{state: &NetworkState{}} + mc := newMockNetConn() + conn := models.NewConn(mc) + req := models.Request{ID: 123, Method: "network.hotspot.start"} + + HandleRequest(conn, req, manager) + + var resp models.Response[any] + err := json.NewDecoder(mc.writeBuf).Decode(&resp) + require.NoError(t, err) + + assert.Equal(t, 123, resp.ID) + assert.Contains(t, resp.Error, ErrHotspotNotSupported.Error()) + }) +} + func TestHandleGetNetworkInfo(t *testing.T) { t.Run("missing ssid parameter", func(t *testing.T) { manager := &Manager{ diff --git a/core/internal/server/network/manager.go b/core/internal/server/network/manager.go index b91ae50b8..8c586c07a 100644 --- a/core/internal/server/network/manager.go +++ b/core/internal/server/network/manager.go @@ -16,6 +16,10 @@ import ( // on the system. var ErrNoNetworkBackend = errors.New("no supported network backend found") +// ErrHotspotNotSupported is returned when the active backend does not implement +// hotspot operations. +var ErrHotspotNotSupported = errors.New("hotspot not supported by active network backend") + func NewManager() (*Manager, error) { detection, err := DetectNetworkStack() if err != nil { @@ -112,11 +116,17 @@ func NewManager() (*Manager, error) { return m, nil } +func (m *Manager) hotspotBackend() (HotspotBackend, bool) { + backend, ok := m.backend.(HotspotBackend) + return backend, ok +} + func (m *Manager) syncStateFromBackend() error { backendState, err := m.backend.GetCurrentState() if err != nil { return err } + _, hotspotSupported := m.hotspotBackend() m.stateMutex.Lock() m.state.Backend = backendState.Backend @@ -136,6 +146,28 @@ func (m *Manager) syncStateFromBackend() error { m.state.WiFiNetworks = backendState.WiFiNetworks m.state.SavedWiFiNetworks = backendState.SavedWiFiNetworks m.state.WiFiDevices = backendState.WiFiDevices + m.state.HotspotSupported = hotspotSupported + if hotspotSupported { + m.state.HotspotAvailable = backendState.HotspotAvailable + m.state.HotspotConfigured = backendState.HotspotConfigured + m.state.HotspotEnabled = backendState.HotspotEnabled + m.state.HotspotActivating = backendState.HotspotActivating + m.state.HotspotSecured = backendState.HotspotSecured + m.state.HotspotSSID = backendState.HotspotSSID + m.state.HotspotDevice = backendState.HotspotDevice + m.state.HotspotBand = backendState.HotspotBand + m.state.HotspotLastError = backendState.HotspotLastError + } else { + m.state.HotspotAvailable = false + m.state.HotspotConfigured = false + m.state.HotspotEnabled = false + m.state.HotspotActivating = false + m.state.HotspotSecured = false + m.state.HotspotSSID = "" + m.state.HotspotDevice = "" + m.state.HotspotBand = "" + m.state.HotspotLastError = "" + } m.state.WiredConnections = backendState.WiredConnections m.state.VPNProfiles = backendState.VPNProfiles m.state.VPNActive = backendState.VPNActive @@ -210,11 +242,37 @@ func stateChangedMeaningfully(old, new *NetworkState) bool { if old.WiFiIP != new.WiFiIP { return true } - if !signalChangeSignificant(old.WiFiSignal, new.WiFiSignal) { - if old.WiFiSignal != new.WiFiSignal { - return false - } - } else if old.WiFiSignal != new.WiFiSignal { + if old.HotspotSupported != new.HotspotSupported { + return true + } + if old.HotspotAvailable != new.HotspotAvailable { + return true + } + if old.HotspotConfigured != new.HotspotConfigured { + return true + } + if old.HotspotEnabled != new.HotspotEnabled { + return true + } + if old.HotspotActivating != new.HotspotActivating { + return true + } + if old.HotspotSecured != new.HotspotSecured { + return true + } + if old.HotspotLastError != new.HotspotLastError { + return true + } + if old.HotspotSSID != new.HotspotSSID { + return true + } + if old.HotspotDevice != new.HotspotDevice { + return true + } + if old.HotspotBand != new.HotspotBand { + return true + } + if old.WiFiSignal != new.WiFiSignal && signalChangeSignificant(old.WiFiSignal, new.WiFiSignal) { return true } if old.IsConnecting != new.IsConnecting { @@ -279,6 +337,28 @@ func stateChangedMeaningfully(old, new *NetworkState) bool { } } + // Per-device signal strength is intentionally not compared: it churns + // constantly and the primary device's signal is already debounced above. + for i := range old.WiFiDevices { + oldDev := &old.WiFiDevices[i] + newDev := &new.WiFiDevices[i] + if oldDev.Name != newDev.Name { + return true + } + if oldDev.State != newDev.State { + return true + } + if oldDev.Connected != newDev.Connected { + return true + } + if oldDev.APCapable != newDev.APCapable { + return true + } + if oldDev.SSID != newDev.SSID { + return true + } + } + for i := range old.WiredConnections { oldNet := &old.WiredConnections[i] newNet := &new.WiredConnections[i] @@ -562,6 +642,69 @@ func (m *Manager) DisconnectWiFi() error { return m.backend.DisconnectWiFi() } +func (m *Manager) ConfigureHotspot(req HotspotRequest) error { + backend, ok := m.hotspotBackend() + if !ok { + return ErrHotspotNotSupported + } + + if err := backend.ConfigureHotspot(req); err != nil { + return err + } + + if err := m.syncStateFromBackend(); err != nil { + return err + } + m.notifySubscribers() + + return nil +} + +func (m *Manager) StartHotspot() error { + backend, ok := m.hotspotBackend() + if !ok { + return ErrHotspotNotSupported + } + + if err := backend.StartHotspot(); err != nil { + return err + } + + if err := m.syncStateFromBackend(); err != nil { + return err + } + m.notifySubscribers() + + return nil +} + +func (m *Manager) StopHotspot() error { + backend, ok := m.hotspotBackend() + if !ok { + return ErrHotspotNotSupported + } + + if err := backend.StopHotspot(); err != nil { + return err + } + + if err := m.syncStateFromBackend(); err != nil { + return err + } + m.notifySubscribers() + + return nil +} + +func (m *Manager) GetHotspotSecrets() (string, error) { + backend, ok := m.hotspotBackend() + if !ok { + return "", ErrHotspotNotSupported + } + + return backend.GetHotspotSecrets() +} + func (m *Manager) ForgetWiFiNetwork(ssid string) error { return m.backend.ForgetWiFiNetwork(ssid) } diff --git a/core/internal/server/network/manager_test.go b/core/internal/server/network/manager_test.go index 79545c546..8c53ce5a4 100644 --- a/core/internal/server/network/manager_test.go +++ b/core/internal/server/network/manager_test.go @@ -10,9 +10,13 @@ import ( func TestManager_GetState(t *testing.T) { state := &NetworkState{ - NetworkStatus: StatusWiFi, - WiFiSSID: "TestNetwork", - WiFiConnected: true, + NetworkStatus: StatusWiFi, + WiFiSSID: "TestNetwork", + WiFiConnected: true, + HotspotSupported: true, + HotspotAvailable: true, + HotspotConfigured: true, + HotspotSSID: "DMS Hotspot", } manager := &Manager{ @@ -24,6 +28,233 @@ func TestManager_GetState(t *testing.T) { assert.Equal(t, StatusWiFi, result.NetworkStatus) assert.Equal(t, "TestNetwork", result.WiFiSSID) assert.True(t, result.WiFiConnected) + assert.True(t, result.HotspotSupported) + assert.True(t, result.HotspotAvailable) + assert.True(t, result.HotspotConfigured) + assert.Equal(t, "DMS Hotspot", result.HotspotSSID) +} + +func TestStateChangedMeaningfully_HotspotFields(t *testing.T) { + tests := []struct { + name string + old NetworkState + new NetworkState + }{ + { + name: "supported", + old: NetworkState{}, + new: NetworkState{HotspotSupported: true}, + }, + { + name: "available", + old: NetworkState{}, + new: NetworkState{HotspotAvailable: true}, + }, + { + name: "configured", + old: NetworkState{}, + new: NetworkState{HotspotConfigured: true}, + }, + { + name: "enabled", + old: NetworkState{}, + new: NetworkState{HotspotEnabled: true}, + }, + { + name: "ssid", + old: NetworkState{HotspotSSID: "DMS Hotspot"}, + new: NetworkState{HotspotSSID: "Other Hotspot"}, + }, + { + name: "device", + old: NetworkState{HotspotDevice: "wlan0"}, + new: NetworkState{HotspotDevice: "wlan1"}, + }, + { + name: "band", + old: NetworkState{HotspotBand: "bg"}, + new: NetworkState{HotspotBand: "a"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.True(t, stateChangedMeaningfully(&tt.old, &tt.new)) + }) + } +} + +func TestStateChangedMeaningfully_WiFiDeviceFields(t *testing.T) { + device := func(mutate func(d *WiFiDevice)) []WiFiDevice { + d := WiFiDevice{Name: "wlan1", State: "disconnected", Signal: 60} + if mutate != nil { + mutate(&d) + } + return []WiFiDevice{d} + } + + tests := []struct { + name string + mutate func(d *WiFiDevice) + changed bool + }{ + {name: "state", mutate: func(d *WiFiDevice) { d.State = "connecting" }, changed: true}, + {name: "connected", mutate: func(d *WiFiDevice) { d.Connected = true }, changed: true}, + {name: "apCapable", mutate: func(d *WiFiDevice) { d.APCapable = true }, changed: true}, + {name: "ssid", mutate: func(d *WiFiDevice) { d.SSID = "HomeNet" }, changed: true}, + {name: "signal jitter is ignored", mutate: func(d *WiFiDevice) { d.Signal = 63 }, changed: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + old := NetworkState{WiFiDevices: device(nil)} + new := NetworkState{WiFiDevices: device(tt.mutate)} + assert.Equal(t, tt.changed, stateChangedMeaningfully(&old, &new)) + }) + } +} + +func TestStateChangedMeaningfully_PrimarySignalJitterDoesNotVetoOtherChanges(t *testing.T) { + old := NetworkState{ + WiFiSignal: 60, + WiFiDevices: []WiFiDevice{{Name: "wlan1", State: "disconnected"}}, + } + new := NetworkState{ + WiFiSignal: 63, + WiFiDevices: []WiFiDevice{{Name: "wlan1", State: "connected", Connected: true}}, + } + assert.True(t, stateChangedMeaningfully(&old, &new), "insignificant signal jitter must not suppress a device change") + + jitterOnlyOld := NetworkState{WiFiSignal: 60} + jitterOnlyNew := NetworkState{WiFiSignal: 63} + assert.False(t, stateChangedMeaningfully(&jitterOnlyOld, &jitterOnlyNew), "insignificant signal jitter alone must stay debounced") +} + +type testHotspotBackend struct { + *IWDBackend + configureCalled bool + startCalled bool + stopCalled bool + getSecretsCalled bool + configureReq HotspotRequest + secrets string +} + +func (b *testHotspotBackend) ConfigureHotspot(req HotspotRequest) error { + b.configureCalled = true + b.configureReq = req + + b.stateMutex.Lock() + b.state.HotspotAvailable = true + b.state.HotspotConfigured = true + b.state.HotspotSSID = req.SSID + b.state.HotspotDevice = req.Device + b.state.HotspotBand = req.Band + b.stateMutex.Unlock() + + return nil +} + +func (b *testHotspotBackend) StartHotspot() error { + b.startCalled = true + + b.stateMutex.Lock() + b.state.HotspotEnabled = true + b.stateMutex.Unlock() + + return nil +} + +func (b *testHotspotBackend) StopHotspot() error { + b.stopCalled = true + + b.stateMutex.Lock() + b.state.HotspotEnabled = false + b.stateMutex.Unlock() + + return nil +} + +func (b *testHotspotBackend) GetHotspotSecrets() (string, error) { + b.getSecretsCalled = true + return b.secrets, nil +} + +func TestManager_HotspotUnsupportedBackend(t *testing.T) { + backend, err := NewIWDBackend() + assert.NoError(t, err) + + backend.stateMutex.Lock() + backend.state.HotspotAvailable = true + backend.state.HotspotConfigured = true + backend.state.HotspotEnabled = true + backend.state.HotspotSSID = "Should Not Leak" + backend.state.HotspotDevice = "wlan0" + backend.state.HotspotBand = "bg" + backend.stateMutex.Unlock() + + manager := NewTestManager(backend, &NetworkState{}) + assert.NoError(t, manager.syncStateFromBackend()) + + state := manager.GetState() + assert.False(t, state.HotspotSupported) + assert.False(t, state.HotspotAvailable) + assert.False(t, state.HotspotConfigured) + assert.False(t, state.HotspotEnabled) + assert.Empty(t, state.HotspotSSID) + assert.Empty(t, state.HotspotDevice) + assert.Empty(t, state.HotspotBand) + + assert.ErrorIs(t, manager.ConfigureHotspot(HotspotRequest{SSID: "DMS Hotspot"}), ErrHotspotNotSupported) + assert.ErrorIs(t, manager.StartHotspot(), ErrHotspotNotSupported) + assert.ErrorIs(t, manager.StopHotspot(), ErrHotspotNotSupported) + + _, err = manager.GetHotspotSecrets() + assert.ErrorIs(t, err, ErrHotspotNotSupported) +} + +func TestManager_HotspotSupportedBackend(t *testing.T) { + iwdBackend, err := NewIWDBackend() + assert.NoError(t, err) + + backend := &testHotspotBackend{IWDBackend: iwdBackend} + manager := NewTestManager(backend, &NetworkState{}) + + req := HotspotRequest{ + SSID: "DMS Hotspot", + Password: "hunter2-password", + Device: "wlan0", + Band: "bg", + } + + err = manager.ConfigureHotspot(req) + assert.NoError(t, err) + assert.True(t, backend.configureCalled) + assert.Equal(t, req, backend.configureReq) + + state := manager.GetState() + assert.True(t, state.HotspotSupported) + assert.True(t, state.HotspotAvailable) + assert.True(t, state.HotspotConfigured) + assert.Equal(t, "DMS Hotspot", state.HotspotSSID) + assert.Equal(t, "wlan0", state.HotspotDevice) + assert.Equal(t, "bg", state.HotspotBand) + + err = manager.StartHotspot() + assert.NoError(t, err) + assert.True(t, backend.startCalled) + assert.True(t, manager.GetState().HotspotEnabled) + + err = manager.StopHotspot() + assert.NoError(t, err) + assert.True(t, backend.stopCalled) + assert.False(t, manager.GetState().HotspotEnabled) + + backend.secrets = "hunter2-password" + password, err := manager.GetHotspotSecrets() + assert.NoError(t, err) + assert.True(t, backend.getSecretsCalled) + assert.Equal(t, "hunter2-password", password) } func TestManager_NotifySubscribers(t *testing.T) { diff --git a/core/internal/server/network/priority.go b/core/internal/server/network/priority.go index 8ee7fdf89..35d2e88ae 100644 --- a/core/internal/server/network/priority.go +++ b/core/internal/server/network/priority.go @@ -140,6 +140,18 @@ func (m *Manager) setConnectionPriority(connType string, autoconnectPriority int continue } + // AP-mode profiles (hotspots) are not routing candidates; leave their + // autoconnect priority and metrics alone. + if cType == "802-11-wireless" { + if wifiSection, ok := settings["802-11-wireless"]; ok { + if modeVariant, ok := wifiSection["mode"]; ok { + if mode, _ := modeVariant.Value().(string); mode == "ap" { + continue + } + } + } + } + connName := "" if idVariant, ok := connSection["id"]; ok { connName, _ = idVariant.Value().(string) diff --git a/core/internal/server/network/types.go b/core/internal/server/network/types.go index ba982bcf1..3e28609de 100644 --- a/core/internal/server/network/types.go +++ b/core/internal/server/network/types.go @@ -47,6 +47,7 @@ type WiFiDevice struct { HwAddress string `json:"hwAddress"` State string `json:"state"` Connected bool `json:"connected"` + APCapable bool `json:"apCapable"` SSID string `json:"ssid,omitempty"` BSSID string `json:"bssid,omitempty"` Signal uint8 `json:"signal,omitempty"` @@ -114,6 +115,16 @@ type NetworkState struct { WiFiNetworks []WiFiNetwork `json:"wifiNetworks"` SavedWiFiNetworks []WiFiNetwork `json:"savedWifiNetworks"` WiFiDevices []WiFiDevice `json:"wifiDevices"` + HotspotSupported bool `json:"hotspotSupported"` + HotspotAvailable bool `json:"hotspotAvailable"` + HotspotConfigured bool `json:"hotspotConfigured"` + HotspotEnabled bool `json:"hotspotEnabled"` + HotspotActivating bool `json:"hotspotActivating"` + HotspotSecured bool `json:"hotspotSecured"` + HotspotSSID string `json:"hotspotSSID"` + HotspotDevice string `json:"hotspotDevice"` + HotspotBand string `json:"hotspotBand"` + HotspotLastError string `json:"hotspotLastError"` WiredConnections []WiredConnection `json:"wiredConnections"` VPNProfiles []VPNProfile `json:"vpnProfiles"` VPNActive []VPNActive `json:"vpnActive"` @@ -142,6 +153,13 @@ type ConnectionRequest struct { UseSystemCACerts *bool `json:"useSystemCACerts,omitempty"` } +type HotspotRequest struct { + SSID string `json:"ssid"` + Password string `json:"password,omitempty"` + Device string `json:"device,omitempty"` + Band string `json:"band,omitempty"` +} + type WiredConnection struct { Path dbus.ObjectPath `json:"path"` ID string `json:"id"` diff --git a/quickshell/Modules/ControlCenter/Details/NetworkDetail.qml b/quickshell/Modules/ControlCenter/Details/NetworkDetail.qml index af666683f..f5608f071 100644 --- a/quickshell/Modules/ControlCenter/Details/NetworkDetail.qml +++ b/quickshell/Modules/ControlCenter/Details/NetworkDetail.qml @@ -6,6 +6,7 @@ import qs.Modules.Network import qs.Services import qs.Widgets import qs.Modals +import qs.Modals.Common Rectangle { id: root @@ -17,10 +18,10 @@ Rectangle { if (height > 0) return height; if (NetworkService.wifiToggling) - return headerRow.height + wifiToggleContent.height + Theme.spacingM; + return headerRow.height + hotspotContentHeight + wifiToggleContent.height + Theme.spacingM; if (NetworkService.wifiEnabled) - return headerRow.height + wifiContent.height + Theme.spacingM; - return headerRow.height + wifiOffContent.height + Theme.spacingM; + return headerRow.height + hotspotContentHeight + wifiContent.height + Theme.spacingM; + return headerRow.height + hotspotContentHeight + wifiOffContent.height + Theme.spacingM; } radius: Theme.cornerRadius color: Theme.nestedSurface @@ -39,6 +40,31 @@ Rectangle { property bool hasWifiAvailable: (NetworkService.wifiDevices?.length ?? 0) > 0 property bool hasBothConnectionTypes: hasEthernetAvailable && hasWifiAvailable property int maxPinnedNetworks: 3 + readonly property int hotspotContentHeight: currentPreferenceIndex === 1 && NetworkService.hotspotAvailable ? 56 + Theme.spacingS : 0 + + property var hotspotStartConfirm: ConfirmModal {} + + function explainHotspotNeedsWiFi() { + ToastService.showError(I18n.tr("WiFi is disabled", "hotspot start error title"), I18n.tr("Enable WiFi before starting the hotspot.", "hotspot WiFi requirement message")); + } + + function startHotspotWithConfirm() { + if (!NetworkService.hotspotWouldDisconnectWifi) { + NetworkService.startHotspot(); + return; + } + hotspotStartConfirm.showWithOptions({ + title: I18n.tr("Start Hotspot?", "hotspot start confirmation title"), + message: I18n.tr("This will disconnect WiFi from \"%1\" — the radio can't host a hotspot and stay connected at the same time. Internet sharing will need another connection, such as Ethernet.", "hotspot WiFi disconnection confirmation message").arg(NetworkService.currentWifiSSID), + confirmText: I18n.tr("Start", "hotspot start confirmation action"), + onConfirm: () => NetworkService.startHotspot() + }); + } + + function openHotspotSettings() { + PopoutService.closeControlCenter(); + PopoutService.openSettingsWithTab("network_wifi"); + } function normalizePinList(value) { if (Array.isArray(value)) @@ -159,12 +185,183 @@ Rectangle { } Item { - id: wifiToggleContent + id: hotspotRow anchors.top: headerRow.bottom anchors.left: parent.left anchors.right: parent.right anchors.margins: Theme.spacingM anchors.topMargin: Theme.spacingM + visible: currentPreferenceIndex === 1 && NetworkService.hotspotAvailable + height: visible ? 56 : 0 + + Rectangle { + anchors.fill: parent + radius: Theme.cornerRadius + color: hotspotMouseArea.containsMouse ? Theme.primaryHoverLight : Theme.surfaceLight + border.width: NetworkService.hotspotEnabled ? 2 : 1 + border.color: NetworkService.hotspotEnabled ? Theme.primary : Theme.outlineLight + + Row { + anchors.left: parent.left + anchors.leftMargin: Theme.spacingM + anchors.right: hotspotRightControls.left + anchors.rightMargin: Theme.spacingS + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingS + + DankIcon { + name: NetworkService.hotspotEnabled ? "wifi_tethering" : "wifi_tethering_off" + size: Theme.iconSize - 4 + color: NetworkService.hotspotEnabled ? Theme.primary : Theme.surfaceText + anchors.verticalCenter: parent.verticalCenter + } + + Column { + id: hotspotTextColumn + + readonly property bool warnsWifiDrop: NetworkService.hotspotConfigured && NetworkService.wifiEnabled && !NetworkService.hotspotBusy && !NetworkService.hotspotActivating && !NetworkService.hotspotEnabled && NetworkService.hotspotWouldDisconnectWifi + + anchors.verticalCenter: parent.verticalCenter + spacing: 2 + width: parent.width - Theme.iconSize - Theme.spacingS + + StyledText { + text: NetworkService.hotspotConfigured ? I18n.tr("Hotspot", "hotspot control label") : I18n.tr("Set up hotspot", "hotspot setup action label") + font.pixelSize: Theme.fontSizeMedium + font.weight: NetworkService.hotspotEnabled ? Font.Medium : Font.Normal + color: NetworkService.hotspotEnabled ? Theme.primary : Theme.surfaceText + elide: Text.ElideRight + width: parent.width + } + + StyledText { + visible: !hotspotTextColumn.warnsWifiDrop + text: { + if (!NetworkService.hotspotConfigured) + return I18n.tr("Set up hotspot in Settings", "unconfigured hotspot status message"); + if (NetworkService.hotspotBusy || NetworkService.hotspotActivating) + return I18n.tr("Starting...", "hotspot activation status"); + if (NetworkService.hotspotEnabled) + return NetworkService.hotspotSSID || I18n.tr("Running", "hotspot active status"); + if (!NetworkService.wifiEnabled) + return I18n.tr("WiFi disabled", "hotspot unavailable status"); + return NetworkService.hotspotSSID || I18n.tr("Ready", "hotspot ready status"); + } + font.pixelSize: Theme.fontSizeSmall + color: NetworkService.hotspotEnabled ? Theme.primary : Theme.surfaceVariantText + elide: Text.ElideRight + width: parent.width + } + + Row { + visible: hotspotTextColumn.warnsWifiDrop + width: parent.width + spacing: Theme.spacingXS + + StyledText { + id: hotspotWarnSsid + text: NetworkService.hotspotSSID || I18n.tr("Ready", "hotspot ready status") + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + elide: Text.ElideRight + width: Math.min(implicitWidth, parent.width / 2) + } + + StyledText { + id: hotspotWarnSeparator + text: "•" + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + } + + StyledText { + text: I18n.tr("Will disconnect \"%1\"", "hotspot WiFi disconnection warning").arg(NetworkService.currentWifiSSID) + font.pixelSize: Theme.fontSizeSmall + color: Theme.warning + elide: Text.ElideRight + width: Math.max(0, parent.width - hotspotWarnSsid.width - hotspotWarnSeparator.width - Theme.spacingXS * 2) + } + } + } + } + + Row { + id: hotspotRightControls + anchors.right: parent.right + anchors.rightMargin: Theme.spacingM + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingS + + StyledText { + text: I18n.tr("Setup", "hotspot setup action") + font.pixelSize: Theme.fontSizeSmall + color: Theme.primary + visible: !NetworkService.hotspotConfigured + anchors.verticalCenter: parent.verticalCenter + } + + DankIcon { + name: "chevron_right" + size: 20 + color: Theme.primary + visible: !NetworkService.hotspotConfigured + anchors.verticalCenter: parent.verticalCenter + } + + DankSpinner { + readonly property bool hotspotWorking: NetworkService.hotspotBusy || NetworkService.hotspotActivating + size: 20 + strokeWidth: 2 + color: Theme.primary + running: hotspotWorking + visible: NetworkService.hotspotConfigured && hotspotWorking + anchors.verticalCenter: parent.verticalCenter + } + + DankToggle { + readonly property bool hotspotWorking: NetworkService.hotspotBusy || NetworkService.hotspotActivating + checked: NetworkService.hotspotEnabled + enabled: NetworkService.hotspotConfigured && !hotspotWorking + visible: NetworkService.hotspotConfigured && !hotspotWorking + onToggled: checked => { + if (checked) { + if (!NetworkService.wifiEnabled) { + root.explainHotspotNeedsWiFi(); + return; + } + root.startHotspotWithConfirm(); + } else { + NetworkService.stopHotspot(); + } + } + } + } + + DankRipple { + id: hotspotRipple + cornerRadius: parent.radius + } + + MouseArea { + id: hotspotMouseArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + enabled: !NetworkService.hotspotConfigured + visible: !NetworkService.hotspotConfigured + onPressed: mouse => hotspotRipple.trigger(mouse.x, mouse.y) + onClicked: root.openHotspotSettings() + } + } + } + + Item { + id: wifiToggleContent + anchors.top: hotspotRow.visible ? hotspotRow.bottom : headerRow.bottom + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: Theme.spacingM + anchors.topMargin: hotspotRow.visible ? Theme.spacingS : Theme.spacingM visible: currentPreferenceIndex === 1 && NetworkService.wifiToggling height: visible ? wifiToggleColumn.implicitHeight + Theme.spacingM * 2 : 0 @@ -201,11 +398,11 @@ Rectangle { Item { id: wifiOffContent - anchors.top: headerRow.bottom + anchors.top: hotspotRow.visible ? hotspotRow.bottom : headerRow.bottom anchors.left: parent.left anchors.right: parent.right anchors.margins: Theme.spacingM - anchors.topMargin: Theme.spacingM + anchors.topMargin: hotspotRow.visible ? Theme.spacingS : Theme.spacingM visible: currentPreferenceIndex === 1 && !NetworkService.wifiEnabled && !NetworkService.wifiToggling height: visible ? wifiOffColumn.implicitHeight + Theme.spacingM * 2 : 0 @@ -482,12 +679,12 @@ Rectangle { Item { id: wifiScanningOverlay - anchors.top: headerRow.bottom + anchors.top: hotspotRow.visible ? hotspotRow.bottom : headerRow.bottom anchors.left: parent.left anchors.right: parent.right anchors.bottom: parent.bottom anchors.margins: Theme.spacingM - anchors.topMargin: Theme.spacingM + anchors.topMargin: hotspotRow.visible ? Theme.spacingS : Theme.spacingM visible: currentPreferenceIndex === 1 && NetworkService.wifiEnabled && !NetworkService.wifiToggling && NetworkService.wifiInterface && (NetworkService.wifiNetworks?.length ?? 0) < 1 && NetworkService.isScanning DankIcon { @@ -509,12 +706,12 @@ Rectangle { DankListView { id: wifiContent - anchors.top: headerRow.bottom + anchors.top: hotspotRow.visible ? hotspotRow.bottom : headerRow.bottom anchors.left: parent.left anchors.right: parent.right anchors.bottom: parent.bottom anchors.margins: Theme.spacingM - anchors.topMargin: Theme.spacingM + anchors.topMargin: hotspotRow.visible ? Theme.spacingS : Theme.spacingM visible: currentPreferenceIndex === 1 && NetworkService.wifiEnabled && !NetworkService.wifiToggling && !wifiScanningOverlay.visible clip: true spacing: Theme.spacingS diff --git a/quickshell/Modules/Settings/NetworkWifiTab.qml b/quickshell/Modules/Settings/NetworkWifiTab.qml index 4fd967110..28171ae35 100644 --- a/quickshell/Modules/Settings/NetworkWifiTab.qml +++ b/quickshell/Modules/Settings/NetworkWifiTab.qml @@ -1263,6 +1263,359 @@ Item { } } } + + SettingsCard { + id: hotspotCard + + width: parent.width + title: I18n.tr("Hotspot", "hotspot settings card title") + iconName: "wifi_tethering" + settingKey: "networkHotspot" + tags: ["wifi", "wi-fi", "wireless", "network", "hotspot", "access point", "sharing", "ssid"] + visible: NetworkService.hotspotAvailable + + property string ssid: NetworkService.hotspotSSID || "" + property string password: "" + property string device: NetworkService.hotspotDevice || "" + property string band: NetworkService.hotspotBand || "" + property bool editing: false + property bool passwordLoading: false + property bool passwordResolved: true + property int passwordRequestId: 0 + property int passwordEditRevision: 0 + readonly property bool showForm: !NetworkService.hotspotConfigured || editing + readonly property bool starting: NetworkService.hotspotBusy || NetworkService.hotspotActivating + property var startConfirm: ConfirmModal {} + + function confirmThenStart(targetDevice, targetBand, startFn) { + if (!NetworkService.hotspotTargetWouldDisconnectWifi(targetDevice, targetBand)) { + startFn(); + return; + } + startConfirm.showWithOptions({ + title: I18n.tr("Start Hotspot?", "hotspot start confirmation title"), + message: I18n.tr("This will disconnect WiFi from \"%1\" — the radio can't host a hotspot and stay connected at the same time. Internet sharing will need another connection, such as Ethernet.", "hotspot WiFi disconnection confirmation message").arg(NetworkService.currentWifiSSID), + confirmText: I18n.tr("Start", "hotspot start confirmation action"), + onConfirm: startFn + }); + } + + function bandLabel(value) { + switch (value) { + case "bg": + return I18n.tr("2.4 GHz", "hotspot WiFi band option"); + case "a": + return I18n.tr("5 GHz", "hotspot WiFi band option"); + default: + return I18n.tr("Auto", "hotspot device or band option"); + } + } + + function bandValue(label) { + if (label === I18n.tr("2.4 GHz", "hotspot WiFi band option")) + return "bg"; + if (label === I18n.tr("5 GHz", "hotspot WiFi band option")) + return "a"; + return ""; + } + + function syncFromService() { + ssid = NetworkService.hotspotSSID || ssid || ""; + device = NetworkService.hotspotDevice || ""; + band = NetworkService.hotspotBand || ""; + } + + function beginEditing() { + syncFromService(); + password = ""; + editing = true; + passwordRequestId++; + const requestId = passwordRequestId; + const editRevision = passwordEditRevision; + passwordLoading = NetworkService.hotspotSecured; + passwordResolved = !NetworkService.hotspotSecured; + if (NetworkService.hotspotSecured) { + NetworkService.getHotspotSecrets(response => { + if (!editing || requestId !== passwordRequestId) + return; + passwordLoading = false; + if (response.error) { + ToastService.showError(I18n.tr("Couldn't load hotspot password", "hotspot password error title"), I18n.tr("Re-enter the password before saving.", "hotspot password recovery message")); + } else { + const storedPassword = response.result?.password ?? response.password ?? ""; + if (!storedPassword) { + ToastService.showError(I18n.tr("Couldn't load hotspot password", "hotspot password error title"), I18n.tr("Re-enter the password before saving.", "hotspot password recovery message")); + } else { + passwordResolved = true; + if (editRevision === passwordEditRevision) { + password = storedPassword; + } + } + } + }); + } + } + + function stopEditing() { + passwordRequestId++; + editing = false; + password = ""; + passwordLoading = false; + passwordResolved = true; + } + + function buildCanConfigure() { + return ssid.trim().length > 0 && passwordResolved && !passwordLoading && !NetworkService.hotspotBusy && !NetworkService.hotspotEnabled && !NetworkService.hotspotActivating; + } + + function explainWiFiDisabled() { + ToastService.showError(I18n.tr("WiFi is disabled", "hotspot start error title"), I18n.tr("Enable WiFi before starting the hotspot.", "hotspot WiFi requirement message")); + } + + function saveOnly() { + if (!buildCanConfigure()) + return; + NetworkService.configureHotspot(ssid.trim(), password, device, band, response => { + if (!response.error) { + stopEditing(); + ToastService.showInfo(I18n.tr("Hotspot saved", "hotspot configuration success message")); + } + }); + } + + function startOrStop() { + if (NetworkService.hotspotEnabled) { + NetworkService.stopHotspot(response => { + if (!response.error) + ToastService.showInfo(I18n.tr("Hotspot stopped", "hotspot stop success message")); + }); + return; + } + + if (!NetworkService.wifiEnabled) { + explainWiFiDisabled(); + return; + } + + if (showForm) { + if (!buildCanConfigure()) + return; + confirmThenStart(device, band, () => { + NetworkService.configureAndStartHotspot(ssid.trim(), password, device, band, response => { + if (!response.error) + stopEditing(); + }); + }); + return; + } + + confirmThenStart(NetworkService.hotspotDevice, NetworkService.hotspotBand, () => NetworkService.startHotspot()); + } + + onVisibleChanged: if (visible) + syncFromService() + + Column { + width: parent.width + spacing: Theme.spacingM + + StyledText { + width: parent.width + text: { + if (NetworkService.hotspotEnabled) + return I18n.tr("Your hotspot is running.", "hotspot active status message"); + if (hotspotCard.starting) + return I18n.tr("Starting hotspot...", "hotspot activation status message"); + if (NetworkService.hotspotConfigured) + return I18n.tr("Your hotspot profile is saved and ready to start.", "configured hotspot status message"); + return I18n.tr("Set up a WiFi hotspot for sharing this connection.", "unconfigured hotspot description"); + } + font.pixelSize: Theme.fontSizeSmall + color: NetworkService.hotspotEnabled ? Theme.primary : Theme.surfaceVariantText + wrapMode: Text.WordWrap + } + + StyledText { + width: parent.width + text: I18n.tr("WiFi is disabled. You can still edit and save hotspot settings, but starting the hotspot requires WiFi to be enabled.", "hotspot WiFi requirement explanation") + font.pixelSize: Theme.fontSizeSmall + color: Theme.warning + wrapMode: Text.WordWrap + visible: !NetworkService.wifiEnabled + } + + StyledText { + width: parent.width + text: I18n.tr("Starting the hotspot will disconnect WiFi from \"%1\" — the radio can't do both at once. Sharing internet then requires another connection, such as Ethernet.", "hotspot WiFi disconnection warning").arg(NetworkService.currentWifiSSID) + font.pixelSize: Theme.fontSizeSmall + color: Theme.warning + wrapMode: Text.WordWrap + visible: NetworkService.wifiEnabled && !NetworkService.hotspotEnabled && !hotspotCard.starting && (hotspotCard.showForm ? NetworkService.hotspotTargetWouldDisconnectWifi(hotspotCard.device, hotspotCard.band) : NetworkService.hotspotWouldDisconnectWifi) + } + + Row { + width: parent.width + spacing: Theme.spacingM + visible: !hotspotCard.showForm + + DankIcon { + name: NetworkService.hotspotEnabled ? "wifi_tethering" : "wifi_tethering_off" + size: Theme.iconSize + color: NetworkService.hotspotEnabled ? Theme.primary : Theme.surfaceVariantText + anchors.verticalCenter: parent.verticalCenter + } + + Column { + width: parent.width - Theme.iconSize - Theme.spacingM + spacing: 2 + anchors.verticalCenter: parent.verticalCenter + + StyledText { + width: parent.width + text: NetworkService.hotspotSSID + font.pixelSize: Theme.fontSizeMedium + font.weight: Font.Medium + color: Theme.surfaceText + elide: Text.ElideRight + } + + StyledText { + width: parent.width + text: { + const parts = [NetworkService.hotspotSecured ? I18n.tr("WPA2 password", "hotspot security summary") : I18n.tr("Open network", "hotspot security summary"), hotspotCard.bandLabel(NetworkService.hotspotBand)]; + if (NetworkService.hotspotDevice) + parts.push(NetworkService.hotspotDevice); + return parts.join(" • "); + } + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + elide: Text.ElideRight + } + } + } + + Column { + width: parent.width + spacing: Theme.spacingM + visible: hotspotCard.showForm + + DankTextField { + width: parent.width + labelText: I18n.tr("Hotspot name", "hotspot SSID field label") + placeholderText: I18n.tr("SSID", "hotspot network name placeholder") + text: hotspotCard.ssid + leftIconName: "badge" + showClearButton: true + onTextEdited: hotspotCard.ssid = text + onAccepted: hotspotCard.saveOnly() + } + + DankTextField { + width: parent.width + labelText: I18n.tr("Password", "hotspot password field label") + placeholderText: I18n.tr("Optional; leave blank for open hotspot", "hotspot password field placeholder") + text: hotspotCard.password + leftIconName: "key" + showPasswordToggle: true + echoMode: passwordVisible ? TextInput.Normal : TextInput.Password + onTextEdited: { + hotspotCard.password = text; + hotspotCard.passwordEditRevision++; + if (text.length > 0) + hotspotCard.passwordResolved = true; + } + onAccepted: hotspotCard.saveOnly() + } + + Row { + width: parent.width + spacing: Theme.spacingM + + DankDropdown { + width: (parent.width - Theme.spacingM) / 2 + text: I18n.tr("Device", "hotspot WiFi device field label") + description: I18n.tr("Optional", "hotspot WiFi device field description") + currentValue: hotspotCard.device || I18n.tr("Auto", "hotspot device or band option") + options: { + const devices = NetworkService.wifiDevices || []; + return [I18n.tr("Auto", "hotspot device or band option")].concat(devices.filter(d => d.apCapable).map(d => d.name)); + } + onValueChanged: value => hotspotCard.device = value === I18n.tr("Auto", "hotspot device or band option") ? "" : value + } + + DankDropdown { + width: (parent.width - Theme.spacingM) / 2 + text: I18n.tr("Band", "hotspot WiFi band field label") + description: I18n.tr("Optional", "hotspot WiFi band field description") + currentValue: hotspotCard.bandLabel(hotspotCard.band) + options: [I18n.tr("Auto", "hotspot device or band option"), I18n.tr("2.4 GHz", "hotspot WiFi band option"), I18n.tr("5 GHz", "hotspot WiFi band option")] + onValueChanged: value => hotspotCard.band = hotspotCard.bandValue(value) + } + } + } + + Row { + width: parent.width + spacing: Theme.spacingS + + Item { + width: Math.max(0, parent.width - (cancelButton.visible ? cancelButton.width + Theme.spacingS : 0) - (editButton.visible ? editButton.width + Theme.spacingS : 0) - (saveButton.visible ? saveButton.width + Theme.spacingS : 0) - (startStopButton.width + Theme.spacingS)) + height: 1 + } + + DankButton { + id: cancelButton + visible: hotspotCard.editing + text: I18n.tr("Cancel", "cancel hotspot editing action") + buttonHeight: 36 + backgroundColor: Theme.surfaceVariant + textColor: Theme.surfaceText + onClicked: hotspotCard.stopEditing() + } + + DankButton { + id: editButton + visible: !hotspotCard.showForm + text: I18n.tr("Edit", "edit hotspot action") + iconName: "edit" + buttonHeight: 36 + enabled: !NetworkService.hotspotEnabled && !hotspotCard.starting + backgroundColor: Theme.surfaceVariant + textColor: Theme.surfaceText + onClicked: hotspotCard.beginEditing() + } + + DankButton { + id: saveButton + visible: hotspotCard.showForm + text: hotspotCard.passwordLoading ? I18n.tr("Loading...", "hotspot password loading status") : (NetworkService.hotspotBusy ? I18n.tr("Saving...", "hotspot configuration saving status") : I18n.tr("Save", "save hotspot configuration action")) + iconName: "save" + buttonHeight: 36 + enabled: hotspotCard.buildCanConfigure() + backgroundColor: Theme.surfaceVariant + textColor: Theme.surfaceText + onClicked: hotspotCard.saveOnly() + } + + DankButton { + id: startStopButton + text: { + if (NetworkService.hotspotEnabled) + return I18n.tr("Stop", "stop hotspot action"); + if (hotspotCard.starting) + return I18n.tr("Starting...", "hotspot activation status"); + return hotspotCard.showForm ? I18n.tr("Save & Start", "save and start hotspot action") : I18n.tr("Start", "start hotspot action"); + } + iconName: NetworkService.hotspotEnabled ? "stop" : "wifi_tethering" + buttonHeight: 36 + enabled: !hotspotCard.starting && (NetworkService.hotspotEnabled || hotspotCard.buildCanConfigure()) + backgroundColor: NetworkService.hotspotEnabled ? Theme.error : Theme.primary + textColor: NetworkService.hotspotEnabled ? Theme.surfaceText : Theme.onPrimary + onClicked: hotspotCard.startOrStop() + } + } + } + } } } } diff --git a/quickshell/Services/DMSNetworkService.qml b/quickshell/Services/DMSNetworkService.qml index a6788ad1f..addd6c0d4 100644 --- a/quickshell/Services/DMSNetworkService.qml +++ b/quickshell/Services/DMSNetworkService.qml @@ -70,6 +70,29 @@ Singleton { property string targetPreference: "" property var savedWifiNetworks: [] readonly property int savedWifiStateApiVersion: 26 + readonly property int hotspotApiVersion: 28 + property bool backendHotspotSupported: false + property bool backendHotspotAvailable: false + property bool backendHotspotConfigured: false + property bool backendHotspotEnabled: false + property bool backendHotspotActivating: false + property bool backendHotspotSecured: false + property string backendHotspotSSID: "" + property string backendHotspotDevice: "" + property string backendHotspotBand: "" + property string backendHotspotLastError: "" + readonly property bool hotspotSupported: DMSService.isConnected && networkAvailable && DMSService.apiVersion >= hotspotApiVersion && backendHotspotSupported + readonly property bool hotspotAvailable: hotspotSupported && backendHotspotAvailable + readonly property bool hotspotConfigured: hotspotSupported && backendHotspotConfigured + readonly property bool hotspotEnabled: hotspotSupported && backendHotspotEnabled + readonly property bool hotspotActivating: hotspotSupported && backendHotspotActivating + readonly property bool hotspotSecured: hotspotSupported && backendHotspotSecured + readonly property bool hotspotWouldDisconnectWifi: hotspotSupported && !hotspotEnabled && !hotspotActivating && hotspotTargetWouldDisconnectWifi(backendHotspotDevice, backendHotspotBand) + readonly property string hotspotSSID: hotspotSupported ? backendHotspotSSID : "" + readonly property string hotspotDevice: hotspotSupported ? backendHotspotDevice : "" + readonly property string hotspotBand: hotspotSupported ? backendHotspotBand : "" + property bool hotspotBusy: false + property string hotspotError: "" property string connectionStatus: "" property string lastConnectionError: "" property bool passwordDialogShouldReopen: false @@ -318,6 +341,43 @@ Singleton { wifiDevices = state.wifiDevices || []; connectingDevice = state.connectingDevice || ""; + if (DMSService.apiVersion >= hotspotApiVersion) { + const supportsHotspot = state.hotspotSupported === true; + const wasHotspotEnabled = backendHotspotEnabled; + const wasHotspotActivating = backendHotspotActivating; + backendHotspotSupported = supportsHotspot; + backendHotspotAvailable = state.hotspotAvailable === true; + backendHotspotConfigured = state.hotspotConfigured === true; + backendHotspotEnabled = state.hotspotEnabled === true; + backendHotspotActivating = state.hotspotActivating === true; + backendHotspotSecured = state.hotspotSecured === true; + backendHotspotSSID = state.hotspotSSID || ""; + backendHotspotDevice = state.hotspotDevice || ""; + backendHotspotBand = state.hotspotBand || ""; + backendHotspotLastError = state.hotspotLastError || ""; + + // Activation is asynchronous: only toast outcomes of starts we watched + // begin, so restoring state after a shell restart stays silent. + const watchedStart = wasHotspotActivating || hotspotBusy; + if (watchedStart && !wasHotspotEnabled && backendHotspotEnabled) { + ToastService.showInfo(I18n.tr("Hotspot started", "hotspot start success message")); + } else if (wasHotspotActivating && !backendHotspotActivating && !backendHotspotEnabled && backendHotspotLastError) { + hotspotError = backendHotspotLastError; + ToastService.showError(I18n.tr("Failed to start hotspot", "hotspot start error title"), hotspotErrorMessage(backendHotspotLastError)); + } + } else { + backendHotspotSupported = false; + backendHotspotAvailable = false; + backendHotspotConfigured = false; + backendHotspotEnabled = false; + backendHotspotActivating = false; + backendHotspotSecured = false; + backendHotspotSSID = ""; + backendHotspotDevice = ""; + backendHotspotBand = ""; + backendHotspotLastError = ""; + } + currentWifiSSID = state.wifiSSID || ""; wifiSignalStrength = state.wifiSignal || 0; @@ -1026,6 +1086,137 @@ Singleton { }); } + function configureHotspot(ssid, password = "", device = "", band = "", callback = null) { + if (!hotspotSupported || hotspotBusy) + return false; + + const params = { + ssid: ssid + }; + if (password) + params.password = password; + if (device) + params.device = device; + if (band) + params.band = band; + + hotspotBusy = true; + hotspotError = ""; + + DMSService.sendRequest("network.hotspot.configure", params, response => { + hotspotBusy = false; + if (response.error) { + hotspotError = response.error; + ToastService.showError(I18n.tr("Failed to configure hotspot", "hotspot configuration error title"), response.error); + } else { + Qt.callLater(() => getState()); + } + if (callback) + callback(response); + }); + + return true; + } + + function startHotspot(callback = null) { + if (!hotspotAvailable || hotspotBusy) + return false; + + hotspotBusy = true; + hotspotError = ""; + + DMSService.sendRequest("network.hotspot.start", null, response => { + hotspotBusy = false; + if (response.error) { + hotspotError = response.error; + ToastService.showError(I18n.tr("Failed to start hotspot", "hotspot start error title"), response.error); + } else { + Qt.callLater(() => getState()); + } + if (callback) + callback(response); + }); + + return true; + } + + function stopHotspot(callback = null) { + if (!hotspotSupported || hotspotBusy) + return false; + + hotspotBusy = true; + hotspotError = ""; + + DMSService.sendRequest("network.hotspot.stop", null, response => { + hotspotBusy = false; + if (response.error) { + hotspotError = response.error; + ToastService.showError(I18n.tr("Failed to stop hotspot", "hotspot stop error title"), response.error); + } else { + Qt.callLater(() => getState()); + } + if (callback) + callback(response); + }); + + return true; + } + + function configureAndStartHotspot(ssid, password = "", device = "", band = "", callback = null) { + return configureHotspot(ssid, password, device, band, configureResponse => { + if (configureResponse.error) { + if (callback) + callback(configureResponse); + return; + } + startHotspot(callback); + }); + } + + function getHotspotSecrets(callback) { + if (!hotspotSupported) { + if (callback) + callback({ + error: "hotspot not supported" + }); + return false; + } + + DMSService.sendRequest("network.hotspot.getSecrets", null, response => { + if (callback) + callback(response); + }); + + return true; + } + + function hotspotTargetWouldDisconnectWifi(device, band = "") { + const devices = wifiDevices || []; + const active = devices.find(d => d.connected); + if (!active) + return false; + if (device) { + const target = devices.find(d => d.name === device); + return target ? target.connected : device === active.name; + } + // Band capabilities are not exposed per device, so a fixed-band automatic + // selection cannot safely promise that an idle radio will be usable. + if (band) + return true; + return !devices.some(d => d.apCapable && !d.connected && d.state === "disconnected" && d.name !== active.name); + } + + function hotspotErrorMessage(code) { + switch (code) { + case "hotspot-ip-config-failed": + return I18n.tr("IP sharing setup failed. Check that dnsmasq is installed.", "hotspot IP configuration failure message"); + case "hotspot-supplicant-failed": + return I18n.tr("The WiFi adapter could not start access point mode.", "hotspot adapter failure message"); + default: + return I18n.tr("Hotspot activation failed.", "generic hotspot activation failure message"); + } + } + function refreshSavedWifiNetworks() { if (!networkAvailable) return; diff --git a/quickshell/Services/NetworkService.qml b/quickshell/Services/NetworkService.qml index 7c2ab2887..7bd82a8f9 100644 --- a/quickshell/Services/NetworkService.qml +++ b/quickshell/Services/NetworkService.qml @@ -55,6 +55,19 @@ Singleton { property string targetPreference: activeService?.targetPreference ?? "" property var savedWifiNetworks: activeService?.savedWifiNetworks ?? [] readonly property int savedWifiStateApiVersion: activeService?.savedWifiStateApiVersion ?? 26 + readonly property int hotspotApiVersion: activeService?.hotspotApiVersion ?? 28 + property bool hotspotSupported: activeService?.hotspotSupported ?? false + property bool hotspotAvailable: activeService?.hotspotAvailable ?? false + property bool hotspotConfigured: activeService?.hotspotConfigured ?? false + property bool hotspotEnabled: activeService?.hotspotEnabled ?? false + property bool hotspotActivating: activeService?.hotspotActivating ?? false + property bool hotspotSecured: activeService?.hotspotSecured ?? false + property bool hotspotWouldDisconnectWifi: activeService?.hotspotWouldDisconnectWifi ?? false + property string hotspotSSID: activeService?.hotspotSSID ?? "" + property string hotspotDevice: activeService?.hotspotDevice ?? "" + property string hotspotBand: activeService?.hotspotBand ?? "" + property bool hotspotBusy: activeService?.hotspotBusy ?? false + property string hotspotError: activeService?.hotspotError ?? "" property string connectionStatus: activeService?.connectionStatus ?? "" property string lastConnectionError: activeService?.lastConnectionError ?? "" property bool passwordDialogShouldReopen: activeService?.passwordDialogShouldReopen ?? false @@ -312,6 +325,48 @@ Singleton { } } + function configureHotspot(ssid, password = "", device = "", band = "", callback = null) { + if (activeService && activeService.configureHotspot) { + return activeService.configureHotspot(ssid, password, device, band, callback); + } + return false; + } + + function startHotspot(callback = null) { + if (activeService && activeService.startHotspot) { + return activeService.startHotspot(callback); + } + return false; + } + + function stopHotspot(callback = null) { + if (activeService && activeService.stopHotspot) { + return activeService.stopHotspot(callback); + } + return false; + } + + function configureAndStartHotspot(ssid, password = "", device = "", band = "", callback = null) { + if (activeService && activeService.configureAndStartHotspot) { + return activeService.configureAndStartHotspot(ssid, password, device, band, callback); + } + return false; + } + + function getHotspotSecrets(callback) { + if (activeService && activeService.getHotspotSecrets) { + return activeService.getHotspotSecrets(callback); + } + return false; + } + + function hotspotTargetWouldDisconnectWifi(device, band = "") { + if (activeService && activeService.hotspotTargetWouldDisconnectWifi) { + return activeService.hotspotTargetWouldDisconnectWifi(device, band); + } + return false; + } + function setWifiDeviceOverride(deviceName) { if (activeService && activeService.setWifiDeviceOverride) { activeService.setWifiDeviceOverride(deviceName); diff --git a/quickshell/translations/settings_search_index.json b/quickshell/translations/settings_search_index.json index e362b2d1f..5a7a348f0 100644 --- a/quickshell/translations/settings_search_index.json +++ b/quickshell/translations/settings_search_index.json @@ -9475,6 +9475,27 @@ ], "icon": "settings_ethernet" }, + { + "section": "networkHotspot", + "label": "Hotspot", + "tabIndex": 40, + "category": "Network", + "keywords": [ + "access point", + "connectivity", + "hotspot", + "network", + "online", + "optional", + "sharing", + "ssid", + "wi-fi", + "wifi", + "wireless" + ], + "icon": "wifi_tethering", + "description": "Optional" + }, { "section": "networkSavedWifi", "label": "Saved Networks",