From 85b63219b9f359706ffc43fd9c7f0d063bb016c5 Mon Sep 17 00:00:00 2001 From: jbwfu <75001777+jbwfu@users.noreply.github.com> Date: Tue, 16 Jun 2026 21:06:29 +0800 Subject: [PATCH] feat(network): add saved WiFi state to settings (#2648) --- core/internal/server/network/API.md | 2 + core/internal/server/network/backend.go | 1 + .../server/network/backend_hybrid_test.go | 16 + core/internal/server/network/backend_iwd.go | 5 + .../server/network/backend_iwd_signals.go | 73 ++- .../server/network/backend_iwd_test.go | 87 +++ .../server/network/backend_iwd_wifi.go | 193 +++++-- .../server/network/backend_networkmanager.go | 5 + .../network/backend_networkmanager_signals.go | 77 ++- .../network/backend_networkmanager_wifi.go | 264 +++++---- .../backend_networkmanager_wifi_test.go | 49 ++ core/internal/server/network/manager.go | 29 +- core/internal/server/network/types.go | 2 + core/internal/server/network/wifi_saved.go | 103 ++++ .../server/network/wifi_saved_test.go | 170 ++++++ core/internal/server/server.go | 2 +- .../ControlCenter/Details/NetworkDetail.qml | 2 +- .../Modules/Settings/NetworkWifiTab.qml | 501 +++++++++++++++++- quickshell/Services/DMSNetworkService.qml | 27 +- quickshell/Services/LegacyNetworkService.qml | 4 +- quickshell/Services/NetworkService.qml | 7 + .../translations/settings_search_index.json | 24 + 22 files changed, 1436 insertions(+), 207 deletions(-) create mode 100644 core/internal/server/network/wifi_saved.go create mode 100644 core/internal/server/network/wifi_saved_test.go diff --git a/core/internal/server/network/API.md b/core/internal/server/network/API.md index 361ebace..e70ce874 100644 --- a/core/internal/server/network/API.md +++ b/core/internal/server/network/API.md @@ -125,6 +125,8 @@ State updates are sent whenever network configuration changes: - `wifiConnected`: Whether associated with an access point - `wifiSSID`: Currently connected network name - `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`). - `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 2ada0f15..5cbcc661 100644 --- a/core/internal/server/network/backend.go +++ b/core/internal/server/network/backend.go @@ -67,6 +67,7 @@ type BackendState struct { WiFiBSSID string WiFiSignal uint8 WiFiNetworks []WiFiNetwork + SavedWiFiNetworks []WiFiNetwork WiFiDevices []WiFiDevice WiredConnections []WiredConnection VPNProfiles []VPNProfile diff --git a/core/internal/server/network/backend_hybrid_test.go b/core/internal/server/network/backend_hybrid_test.go index cd061ed1..5b8b0d57 100644 --- a/core/internal/server/network/backend_hybrid_test.go +++ b/core/internal/server/network/backend_hybrid_test.go @@ -27,6 +27,19 @@ func TestHybridIwdNetworkdBackend_GetCurrentState_MergesState(t *testing.T) { wifi.state.WiFiBSSID = "00:11:22:33:44:55" wifi.state.WiFiSignal = 75 wifi.state.WiFiDevice = "wlan0" + wifi.state.SavedWiFiNetworks = []WiFiNetwork{ + { + SSID: "TestNetwork", + Saved: true, + Autoconnect: true, + Connected: true, + }, + { + SSID: "AwayNetwork", + Saved: true, + OutOfRange: true, + }, + } l3.state.WiFiIP = "192.168.1.100" l3.state.EthernetConnected = false @@ -42,6 +55,9 @@ func TestHybridIwdNetworkdBackend_GetCurrentState_MergesState(t *testing.T) { assert.True(t, state.WiFiConnected) assert.False(t, state.EthernetConnected) assert.Equal(t, StatusWiFi, state.NetworkStatus) + assert.Len(t, state.SavedWiFiNetworks, 2) + assert.Equal(t, "TestNetwork", state.SavedWiFiNetworks[0].SSID) + assert.True(t, state.SavedWiFiNetworks[1].OutOfRange) } func TestHybridIwdNetworkdBackend_GetCurrentState_EthernetPriority(t *testing.T) { diff --git a/core/internal/server/network/backend_iwd.go b/core/internal/server/network/backend_iwd.go index f73aaf4b..df4a2b87 100644 --- a/core/internal/server/network/backend_iwd.go +++ b/core/internal/server/network/backend_iwd.go @@ -80,6 +80,10 @@ func (b *IWDBackend) Initialize() error { return fmt.Errorf("failed to discover iwd devices: %w", err) } + if err := b.updateSavedWiFiNetworks(); err != nil { + log.Warnf("Failed to get initial saved WiFi networks: %v", err) + } + if err := b.updateState(); err != nil { conn.Close() return fmt.Errorf("failed to get initial state: %w", err) @@ -145,6 +149,7 @@ func (b *IWDBackend) GetCurrentState() (*BackendState, error) { state := *b.state state.WiFiNetworks = append([]WiFiNetwork(nil), b.state.WiFiNetworks...) + state.SavedWiFiNetworks = append([]WiFiNetwork(nil), b.state.SavedWiFiNetworks...) state.WiredConnections = append([]WiredConnection(nil), b.state.WiredConnections...) state.WiFiDevices = b.getWiFiDevicesLocked() diff --git a/core/internal/server/network/backend_iwd_signals.go b/core/internal/server/network/backend_iwd_signals.go index bde4753f..2a4deb29 100644 --- a/core/internal/server/network/backend_iwd_signals.go +++ b/core/internal/server/network/backend_iwd_signals.go @@ -45,12 +45,42 @@ func (b *IWDBackend) StartMonitoring(onStateChange func()) error { } } + if err := b.conn.AddMatchSignal( + dbus.WithMatchInterface(dbusPropertiesInterface), + dbus.WithMatchMember("PropertiesChanged"), + dbus.WithMatchArg(0, iwdKnownNetworkInterface), + ); err != nil { + return fmt.Errorf("failed to add known network signal match: %w", err) + } + + if err := b.conn.AddMatchSignal( + dbus.WithMatchInterface(dbusObjectManager), + dbus.WithMatchMember("InterfacesAdded"), + ); err != nil { + return fmt.Errorf("failed to add iwd interfaces-added signal match: %w", err) + } + + if err := b.conn.AddMatchSignal( + dbus.WithMatchInterface(dbusObjectManager), + dbus.WithMatchMember("InterfacesRemoved"), + ); err != nil { + return fmt.Errorf("failed to add iwd interfaces-removed signal match: %w", err) + } + b.sigWG.Add(1) go b.signalHandler(sigChan) return nil } +func (b *IWDBackend) refreshWiFiNetworkState() bool { + _, err := b.updateWiFiNetworks() + if err == nil { + return true + } + return b.updateSavedWiFiNetworks() == nil +} + func (b *IWDBackend) signalHandler(sigChan chan *dbus.Signal) { defer b.sigWG.Done() @@ -66,11 +96,36 @@ func (b *IWDBackend) signalHandler(sigChan chan *dbus.Signal) { return } - if sig.Name != dbusPropertiesInterface+".PropertiesChanged" { + if sig.Name == dbusObjectManager+".InterfacesAdded" { + if len(sig.Body) >= 2 { + if interfaces, ok := sig.Body[1].(map[string]map[string]dbus.Variant); ok { + if _, ok := interfaces[iwdKnownNetworkInterface]; ok { + if b.refreshWiFiNetworkState() && b.onStateChange != nil { + b.onStateChange() + } + } + } + } continue } - if len(sig.Body) < 2 { + if sig.Name == dbusObjectManager+".InterfacesRemoved" { + if len(sig.Body) >= 2 { + if interfaces, ok := sig.Body[1].([]string); ok { + for _, iface := range interfaces { + if iface == iwdKnownNetworkInterface { + if b.refreshWiFiNetworkState() && b.onStateChange != nil { + b.onStateChange() + } + break + } + } + } + } + continue + } + + if sig.Name != dbusPropertiesInterface+".PropertiesChanged" || len(sig.Body) < 2 { continue } @@ -87,6 +142,9 @@ func (b *IWDBackend) signalHandler(sigChan chan *dbus.Signal) { stateChanged := false switch iface { + case iwdKnownNetworkInterface: + stateChanged = b.refreshWiFiNetworkState() + case iwdDeviceInterface: if sig.Path == b.devicePath { if poweredVar, ok := changed["Powered"]; ok { @@ -105,13 +163,7 @@ func (b *IWDBackend) signalHandler(sigChan chan *dbus.Signal) { if sig.Path == b.stationPath { if scanningVar, ok := changed["Scanning"]; ok { if scanning, ok := scanningVar.Value().(bool); ok && !scanning { - networks, err := b.updateWiFiNetworks() - if err == nil { - b.stateMutex.Lock() - b.state.WiFiNetworks = networks - b.stateMutex.Unlock() - stateChanged = true - } + stateChanged = b.refreshWiFiNetworkState() || stateChanged b.stateMutex.RLock() wifiConnected := b.state.WiFiConnected @@ -236,6 +288,7 @@ func (b *IWDBackend) signalHandler(sigChan chan *dbus.Signal) { } } + b.refreshWiFiNetworkState() stateChanged = true if att != nil && isTarget { @@ -282,6 +335,7 @@ func (b *IWDBackend) signalHandler(sigChan chan *dbus.Signal) { b.state.NetworkStatus = StatusDisconnected } b.stateMutex.Unlock() + b.refreshWiFiNetworkState() stateChanged = true } } @@ -342,6 +396,7 @@ func (b *IWDBackend) signalHandler(sigChan chan *dbus.Signal) { stateChanged = true } b.stateMutex.Unlock() + b.refreshWiFiNetworkState() } } } diff --git a/core/internal/server/network/backend_iwd_test.go b/core/internal/server/network/backend_iwd_test.go index 829e4132..da1d9dc1 100644 --- a/core/internal/server/network/backend_iwd_test.go +++ b/core/internal/server/network/backend_iwd_test.go @@ -4,6 +4,7 @@ import ( "testing" "time" + "github.com/godbus/dbus/v5" "github.com/stretchr/testify/assert" ) @@ -168,6 +169,92 @@ func TestIWDBackend_MapIwdDBusError(t *testing.T) { } } +func TestIWDSavedWiFiProfilesFromManagedObjects(t *testing.T) { + objects := map[dbus.ObjectPath]map[string]map[string]dbus.Variant{ + "/net/connman/iwd/known_network/1": { + iwdKnownNetworkInterface: { + "Name": dbus.MakeVariant("Home"), + "AutoConnect": dbus.MakeVariant(false), + "Hidden": dbus.MakeVariant(true), + "Type": dbus.MakeVariant("psk"), + }, + }, + "/net/connman/iwd/known_network/2": { + iwdKnownNetworkInterface: { + "Name": dbus.MakeVariant("Office"), + "Type": dbus.MakeVariant("8021x"), + }, + }, + "/net/connman/iwd/known_network/3": { + iwdKnownNetworkInterface: { + "Name": dbus.MakeVariant("Cafe"), + "Type": dbus.MakeVariant("open"), + }, + }, + "/net/connman/iwd/network/1": { + iwdNetworkInterface: { + "Name": dbus.MakeVariant("VisibleOnly"), + }, + }, + } + + profiles := iwdSavedWiFiProfilesFromManagedObjects(objects) + + assert.Len(t, profiles, 3) + assert.False(t, profiles["Home"].Autoconnect) + assert.True(t, profiles["Home"].Hidden) + assert.True(t, profiles["Home"].Secured) + assert.False(t, profiles["Home"].Enterprise) + + assert.True(t, profiles["Office"].Autoconnect) + assert.True(t, profiles["Office"].Secured) + assert.True(t, profiles["Office"].Enterprise) + + assert.True(t, profiles["Cafe"].Autoconnect) + assert.False(t, profiles["Cafe"].Secured) + assert.False(t, profiles["Cafe"].Enterprise) +} + +func TestIWDWiFiNetworksFromVisibleIncludesConnectedHiddenFallback(t *testing.T) { + profiles := map[string]savedWiFiProfile{ + "Home": { + Autoconnect: true, + Secured: true, + Hidden: true, + Mode: "infrastructure", + }, + } + visible := []WiFiNetwork{ + { + SSID: "Cafe", + Signal: 42, + Secured: false, + }, + } + + networks := iwdWiFiNetworksFromVisible(visible, profiles, "Home", true, 68) + savedNetworks := savedWiFiNetworksFromProfiles(profiles, map[string]WiFiNetwork{ + networks[0].SSID: networks[0], + networks[1].SSID: networks[1], + }, "Home", true) + + assert.Len(t, networks, 2) + assert.Equal(t, "Cafe", networks[0].SSID) + assert.False(t, networks[0].Connected) + + assert.Equal(t, "Home", networks[1].SSID) + assert.True(t, networks[1].Connected) + assert.True(t, networks[1].Hidden) + assert.True(t, networks[1].Saved) + assert.True(t, networks[1].Autoconnect) + assert.Equal(t, uint8(68), networks[1].Signal) + + assert.Len(t, savedNetworks, 1) + assert.Equal(t, "Home", savedNetworks[0].SSID) + assert.True(t, savedNetworks[0].Connected) + assert.False(t, savedNetworks[0].OutOfRange) +} + func TestConnectAttempt_Finalization(t *testing.T) { backend, _ := NewIWDBackend() backend.state = &BackendState{} diff --git a/core/internal/server/network/backend_iwd_wifi.go b/core/internal/server/network/backend_iwd_wifi.go index b3bea13b..f98c16f0 100644 --- a/core/internal/server/network/backend_iwd_wifi.go +++ b/core/internal/server/network/backend_iwd_wifi.go @@ -164,22 +164,18 @@ func (b *IWDBackend) updateWiFiNetworks() ([]WiFiNetwork, error) { return nil, fmt.Errorf("failed to get networks: %w", err) } - knownNetworks, err := b.getKnownNetworks() + savedProfiles, err := b.getIWDSavedWiFiProfiles() if err != nil { - knownNetworks = make(map[string]bool) - } - - autoconnectMap, err := b.getAutoconnectSettings() - if err != nil { - autoconnectMap = make(map[string]bool) + savedProfiles = make(map[string]savedWiFiProfile) } b.stateMutex.RLock() currentSSID := b.state.WiFiSSID wifiConnected := b.state.WiFiConnected + wifiSignal := b.state.WiFiSignal b.stateMutex.RUnlock() - networks := make([]WiFiNetwork, 0, len(orderedNetworks)) + visibleNetworks := make([]WiFiNetwork, 0, len(orderedNetworks)) for _, netData := range orderedNetworks { if len(netData) < 2 { continue @@ -225,23 +221,26 @@ func (b *IWDBackend) updateWiFiNetworks() ([]WiFiNetwork, error) { secured := netType != "open" - network := WiFiNetwork{ - SSID: name, - Signal: signal, - Secured: secured, - Connected: wifiConnected && name == currentSSID, - Saved: knownNetworks[name], - Autoconnect: autoconnectMap[name], - Enterprise: netType == "8021x", - } - - networks = append(networks, network) + visibleNetworks = append(visibleNetworks, WiFiNetwork{ + SSID: name, + Signal: signal, + Secured: secured, + Enterprise: netType == "8021x", + }) } + networks := iwdWiFiNetworksFromVisible(visibleNetworks, savedProfiles, currentSSID, wifiConnected, wifiSignal) + visibleNetworkMap := make(map[string]WiFiNetwork, len(networks)) + for _, network := range networks { + visibleNetworkMap[network.SSID] = network + } + savedNetworks := savedWiFiNetworksFromProfiles(savedProfiles, visibleNetworkMap, currentSSID, wifiConnected) + sortWiFiNetworks(networks) b.stateMutex.Lock() b.state.WiFiNetworks = networks + b.state.SavedWiFiNetworks = savedNetworks b.stateMutex.Unlock() now := time.Now() @@ -254,30 +253,129 @@ func (b *IWDBackend) updateWiFiNetworks() ([]WiFiNetwork, error) { return networks, nil } -func (b *IWDBackend) getKnownNetworks() (map[string]bool, error) { - obj := b.conn.Object(iwdBusName, iwdObjectPath) - - var objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant - err := obj.Call(dbusObjectManager+".GetManagedObjects", 0).Store(&objects) +func (b *IWDBackend) updateSavedWiFiNetworks() error { + savedProfiles, err := b.getIWDSavedWiFiProfiles() if err != nil { - return nil, err + return err } - known := make(map[string]bool) - for _, interfaces := range objects { - if knownProps, ok := interfaces[iwdKnownNetworkInterface]; ok { - if nameVar, ok := knownProps["Name"]; ok { - if name, ok := nameVar.Value().(string); ok { - known[name] = true - } - } - } - } + b.stateMutex.RLock() + currentSSID := b.state.WiFiSSID + wifiConnected := b.state.WiFiConnected + wifiNetworks := append([]WiFiNetwork(nil), b.state.WiFiNetworks...) + b.stateMutex.RUnlock() - return known, nil + wifiNetworks, savedNetworks := refreshSavedWiFiState(wifiNetworks, savedProfiles, currentSSID, wifiConnected) + + b.stateMutex.Lock() + b.state.WiFiNetworks = wifiNetworks + b.state.SavedWiFiNetworks = savedNetworks + b.stateMutex.Unlock() + + return nil } -func (b *IWDBackend) getAutoconnectSettings() (map[string]bool, error) { +func iwdWiFiNetworksFromVisible(visibleNetworks []WiFiNetwork, savedProfiles map[string]savedWiFiProfile, currentSSID string, wifiConnected bool, wifiSignal uint8) []WiFiNetwork { + networks := make([]WiFiNetwork, 0, len(visibleNetworks)+1) + seenSSIDs := make(map[string]struct{}, len(visibleNetworks)+1) + + for _, network := range visibleNetworks { + profile, saved := savedProfiles[network.SSID] + network.Connected = wifiConnected && network.SSID == currentSSID + network.Saved = saved + network.Autoconnect = profile.Autoconnect + network.Hidden = network.Hidden || profile.Hidden + network.Secured = network.Secured || profile.Secured + network.Enterprise = network.Enterprise || profile.Enterprise + if network.Mode == "" { + network.Mode = profile.Mode + } + networks = append(networks, network) + seenSSIDs[network.SSID] = struct{}{} + } + + if wifiConnected && currentSSID != "" { + if _, exists := seenSSIDs[currentSSID]; !exists { + profile, saved := savedProfiles[currentSSID] + secured := profile.Secured + if !saved { + secured = true + } + mode := profile.Mode + if mode == "" { + mode = "infrastructure" + } + + networks = append(networks, WiFiNetwork{ + SSID: currentSSID, + Signal: wifiSignal, + Secured: secured, + Enterprise: profile.Enterprise, + Connected: true, + Saved: saved, + Autoconnect: profile.Autoconnect, + Hidden: true, + Mode: mode, + }) + } + } + + return networks +} + +func iwdSavedWiFiProfilesFromManagedObjects(objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant) map[string]savedWiFiProfile { + profiles := make(map[string]savedWiFiProfile) + + for _, interfaces := range objects { + knownProps, ok := interfaces[iwdKnownNetworkInterface] + if !ok { + continue + } + + nameVar, ok := knownProps["Name"] + if !ok { + continue + } + name, ok := nameVar.Value().(string) + if !ok || name == "" { + continue + } + + profile := savedWiFiProfile{ + Autoconnect: true, + Mode: "infrastructure", + } + if acVar, ok := knownProps["AutoConnect"]; ok { + if autoconnect, ok := acVar.Value().(bool); ok { + profile.Autoconnect = autoconnect + } + } + if hiddenVar, ok := knownProps["Hidden"]; ok { + if hidden, ok := hiddenVar.Value().(bool); ok { + profile.Hidden = hidden + } + } + if typeVar, ok := knownProps["Type"]; ok { + if networkType, ok := typeVar.Value().(string); ok { + profile.Secured = networkType != "" && networkType != "open" + profile.Enterprise = networkType == "8021x" + } + } + + if existing, ok := profiles[name]; ok { + profile.Autoconnect = profile.Autoconnect || existing.Autoconnect + profile.Hidden = profile.Hidden || existing.Hidden + profile.Secured = profile.Secured || existing.Secured + profile.Enterprise = profile.Enterprise || existing.Enterprise + } + + profiles[name] = profile + } + + return profiles +} + +func (b *IWDBackend) getIWDSavedWiFiProfiles() (map[string]savedWiFiProfile, error) { obj := b.conn.Object(iwdBusName, iwdObjectPath) var objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant @@ -286,24 +384,7 @@ func (b *IWDBackend) getAutoconnectSettings() (map[string]bool, error) { return nil, err } - autoconnectMap := make(map[string]bool) - for _, interfaces := range objects { - if knownProps, ok := interfaces[iwdKnownNetworkInterface]; ok { - if nameVar, ok := knownProps["Name"]; ok { - if name, ok := nameVar.Value().(string); ok { - autoconnect := true - if acVar, ok := knownProps["AutoConnect"]; ok { - if ac, ok := acVar.Value().(bool); ok { - autoconnect = ac - } - } - autoconnectMap[name] = autoconnect - } - } - } - } - - return autoconnectMap, nil + return iwdSavedWiFiProfilesFromManagedObjects(objects), nil } func (b *IWDBackend) GetWiFiNetworkDetails(ssid string) (*NetworkInfoResponse, error) { @@ -614,6 +695,8 @@ func (b *IWDBackend) ForgetWiFiNetwork(ssid string) error { b.stateMutex.Unlock() } + _, _ = b.updateWiFiNetworks() + if b.onStateChange != nil { b.onStateChange() } diff --git a/core/internal/server/network/backend_networkmanager.go b/core/internal/server/network/backend_networkmanager.go index 2233975a..e00ae300 100644 --- a/core/internal/server/network/backend_networkmanager.go +++ b/core/internal/server/network/backend_networkmanager.go @@ -222,6 +222,10 @@ func (b *NetworkManagerBackend) Initialize() error { log.Warnf("Failed to update WiFi state: %v", err) } + if err := b.updateSavedWiFiNetworks(); err != nil { + log.Warnf("Failed to get initial saved WiFi networks: %v", err) + } + if wifiEnabled { if _, err := b.updateWiFiNetworks(); err != nil { log.Warnf("Failed to get initial networks: %v", err) @@ -261,6 +265,7 @@ func (b *NetworkManagerBackend) GetCurrentState() (*BackendState, error) { state := *b.state state.WiFiNetworks = append([]WiFiNetwork(nil), b.state.WiFiNetworks...) + state.SavedWiFiNetworks = append([]WiFiNetwork(nil), b.state.SavedWiFiNetworks...) state.WiFiDevices = append([]WiFiDevice(nil), b.state.WiFiDevices...) state.WiredConnections = append([]WiredConnection(nil), b.state.WiredConnections...) state.EthernetDevices = append([]EthernetDevice(nil), b.state.EthernetDevices...) diff --git a/core/internal/server/network/backend_networkmanager_signals.go b/core/internal/server/network/backend_networkmanager_signals.go index 5274ed0c..91825141 100644 --- a/core/internal/server/network/backend_networkmanager_signals.go +++ b/core/internal/server/network/backend_networkmanager_signals.go @@ -5,6 +5,12 @@ import ( "github.com/godbus/dbus/v5" ) +const ( + dbusNMSettingsPath = "/org/freedesktop/NetworkManager/Settings" + dbusNMSettingsInterface = "org.freedesktop.NetworkManager.Settings" + dbusNMSettingsConnectionInterface = "org.freedesktop.NetworkManager.Settings.Connection" +) + func (b *NetworkManagerBackend) startSignalPump() error { conn, err := dbus.ConnectSystemBus() if err != nil { @@ -27,8 +33,8 @@ func (b *NetworkManagerBackend) startSignalPump() error { } if err := conn.AddMatchSignal( - dbus.WithMatchObjectPath(dbus.ObjectPath("/org/freedesktop/NetworkManager/Settings")), - dbus.WithMatchInterface("org.freedesktop.NetworkManager.Settings"), + dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMSettingsPath)), + dbus.WithMatchInterface(dbusNMSettingsInterface), dbus.WithMatchMember("NewConnection"), ); err != nil { conn.RemoveMatchSignal( @@ -42,8 +48,8 @@ func (b *NetworkManagerBackend) startSignalPump() error { } if err := conn.AddMatchSignal( - dbus.WithMatchObjectPath(dbus.ObjectPath("/org/freedesktop/NetworkManager/Settings")), - dbus.WithMatchInterface("org.freedesktop.NetworkManager.Settings"), + dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMSettingsPath)), + dbus.WithMatchInterface(dbusNMSettingsInterface), dbus.WithMatchMember("ConnectionRemoved"), ); err != nil { conn.RemoveMatchSignal( @@ -52,8 +58,8 @@ func (b *NetworkManagerBackend) startSignalPump() error { dbus.WithMatchMember("PropertiesChanged"), ) conn.RemoveMatchSignal( - dbus.WithMatchObjectPath(dbus.ObjectPath("/org/freedesktop/NetworkManager/Settings")), - dbus.WithMatchInterface("org.freedesktop.NetworkManager.Settings"), + dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMSettingsPath)), + dbus.WithMatchInterface(dbusNMSettingsInterface), dbus.WithMatchMember("NewConnection"), ) conn.RemoveSignal(signals) @@ -61,6 +67,31 @@ func (b *NetworkManagerBackend) startSignalPump() error { return err } + if err := conn.AddMatchSignal( + dbus.WithMatchPathNamespace(dbus.ObjectPath(dbusNMSettingsPath)), + dbus.WithMatchInterface(dbusNMSettingsConnectionInterface), + dbus.WithMatchMember("Updated"), + ); err != nil { + conn.RemoveMatchSignal( + dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMPath)), + dbus.WithMatchInterface(dbusPropsInterface), + dbus.WithMatchMember("PropertiesChanged"), + ) + conn.RemoveMatchSignal( + dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMSettingsPath)), + dbus.WithMatchInterface(dbusNMSettingsInterface), + dbus.WithMatchMember("NewConnection"), + ) + conn.RemoveMatchSignal( + dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMSettingsPath)), + dbus.WithMatchInterface(dbusNMSettingsInterface), + dbus.WithMatchMember("ConnectionRemoved"), + ) + conn.RemoveSignal(signals) + conn.Close() + return err + } + if err := conn.AddMatchSignal( dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMPath)), dbus.WithMatchInterface(dbusNMInterface), @@ -137,6 +168,32 @@ func (b *NetworkManagerBackend) stopSignalPump() { dbus.WithMatchMember("PropertiesChanged"), ) + b.dbusConn.RemoveMatchSignal( + dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMSettingsPath)), + dbus.WithMatchInterface(dbusNMSettingsInterface), + dbus.WithMatchMember("NewConnection"), + ) + b.dbusConn.RemoveMatchSignal( + dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMSettingsPath)), + dbus.WithMatchInterface(dbusNMSettingsInterface), + dbus.WithMatchMember("ConnectionRemoved"), + ) + b.dbusConn.RemoveMatchSignal( + dbus.WithMatchPathNamespace(dbus.ObjectPath(dbusNMSettingsPath)), + dbus.WithMatchInterface(dbusNMSettingsConnectionInterface), + dbus.WithMatchMember("Updated"), + ) + b.dbusConn.RemoveMatchSignal( + dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMPath)), + dbus.WithMatchInterface(dbusNMInterface), + dbus.WithMatchMember("DeviceAdded"), + ) + b.dbusConn.RemoveMatchSignal( + dbus.WithMatchObjectPath(dbus.ObjectPath(dbusNMPath)), + dbus.WithMatchInterface(dbusNMInterface), + dbus.WithMatchMember("DeviceRemoved"), + ) + for _, info := range b.wifiDevices { b.dbusConn.RemoveMatchSignal( dbus.WithMatchObjectPath(dbus.ObjectPath(info.device.GetPath())), @@ -164,9 +221,13 @@ func (b *NetworkManagerBackend) stopSignalPump() { } func (b *NetworkManagerBackend) handleDBusSignal(sig *dbus.Signal) { - if sig.Name == "org.freedesktop.NetworkManager.Settings.NewConnection" || - sig.Name == "org.freedesktop.NetworkManager.Settings.ConnectionRemoved" { + if sig.Name == dbusNMSettingsInterface+".NewConnection" || + sig.Name == dbusNMSettingsInterface+".ConnectionRemoved" || + sig.Name == dbusNMSettingsConnectionInterface+".Updated" { b.ListVPNProfiles() + if err := b.updateSavedWiFiNetworks(); err != nil { + b.updateWiFiNetworks() + } if b.onStateChange != nil { b.onStateChange() } diff --git a/core/internal/server/network/backend_networkmanager_wifi.go b/core/internal/server/network/backend_networkmanager_wifi.go index 5cb5c09a..90821cef 100644 --- a/core/internal/server/network/backend_networkmanager_wifi.go +++ b/core/internal/server/network/backend_networkmanager_wifi.go @@ -225,24 +225,14 @@ func (b *NetworkManagerBackend) GetWiFiQRCodeContent(ssid string) (string, error return "", fmt.Errorf("failed to identify security type of network `%s`", ssid) } - var securityType string switch keyMgmt { case "none": - authAlg, _ := secSettings["auth-alg"].(string) - switch authAlg { - case "open": - securityType = "nopass" - default: - securityType = "WEP" - } + return "", fmt.Errorf("QR code generation only supports WPA-PSK connections, `%s` is open or WEP", ssid) case "ieee8021x": - securityType = "WEP" + return "", fmt.Errorf("QR code generation only supports WPA-PSK connections, `%s` is enterprise", ssid) + case "wpa-psk", "sae", "wpa-psk-sae": default: - securityType = "WPA" - } - - if securityType != "WPA" { - return "", fmt.Errorf("QR code generation only supports WPA connections, `%s` uses %s", ssid, securityType) + return "", fmt.Errorf("QR code generation only supports WPA-PSK connections, `%s` uses %s", ssid, keyMgmt) } var psk string @@ -276,7 +266,7 @@ func (b *NetworkManagerBackend) GetWiFiQRCodeContent(ssid string) (string, error return "", fmt.Errorf("failed to retrieve password for `%s`", ssid) } - return FormatWiFiQRString(securityType, ssid, psk), nil + return FormatWiFiQRString("WPA", ssid, psk), nil } func (b *NetworkManagerBackend) ConnectWiFi(req ConnectionRequest) error { @@ -405,6 +395,74 @@ func (b *NetworkManagerBackend) ForgetWiFiNetwork(ssid string) error { return nil } +func getSavedWiFiProfiles(connections []gonetworkmanager.Connection) map[string]savedWiFiProfile { + profiles := make(map[string]savedWiFiProfile) + + for _, conn := range connections { + connSettings, err := conn.GetSettings() + if err != nil { + 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 + } + + ssidBytes, ok := wifiSettings["ssid"].([]byte) + if !ok || len(ssidBytes) == 0 { + continue + } + + ssid := string(ssidBytes) + profile := savedWiFiProfile{ + Autoconnect: true, + Mode: "infrastructure", + } + + if ac, ok := connMeta["autoconnect"].(bool); ok { + profile.Autoconnect = ac + } + if hidden, ok := wifiSettings["hidden"].(bool); ok { + profile.Hidden = hidden + } + if mode, ok := wifiSettings["mode"].(string); ok && mode != "" { + profile.Mode = mode + } + if _, ok := connSettings["802-11-wireless-security"]; ok { + profile.Secured = true + } + if _, ok := connSettings["802-1x"]; ok { + profile.Enterprise = true + profile.Secured = true + } + + if existing, ok := profiles[ssid]; ok { + profile.Autoconnect = profile.Autoconnect || existing.Autoconnect + profile.Hidden = profile.Hidden || existing.Hidden + profile.Secured = profile.Secured || existing.Secured + profile.Enterprise = profile.Enterprise || existing.Enterprise + if profile.Mode == "" { + profile.Mode = existing.Mode + } + } + + profiles[ssid] = profile + } + + return profiles +} + func (b *NetworkManagerBackend) IsConnectingTo(ssid string) bool { b.stateMutex.RLock() defer b.stateMutex.RUnlock() @@ -442,47 +500,7 @@ func (b *NetworkManagerBackend) updateWiFiNetworks() ([]WiFiNetwork, error) { return nil, fmt.Errorf("failed to get connections: %w", err) } - savedSSIDs := make(map[string]bool) - autoconnectMap := make(map[string]bool) - hiddenSSIDs := make(map[string]bool) - for _, conn := range connections { - connSettings, err := conn.GetSettings() - if err != nil { - 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 - } - - ssidBytes, ok := wifiSettings["ssid"].([]byte) - if !ok { - continue - } - - ssid := string(ssidBytes) - savedSSIDs[ssid] = true - autoconnect := true - if ac, ok := connMeta["autoconnect"].(bool); ok { - autoconnect = ac - } - autoconnectMap[ssid] = autoconnect - - if hidden, ok := wifiSettings["hidden"].(bool); ok && hidden { - hiddenSSIDs[ssid] = true - } - } + savedProfiles := getSavedWiFiProfiles(connections) b.stateMutex.RLock() currentSSID := b.state.WiFiSSID @@ -491,8 +509,8 @@ func (b *NetworkManagerBackend) updateWiFiNetworks() ([]WiFiNetwork, error) { wifiBSSID := b.state.WiFiBSSID b.stateMutex.RUnlock() - seenSSIDs := make(map[string]*WiFiNetwork) - networks := []WiFiNetwork{} + seenSSIDs := make(map[string]int) + networks := make([]WiFiNetwork, 0, len(apPaths)+1) for _, ap := range apPaths { ssid, err := ap.GetPropertySSID() @@ -500,7 +518,8 @@ func (b *NetworkManagerBackend) updateWiFiNetworks() ([]WiFiNetwork, error) { continue } - if existing, exists := seenSSIDs[ssid]; exists { + if existingIndex, exists := seenSSIDs[ssid]; exists { + existing := &networks[existingIndex] strength, _ := ap.GetPropertyStrength() if strength > existing.Signal { existing.Signal = strength @@ -550,6 +569,7 @@ func (b *NetworkManagerBackend) updateWiFiNetworks() ([]WiFiNetwork, error) { } } + profile, saved := savedProfiles[ssid] network := WiFiNetwork{ SSID: ssid, BSSID: bssid, @@ -557,45 +577,86 @@ func (b *NetworkManagerBackend) updateWiFiNetworks() ([]WiFiNetwork, error) { Secured: secured, Enterprise: enterprise, Connected: isConnected, - Saved: savedSSIDs[ssid], - Autoconnect: autoconnectMap[ssid], - Hidden: hiddenSSIDs[ssid], + Saved: saved, + Autoconnect: profile.Autoconnect, + Hidden: profile.Hidden, Frequency: freq, Mode: modeStr, Rate: rate, Channel: channel, } - seenSSIDs[ssid] = &network networks = append(networks, network) + seenSSIDs[ssid] = len(networks) - 1 } if wifiConnected && currentSSID != "" { if _, exists := seenSSIDs[currentSSID]; !exists { + profile, saved := savedProfiles[currentSSID] hiddenNetwork := WiFiNetwork{ SSID: currentSSID, BSSID: wifiBSSID, Signal: wifiSignal, Secured: true, Connected: true, - Saved: savedSSIDs[currentSSID], - Autoconnect: autoconnectMap[currentSSID], + Saved: saved, + Autoconnect: profile.Autoconnect, Hidden: true, Mode: "infrastructure", } networks = append(networks, hiddenNetwork) + seenSSIDs[currentSSID] = len(networks) - 1 } } + visibleNetworks := wiFiNetworksBySSID(networks, true) + savedNetworks := savedWiFiNetworksFromProfiles(savedProfiles, visibleNetworks, currentSSID, wifiConnected) + sortWiFiNetworks(networks) b.stateMutex.Lock() b.state.WiFiNetworks = networks + b.state.SavedWiFiNetworks = savedNetworks b.stateMutex.Unlock() return networks, nil } +func (b *NetworkManagerBackend) updateSavedWiFiNetworks() error { + s := b.settings + if s == nil { + var err error + s, err = gonetworkmanager.NewSettings() + if err != nil { + return fmt.Errorf("failed to get settings: %w", err) + } + b.settings = s + } + + settingsMgr := s.(gonetworkmanager.Settings) + connections, err := settingsMgr.ListConnections() + if err != nil { + return fmt.Errorf("failed to get connections: %w", err) + } + + savedProfiles := getSavedWiFiProfiles(connections) + + b.stateMutex.RLock() + currentSSID := b.state.WiFiSSID + wifiConnected := b.state.WiFiConnected + wifiNetworks := append([]WiFiNetwork(nil), b.state.WiFiNetworks...) + b.stateMutex.RUnlock() + + wifiNetworks, savedNetworks := refreshSavedWiFiState(wifiNetworks, savedProfiles, currentSSID, wifiConnected) + + b.stateMutex.Lock() + b.state.WiFiNetworks = wifiNetworks + b.state.SavedWiFiNetworks = savedNetworks + b.stateMutex.Unlock() + + return nil +} + func (b *NetworkManagerBackend) findConnection(ssid string) (gonetworkmanager.Connection, error) { s := b.settings if s == nil { @@ -975,49 +1036,14 @@ func (b *NetworkManagerBackend) updateAllWiFiDevices() { return } - savedSSIDs := make(map[string]bool) - autoconnectMap := make(map[string]bool) - hiddenSSIDs := make(map[string]bool) - for _, conn := range connections { - connSettings, err := conn.GetSettings() - if err != nil { - 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 - } - - ssidBytes, ok := wifiSettings["ssid"].([]byte) - if !ok { - continue - } - - ssid := string(ssidBytes) - savedSSIDs[ssid] = true - autoconnect := true - if ac, ok := connMeta["autoconnect"].(bool); ok { - autoconnect = ac - } - autoconnectMap[ssid] = autoconnect - - if hidden, ok := wifiSettings["hidden"].(bool); ok && hidden { - hiddenSSIDs[ssid] = true - } - } + savedProfiles := getSavedWiFiProfiles(connections) var devices []WiFiDevice + visibleNetworks := make(map[string]WiFiNetwork) + b.stateMutex.RLock() + currentSSID := b.state.WiFiSSID + wifiConnected := b.state.WiFiConnected + b.stateMutex.RUnlock() for name, devInfo := range b.wifiDevices { state, _ := devInfo.device.GetPropertyState() @@ -1050,14 +1076,16 @@ func (b *NetworkManagerBackend) updateAllWiFiDevices() { apPaths, err := devInfo.wireless.GetAccessPoints() var networks []WiFiNetwork if err == nil { - seenSSIDs := make(map[string]*WiFiNetwork) + seenSSIDs := make(map[string]int) + networks = make([]WiFiNetwork, 0, len(apPaths)+1) for _, ap := range apPaths { apSSID, err := ap.GetPropertySSID() if err != nil || apSSID == "" { continue } - if existing, exists := seenSSIDs[apSSID]; exists { + if existingIndex, exists := seenSSIDs[apSSID]; exists { + existing := &networks[existingIndex] strength, _ := ap.GetPropertyStrength() if strength > existing.Signal { existing.Signal = strength @@ -1107,6 +1135,7 @@ func (b *NetworkManagerBackend) updateAllWiFiDevices() { } } + profile, saved := savedProfiles[apSSID] network := WiFiNetwork{ SSID: apSSID, BSSID: apBSSID, @@ -1114,9 +1143,9 @@ func (b *NetworkManagerBackend) updateAllWiFiDevices() { Secured: secured, Enterprise: enterprise, Connected: isConnected, - Saved: savedSSIDs[apSSID], - Autoconnect: autoconnectMap[apSSID], - Hidden: hiddenSSIDs[apSSID], + Saved: saved, + Autoconnect: profile.Autoconnect, + Hidden: profile.Hidden, Frequency: freq, Mode: modeStr, Rate: rate, @@ -1124,25 +1153,31 @@ func (b *NetworkManagerBackend) updateAllWiFiDevices() { Device: name, } - seenSSIDs[apSSID] = &network networks = append(networks, network) + seenSSIDs[apSSID] = len(networks) - 1 + if existing, ok := visibleNetworks[apSSID]; !ok || network.Signal > existing.Signal { + visibleNetworks[apSSID] = network + } } if connected && ssid != "" { if _, exists := seenSSIDs[ssid]; !exists { + profile, saved := savedProfiles[ssid] hiddenNetwork := WiFiNetwork{ SSID: ssid, BSSID: bssid, Signal: signal, Secured: true, Connected: true, - Saved: savedSSIDs[ssid], - Autoconnect: autoconnectMap[ssid], + Saved: saved, + Autoconnect: profile.Autoconnect, Hidden: true, Mode: "infrastructure", Device: name, } networks = append(networks, hiddenNetwork) + seenSSIDs[ssid] = len(networks) - 1 + visibleNetworks[ssid] = hiddenNetwork } } @@ -1168,6 +1203,7 @@ func (b *NetworkManagerBackend) updateAllWiFiDevices() { b.stateMutex.Lock() b.state.WiFiDevices = devices + b.state.SavedWiFiNetworks = savedWiFiNetworksFromProfiles(savedProfiles, visibleNetworks, currentSSID, wifiConnected) b.stateMutex.Unlock() } diff --git a/core/internal/server/network/backend_networkmanager_wifi_test.go b/core/internal/server/network/backend_networkmanager_wifi_test.go index 79c2c5bc..c4a5018f 100644 --- a/core/internal/server/network/backend_networkmanager_wifi_test.go +++ b/core/internal/server/network/backend_networkmanager_wifi_test.go @@ -4,6 +4,7 @@ import ( "testing" mock_gonetworkmanager "github.com/AvengeMedia/DankMaterialShell/core/internal/mocks/github.com/Wifx/gonetworkmanager/v2" + "github.com/Wifx/gonetworkmanager/v2" "github.com/stretchr/testify/assert" ) @@ -176,6 +177,54 @@ func TestNetworkManagerBackend_UpdateWiFiNetworks_NoDevice(t *testing.T) { assert.Contains(t, err.Error(), "no WiFi device available") } +func TestNetworkManagerBackend_UpdateSavedWiFiNetworksPreservesVisibleSavedNetworks(t *testing.T) { + mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) + mockSettings := mock_gonetworkmanager.NewMockSettings(t) + mockConn := mock_gonetworkmanager.NewMockConnection(t) + + backend, err := NewNetworkManagerBackend(mockNM) + assert.NoError(t, err) + backend.settings = mockSettings + + backend.stateMutex.Lock() + backend.state.WiFiNetworks = []WiFiNetwork{ + { + SSID: "Home", + Signal: 76, + }, + } + backend.stateMutex.Unlock() + + settings := gonetworkmanager.ConnectionSettings{ + "connection": { + "type": "802-11-wireless", + "autoconnect": true, + }, + "802-11-wireless": { + "ssid": []byte("Home"), + }, + "802-11-wireless-security": {}, + } + mockSettings.EXPECT().ListConnections().Return([]gonetworkmanager.Connection{mockConn}, nil) + mockConn.EXPECT().GetSettings().Return(settings, nil) + + err = backend.updateSavedWiFiNetworks() + assert.NoError(t, err) + + backend.stateMutex.RLock() + savedNetworks := append([]WiFiNetwork(nil), backend.state.SavedWiFiNetworks...) + wifiNetworks := append([]WiFiNetwork(nil), backend.state.WiFiNetworks...) + backend.stateMutex.RUnlock() + + assert.Len(t, wifiNetworks, 1) + assert.True(t, wifiNetworks[0].Saved) + assert.Len(t, savedNetworks, 1) + assert.Equal(t, "Home", savedNetworks[0].SSID) + assert.True(t, savedNetworks[0].Saved) + assert.False(t, savedNetworks[0].OutOfRange) + assert.Equal(t, uint8(76), savedNetworks[0].Signal) +} + func TestNetworkManagerBackend_FindConnection_NoSettings(t *testing.T) { mockNM := mock_gonetworkmanager.NewMockNetworkManager(t) diff --git a/core/internal/server/network/manager.go b/core/internal/server/network/manager.go index 5c6657ef..259b97ed 100644 --- a/core/internal/server/network/manager.go +++ b/core/internal/server/network/manager.go @@ -64,9 +64,10 @@ func NewManager() (*Manager, error) { m := &Manager{ backend: backend, state: &NetworkState{ - NetworkStatus: StatusDisconnected, - Preference: PreferenceAuto, - WiFiNetworks: []WiFiNetwork{}, + NetworkStatus: StatusDisconnected, + Preference: PreferenceAuto, + WiFiNetworks: []WiFiNetwork{}, + SavedWiFiNetworks: []WiFiNetwork{}, }, stateMutex: sync.RWMutex{}, @@ -120,6 +121,7 @@ func (m *Manager) syncStateFromBackend() error { m.state.WiFiBSSID = backendState.WiFiBSSID m.state.WiFiSignal = backendState.WiFiSignal m.state.WiFiNetworks = backendState.WiFiNetworks + m.state.SavedWiFiNetworks = backendState.SavedWiFiNetworks m.state.WiFiDevices = backendState.WiFiDevices m.state.WiredConnections = backendState.WiredConnections m.state.VPNProfiles = backendState.VPNProfiles @@ -156,6 +158,7 @@ func (m *Manager) snapshotState() NetworkState { defer m.stateMutex.RUnlock() s := *m.state s.WiFiNetworks = append([]WiFiNetwork(nil), m.state.WiFiNetworks...) + s.SavedWiFiNetworks = append([]WiFiNetwork(nil), m.state.SavedWiFiNetworks...) s.WiFiDevices = append([]WiFiDevice(nil), m.state.WiFiDevices...) s.WiredConnections = append([]WiredConnection(nil), m.state.WiredConnections...) s.EthernetDevices = append([]EthernetDevice(nil), m.state.EthernetDevices...) @@ -211,6 +214,9 @@ func stateChangedMeaningfully(old, new *NetworkState) bool { if len(old.WiFiNetworks) != len(new.WiFiNetworks) { return true } + if len(old.SavedWiFiNetworks) != len(new.SavedWiFiNetworks) { + return true + } if len(old.WiFiDevices) != len(new.WiFiDevices) { return true } @@ -238,6 +244,23 @@ func stateChangedMeaningfully(old, new *NetworkState) bool { } } + for i := range old.SavedWiFiNetworks { + oldNet := &old.SavedWiFiNetworks[i] + newNet := &new.SavedWiFiNetworks[i] + if oldNet.SSID != newNet.SSID { + return true + } + if oldNet.Connected != newNet.Connected { + return true + } + if oldNet.Autoconnect != newNet.Autoconnect { + return true + } + if oldNet.OutOfRange != newNet.OutOfRange { + return true + } + } + for i := range old.WiredConnections { oldNet := &old.WiredConnections[i] newNet := &new.WiredConnections[i] diff --git a/core/internal/server/network/types.go b/core/internal/server/network/types.go index 93448cfb..e6da82b7 100644 --- a/core/internal/server/network/types.go +++ b/core/internal/server/network/types.go @@ -34,6 +34,7 @@ type WiFiNetwork struct { Saved bool `json:"saved"` Autoconnect bool `json:"autoconnect"` Hidden bool `json:"hidden"` + OutOfRange bool `json:"outOfRange"` Frequency uint32 `json:"frequency"` Mode string `json:"mode"` Rate uint32 `json:"rate"` @@ -111,6 +112,7 @@ type NetworkState struct { WiFiBSSID string `json:"wifiBSSID"` WiFiSignal uint8 `json:"wifiSignal"` WiFiNetworks []WiFiNetwork `json:"wifiNetworks"` + SavedWiFiNetworks []WiFiNetwork `json:"savedWifiNetworks"` WiFiDevices []WiFiDevice `json:"wifiDevices"` WiredConnections []WiredConnection `json:"wiredConnections"` VPNProfiles []VPNProfile `json:"vpnProfiles"` diff --git a/core/internal/server/network/wifi_saved.go b/core/internal/server/network/wifi_saved.go new file mode 100644 index 00000000..f657ff45 --- /dev/null +++ b/core/internal/server/network/wifi_saved.go @@ -0,0 +1,103 @@ +package network + +import "sort" + +type savedWiFiProfile struct { + Autoconnect bool + Hidden bool + Secured bool + Enterprise bool + Mode string +} + +// Saved WiFi state is keyed by SSID because the UI/API accepts SSID actions. +// Multiple backend profiles for the same SSID are intentionally collapsed here. +func mergeSavedProfilesIntoWiFiNetworks(networks []WiFiNetwork, profiles map[string]savedWiFiProfile, currentSSID string, wifiConnected bool) []WiFiNetwork { + merged := make([]WiFiNetwork, len(networks)) + for i, network := range networks { + profile, saved := profiles[network.SSID] + network.Connected = wifiConnected && network.SSID == currentSSID + network.Saved = saved + if saved { + network.Autoconnect = profile.Autoconnect + network.Hidden = network.Hidden || profile.Hidden + network.Secured = network.Secured || profile.Secured + network.Enterprise = network.Enterprise || profile.Enterprise + if network.Mode == "" { + network.Mode = profile.Mode + } + } else { + network.Autoconnect = false + } + merged[i] = network + } + return merged +} + +func wiFiNetworksBySSID(networks []WiFiNetwork, visibleOnly bool) map[string]WiFiNetwork { + visible := make(map[string]WiFiNetwork, len(networks)) + for _, network := range networks { + if visibleOnly && network.OutOfRange { + continue + } + visible[network.SSID] = network + } + return visible +} + +func refreshSavedWiFiState(networks []WiFiNetwork, profiles map[string]savedWiFiProfile, currentSSID string, wifiConnected bool) ([]WiFiNetwork, []WiFiNetwork) { + mergedNetworks := mergeSavedProfilesIntoWiFiNetworks(networks, profiles, currentSSID, wifiConnected) + visibleNetworks := wiFiNetworksBySSID(mergedNetworks, true) + savedNetworks := savedWiFiNetworksFromProfiles(profiles, visibleNetworks, currentSSID, wifiConnected) + return mergedNetworks, savedNetworks +} + +func savedWiFiNetworksFromProfiles(profiles map[string]savedWiFiProfile, visible map[string]WiFiNetwork, currentSSID string, wifiConnected bool) []WiFiNetwork { + networks := make([]WiFiNetwork, 0, len(profiles)) + for ssid, profile := range profiles { + if network, ok := visible[ssid]; ok { + network.Saved = true + network.Autoconnect = profile.Autoconnect + network.Hidden = network.Hidden || profile.Hidden + network.Secured = network.Secured || profile.Secured + network.Enterprise = network.Enterprise || profile.Enterprise + network.OutOfRange = false + if network.Mode == "" { + network.Mode = profile.Mode + } + networks = append(networks, network) + continue + } + + isConnected := wifiConnected && ssid == currentSSID + networks = append(networks, WiFiNetwork{ + SSID: ssid, + Secured: profile.Secured, + Enterprise: profile.Enterprise, + Connected: isConnected, + Saved: true, + Autoconnect: profile.Autoconnect, + Hidden: profile.Hidden, + OutOfRange: !isConnected, + Mode: profile.Mode, + }) + } + + sort.Slice(networks, func(i, j int) bool { + if networks[i].Connected && !networks[j].Connected { + return true + } + if !networks[i].Connected && networks[j].Connected { + return false + } + if networks[i].OutOfRange != networks[j].OutOfRange { + return !networks[i].OutOfRange + } + if networks[i].Signal != networks[j].Signal { + return networks[i].Signal > networks[j].Signal + } + return networks[i].SSID < networks[j].SSID + }) + + return networks +} diff --git a/core/internal/server/network/wifi_saved_test.go b/core/internal/server/network/wifi_saved_test.go new file mode 100644 index 00000000..2f83a671 --- /dev/null +++ b/core/internal/server/network/wifi_saved_test.go @@ -0,0 +1,170 @@ +package network + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMergeSavedProfilesIntoWiFiNetworks(t *testing.T) { + networks := []WiFiNetwork{ + { + SSID: "Home", + Signal: 80, + Secured: false, + Autoconnect: false, + }, + { + SSID: "Cafe", + Signal: 50, + Secured: false, + Autoconnect: true, + }, + } + profiles := map[string]savedWiFiProfile{ + "Home": { + Autoconnect: true, + Hidden: true, + Secured: true, + Mode: "infrastructure", + }, + } + + merged := mergeSavedProfilesIntoWiFiNetworks(networks, profiles, "Home", true) + + assert.Len(t, merged, 2) + assert.Equal(t, "Home", merged[0].SSID) + assert.True(t, merged[0].Connected) + assert.True(t, merged[0].Saved) + assert.True(t, merged[0].Autoconnect) + assert.True(t, merged[0].Hidden) + assert.True(t, merged[0].Secured) + assert.Equal(t, "infrastructure", merged[0].Mode) + + assert.Equal(t, "Cafe", merged[1].SSID) + assert.False(t, merged[1].Saved) + assert.False(t, merged[1].Autoconnect) +} + +func TestSavedWiFiNetworksFromProfilesOutOfRangeWithoutVisibleNetworks(t *testing.T) { + profiles := map[string]savedWiFiProfile{ + "Home": { + Autoconnect: true, + Secured: true, + Mode: "infrastructure", + }, + } + + networks := savedWiFiNetworksFromProfiles(profiles, nil, "", false) + + assert.Len(t, networks, 1) + assert.Equal(t, "Home", networks[0].SSID) + assert.True(t, networks[0].Saved) + assert.True(t, networks[0].OutOfRange) + assert.Equal(t, uint8(0), networks[0].Signal) +} + +func TestSavedWiFiNetworksFromProfilesKeepsConnectedCurrentNetworkInRange(t *testing.T) { + profiles := map[string]savedWiFiProfile{ + "Home": { + Autoconnect: true, + Secured: true, + }, + } + + networks := savedWiFiNetworksFromProfiles(profiles, nil, "Home", true) + + assert.Len(t, networks, 1) + assert.Equal(t, "Home", networks[0].SSID) + assert.True(t, networks[0].Connected) + assert.False(t, networks[0].OutOfRange) +} + +func TestSavedWiFiNetworksFromProfilesIncludesOutOfRange(t *testing.T) { + profiles := map[string]savedWiFiProfile{ + "Home": { + Autoconnect: true, + Hidden: true, + Secured: true, + Mode: "infrastructure", + }, + "Office": { + Autoconnect: false, + Secured: true, + Enterprise: true, + Mode: "infrastructure", + }, + } + visible := map[string]WiFiNetwork{ + "Home": { + SSID: "Home", + Signal: 72, + Secured: true, + Connected: true, + }, + } + + networks := savedWiFiNetworksFromProfiles(profiles, visible, "Home", true) + + assert.Len(t, networks, 2) + assert.Equal(t, "Home", networks[0].SSID) + assert.True(t, networks[0].Saved) + assert.True(t, networks[0].Connected) + assert.False(t, networks[0].OutOfRange) + assert.True(t, networks[0].Hidden) + assert.Equal(t, uint8(72), networks[0].Signal) + + assert.Equal(t, "Office", networks[1].SSID) + assert.True(t, networks[1].Saved) + assert.False(t, networks[1].Autoconnect) + assert.True(t, networks[1].Enterprise) + assert.True(t, networks[1].OutOfRange) +} + +func TestWiFiNetworksBySSIDVisibleOnlySkipsOutOfRange(t *testing.T) { + visible := wiFiNetworksBySSID([]WiFiNetwork{ + {SSID: "Home", Signal: 70}, + {SSID: "Office", Signal: 0, OutOfRange: true}, + }, true) + + assert.Contains(t, visible, "Home") + assert.NotContains(t, visible, "Office") +} + +func TestRefreshSavedWiFiStatePreservesVisibleSavedNetworks(t *testing.T) { + networks := []WiFiNetwork{ + { + SSID: "Home", + Signal: 82, + }, + } + profiles := map[string]savedWiFiProfile{ + "Home": { + Autoconnect: true, + Secured: true, + Mode: "infrastructure", + }, + "Office": { + Autoconnect: false, + Secured: true, + Mode: "infrastructure", + }, + } + + mergedNetworks, savedNetworks := refreshSavedWiFiState(networks, profiles, "", false) + + assert.Len(t, mergedNetworks, 1) + assert.Equal(t, "Home", mergedNetworks[0].SSID) + assert.True(t, mergedNetworks[0].Saved) + assert.True(t, mergedNetworks[0].Autoconnect) + + assert.Len(t, savedNetworks, 2) + assert.Equal(t, "Home", savedNetworks[0].SSID) + assert.True(t, savedNetworks[0].Saved) + assert.False(t, savedNetworks[0].OutOfRange) + assert.Equal(t, uint8(82), savedNetworks[0].Signal) + + assert.Equal(t, "Office", savedNetworks[1].SSID) + assert.True(t, savedNetworks[1].Saved) + assert.True(t, savedNetworks[1].OutOfRange) +} diff --git a/core/internal/server/server.go b/core/internal/server/server.go index 0d025dea..182f8755 100644 --- a/core/internal/server/server.go +++ b/core/internal/server/server.go @@ -38,7 +38,7 @@ import ( "github.com/AvengeMedia/DankMaterialShell/core/pkg/syncmap" ) -const APIVersion = 25 +const APIVersion = 26 var CLIVersion = "dev" diff --git a/quickshell/Modules/ControlCenter/Details/NetworkDetail.qml b/quickshell/Modules/ControlCenter/Details/NetworkDetail.qml index 550acf21..364478fd 100644 --- a/quickshell/Modules/ControlCenter/Details/NetworkDetail.qml +++ b/quickshell/Modules/ControlCenter/Details/NetworkDetail.qml @@ -721,7 +721,7 @@ Rectangle { DankActionButton { id: qrCodeButton - visible: modelData.secured && modelData.saved + visible: modelData.secured && modelData.saved && !(modelData.enterprise || false) anchors.right: parent.right anchors.rightMargin: optionsButton.width + pinWifiRow.width + 3 * Theme.spacingM + Theme.spacingS anchors.verticalCenter: parent.verticalCenter diff --git a/quickshell/Modules/Settings/NetworkWifiTab.qml b/quickshell/Modules/Settings/NetworkWifiTab.qml index bb39ad6b..00a73d45 100644 --- a/quickshell/Modules/Settings/NetworkWifiTab.qml +++ b/quickshell/Modules/Settings/NetworkWifiTab.qml @@ -1,6 +1,7 @@ pragma ComponentBehavior: Bound import QtQuick +import QtQuick.Controls import QtQuick.Layouts import qs.Common import qs.Modules.Settings.Widgets @@ -16,6 +17,7 @@ Item { Component.onCompleted: { NetworkService.addRef(); + Qt.callLater(() => NetworkService.refreshSavedWifiNetworks()); } Component.onDestruction: { @@ -40,6 +42,7 @@ Item { id: root property string expandedWifiSsid: "" + property string expandedSavedWifiSsid: "" property int maxPinnedWifiNetworks: 3 function normalizePinList(value) { @@ -84,6 +87,79 @@ Item { settingKey: "networkWifi" tags: ["wifi", "wi-fi", "wireless", "network", "ssid", "adapter", "radio"] + function visibleWifiBySsid(ssid) { + const networks = NetworkService.wifiNetworks || []; + return networks.find(network => network.ssid === ssid) || null; + } + + function mergedSavedWifiNetworks() { + const saved = NetworkService.savedWifiNetworks || []; + const supportsSavedWifiState = DMSService.apiVersion >= NetworkService.savedWifiStateApiVersion; + const result = []; + const seen = new Set(); + + for (const network of saved) { + if (!network?.ssid || seen.has(network.ssid)) + continue; + const isOutOfRange = supportsSavedWifiState ? network.outOfRange === true : false; + const visibleNetwork = !isOutOfRange ? visibleWifiBySsid(network.ssid) : null; + if (visibleNetwork) { + result.push(Object.assign({}, network, visibleNetwork, { + saved: true, + autoconnect: network.autoconnect ?? visibleNetwork.autoconnect, + hidden: (network.hidden || false) || (visibleNetwork.hidden || false), + outOfRange: false + })); + } else { + result.push(Object.assign({}, network, { + saved: true, + outOfRange: isOutOfRange + })); + } + seen.add(network.ssid); + } + + return result; + } + + function sortedSavedWifiNetworks() { + const ssid = NetworkService.currentWifiSSID; + const pinnedList = root.getPinnedWifiNetworks(); + let sorted = root.mergedSavedWifiNetworks(); + + sorted.sort((a, b) => { + const aPinnedIndex = pinnedList.indexOf(a.ssid); + const bPinnedIndex = pinnedList.indexOf(b.ssid); + if (aPinnedIndex !== -1 || bPinnedIndex !== -1) { + if (aPinnedIndex === -1) + return 1; + if (bPinnedIndex === -1) + return -1; + return aPinnedIndex - bPinnedIndex; + } + if (a.ssid === ssid) + return -1; + if (b.ssid === ssid) + return 1; + if ((a.outOfRange || false) !== (b.outOfRange || false)) + return (a.outOfRange || false) ? 1 : -1; + if ((a.signal || 0) !== (b.signal || 0)) + return (b.signal || 0) - (a.signal || 0); + return (a.ssid || "").localeCompare(b.ssid || ""); + }); + return sorted; + } + + function showForgetNetworkConfirm(ssid) { + forgetNetworkConfirm.showWithOptions({ + title: I18n.tr("Forget Network"), + message: I18n.tr("Forget \"%1\"?").arg(ssid), + confirmText: I18n.tr("Forget"), + confirmColor: Theme.error, + onConfirm: () => NetworkService.forgetWifiNetwork(ssid) + }); + } + Column { id: wifiSection @@ -563,7 +639,7 @@ Item { DankActionButton { iconName: "qr_code" buttonSize: 28 - visible: modelData.secured && modelData.saved + visible: modelData.secured && modelData.saved && !(modelData.enterprise || false) onClicked: { PopoutService.showWifiQRCodeModal(modelData.ssid); } @@ -584,13 +660,7 @@ Item { iconColor: Theme.error visible: modelData.saved || isConnected onClicked: { - forgetNetworkConfirm.showWithOptions({ - title: I18n.tr("Forget Network"), - message: I18n.tr("Forget \"%1\"?").arg(modelData.ssid), - confirmText: I18n.tr("Forget"), - confirmColor: Theme.error, - onConfirm: () => NetworkService.forgetWifiNetwork(modelData.ssid) - }); + root.showForgetNetworkConfirm(modelData.ssid); } } } @@ -756,6 +826,421 @@ Item { } } } + SettingsCard { + id: savedWifiCard + + readonly property var savedNetworks: root.sortedSavedWifiNetworks() + + width: parent.width + title: I18n.tr("Saved Networks") + iconName: "bookmark" + settingKey: "networkSavedWifi" + tags: ["wifi", "wi-fi", "wireless", "network", "saved", "known", "ssid", "autoconnect", "forget"] + collapsible: true + expanded: false + visible: savedNetworks.length > 0 + + headerActions: [ + StyledText { + text: savedWifiCard.savedNetworks.length + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + verticalAlignment: Text.AlignVCenter + } + ] + + Column { + width: parent.width + spacing: 4 + + Repeater { + model: savedWifiCard.expanded ? savedWifiCard.savedNetworks : [] + + delegate: Rectangle { + id: savedWifiDelegate + + required property var modelData + required property int index + + readonly property bool isConnected: modelData.ssid === NetworkService.currentWifiSSID + readonly property bool isPinned: root.getPinnedWifiNetworks().includes(modelData.ssid) + readonly property bool isOutOfRange: modelData.outOfRange || false + readonly property bool isExpanded: !isOutOfRange && root.expandedSavedWifiSsid === modelData.ssid + + width: parent.width + height: isExpanded ? 56 + savedWifiExpandedContent.height : 56 + radius: Theme.cornerRadius + color: savedWifiMouseArea.containsMouse ? Theme.primaryHoverLight : Theme.surfaceLight + border.width: isConnected ? 2 : 0 + border.color: Theme.primary + clip: true + + Behavior on height { + NumberAnimation { + duration: 150 + easing.type: Easing.OutQuad + } + } + + Column { + anchors.fill: parent + spacing: 0 + + Item { + width: parent.width + height: 56 + + Row { + anchors.left: parent.left + anchors.leftMargin: Theme.spacingM + anchors.verticalCenter: parent.verticalCenter + anchors.right: savedWifiActions.left + anchors.rightMargin: Theme.spacingS + spacing: Theme.spacingS + + DankIcon { + name: { + if (isOutOfRange) + return "wifi_off"; + const s = modelData.signal || 0; + if (s >= 50) + return "wifi"; + if (s >= 25) + return "wifi_2_bar"; + return "wifi_1_bar"; + } + size: 20 + color: isConnected ? Theme.primary : Theme.surfaceText + anchors.verticalCenter: parent.verticalCenter + } + + Column { + anchors.verticalCenter: parent.verticalCenter + spacing: 2 + width: parent.width - 20 - Theme.spacingS + + Row { + anchors.left: parent.left + spacing: Theme.spacingXS + width: parent.width + + StyledText { + text: modelData.ssid || I18n.tr("Unknown") + font.pixelSize: Theme.fontSizeMedium + color: isConnected ? Theme.primary : Theme.surfaceText + font.weight: isConnected ? Font.Medium : Font.Normal + elide: Text.ElideRight + width: Math.max(0, parent.width - (savedWifiHiddenIcon.visible ? savedWifiHiddenIcon.width + Theme.spacingXS : 0)) + } + + DankIcon { + id: savedWifiHiddenIcon + name: "visibility_off" + size: 14 + color: Theme.surfaceVariantText + visible: modelData.hidden || false + anchors.verticalCenter: parent.verticalCenter + } + } + + StyledText { + text: { + const parts = [isConnected ? I18n.tr("Connected") : (modelData.secured ? I18n.tr("Secured") : I18n.tr("Open"))]; + parts.push(isOutOfRange ? I18n.tr("Unavailable") : (modelData.signal || 0) + "%"); + if (modelData.hidden || false) + parts.push(I18n.tr("Hidden")); + return parts.join(" • "); + } + font.pixelSize: Theme.fontSizeSmall + color: isConnected ? Theme.primary : Theme.surfaceVariantText + width: parent.width + elide: Text.ElideRight + } + } + } + + Row { + id: savedWifiActions + anchors.right: parent.right + anchors.rightMargin: Theme.spacingS + anchors.verticalCenter: parent.verticalCenter + spacing: Theme.spacingXS + + Rectangle { + width: 28 + height: 28 + radius: 14 + color: savedWifiExpandBtn.containsMouse ? Theme.surfacePressed : "transparent" + visible: !isOutOfRange + + DankIcon { + anchors.centerIn: parent + name: isExpanded ? "expand_less" : "expand_more" + size: 18 + color: Theme.surfaceText + } + + MouseArea { + id: savedWifiExpandBtn + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: { + if (isExpanded) { + root.expandedSavedWifiSsid = ""; + } else { + root.expandedSavedWifiSsid = modelData.ssid; + } + } + } + } + + DankActionButton { + iconName: "qr_code" + buttonSize: 28 + visible: modelData.secured && !(modelData.enterprise || false) + onClicked: { + PopoutService.showWifiQRCodeModal(modelData.ssid); + } + } + + DankActionButton { + iconName: "push_pin" + buttonSize: 28 + iconColor: isPinned ? Theme.primary : Theme.surfaceVariantText + onClicked: { + root.toggleWifiPin(modelData.ssid); + } + } + + DankActionButton { + id: savedWifiMoreButton + iconName: "more_horiz" + buttonSize: 28 + onClicked: { + if (savedWifiMenu.visible) { + savedWifiMenu.close(); + return; + } + savedWifiMenu.popup(savedWifiMoreButton, -savedWifiMenu.width + savedWifiMoreButton.width, savedWifiMoreButton.height + Theme.spacingXS); + } + } + } + + MouseArea { + id: savedWifiMouseArea + anchors.fill: parent + anchors.rightMargin: savedWifiActions.width + Theme.spacingM + hoverEnabled: true + cursorShape: isOutOfRange ? Qt.ArrowCursor : Qt.PointingHandCursor + onClicked: { + if (isOutOfRange) + return; + if (isExpanded) { + root.expandedSavedWifiSsid = ""; + } else { + root.expandedSavedWifiSsid = modelData.ssid; + } + } + } + } + + Column { + id: savedWifiExpandedContent + width: parent.width + visible: isExpanded + + Rectangle { + width: parent.width - Theme.spacingM * 2 + height: 1 + x: Theme.spacingM + color: Theme.outlineLight + } + + Item { + width: parent.width + height: savedWifiDetailsColumn.implicitHeight + Theme.spacingM * 2 + + Column { + id: savedWifiDetailsColumn + anchors.fill: parent + anchors.margins: Theme.spacingM + spacing: Theme.spacingS + + Flow { + width: parent.width + spacing: Theme.spacingXS + + Repeater { + model: { + const fields = []; + const net = modelData; + if (!net) + return fields; + + fields.push({ + label: I18n.tr("Signal"), + value: (net.signal || 0) + "%" + }); + if (net.frequency) + fields.push({ + label: I18n.tr("Frequency"), + value: (net.frequency / 1000).toFixed(1) + " GHz" + }); + if (net.channel) + fields.push({ + label: I18n.tr("Channel"), + value: String(net.channel) + }); + if (net.rate) + fields.push({ + label: I18n.tr("Rate"), + value: net.rate + " Mbps" + }); + if (net.mode) + fields.push({ + label: I18n.tr("Mode"), + value: net.mode + }); + if (net.bssid) + fields.push({ + label: I18n.tr("BSSID"), + value: net.bssid + }); + fields.push({ + label: I18n.tr("Security"), + value: net.secured ? (net.enterprise ? I18n.tr("Enterprise") : I18n.tr("WPA/WPA2")) : I18n.tr("Open") + }); + + return fields; + } + + delegate: Rectangle { + required property var modelData + required property int index + + width: savedWifiFieldContent.width + Theme.spacingM * 2 + height: 32 + radius: Theme.cornerRadius - 2 + color: Theme.surfaceContainerHigh + border.width: 1 + border.color: Theme.outlineLight + + Row { + id: savedWifiFieldContent + anchors.centerIn: parent + spacing: Theme.spacingXS + + StyledText { + text: modelData.label + ":" + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceVariantText + anchors.verticalCenter: parent.verticalCenter + } + + StyledText { + text: modelData.value + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceText + font.weight: Font.Medium + anchors.verticalCenter: parent.verticalCenter + } + } + } + } + } + } + } + } + } + + Menu { + id: savedWifiMenu + width: 170 + closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutsideParent + + background: Rectangle { + color: Theme.withAlpha(Theme.surfaceContainer, Theme.popupTransparency) + radius: Theme.cornerRadius + border.width: 0 + } + + MenuItem { + text: isConnected ? I18n.tr("Disconnect") : I18n.tr("Connect") + height: isOutOfRange ? 0 : 32 + visible: !isOutOfRange + + contentItem: StyledText { + text: parent.text + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceText + leftPadding: Theme.spacingS + verticalAlignment: Text.AlignVCenter + } + + background: Rectangle { + color: parent.hovered ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) : "transparent" + radius: Theme.cornerRadius / 2 + } + + onTriggered: { + if (isConnected) { + NetworkService.disconnectWifi(); + return; + } + NetworkService.connectToWifi(modelData.ssid); + } + } + + MenuItem { + text: modelData.autoconnect ? I18n.tr("Disable Autoconnect") : I18n.tr("Enable Autoconnect") + height: DMSService.apiVersion > 13 ? 32 : 0 + visible: DMSService.apiVersion > 13 + + contentItem: StyledText { + text: parent.text + font.pixelSize: Theme.fontSizeSmall + color: Theme.surfaceText + leftPadding: Theme.spacingS + verticalAlignment: Text.AlignVCenter + } + + background: Rectangle { + color: parent.hovered ? Qt.rgba(Theme.primary.r, Theme.primary.g, Theme.primary.b, 0.08) : "transparent" + radius: Theme.cornerRadius / 2 + } + + onTriggered: { + NetworkService.setWifiAutoconnect(modelData.ssid, !(modelData.autoconnect || false)); + } + } + + MenuItem { + text: I18n.tr("Forget Network") + height: 32 + + contentItem: StyledText { + text: parent.text + font.pixelSize: Theme.fontSizeSmall + color: Theme.error + leftPadding: Theme.spacingS + verticalAlignment: Text.AlignVCenter + } + + background: Rectangle { + color: parent.hovered ? Qt.rgba(Theme.error.r, Theme.error.g, Theme.error.b, 0.08) : "transparent" + radius: Theme.cornerRadius / 2 + } + + onTriggered: { + root.showForgetNetworkConfirm(modelData.ssid); + } + } + } + } + } + } + } } } } diff --git a/quickshell/Services/DMSNetworkService.qml b/quickshell/Services/DMSNetworkService.qml index db71f0a2..837b2d49 100644 --- a/quickshell/Services/DMSNetworkService.qml +++ b/quickshell/Services/DMSNetworkService.qml @@ -69,6 +69,7 @@ Singleton { property bool changingPreference: false property string targetPreference: "" property var savedWifiNetworks: [] + readonly property int savedWifiStateApiVersion: 26 property string connectionStatus: "" property string lastConnectionError: "" property bool passwordDialogShouldReopen: false @@ -309,17 +310,21 @@ Singleton { if (state.wifiNetworks) { wifiNetworks = state.wifiNetworks; + } + if (state.wifiNetworks || state.savedWifiNetworks) { + const hasSavedWifiState = DMSService.apiVersion >= savedWifiStateApiVersion && Array.isArray(state.savedWifiNetworks); + const sourceSavedNetworks = hasSavedWifiState ? state.savedWifiNetworks : (state.wifiNetworks || []).filter(network => network.saved); const saved = []; const mapping = {}; - for (const network of state.wifiNetworks) { - if (network.saved) { - saved.push({ - ssid: network.ssid, - saved: true - }); + for (const network of sourceSavedNetworks) { + const normalized = Object.assign({}, network, { + saved: true, + outOfRange: hasSavedWifiState ? network.outOfRange === true : false + }); + saved.push(normalized); + if (network?.ssid) mapping[network.ssid] = network.ssid; - } } savedConnections = saved; savedWifiNetworks = saved; @@ -596,6 +601,7 @@ Singleton { } wifiNetworks = updated; networksUpdated(); + Qt.callLater(() => refreshSavedWifiNetworks()); } forgetSSID = ""; }); @@ -985,4 +991,11 @@ Singleton { } }); } + + function refreshSavedWifiNetworks() { + if (!networkAvailable) + return; + + getState(); + } } diff --git a/quickshell/Services/LegacyNetworkService.qml b/quickshell/Services/LegacyNetworkService.qml index 9ece973b..f35e516d 100644 --- a/quickshell/Services/LegacyNetworkService.qml +++ b/quickshell/Services/LegacyNetworkService.qml @@ -142,9 +142,11 @@ Singleton { readonly property var savedConnections: wifiNetworks.filter(n => n.saved).map(n => ({ "ssid": n.ssid, - "saved": true + "saved": true, + "outOfRange": false })) readonly property var savedWifiNetworks: savedConnections + readonly property int savedWifiStateApiVersion: 26 readonly property var ssidToConnectionName: { const map = {}; for (const n of wifiNetworks) { diff --git a/quickshell/Services/NetworkService.qml b/quickshell/Services/NetworkService.qml index 3bcb6135..32c55c7a 100644 --- a/quickshell/Services/NetworkService.qml +++ b/quickshell/Services/NetworkService.qml @@ -54,6 +54,7 @@ Singleton { property bool changingPreference: activeService?.changingPreference ?? false property string targetPreference: activeService?.targetPreference ?? "" property var savedWifiNetworks: activeService?.savedWifiNetworks ?? [] + readonly property int savedWifiStateApiVersion: activeService?.savedWifiStateApiVersion ?? 26 property string connectionStatus: activeService?.connectionStatus ?? "" property string lastConnectionError: activeService?.lastConnectionError ?? "" property bool passwordDialogShouldReopen: activeService?.passwordDialogShouldReopen ?? false @@ -180,6 +181,12 @@ Singleton { } } + function refreshSavedWifiNetworks() { + if (activeService && activeService.refreshSavedWifiNetworks) { + activeService.refreshSavedWifiNetworks(); + } + } + function connectToWifi(ssid, password = "", username = "", anonymousIdentity = "", domainSuffixMatch = "") { if (activeService && activeService.connectToWifi) { activeService.connectToWifi(ssid, password, username, anonymousIdentity, domainSuffixMatch); diff --git a/quickshell/translations/settings_search_index.json b/quickshell/translations/settings_search_index.json index 124e25ea..bbf180aa 100644 --- a/quickshell/translations/settings_search_index.json +++ b/quickshell/translations/settings_search_index.json @@ -8882,6 +8882,30 @@ ], "icon": "settings_ethernet" }, + { + "section": "networkSavedWifi", + "label": "Saved Networks", + "tabIndex": 40, + "category": "Network", + "keywords": [ + "autoconnect", + "connection", + "connectivity", + "ethernet", + "forget", + "internet", + "known", + "network", + "networks", + "online", + "saved", + "ssid", + "wi-fi", + "wifi", + "wireless" + ], + "icon": "bookmark" + }, { "section": "networkWifi", "label": "WiFi",