Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cosmic-applet-network/i18n/en/cosmic_applet_network.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
99 changes: 98 additions & 1 deletion cosmic-applet-network/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ struct CosmicNetworkApplet {
// UI state
nm_sender: Option<UnboundedSender<NetworkManagerRequest>>,
show_visible_networks: bool,
show_available_vpns: bool,
new_connection: Option<NewConnectionState>,
conn: Option<Connection>,
timeline: Timeline,
Expand All @@ -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);
Expand Down Expand Up @@ -246,7 +323,10 @@ pub(crate) enum Message {
ResetFailedKnownSsid(String, HwAddress),
OpenHwDevice(Option<HwAddress>),
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 {
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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)
}

Expand Down
45 changes: 45 additions & 0 deletions cosmic-applet-network/src/network_manager/available_vpns.rs
Original file line number Diff line number Diff line change
@@ -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<Vec<VpnConnection>> {
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)
}
127 changes: 127 additions & 0 deletions cosmic-applet-network/src/network_manager/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub mod active_conns;
pub mod available_vpns;
pub mod available_wifi;
pub mod current_networks;
pub mod devices;
Expand Down Expand Up @@ -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},
};
Expand Down Expand Up @@ -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()),
Copy link
Contributor

@Cheong-Lau Cheong-Lau Oct 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤓 I have a small nit, uuid can just be moved here without cloning, since the error path returns afterwards and doesn't use it anymore. Same thing with name on line 363

Sorry, I had to 🙃, it lgtm otherwise

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, you are absolutely right! Fixed in fa88dcd

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;
}
Expand Down Expand Up @@ -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)]
Expand All @@ -387,6 +508,7 @@ pub struct NetworkManagerState {
pub wireless_access_points: Vec<AccessPoint>,
pub active_conns: Vec<ActiveConnectionInfo>,
pub known_access_points: Vec<AccessPoint>,
pub available_vpns: Vec<VpnConnection>,
pub wifi_enabled: bool,
pub airplane_mode: bool,
pub connectivity: NmConnectivityState,
Expand All @@ -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,
Expand Down Expand Up @@ -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_)
}

Expand All @@ -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>(
Expand Down