From 28f40afccfa08c746eebb01a22eb0587dd3585f4 Mon Sep 17 00:00:00 2001 From: bbedward Date: Tue, 23 Jun 2026 13:58:58 -0400 Subject: [PATCH] fix(network): fix excessive network prompting on failures, add VPN connection statuses --- .../server/network/agent_networkmanager.go | 56 ++++++++- .../network/agent_networkmanager_test.go | 51 ++++++++ core/internal/server/network/backend.go | 2 + .../server/network/backend_networkmanager.go | 112 ++++++++++++++++++ .../network/backend_networkmanager_signals.go | 68 +++++++++++ .../network/backend_networkmanager_state.go | 8 ++ .../network/backend_networkmanager_vpn.go | 63 ++++++++-- .../network/backend_networkmanager_wifi.go | 2 + core/internal/server/network/manager.go | 5 + core/internal/server/network/types.go | 2 + quickshell/DMSShell.qml | 14 +-- .../BuiltinPlugins/VpnWidget.qml | 9 +- quickshell/Services/DMSNetworkService.qml | 24 ++++ quickshell/Services/VPNService.qml | 23 ++++ quickshell/Widgets/VpnProfileDelegate.qml | 63 +++++++++- 15 files changed, 471 insertions(+), 31 deletions(-) diff --git a/core/internal/server/network/agent_networkmanager.go b/core/internal/server/network/agent_networkmanager.go index e0dd42d7..a659b35f 100644 --- a/core/internal/server/network/agent_networkmanager.go +++ b/core/internal/server/network/agent_networkmanager.go @@ -361,11 +361,27 @@ func (a *SecretAgent) GetSecrets( } } - // Phase 4: Non-interactive secret retrieval (keyring). - // Always try the keyring even when REQUEST_NEW is set — the vault may have - // been unlocked by a prior call's Prompt flow, making the lookup non-interactive. - if secretOut := a.trySecretService(connUuid, settingName, fields); secretOut != nil { - return secretOut, nil + // Phase 4: Non-interactive secret retrieval. REQUEST_NEW means NM thinks the + // secret is wrong, so force a prompt; otherwise reuse keyring then cached secret. + requestNew := flags&nmSecretAgentFlagRequestNew != 0 + if requestNew { + if a.backend != nil { + a.backend.clearCachedWiFiSecret(connUuid) + } + } else { + if secretOut := a.trySecretService(connUuid, settingName, fields); secretOut != nil { + return secretOut, nil + } + + switch settingName { + case "802-11-wireless-security", "802-1x": + if a.backend != nil { + if cached := a.backend.lookupCachedWiFiSecret(connUuid, settingName); cached != nil { + log.Infof("[SecretAgent] Reusing cached WiFi secret for %s (no REQUEST_NEW)", connUuid) + return buildWiFiSecretsResponse(settingName, cached), nil + } + } + } } // Phase 5: If interaction is not allowed, we're done. @@ -436,6 +452,8 @@ func (a *SecretAgent) GetSecrets( } a.backend.stateMutex.Unlock() + a.backend.clearCachedWiFiSecret(connUuid) + // If this was a WiFi connection that was just cancelled, remove the connection profile // (it was created with AddConnection but activation was cancelled) // Only do this for newly created connections, not pre-existing ones. @@ -539,6 +557,13 @@ func (a *SecretAgent) GetSecrets( log.Infof("[SecretAgent] Queued credentials persist for after connection succeeds") } + if a.backend != nil { + switch settingName { + case "802-11-wireless-security", "802-1x": + a.backend.cacheWiFiSecret(connUuid, ssid, settingName, reply.Secrets) + } + } + return out, nil } @@ -906,6 +931,27 @@ func reasonFromFlags(flags uint32) string { return "required" } +func buildWiFiSecretsResponse(settingName string, secrets map[string]string) nmSettingMap { + sec := nmVariantMap{} + switch settingName { + case "802-1x": + for k, v := range secrets { + switch k { + case "password", "private-key-password", "phase2-private-key-password", "pin": + sec[k] = dbus.MakeVariant(v) + } + } + default: + for k, v := range secrets { + sec[k] = dbus.MakeVariant(v) + } + } + + out := nmSettingMap{} + out[settingName] = sec + return out +} + func buildGPSamlSecretsResponse(settingName, cookie, host, fingerprint string) nmSettingMap { out := nmSettingMap{} vpnSec := nmVariantMap{} diff --git a/core/internal/server/network/agent_networkmanager_test.go b/core/internal/server/network/agent_networkmanager_test.go index 6ae6c8f4..701a5b19 100644 --- a/core/internal/server/network/agent_networkmanager_test.go +++ b/core/internal/server/network/agent_networkmanager_test.go @@ -374,6 +374,57 @@ func TestSecretAgent_GetSecrets_NoInteractionFlag(t *testing.T) { assert.Contains(t, err.Error(), "NoSecrets") } +func TestBuildWiFiSecretsResponse(t *testing.T) { + t.Run("wpa-psk returns psk", func(t *testing.T) { + out := buildWiFiSecretsResponse("802-11-wireless-security", map[string]string{"psk": "hunter2"}) + + sec, ok := out["802-11-wireless-security"] + assert.True(t, ok) + assert.Equal(t, "hunter2", sec["psk"].Value()) + }) + + t.Run("802-1x keeps secrets and drops identity", func(t *testing.T) { + out := buildWiFiSecretsResponse("802-1x", map[string]string{ + "identity": "john", + "password": "hunter2", + }) + + sec := out["802-1x"] + assert.Equal(t, "hunter2", sec["password"].Value()) + _, hasIdentity := sec["identity"] + assert.False(t, hasIdentity, "identity is persisted separately, not returned as a secret") + }) +} + +func TestWiFiSecretCache(t *testing.T) { + b := &NetworkManagerBackend{} + + b.cacheWiFiSecret("uuid-1", "HomeNet", "802-11-wireless-security", map[string]string{"psk": "hunter2"}) + + got := b.lookupCachedWiFiSecret("uuid-1", "802-11-wireless-security") + assert.Equal(t, map[string]string{"psk": "hunter2"}, got) + + assert.Nil(t, b.lookupCachedWiFiSecret("uuid-1", "802-1x"), "setting mismatch must miss") + assert.Nil(t, b.lookupCachedWiFiSecret("uuid-2", "802-11-wireless-security"), "uuid mismatch must miss") + assert.Nil(t, b.lookupCachedWiFiSecret("", "802-11-wireless-security"), "empty uuid must miss") + + // REQUEST_NEW path clears by uuid. + b.clearCachedWiFiSecret("uuid-1") + assert.Nil(t, b.lookupCachedWiFiSecret("uuid-1", "802-11-wireless-security")) + + // Returned map is a copy: mutating it must not affect the cache. + b.cacheWiFiSecret("uuid-1", "HomeNet", "802-11-wireless-security", map[string]string{"psk": "hunter2"}) + got = b.lookupCachedWiFiSecret("uuid-1", "802-11-wireless-security") + got["psk"] = "tampered" + assert.Equal(t, "hunter2", b.lookupCachedWiFiSecret("uuid-1", "802-11-wireless-security")["psk"]) + + // Terminal-state path clears by SSID. + b.clearCachedWiFiSecretBySSID("OtherNet") + assert.NotNil(t, b.lookupCachedWiFiSecret("uuid-1", "802-11-wireless-security"), "ssid mismatch must not clear") + b.clearCachedWiFiSecretBySSID("HomeNet") + assert.Nil(t, b.lookupCachedWiFiSecret("uuid-1", "802-11-wireless-security")) +} + func TestNmVariantMap(t *testing.T) { // Test that nmVariantMap and nmSettingMap work correctly settingMap := make(nmSettingMap) diff --git a/core/internal/server/network/backend.go b/core/internal/server/network/backend.go index 5cbcc661..f000dc48 100644 --- a/core/internal/server/network/backend.go +++ b/core/internal/server/network/backend.go @@ -79,4 +79,6 @@ type BackendState struct { IsConnectingVPN bool ConnectingVPNUUID string LastError string + VPNError string + VPNErrorUuid string } diff --git a/core/internal/server/network/backend_networkmanager.go b/core/internal/server/network/backend_networkmanager.go index e00ae300..89b65fa3 100644 --- a/core/internal/server/network/backend_networkmanager.go +++ b/core/internal/server/network/backend_networkmanager.go @@ -16,6 +16,9 @@ const ( dbusNMWiredInterface = "org.freedesktop.NetworkManager.Device.Wired" dbusNMWirelessInterface = "org.freedesktop.NetworkManager.Device.Wireless" dbusNMAccessPointInterface = "org.freedesktop.NetworkManager.AccessPoint" + dbusNMActiveConnInterface = "org.freedesktop.NetworkManager.Connection.Active" + dbusNMVPNConnInterface = "org.freedesktop.NetworkManager.VPN.Connection" + dbusNMActiveConnPath = "/org/freedesktop/NetworkManager/ActiveConnection" dbusPropsInterface = "org.freedesktop.DBus.Properties" NmDeviceStateReasonWrongPassword = 8 @@ -77,6 +80,8 @@ type NetworkManagerBackend struct { cachedPKCS11Mu sync.Mutex cachedGPSamlCookie *cachedGPSamlCookie cachedGPSamlMu sync.Mutex + cachedWiFiSecret *cachedWiFiSecret + cachedWiFiSecretMu sync.Mutex onStateChange func() } @@ -99,6 +104,15 @@ type cachedPKCS11PIN struct { PIN string } +// cachedWiFiSecret reuses a just-entered WiFi/802.1x secret across repeat +// GetSecrets calls in one activation, so NM retries don't re-prompt. +type cachedWiFiSecret struct { + ConnectionUUID string + SSID string + SettingName string + Secrets map[string]string +} + type cachedGPSamlCookie struct { ConnectionUUID string Cookie string @@ -340,6 +354,104 @@ func (b *NetworkManagerBackend) CancelCredentials(token string) error { }) } +// mergeStoredSecrets re-fetches stored secrets and folds them into settings +// before an Update. GetSettings never returns secrets and Update replaces the +// whole connection, so a bare GetSettings->Update wipes system-owned passwords +// (e.g. an OpenVPN password with password-flags=0). Only fills keys that aren't +// already being set, so an explicit credential change still wins. +func mergeStoredSecrets(conn gonetworkmanager.Connection, settings gonetworkmanager.ConnectionSettings) { + for setting := range settings { + switch setting { + case "vpn", "802-11-wireless-security", "802-1x": + default: + continue + } + + secrets, err := conn.GetSecrets(setting) + if err != nil { + continue + } + + section, ok := secrets[setting] + if !ok { + continue + } + + for k, v := range section { + if _, exists := settings[setting][k]; exists { + continue + } + settings[setting][k] = v + } + } +} + +func (b *NetworkManagerBackend) cacheWiFiSecret(connUUID, ssid, settingName string, secrets map[string]string) { + if connUUID == "" || len(secrets) == 0 { + return + } + + copied := make(map[string]string, len(secrets)) + for k, v := range secrets { + copied[k] = v + } + + b.cachedWiFiSecretMu.Lock() + b.cachedWiFiSecret = &cachedWiFiSecret{ + ConnectionUUID: connUUID, + SSID: ssid, + SettingName: settingName, + Secrets: copied, + } + b.cachedWiFiSecretMu.Unlock() +} + +func (b *NetworkManagerBackend) lookupCachedWiFiSecret(connUUID, settingName string) map[string]string { + if connUUID == "" { + return nil + } + + b.cachedWiFiSecretMu.Lock() + defer b.cachedWiFiSecretMu.Unlock() + + cached := b.cachedWiFiSecret + if cached == nil || cached.ConnectionUUID != connUUID || cached.SettingName != settingName { + return nil + } + + copied := make(map[string]string, len(cached.Secrets)) + for k, v := range cached.Secrets { + copied[k] = v + } + return copied +} + +func (b *NetworkManagerBackend) clearCachedWiFiSecret(connUUID string) { + b.cachedWiFiSecretMu.Lock() + defer b.cachedWiFiSecretMu.Unlock() + + if connUUID == "" { + b.cachedWiFiSecret = nil + return + } + if b.cachedWiFiSecret != nil && b.cachedWiFiSecret.ConnectionUUID == connUUID { + b.cachedWiFiSecret = nil + } +} + +func (b *NetworkManagerBackend) clearCachedWiFiSecretBySSID(ssid string) { + if ssid == "" { + return + } + + b.cachedWiFiSecretMu.Lock() + defer b.cachedWiFiSecretMu.Unlock() + + if b.cachedWiFiSecret != nil && b.cachedWiFiSecret.SSID == ssid { + b.cachedWiFiSecret = nil + } +} + func (b *NetworkManagerBackend) ensureWiFiDevice() error { if b.wifiDev != nil { return nil diff --git a/core/internal/server/network/backend_networkmanager_signals.go b/core/internal/server/network/backend_networkmanager_signals.go index 91825141..1ea52cba 100644 --- a/core/internal/server/network/backend_networkmanager_signals.go +++ b/core/internal/server/network/backend_networkmanager_signals.go @@ -136,6 +136,29 @@ func (b *NetworkManagerBackend) startSignalPump() error { } } + // activating->activated/failed fires on the active-connection object, not the + // manager's ActiveConnections property. VPN.Connection covers plugin VPNs; + // Connection.Active covers the rest, including WireGuard. + if err := conn.AddMatchSignal( + dbus.WithMatchPathNamespace(dbus.ObjectPath(dbusNMActiveConnPath)), + dbus.WithMatchInterface(dbusNMVPNConnInterface), + dbus.WithMatchMember("VpnStateChanged"), + ); err != nil { + conn.RemoveSignal(signals) + conn.Close() + return err + } + + if err := conn.AddMatchSignal( + dbus.WithMatchPathNamespace(dbus.ObjectPath(dbusNMActiveConnPath)), + dbus.WithMatchInterface(dbusNMActiveConnInterface), + dbus.WithMatchMember("StateChanged"), + ); err != nil { + conn.RemoveSignal(signals) + conn.Close() + return err + } + b.sigWG.Add(1) go func() { defer b.sigWG.Done() @@ -193,6 +216,16 @@ func (b *NetworkManagerBackend) stopSignalPump() { dbus.WithMatchInterface(dbusNMInterface), dbus.WithMatchMember("DeviceRemoved"), ) + b.dbusConn.RemoveMatchSignal( + dbus.WithMatchPathNamespace(dbus.ObjectPath(dbusNMActiveConnPath)), + dbus.WithMatchInterface(dbusNMVPNConnInterface), + dbus.WithMatchMember("VpnStateChanged"), + ) + b.dbusConn.RemoveMatchSignal( + dbus.WithMatchPathNamespace(dbus.ObjectPath(dbusNMActiveConnPath)), + dbus.WithMatchInterface(dbusNMActiveConnInterface), + dbus.WithMatchMember("StateChanged"), + ) for _, info := range b.wifiDevices { b.dbusConn.RemoveMatchSignal( @@ -234,6 +267,20 @@ func (b *NetworkManagerBackend) handleDBusSignal(sig *dbus.Signal) { return } + if sig.Name == dbusNMVPNConnInterface+".VpnStateChanged" { + if len(sig.Body) >= 2 { + state, _ := sig.Body[0].(uint32) + reason, _ := sig.Body[1].(uint32) + b.handleVPNStateChange(state, reason) + } + return + } + + if sig.Name == dbusNMActiveConnInterface+".StateChanged" { + b.handleActiveConnectionStateChange() + return + } + if sig.Name == "org.freedesktop.NetworkManager.DeviceAdded" { if len(sig.Body) >= 1 { if devicePath, ok := sig.Body[0].(dbus.ObjectPath); ok { @@ -320,6 +367,27 @@ func (b *NetworkManagerBackend) handleNetworkManagerChange(changes map[string]db } } +func (b *NetworkManagerBackend) handleActiveConnectionStateChange() { + b.updateVPNConnectionState() + b.ListActiveVPN() + if b.onStateChange != nil { + b.onStateChange() + } +} + +func (b *NetworkManagerBackend) handleVPNStateChange(state, reason uint32) { + if state == nmVPNStateFailed { + b.stateMutex.Lock() + if uuid := b.state.ConnectingVPNUUID; uuid != "" { + b.state.VPNError = vpnFailureMessage(reason) + b.state.VPNErrorUuid = uuid + } + b.stateMutex.Unlock() + } + + b.handleActiveConnectionStateChange() +} + func (b *NetworkManagerBackend) handleDeviceChange(devicePath dbus.ObjectPath, changes map[string]dbus.Variant) { var needsUpdate bool var stateChanged bool diff --git a/core/internal/server/network/backend_networkmanager_state.go b/core/internal/server/network/backend_networkmanager_state.go index ba77f6d4..348fed30 100644 --- a/core/internal/server/network/backend_networkmanager_state.go +++ b/core/internal/server/network/backend_networkmanager_state.go @@ -213,6 +213,7 @@ func (b *NetworkManagerBackend) updateWiFiState() error { } var forgetSSID string + var doneSSID string b.stateMutex.Lock() @@ -226,6 +227,7 @@ func (b *NetworkManagerBackend) updateWiFiState() error { b.state.IsConnecting = false b.state.ConnectingSSID = "" b.state.LastError = "" + doneSSID = connectingSSID case failed || (disconnected && !connected): log.Warnf("[updateWiFiState] Connection failed: SSID=%s, state=%d", connectingSSID, state) b.state.IsConnecting = false @@ -240,6 +242,8 @@ func (b *NetworkManagerBackend) updateWiFiState() error { b.lastFailedSSID = connectingSSID b.lastFailedTime = time.Now().Unix() b.failedMutex.Unlock() + + doneSSID = connectingSSID } } @@ -252,6 +256,10 @@ func (b *NetworkManagerBackend) updateWiFiState() error { b.stateMutex.Unlock() + if doneSSID != "" { + b.clearCachedWiFiSecretBySSID(doneSSID) + } + if forgetSSID != "" { log.Infof("[updateWiFiState] User cancelled authentication, removing connection profile for %s", forgetSSID) if err := b.ForgetWiFiNetwork(forgetSSID); err != nil { diff --git a/core/internal/server/network/backend_networkmanager_vpn.go b/core/internal/server/network/backend_networkmanager_vpn.go index 261fcefa..c4cb854a 100644 --- a/core/internal/server/network/backend_networkmanager_vpn.go +++ b/core/internal/server/network/backend_networkmanager_vpn.go @@ -17,6 +17,24 @@ import ( "github.com/godbus/dbus/v5" ) +const nmVPNStateFailed = 6 + +// vpnFailureMessage maps NMVpnConnectionStateReason to a user-facing message. +func vpnFailureMessage(reason uint32) string { + switch reason { + case 9: // NO_SECRETS + return "Authentication required" + case 10: // LOGIN_FAILED + return "Authentication failed" + case 6: // CONNECT_TIMEOUT + return "Connection timed out" + case 7, 8: // SERVICE_START_TIMEOUT, SERVICE_START_FAILED + return "VPN service failed to start" + default: + return "VPN connection failed" + } +} + func (b *NetworkManagerBackend) ListVPNProfiles() ([]VPNProfile, error) { s := b.settings if s == nil { @@ -199,25 +217,38 @@ func (b *NetworkManagerBackend) ListActiveVPN() ([]VPNActive, error) { } func (b *NetworkManagerBackend) ConnectVPN(uuidOrName string, singleActive bool) error { + // Drop any stale connecting state from a prior attempt that never resolved; + // a leftover flag makes the secret agent refuse secrets for other VPNs. + b.stateMutex.Lock() + b.state.IsConnectingVPN = false + b.state.ConnectingVPNUUID = "" + b.state.VPNError = "" + b.state.VPNErrorUuid = "" + b.stateMutex.Unlock() + if singleActive { active, err := b.ListActiveVPN() if err == nil && len(active) > 0 { alreadyConnected := false for _, vpn := range active { - if vpn.UUID == uuidOrName || vpn.Name == uuidOrName { - alreadyConnected = true - break + if vpn.UUID != uuidOrName && vpn.Name != uuidOrName { + continue } + switch vpn.State { + case "activated", "activating": + alreadyConnected = true + } + break } - if !alreadyConnected { - if err := b.DisconnectAllVPN(); err != nil { - log.Warnf("Failed to disconnect existing VPNs: %v", err) - } - time.Sleep(500 * time.Millisecond) - } else { + if alreadyConnected { return nil } + + if err := b.DisconnectAllVPN(); err != nil { + log.Warnf("Failed to disconnect existing VPNs: %v", err) + } + time.Sleep(500 * time.Millisecond) } } @@ -723,6 +754,8 @@ func (b *NetworkManagerBackend) updateVPNConnectionState() { b.state.IsConnectingVPN = false b.state.ConnectingVPNUUID = "" b.state.LastError = "" + b.state.VPNError = "" + b.state.VPNErrorUuid = "" b.stateMutex.Unlock() // Clear cached PKCS11 PIN and SAML cookie on success @@ -748,6 +781,10 @@ func (b *NetworkManagerBackend) updateVPNConnectionState() { b.state.IsConnectingVPN = false b.state.ConnectingVPNUUID = "" b.state.LastError = "VPN connection failed" + if b.state.VPNError == "" { + b.state.VPNError = "VPN connection failed" + } + b.state.VPNErrorUuid = connectingVPNUUID b.stateMutex.Unlock() // Clear cached PKCS11 PIN and SAML cookie on failure @@ -768,6 +805,10 @@ func (b *NetworkManagerBackend) updateVPNConnectionState() { b.state.IsConnectingVPN = false b.state.ConnectingVPNUUID = "" b.state.LastError = "VPN connection failed" + if b.state.VPNError == "" { + b.state.VPNError = "VPN connection failed" + } + b.state.VPNErrorUuid = connectingVPNUUID b.stateMutex.Unlock() // Clear cached PKCS11 PIN and SAML cookie @@ -1217,6 +1258,8 @@ func (b *NetworkManagerBackend) UpdateVPNConfig(connUUID string, updates map[str delete(ipv6, "dns") } + mergeStoredSecrets(conn, settings) + if err := conn.Update(settings); err != nil { return fmt.Errorf("failed to update connection: %w", err) } @@ -1311,6 +1354,8 @@ func (b *NetworkManagerBackend) SetVPNCredentials(connUUID string, username stri delete(ipv6, "dns") } + mergeStoredSecrets(conn, settings) + if err := conn.Update(settings); err != nil { return fmt.Errorf("failed to update connection: %w", err) } diff --git a/core/internal/server/network/backend_networkmanager_wifi.go b/core/internal/server/network/backend_networkmanager_wifi.go index 90821cef..4f4ae830 100644 --- a/core/internal/server/network/backend_networkmanager_wifi.go +++ b/core/internal/server/network/backend_networkmanager_wifi.go @@ -956,6 +956,8 @@ func (b *NetworkManagerBackend) SetWiFiAutoconnect(ssid string, autoconnect bool delete(ipv6, "dns") } + mergeStoredSecrets(conn, settings) + err = conn.Update(settings) if err != nil { return fmt.Errorf("failed to update connection: %w", err) diff --git a/core/internal/server/network/manager.go b/core/internal/server/network/manager.go index 4f2d5c8c..7e10d9dd 100644 --- a/core/internal/server/network/manager.go +++ b/core/internal/server/network/manager.go @@ -135,6 +135,8 @@ func (m *Manager) syncStateFromBackend() error { m.state.ConnectingSSID = backendState.ConnectingSSID m.state.ConnectingDevice = backendState.ConnectingDevice m.state.LastError = backendState.LastError + m.state.VPNError = backendState.VPNError + m.state.VPNErrorUuid = backendState.VPNErrorUuid m.stateMutex.Unlock() return nil @@ -216,6 +218,9 @@ func stateChangedMeaningfully(old, new *NetworkState) bool { if old.LastError != new.LastError { return true } + if old.VPNError != new.VPNError || old.VPNErrorUuid != new.VPNErrorUuid { + return true + } if len(old.WiFiNetworks) != len(new.WiFiNetworks) { return true } diff --git a/core/internal/server/network/types.go b/core/internal/server/network/types.go index e6da82b7..6f7191c4 100644 --- a/core/internal/server/network/types.go +++ b/core/internal/server/network/types.go @@ -121,6 +121,8 @@ type NetworkState struct { ConnectingSSID string `json:"connectingSSID"` ConnectingDevice string `json:"connectingDevice,omitempty"` LastError string `json:"lastError"` + VPNError string `json:"vpnError"` + VPNErrorUuid string `json:"vpnErrorUuid"` } type ConnectionRequest struct { diff --git a/quickshell/DMSShell.qml b/quickshell/DMSShell.qml index 16b69744..66289838 100644 --- a/quickshell/DMSShell.qml +++ b/quickshell/DMSShell.qml @@ -689,23 +689,19 @@ Item { target: NetworkService function onCredentialsNeeded(token, ssid, setting, fields, hints, reason, connType, connName, vpnService, fieldsInfo) { - const now = Date.now(); - const timeSinceLastPrompt = now - lastCredentialsTime; + const alreadyShown = wifiPasswordModalLoader.item && wifiPasswordModalLoader.item.shouldBeVisible; + if (alreadyShown && token === lastCredentialsToken) + return; wifiPasswordModalLoader.active = true; if (!wifiPasswordModalLoader.item) return; - if (wifiPasswordModalLoader.item.shouldBeVisible && timeSinceLastPrompt < 1000) { + if (alreadyShown && lastCredentialsToken !== "" && lastCredentialsToken !== token) NetworkService.cancelCredentials(lastCredentialsToken); - lastCredentialsToken = token; - lastCredentialsTime = now; - wifiPasswordModalLoader.item.showFromPrompt(token, ssid, setting, fields, hints, reason, connType, connName, vpnService, fieldsInfo); - return; - } lastCredentialsToken = token; - lastCredentialsTime = now; + lastCredentialsTime = Date.now(); wifiPasswordModalLoader.item.showFromPrompt(token, ssid, setting, fields, hints, reason, connType, connName, vpnService, fieldsInfo); } } diff --git a/quickshell/Modules/ControlCenter/BuiltinPlugins/VpnWidget.qml b/quickshell/Modules/ControlCenter/BuiltinPlugins/VpnWidget.qml index f971490b..0693d466 100644 --- a/quickshell/Modules/ControlCenter/BuiltinPlugins/VpnWidget.qml +++ b/quickshell/Modules/ControlCenter/BuiltinPlugins/VpnWidget.qml @@ -11,17 +11,22 @@ PluginComponent { service: DMSNetworkService } + readonly property bool vpnActivating: DMSNetworkService.vpnIsBusy || DMSNetworkService.activeState === "activating" + readonly property bool vpnActivated: DMSNetworkService.connected && DMSNetworkService.activeState === "activated" + ccWidgetIcon: "vpn_key" ccWidgetPrimaryText: I18n.tr("VPN") ccWidgetSecondaryText: { - if (!DMSNetworkService.connected) + if (vpnActivating) + return I18n.tr("Connecting…"); + if (!vpnActivated) return I18n.tr("Disconnected"); const names = DMSNetworkService.activeNames || []; if (names.length <= 1) return names[0] || I18n.tr("Connected"); return names[0] + " +" + (names.length - 1); } - ccWidgetIsActive: DMSNetworkService.connected + ccWidgetIsActive: vpnActivated onCcWidgetToggled: DMSNetworkService.toggleVpn() diff --git a/quickshell/Services/DMSNetworkService.qml b/quickshell/Services/DMSNetworkService.qml index 37912ac6..6284ec57 100644 --- a/quickshell/Services/DMSNetworkService.qml +++ b/quickshell/Services/DMSNetworkService.qml @@ -84,6 +84,8 @@ Singleton { property string lastConnectedVpnUuid: "" property string pendingVpnUuid: "" property var vpnBusyStartTime: 0 + property string vpnError: "" + property string vpnErrorUuid: "" property var profiles: { const mergedProfiles = vpnProfiles ? vpnProfiles.slice() : []; @@ -137,6 +139,17 @@ Singleton { property alias isBusy: root.vpnIsBusy property alias connected: root.vpnConnected + function vpnStateForUuid(uuid) { + if (!uuid) + return ""; + const match = vpnActive.find(v => v.uuid === uuid); + return match ? (match.state || "") : ""; + } + + function isVpnConnectingUuid(uuid) { + return vpnStateForUuid(uuid) === "activating" || (vpnIsBusy && pendingVpnUuid === uuid); + } + property string networkInfoSSID: "" property string networkInfoDetails: "" property bool networkInfoLoading: false @@ -372,6 +385,17 @@ Singleton { } } + const incomingVpnError = state.vpnError || ""; + if (incomingVpnError && incomingVpnError !== vpnError) { + vpnIsBusy = false; + pendingVpnUuid = ""; + vpnBusyStartTime = 0; + const failedName = (vpnProfiles.find(p => p.uuid === state.vpnErrorUuid)?.name) || I18n.tr("VPN"); + ToastService.showError(I18n.tr("%1: %2").arg(failedName).arg(incomingVpnError)); + } + vpnError = incomingVpnError; + vpnErrorUuid = state.vpnErrorUuid || ""; + isConnecting = state.isConnecting || false; connectingSSID = state.connectingSSID || ""; connectionError = state.lastError || ""; diff --git a/quickshell/Services/VPNService.qml b/quickshell/Services/VPNService.qml index a752c47f..6d132116 100644 --- a/quickshell/Services/VPNService.qml +++ b/quickshell/Services/VPNService.qml @@ -25,6 +25,7 @@ Singleton { signal importComplete(string uuid, string name) signal configLoaded(var config) signal configUpdated + signal credentialsSet(string uuid) signal vpnDeleted(string uuid) Component.onCompleted: { @@ -149,6 +150,28 @@ Singleton { }); } + function setCredentials(uuid, username, password, save = true) { + if (!available) + return; + const params = { + uuid: uuid, + save: save + }; + if (username) + params.username = username; + if (password) + params.password = password; + + DMSService.sendRequest("network.vpn.setCredentials", params, response => { + if (response.error) { + ToastService.showError(I18n.tr("Failed to save VPN credentials"), response.error); + return; + } + ToastService.showInfo(I18n.tr("VPN credentials saved")); + credentialsSet(uuid); + }); + } + function deleteVpn(uuidOrName) { if (!available) return; diff --git a/quickshell/Widgets/VpnProfileDelegate.qml b/quickshell/Widgets/VpnProfileDelegate.qml index 9b6bb170..d3331a98 100644 --- a/quickshell/Widgets/VpnProfileDelegate.qml +++ b/quickshell/Widgets/VpnProfileDelegate.qml @@ -18,7 +18,9 @@ Rectangle { signal toggleExpand signal deleteRequested - readonly property bool isActive: DMSNetworkService.activeUuids?.includes(profile?.uuid) ?? false + readonly property bool isActive: DMSNetworkService.vpnStateForUuid(profile?.uuid) === "activated" + readonly property bool isConnecting: DMSNetworkService.isVpnConnectingUuid(profile?.uuid) + readonly property bool hasError: !isConnecting && DMSNetworkService.vpnError !== "" && DMSNetworkService.vpnErrorUuid === (profile?.uuid ?? "") readonly property bool isHovered: rowArea.containsMouse || expandBtn.containsMouse || deleteBtn.containsMouse readonly property var configData: (!isTransient && isExpanded) ? VPNService.editConfig : null readonly property var configFields: buildConfigFields() @@ -28,7 +30,7 @@ Rectangle { color: isHovered ? Theme.primaryHoverLight : (isActive ? Theme.primaryPressed : Theme.surfaceLight) border.width: isActive ? 2 : 1 border.color: isActive ? Theme.primary : Theme.outlineLight - opacity: DMSNetworkService.isBusy ? 0.5 : 1.0 + opacity: (DMSNetworkService.isBusy && !isConnecting) ? 0.5 : 1.0 clip: true function buildConfigFields() { @@ -107,10 +109,20 @@ Rectangle { height: 46 - Theme.spacingS * 2 spacing: Theme.spacingS + DankSpinner { + size: 18 + strokeWidth: 2 + color: Theme.warning + running: root.isConnecting + visible: root.isConnecting + anchors.verticalCenter: parent.verticalCenter + } + DankIcon { - name: isActive ? "vpn_lock" : "vpn_key_off" + visible: !root.isConnecting + name: isActive ? "vpn_lock" : (root.hasError ? "error" : "vpn_key_off") size: 20 - color: isActive ? Theme.primary : Theme.surfaceText + color: root.hasError ? Theme.error : (isActive ? Theme.primary : Theme.surfaceText) anchors.verticalCenter: parent.verticalCenter } @@ -130,9 +142,9 @@ Rectangle { } StyledText { - text: VPNService.getVpnTypeFromProfile(profile) + text: root.isConnecting ? I18n.tr("Connecting...") : (root.hasError ? DMSNetworkService.vpnError : VPNService.getVpnTypeFromProfile(profile)) font.pixelSize: Theme.fontSizeSmall - color: Theme.surfaceTextMedium + color: root.isConnecting ? Theme.warning : (root.hasError ? Theme.error : Theme.surfaceTextMedium) wrapMode: Text.NoWrap width: parent.width elide: Text.ElideRight @@ -271,6 +283,45 @@ Rectangle { } } + Column { + width: parent.width + spacing: Theme.spacingXS + visible: !isTransient && !VPNService.configLoading && profile?.type !== "wireguard" + + StyledText { + text: root.hasError ? DMSNetworkService.vpnError : I18n.tr("Credentials") + font.pixelSize: Theme.fontSizeSmall + color: root.hasError ? Theme.error : Theme.surfaceVariantText + } + + DankTextField { + id: usernameField + width: parent.width + placeholderText: I18n.tr("Username") + text: (configData && (configData.username || (configData.data && configData.data.username))) || "" + } + + DankTextField { + id: passwordField + width: parent.width + placeholderText: I18n.tr("Password") + echoMode: TextInput.Password + showPasswordToggle: true + normalBorderColor: root.hasError ? Theme.error : Theme.outlineMedium + } + + DankButton { + text: I18n.tr("Save credentials") + opacity: passwordField.text.length > 0 ? 1 : 0.5 + onClicked: { + if (passwordField.text.length === 0) + return; + VPNService.setCredentials(profile.uuid, usernameField.text, passwordField.text, true); + passwordField.text = ""; + } + } + } + Item { width: 1 height: Theme.spacingXS