From 67415262d56dde211c247656a198d3339bcbf108 Mon Sep 17 00:00:00 2001 From: Akrm Al-Hakimi Date: Wed, 8 Jul 2026 17:42:51 -0400 Subject: [PATCH] fix: show pending VPN state in applet Track VPN activation and deactivation locally while nmrs operations are running. This lets the applet show a spinner immediately, refresh state while NetworkManager settles, and clear pending state on completion or error. Also use VPN-specific icons for VPN rows and prefer active or pending VPN state for the panel icon. --- cosmic-applet-network/src/app.rs | 163 ++++++++++++++++++++++++++----- 1 file changed, 138 insertions(+), 25 deletions(-) diff --git a/cosmic-applet-network/src/app.rs b/cosmic-applet-network/src/app.rs index 13d49d2b..8162fea3 100644 --- a/cosmic-applet-network/src/app.rs +++ b/cosmic-applet-network/src/app.rs @@ -250,6 +250,7 @@ pub struct MyNetworkState { pub devices: Vec>, pub nm_state: NetworkManagerState, pub requested_vpn: Option, + pub pending_vpn: Option, } /// Shared, take-once handle to an `nmrs` [`SecretResponder`]. Cloned freely @@ -269,6 +270,18 @@ pub struct RequestedVpn { secret_keys: Vec, } +#[derive(Debug, Clone)] +pub struct PendingVpn { + uuid: Arc, + action: PendingVpnAction, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PendingVpnAction { + Activate, + Deactivate, +} + #[derive(Clone, Debug)] pub enum ConnectionSettings { Vpn { id: String }, @@ -417,6 +430,11 @@ fn vpn_section<'a>( let is_active = nm_state.nm_state.active_conns.iter().any( |conn| matches!(conn, ActiveConnectionInfo::Vpn { name, .. } if name == id), ); + let pending_action = nm_state + .pending_vpn + .as_ref() + .filter(|pending| pending.uuid.as_ref() == uuid.as_ref()) + .map(|pending| pending.action); let mut btn_content = vec![ icon::from_name("network-vpn-symbolic") @@ -429,6 +447,9 @@ fn vpn_section<'a>( if is_active { btn_content.push(text::body(fl!("connected")).align_x(Alignment::End).into()); } + if pending_action.is_some() { + btn_content.push(indeterminate_circular().size(24.0).into()); + } let mut btn = menu_button( row::with_children(btn_content) @@ -436,7 +457,9 @@ fn vpn_section<'a>( .spacing(8), ); - btn = if is_active { + btn = if pending_action.is_some() { + btn + } else if is_active { btn.on_press(Message::DeactivateVpn(uuid.clone())) } else { btn.on_press(Message::ActivateVpn(uuid.clone())) @@ -724,10 +747,11 @@ fn snapshot_to_applet(snapshot: NetworkSnapshot) -> AppletSnapshot { impl CosmicNetworkApplet { fn apply_snapshot(&mut self, snapshot: AppletSnapshot) { let previous_connectivity = self.nm_state.nm_state.connectivity; - self.update_nm_state(snapshot.state); self.nm_state.devices = snapshot.devices.into_iter().map(Arc::new).collect(); self.nm_state.known_vpns = snapshot.known_vpns; self.nm_state.ssid_to_uuid = snapshot.ssid_to_uuid; + self.update_nm_state(snapshot.state); + self.clear_completed_pending_vpn(); if !previous_connectivity.is_captive() && self.nm_state.nm_state.connectivity.is_captive() { let mut browser = std::process::Command::new("xdg-open"); @@ -741,6 +765,34 @@ impl CosmicNetworkApplet { } } + fn clear_completed_pending_vpn(&mut self) { + let Some(pending) = self.nm_state.pending_vpn.as_ref() else { + return; + }; + let Some(connection) = self.nm_state.known_vpns.get(&pending.uuid) else { + self.nm_state.pending_vpn = None; + self.update_icon_name(); + return; + }; + let id = match connection { + ConnectionSettings::Vpn { id } | ConnectionSettings::Wireguard { id } => id, + }; + let is_active = self + .nm_state + .nm_state + .active_conns + .iter() + .any(|conn| matches!(conn, ActiveConnectionInfo::Vpn { name, .. } if name == id)); + let completed = match pending.action { + PendingVpnAction::Activate => is_active, + PendingVpnAction::Deactivate => !is_active, + }; + if completed { + self.nm_state.pending_vpn = None; + self.update_icon_name(); + } + } + fn update_nm_state(&mut self, mut new_state: NetworkManagerState) { self.update_togglers(&new_state); // check for failed conns that can be reset @@ -777,6 +829,22 @@ impl CosmicNetworkApplet { } fn update_icon_name(&mut self) { + if self + .nm_state + .pending_vpn + .as_ref() + .is_some_and(|pending| pending.action == PendingVpnAction::Activate) + || self + .nm_state + .nm_state + .active_conns + .iter() + .any(|conn| matches!(conn, ActiveConnectionInfo::Vpn { .. })) + { + self.icon_name = "network-vpn-symbolic".to_string(); + return; + } + self.icon_name = self .nm_state .nm_state @@ -789,12 +857,7 @@ impl CosmicNetworkApplet { "network-wired-disconnected-symbolic", ActiveConnectionInfo::WiFi { strength, .. }, ) => wifi_icon(*strength), - (_, ActiveConnectionInfo::Wired { .. }) - if icon_name != "network-vpn-symbolic" => - { - "network-wired-symbolic" - } - (_, ActiveConnectionInfo::Vpn { .. }) => "network-vpn-symbolic", + (_, ActiveConnectionInfo::Wired { .. }) => "network-wired-symbolic", _ => icon_name, }, ) @@ -831,12 +894,18 @@ impl CosmicNetworkApplet { fn connect_vpn(&mut self, uuid: Arc) -> Task> { cosmic::task::future(async move { - match NmrsManager::new().await { - Ok(nm) => match nm.connect_vpn_by_uuid(&uuid).await { - Ok(()) => Message::Refresh, - Err(e) => Message::Error(format!("activate VPN {uuid}: {e}")), - }, - Err(e) => Message::Error(format!("nmrs init: {e}")), + let error = match NmrsManager::new().await { + Ok(nm) => nm + .connect_vpn_by_uuid(&uuid) + .await + .err() + .map(|e| format!("activate VPN {uuid}: {e}")), + Err(e) => Some(format!("nmrs init: {e}")), + }; + Message::VpnOperationFinished { + uuid, + action: PendingVpnAction::Activate, + error, } }) .map(cosmic::Action::App) @@ -934,7 +1003,12 @@ pub(crate) enum Message { Surface(surface::Action), ActivateVpn(Arc), // UUID of VPN to activate DeactivateVpn(Arc), // UUID of VPN to deactivate - ToggleVpnList, // Show/hide available VPNs + VpnOperationFinished { + uuid: Arc, + action: PendingVpnAction, + error: Option, + }, + ToggleVpnList, // Show/hide available VPNs /// An update from the secret agent SecretAgent(NmAgentEvent), /// Connect to a WiFi network access point. @@ -1193,19 +1267,58 @@ impl cosmic::Application for CosmicNetworkApplet { )); } Message::ActivateVpn(uuid) => { - return self.connect_vpn(uuid.clone()); + self.nm_state.pending_vpn = Some(PendingVpn { + uuid: uuid.clone(), + action: PendingVpnAction::Activate, + }); + self.update_icon_name(); + return Task::batch(vec![ + snapshot_task().map(cosmic::Action::App), + self.connect_vpn(uuid.clone()), + ]); } Message::DeactivateVpn(uuid) => { - return cosmic::task::future(async move { - match NmrsManager::new().await { - Ok(nm) => match nm.disconnect_vpn_by_uuid(&uuid).await { - Ok(()) => Message::Refresh, - Err(e) => Message::Error(format!("disconnect VPN {uuid}: {e}")), - }, - Err(e) => Message::Error(format!("nmrs init: {e}")), + self.nm_state.pending_vpn = Some(PendingVpn { + uuid: uuid.clone(), + action: PendingVpnAction::Deactivate, + }); + self.update_icon_name(); + let disconnect_task = cosmic::task::future(async move { + let error = match NmrsManager::new().await { + Ok(nm) => nm + .disconnect_vpn_by_uuid(&uuid) + .await + .err() + .map(|e| format!("disconnect VPN {uuid}: {e}")), + Err(e) => Some(format!("nmrs init: {e}")), + }; + Message::VpnOperationFinished { + uuid, + action: PendingVpnAction::Deactivate, + error, } }) .map(cosmic::Action::App); + return Task::batch(vec![ + snapshot_task().map(cosmic::Action::App), + disconnect_task, + ]); + } + Message::VpnOperationFinished { + uuid, + action, + error, + } => { + if self.nm_state.pending_vpn.as_ref().is_some_and(|pending| { + pending.uuid.as_ref() == uuid.as_ref() && pending.action == action + }) { + self.nm_state.pending_vpn = None; + self.update_icon_name(); + } + if let Some(error) = error { + tracing::error!("{error}"); + } + return snapshot_task().map(cosmic::Action::App); } Message::ToggleVpnList => { self.show_available_vpns = !self.show_available_vpns; @@ -1499,7 +1612,7 @@ impl cosmic::Application for CosmicNetworkApplet { row::with_children([ Element::from( icon::icon( - icon::from_name(self.icon_name.clone()) + icon::from_name("network-vpn-symbolic") .symbolic(true) .into(), ) @@ -1567,7 +1680,7 @@ impl cosmic::Application for CosmicNetworkApplet { row::with_children([ Element::from( icon::icon( - icon::from_name(self.icon_name.clone()) + icon::from_name("network-wired-symbolic") .symbolic(true) .into(), )