From 57abb20ebe15af11b2256176fe2987f710f83842 Mon Sep 17 00:00:00 2001 From: Tomas Hlavacek Date: Sat, 4 Oct 2025 09:56:08 +0200 Subject: [PATCH 1/2] feat(network): add VPN connection management Add the ability to view, activate, and deactivate VPN connections directly from the network applet dropdown menu. Changes: - Add VPN connection discovery via NetworkManager D-Bus API - Implement VPN activate/deactivate handlers - Add collapsible VPN connections section in the UI - Display VPN status (connected/disconnected) with toggle functionality - Position VPN section below wireless networks for easy access VPN connections are discovered from NetworkManager settings and displayed in a dropdown list. Users can click to connect/disconnect VPNs without opening network settings. Active VPN connections show a 'Connected' status. Supports both 'vpn' and 'wireguard' connection types. --- .../i18n/en/cosmic_applet_network.ftl | 1 + cosmic-applet-network/src/app.rs | 99 +++++++++++++- .../src/network_manager/available_vpns.rs | 45 +++++++ .../src/network_manager/mod.rs | 127 ++++++++++++++++++ 4 files changed, 271 insertions(+), 1 deletion(-) create mode 100644 cosmic-applet-network/src/network_manager/available_vpns.rs diff --git a/cosmic-applet-network/i18n/en/cosmic_applet_network.ftl b/cosmic-applet-network/i18n/en/cosmic_applet_network.ftl index 30812895b..3b05600f7 100644 --- a/cosmic-applet-network/i18n/en/cosmic_applet_network.ftl +++ b/cosmic-applet-network/i18n/en/cosmic_applet_network.ftl @@ -14,6 +14,7 @@ connect = Connect cancel = Cancel settings = Network settings... visible-wireless-networks = Visible wireless networks +vpn-connections = VPN connections enter-password = Enter the password or encryption key router-wps-button = You can also connect by pressing the "WPS" button on the router unable-to-connect = Unable to connect to network diff --git a/cosmic-applet-network/src/app.rs b/cosmic-applet-network/src/app.rs index cb97a8c39..2a7b43d7b 100644 --- a/cosmic-applet-network/src/app.rs +++ b/cosmic-applet-network/src/app.rs @@ -101,6 +101,7 @@ struct CosmicNetworkApplet { // UI state nm_sender: Option>, show_visible_networks: bool, + show_available_vpns: bool, new_connection: Option, conn: Option, timeline: Timeline, @@ -122,6 +123,82 @@ fn wifi_icon(strength: u8) -> &'static str { } } +fn vpn_section<'a>( + nm_state: &'a NetworkManagerState, + show_available_vpns: bool, + space_xxs: u16, + space_s: u16, +) -> Column<'a, Message> { + let mut vpn_col = column![]; + + if !nm_state.available_vpns.is_empty() { + let dropdown_icon = if show_available_vpns { + "go-up-symbolic" + } else { + "go-down-symbolic" + }; + + vpn_col = vpn_col.push( + padded_control(divider::horizontal::default()).padding([space_xxs, space_s]) + ); + + let vpn_toggle_btn = menu_button(row![ + text::body(fl!("vpn-connections")) + .width(Length::Fill) + .height(Length::Fixed(24.0)) + .align_y(Alignment::Center), + container(icon::from_name(dropdown_icon).size(16).symbolic(true)) + .center(Length::Fixed(24.0)) + ]) + .on_press(Message::ToggleVpnList); + + vpn_col = vpn_col.push(vpn_toggle_btn); + + if show_available_vpns { + for vpn in &nm_state.available_vpns { + // Check if this VPN is currently active + let is_active = nm_state.active_conns.iter().any(|conn| { + matches!(conn, ActiveConnectionInfo::Vpn { name, .. } if name == &vpn.name) + }); + + let mut btn_content = vec![ + icon::from_name("network-vpn-symbolic") + .size(24) + .symbolic(true) + .into(), + text::body(&vpn.name) + .width(Length::Fill) + .into(), + ]; + + if is_active { + btn_content.push( + text::body(fl!("connected")) + .align_x(Alignment::End) + .into(), + ); + } + + let mut btn = menu_button( + Row::with_children(btn_content) + .align_y(Alignment::Center) + .spacing(8), + ); + + btn = if is_active { + btn.on_press(Message::DeactivateVpn(vpn.name.clone())) + } else { + btn.on_press(Message::ActivateVpn(vpn.uuid.clone())) + }; + + vpn_col = vpn_col.push(btn); + } + } + } + + vpn_col +} + impl CosmicNetworkApplet { fn update_nm_state(&mut self, mut new_state: NetworkManagerState) { self.update_togglers(&new_state); @@ -246,7 +323,10 @@ pub(crate) enum Message { ResetFailedKnownSsid(String, HwAddress), OpenHwDevice(Option), TogglePasswordVisibility, - Surface(surface::Action), // Errored(String), + Surface(surface::Action), + ActivateVpn(String), // UUID of VPN to activate + DeactivateVpn(String), // Name of VPN to deactivate + ToggleVpnList, // Show/hide available VPNs } impl cosmic::Application for CosmicNetworkApplet { @@ -612,6 +692,19 @@ impl cosmic::Application for CosmicNetworkApplet { *identity = new_identity; } } + Message::ActivateVpn(uuid) => { + if let Some(tx) = self.nm_sender.as_ref() { + let _ = tx.unbounded_send(NetworkManagerRequest::ActivateVpn(uuid)); + } + } + Message::DeactivateVpn(name) => { + if let Some(tx) = self.nm_sender.as_ref() { + let _ = tx.unbounded_send(NetworkManagerRequest::DeactivateVpn(name)); + } + } + Message::ToggleVpnList => { + self.show_available_vpns = !self.show_available_vpns; + } } Task::none() } @@ -1003,6 +1096,7 @@ impl cosmic::Application for CosmicNetworkApplet { content = content.push(available_connections_btn); if !self.show_visible_networks { + content = content.push(vpn_section(&self.nm_state, self.show_available_vpns, space_xxs, space_s)); return self.view_window_return(content); } @@ -1147,6 +1241,9 @@ impl cosmic::Application for CosmicNetworkApplet { .push(scrollable(Column::with_children(list_col)).height(Length::Fixed(300.0))); } + // Add VPN connections section after wireless networks when they are expanded + content = content.push(vpn_section(&self.nm_state, self.show_available_vpns, space_xxs, space_s)); + self.view_window_return(content) } diff --git a/cosmic-applet-network/src/network_manager/available_vpns.rs b/cosmic-applet-network/src/network_manager/available_vpns.rs new file mode 100644 index 000000000..2df3d75b4 --- /dev/null +++ b/cosmic-applet-network/src/network_manager/available_vpns.rs @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +use cosmic_dbus_networkmanager::settings::{NetworkManagerSettings, connection::Settings}; +use zbus::Connection; + +#[derive(Debug, Clone)] +pub struct VpnConnection { + pub name: String, + pub uuid: String, +} + +/// Load all available VPN connections from NetworkManager settings +pub async fn load_vpn_connections(conn: &Connection) -> anyhow::Result> { + let nm_settings = NetworkManagerSettings::new(conn).await?; + let connections = nm_settings.list_connections().await?; + + let mut vpn_connections = Vec::new(); + + for connection in connections { + let settings_map = match connection.get_settings().await { + Ok(s) => s, + Err(_) => continue, + }; + + let settings = Settings::new(settings_map); + + // Check if this is a VPN connection + if let Some(connection_settings) = &settings.connection { + if let Some(conn_type) = &connection_settings.type_ { + // VPN connections have type "vpn" or "wireguard" + if conn_type == "vpn" || conn_type == "wireguard" { + let name = connection_settings.id.clone().unwrap_or_else(|| "Unknown VPN".to_string()); + let uuid = connection_settings.uuid.clone().unwrap_or_default(); + + vpn_connections.push(VpnConnection { name, uuid }); + } + } + } + } + + // Sort by name for consistent UI + vpn_connections.sort_by(|a, b| a.name.cmp(&b.name)); + + Ok(vpn_connections) +} diff --git a/cosmic-applet-network/src/network_manager/mod.rs b/cosmic-applet-network/src/network_manager/mod.rs index f8afe146d..7275c44df 100644 --- a/cosmic-applet-network/src/network_manager/mod.rs +++ b/cosmic-applet-network/src/network_manager/mod.rs @@ -1,4 +1,5 @@ pub mod active_conns; +pub mod available_vpns; pub mod available_wifi; pub mod current_networks; pub mod devices; @@ -34,6 +35,7 @@ use zbus::{ }; use self::{ + available_vpns::{VpnConnection, load_vpn_connections}, available_wifi::{AccessPoint, handle_wireless_device}, current_networks::{ActiveConnectionInfo, active_connections}, }; @@ -285,6 +287,123 @@ async fn start_listening( }) .await; } + Some(NetworkManagerRequest::ActivateVpn(uuid)) => { + tracing::info!("Activating VPN with UUID: {}", uuid); + let network_manager = match NetworkManager::new(&conn).await { + Ok(n) => n, + Err(e) => { + tracing::error!("Failed to connect to NetworkManager: {:?}", e); + _ = output + .send(NetworkManagerEvent::RequestResponse { + req: NetworkManagerRequest::ActivateVpn(uuid.clone()), + success: false, + state: NetworkManagerState::new(&conn).await.unwrap_or_default(), + }) + .await; + return State::Waiting(conn, rx); + } + }; + + let mut success = false; + + // Find the connection by UUID + if let Ok(nm_settings) = NetworkManagerSettings::new(&conn).await { + if let Ok(connections) = nm_settings.list_connections().await { + for connection in connections { + if let Ok(settings) = connection.get_settings().await { + let settings = Settings::new(settings); + if let Some(conn_settings) = &settings.connection { + if conn_settings.uuid.as_ref() == Some(&uuid) { + // Activate the VPN connection without a specific device + // Call the D-Bus method directly since VPNs don't need a device + use zbus::zvariant::ObjectPath; + let empty_device = ObjectPath::try_from("/").unwrap(); + + match network_manager.inner() + .call_method("ActivateConnection", &(connection.inner().path(), empty_device.clone(), empty_device)) + .await + { + Ok(_) => { + tracing::info!("Successfully activated VPN: {}", uuid); + success = true; + } + Err(e) => { + tracing::error!("Failed to activate VPN {}: {:?}", uuid, e); + } + } + break; + } + } + } + } + } + } + + if !success { + tracing::warn!("VPN connection with UUID {} not found or failed to activate", uuid); + } + + let state = NetworkManagerState::new(&conn).await.unwrap_or_default(); + _ = output + .send(NetworkManagerEvent::RequestResponse { + req: NetworkManagerRequest::ActivateVpn(uuid), + success, + state, + }) + .await; + } + Some(NetworkManagerRequest::DeactivateVpn(name)) => { + tracing::info!("Deactivating VPN: {}", name); + let network_manager = match NetworkManager::new(&conn).await { + Ok(n) => n, + Err(e) => { + tracing::error!("Failed to connect to NetworkManager: {:?}", e); + _ = output + .send(NetworkManagerEvent::RequestResponse { + req: NetworkManagerRequest::DeactivateVpn(name.clone()), + success: false, + state: NetworkManagerState::new(&conn).await.unwrap_or_default(), + }) + .await; + return State::Waiting(conn, rx); + } + }; + + let mut success = false; + + // Find and deactivate the active VPN connection by name + if let Ok(active_connections) = network_manager.active_connections().await { + for active_conn in active_connections { + if let Ok(conn_id) = active_conn.id().await { + if conn_id == name && active_conn.vpn().await.unwrap_or(false) { + match network_manager.deactivate_connection(&active_conn).await { + Ok(_) => { + tracing::info!("Successfully deactivated VPN: {}", name); + success = true; + break; + } + Err(e) => { + tracing::error!("Failed to deactivate VPN {}: {:?}", name, e); + } + } + } + } + } + } + + if !success { + tracing::warn!("Active VPN connection '{}' not found or failed to deactivate", name); + } + + let state = NetworkManagerState::new(&conn).await.unwrap_or_default(); + _ = output + .send(NetworkManagerEvent::RequestResponse { + req: NetworkManagerRequest::DeactivateVpn(name), + success, + state, + }) + .await; + } _ => { return State::Finished; } @@ -363,6 +482,8 @@ pub enum NetworkManagerRequest { }, Forget(String, HwAddress), Reload, + ActivateVpn(String), // UUID of VPN connection to activate + DeactivateVpn(String), // Name of active VPN connection to deactivate } #[derive(Debug, Clone)] @@ -387,6 +508,7 @@ pub struct NetworkManagerState { pub wireless_access_points: Vec, pub active_conns: Vec, pub known_access_points: Vec, + pub available_vpns: Vec, pub wifi_enabled: bool, pub airplane_mode: bool, pub connectivity: NmConnectivityState, @@ -398,6 +520,7 @@ impl Default for NetworkManagerState { wireless_access_points: Vec::new(), active_conns: Vec::new(), known_access_points: Vec::new(), + available_vpns: Vec::new(), wifi_enabled: false, airplane_mode: false, connectivity: NmConnectivityState::Unknown, @@ -500,6 +623,9 @@ impl NetworkManagerState { self_.known_access_points = known_access_points; self_.connectivity = network_manager.connectivity().await?; + // Load available VPN connections + self_.available_vpns = load_vpn_connections(conn).await.unwrap_or_default(); + Ok(self_) } @@ -508,6 +634,7 @@ impl NetworkManagerState { self.active_conns = Vec::new(); self.known_access_points = Vec::new(); self.wireless_access_points = Vec::new(); + self.available_vpns = Vec::new(); } async fn connect_wifi<'a>( From fa88dcdd56a7be6eb3e032e17482d979ad479b09 Mon Sep 17 00:00:00 2001 From: Tomas Hlavacek Date: Sun, 5 Oct 2025 16:59:13 +0200 Subject: [PATCH 2/2] feat(network): optimize memory usage in VPN mgmt Avoid cloning strings that are not needed after move --- cosmic-applet-network/src/network_manager/mod.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cosmic-applet-network/src/network_manager/mod.rs b/cosmic-applet-network/src/network_manager/mod.rs index 7275c44df..7b36c90b5 100644 --- a/cosmic-applet-network/src/network_manager/mod.rs +++ b/cosmic-applet-network/src/network_manager/mod.rs @@ -295,7 +295,7 @@ async fn start_listening( tracing::error!("Failed to connect to NetworkManager: {:?}", e); _ = output .send(NetworkManagerEvent::RequestResponse { - req: NetworkManagerRequest::ActivateVpn(uuid.clone()), + req: NetworkManagerRequest::ActivateVpn(uuid), success: false, state: NetworkManagerState::new(&conn).await.unwrap_or_default(), }) @@ -360,7 +360,7 @@ async fn start_listening( tracing::error!("Failed to connect to NetworkManager: {:?}", e); _ = output .send(NetworkManagerEvent::RequestResponse { - req: NetworkManagerRequest::DeactivateVpn(name.clone()), + req: NetworkManagerRequest::DeactivateVpn(name), success: false, state: NetworkManagerState::new(&conn).await.unwrap_or_default(), })