From dc75f1f01d8cf17f254b5eec4c65b6b2a1b182be Mon Sep 17 00:00:00 2001 From: Ron Harel <55725807+ronharel02@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:29:54 +0300 Subject: [PATCH] feat(network): initial WiFi hotspot support (#2825) * Add hotspot contract layer with capability-gated dispatch. Establish the interface and manager plumbing for hotspot support so backends can opt in without expanding the core Backend contract. Only backends that implement HotspotBackend get hotspot state propagated; others are forced to unsupported regardless of what they self-report. * Add IPC handlers and API docs for hotspot actions. Wire up configure/ start/ stop hotspot through the request router so the QML service layer can drive hotspot operations. Bump API version to 27 and document the capability-gating contract clients should follow. * Implement NetworkManager hotspot backend. Implement HotspotBackend on NetworkManagerBackend with DMS-owned profile management, AP capability detection, and band validation. Device resolution is deferred to StartHotspot when no device is specified, so profiles survive hardware changes. * Isolate client Wi-Fi state from AP-mode connections. Filter AP-mode profiles and access points out of all client Wi-Fi paths so the DMS hotspot (and user-created APs) never appear as saved networks, visible networks, or connected state. This protects existing client behavior before hotspot controls are exposed in the UI. * Prefer idle radios for automatic hotspot device selection. * Add hotspot properties and methods to QML service layer. Expose hotspot state and actions through DMSNetworkService, NetworkService, and LegacyNetworkService. Capability is gated on API version and backend-reported support, not backend name checks, and stays stable when Wi-Fi radio is disabled. * Add hotspot controls to Settings and Control Center. Settings shows a hotspot setup card as a sibling in the Wi-Fi tab with SSID, password, device, band, save, and start/ stop controls. Control Center shows a compact row that toggles a configured hotspot or routes to Settings for initial setup. Both stay visible when Wi-Fi is disabled, explaining the requirement instead of hiding. * Add translator context to hotspot strings. --- core/internal/errdefs/errdefs.go | 4 + core/internal/server/network/API.md | 103 ++ core/internal/server/network/backend.go | 16 + .../server/network/backend_networkmanager.go | 10 +- .../network/backend_networkmanager_hotspot.go | 706 ++++++++++++++ .../backend_networkmanager_hotspot_test.go | 918 ++++++++++++++++++ .../network/backend_networkmanager_signals.go | 18 +- .../backend_networkmanager_signals_test.go | 45 + .../network/backend_networkmanager_state.go | 6 + .../network/backend_networkmanager_wifi.go | 94 +- core/internal/server/network/handlers.go | 58 ++ core/internal/server/network/handlers_test.go | 137 +++ core/internal/server/network/manager.go | 153 ++- core/internal/server/network/manager_test.go | 237 ++++- core/internal/server/network/priority.go | 12 + core/internal/server/network/types.go | 18 + .../ControlCenter/Details/NetworkDetail.qml | 217 ++++- .../Modules/Settings/NetworkWifiTab.qml | 353 +++++++ quickshell/Services/DMSNetworkService.qml | 191 ++++ quickshell/Services/NetworkService.qml | 55 ++ .../translations/settings_search_index.json | 21 + 21 files changed, 3305 insertions(+), 67 deletions(-) create mode 100644 core/internal/server/network/backend_networkmanager_hotspot.go create mode 100644 core/internal/server/network/backend_networkmanager_hotspot_test.go 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",