feat(networking): add VPN, WiFi, and Wired network pages

This commit is contained in:
Michael Murphy 2024-09-13 21:45:49 +02:00 committed by GitHub
parent d035ba0cf7
commit fa22b556dd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 2876 additions and 131 deletions

View file

@ -11,7 +11,7 @@ use crate::pages::desktop::{
},
};
use crate::pages::input::{self};
use crate::pages::{self, display, power, sound, system, time};
use crate::pages::{self, display, networking, power, sound, system, time};
use crate::subscription::desktop_files;
use crate::widget::{page_title, search_header};
use crate::PageCommands;
@ -77,10 +77,13 @@ impl SettingsApp {
PageCommands::Time => self.pages.page_id::<time::Page>(),
PageCommands::Touchpad => self.pages.page_id::<input::touchpad::Page>(),
PageCommands::Users => self.pages.page_id::<system::users::Page>(),
PageCommands::Vpn => self.pages.page_id::<networking::vpn::Page>(),
PageCommands::Wallpaper => self.pages.page_id::<desktop::wallpaper::Page>(),
PageCommands::WindowManagement => {
self.pages.page_id::<desktop::window_management::Page>()
}
PageCommands::Wired => self.pages.page_id::<networking::wired::Page>(),
PageCommands::Wireless => self.pages.page_id::<networking::wifi::Page>(),
PageCommands::Workspaces => self.pages.page_id::<desktop::workspaces::Page>(),
}
}
@ -141,6 +144,7 @@ impl cosmic::Application for SettingsApp {
search_selections: Vec::default(),
};
app.insert_page::<networking::Page>();
let desktop_id = app.insert_page::<desktop::Page>().id();
app.insert_page::<display::Page>();
app.insert_page::<sound::Page>();
@ -452,9 +456,27 @@ impl cosmic::Application for SettingsApp {
page::update!(self.pages, message, power::Page);
}
crate::pages::Message::Vpn(message) => {
if let Some(page) = self.pages.page_mut::<networking::vpn::Page>() {
return page.update(message).map(Into::into);
}
}
crate::pages::Message::WiFi(message) => {
if let Some(page) = self.pages.page_mut::<networking::wifi::Page>() {
return page.update(message).map(Into::into);
}
}
crate::pages::Message::WindowManagement(message) => {
page::update!(self.pages, message, desktop::window_management::Page);
}
crate::pages::Message::Wired(message) => {
if let Some(page) = self.pages.page_mut::<networking::wired::Page>() {
return page.update(message).map(Into::into);
}
}
},
Message::OutputAdded(info, output) => {
@ -730,7 +752,7 @@ impl SettingsApp {
custom_header.map(Message::from)
} else if let Some(parent) = page_info.parent {
let page_header = crate::widget::sub_page_header(
page_info.title.as_str(),
page.title().unwrap_or_else(|| page_info.title.as_str()),
self.pages.info[parent].title.as_str(),
Message::Page(parent),
);

View file

@ -78,10 +78,16 @@ pub enum PageCommands {
Touchpad,
/// Users settings page
Users,
/// VPN settings page
Vpn,
/// Wallpaper settings page
Wallpaper,
/// Window management settings page
WindowManagement,
/// Wired settings page
Wired,
/// WiFi settings page
Wireless,
/// Workspaces settings page
Workspaces,
}

View file

@ -24,18 +24,11 @@ use cosmic::{
},
};
use cosmic::{
iced::{wayland::actions::window::SctkWindowSettings, window, Color, Length},
iced::{Color, Length},
prelude::CollectionWidget,
};
use cosmic::{
iced_core::Alignment,
iced_sctk::commands::window::{close_window, get_window},
widget::icon,
};
use cosmic::{
iced_core::{alignment, layout},
iced_runtime::core::image::Handle as ImageHandle,
};
use cosmic::{iced_core::alignment, iced_runtime::core::image::Handle as ImageHandle};
use cosmic::{iced_core::Alignment, widget::icon};
use cosmic::{
widget::{color_picker::ColorPickerUpdate, ColorPickerModel},
Element,

View file

@ -6,6 +6,7 @@ use cosmic_settings_page::Entity;
pub mod desktop;
pub mod display;
pub mod input;
pub mod networking;
pub mod power;
pub mod sound;
pub mod system;
@ -37,7 +38,10 @@ pub enum Message {
Sound(sound::Message),
SystemShortcuts(input::keyboard::shortcuts::ShortcutMessage),
TilingShortcuts(input::keyboard::shortcuts::ShortcutMessage),
Vpn(networking::vpn::Message),
WiFi(networking::wifi::Message),
WindowManagement(desktop::window_management::Message),
Wired(networking::wired::Message),
}
impl From<Message> for crate::Message {

View file

@ -1,10 +0,0 @@
// Copyright 2023 System76 <info@system76.com>
// SPDX-License-Identifier: GPL-3.0-only
use cosmic_settings_page as page;
pub fn info() -> page::Info {
page::Info::new("online-accounts", "goa-panel-symbolic")
.title(fl!("online-accounts"))
.description(fl!("online-accounts", "desc"))
}

View file

@ -1,5 +1,62 @@
// Copyright 2023 System76 <info@system76.com>
// Copyright 2024 System76 <info@system76.com>
// SPDX-License-Identifier: GPL-3.0-only
pub mod accounts;
pub mod vpn;
pub mod wifi;
pub mod wired;
use std::{ffi::OsStr, io, process::ExitStatus};
use cosmic_settings_page as page;
static NM_CONNECTION_EDITOR: &str = "nm-connection-editor";
#[derive(Debug, Default)]
pub struct Page;
impl page::Page<crate::pages::Message> for Page {
fn info(&self) -> cosmic_settings_page::Info {
page::Info::new(
"network-and-wireless",
"preferences-network-and-wireless-symbolic",
)
.title(fl!("network-and-wireless"))
}
}
impl page::AutoBind<crate::pages::Message> for Page {
fn sub_pages(
page: cosmic_settings_page::Insert<crate::pages::Message>,
) -> cosmic_settings_page::Insert<crate::pages::Message> {
page.sub_page::<wired::Page>()
.sub_page::<wifi::Page>()
.sub_page::<vpn::Page>()
}
}
async fn nm_add_vpn_file<P: AsRef<OsStr>>(type_: &str, path: P) -> io::Result<ExitStatus> {
tokio::process::Command::new("nmcli")
.args(["connection", "import", "type", type_, "file"])
.arg(path)
.status()
.await
}
async fn nm_add_wired() -> io::Result<ExitStatus> {
nm_connection_editor(&["--type=802-3-ethernet", "-c"]).await
}
async fn nm_add_wifi() -> io::Result<ExitStatus> {
nm_connection_editor(&["--type=802-11-wireless", "-c"]).await
}
async fn nm_edit_connection(uuid: &str) -> io::Result<ExitStatus> {
nm_connection_editor(&[&["--edit=", uuid].concat()]).await
}
async fn nm_connection_editor(args: &[&str]) -> io::Result<ExitStatus> {
tokio::process::Command::new(NM_CONNECTION_EDITOR)
.args(args)
.status()
.await
}

View file

@ -0,0 +1,877 @@
// Copyright 2024 System76 <info@system76.com>
// SPDX-License-Identifier: GPL-3.0-only
mod nmcli;
use std::sync::Arc;
use anyhow::Context;
use ashpd::desktop::file_chooser::FileFilter;
use cosmic::{
iced::{alignment, Length},
iced_core::text::Wrap,
prelude::CollectionWidget,
widget::{self, icon},
Apply, Command, Element,
};
use cosmic_settings_page::{self as page, section, Section};
use cosmic_settings_subscriptions::network_manager::{
self, current_networks::ActiveConnectionInfo, NetworkManagerState, UUID,
};
use futures::{FutureExt, StreamExt};
use indexmap::IndexMap;
use secure_string::SecureString;
use slab::Slab;
pub type ConnectionId = Arc<str>;
pub type InterfaceId = String;
#[derive(Clone, Debug)]
pub enum Message {
/// Activate a connection
Activate(ConnectionId),
/// Add a network connection
AddNetwork,
/// Cancels an active dialog.
CancelDialog,
/// Connect to a VPN with the given username and password
ConnectWithPassword,
/// Deactivate a connection.
Deactivate(ConnectionId),
/// An error occurred.
Error(String),
/// Update the list of known connections.
KnownConnections(IndexMap<UUID, VpnConnectionSettings>),
/// An update from the network manager daemon
NetworkManager(network_manager::Event),
/// Successfully connected to the system dbus.
NetworkManagerConnect(
(
zbus::Connection,
tokio::sync::mpsc::Sender<crate::pages::Message>,
),
),
/// Updates the password text input
PasswordUpdate(SecureString),
/// Refresh devices and their connection profiles
Refresh,
/// Create a dialog to ask for confirmation of removal.
RemoveProfileRequest(ConnectionId),
/// Remove a connection profile
RemoveProfile(ConnectionId),
/// Opens settings page for the access point.
Settings(ConnectionId),
/// Toggles visibility of password input.
TogglePasswordVisibility,
/// Update NetworkManagerState
UpdateState(NetworkManagerState),
/// Update the devices lists
UpdateDevices(Vec<network_manager::devices::DeviceInfo>),
/// Updates the username text input
UsernameUpdate(String),
/// Display more options for an access point
ViewMore(Option<ConnectionId>),
}
impl From<Message> for crate::app::Message {
fn from(message: Message) -> Self {
crate::pages::Message::Vpn(message).into()
}
}
impl From<Message> for crate::pages::Message {
fn from(message: Message) -> Self {
crate::pages::Message::Vpn(message)
}
}
#[derive(Clone, Debug, Default)]
pub struct VpnConnectionSettings {
id: String,
username: Option<String>,
connection_type: Option<ConnectionType>,
password_flag: Option<PasswordFlag>,
}
impl VpnConnectionSettings {
fn password_flag(&self) -> Option<PasswordFlag> {
self.connection_type
.as_ref()
.map_or(false, |ct| match ct {
ConnectionType::Password => true,
})
.then(|| self.password_flag)
.flatten()
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
enum ConnectionType {
Password,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
enum PasswordFlag {
/// The system is responsible for providing and storing this secret.
None = 0,
/// A user-session secret agent is responsible for providing and storing
/// this secret; when it is required, agents will be asked to provide it.
AgentOwned = 1,
/// This secret should not be saved but should be requested from the user
/// each time it is required. This flag should be used for One-Time-Pad
/// secrets, PIN codes from hardware tokens, or if the user simply does not
/// want to save the secret.
NotSaved = 2,
/// in some situations it cannot be automatically determined that a secret is required or not. This flag hints that the secret is not required and should not be requested from the user.
NotRequired = 4,
}
#[derive(Clone, Debug, Eq, PartialEq)]
enum VpnDialog {
Password {
id: String,
uuid: Arc<str>,
username: String,
password: SecureString,
password_hidden: bool,
},
RemoveProfile(ConnectionId),
}
#[derive(Debug)]
pub struct NmState {
conn: zbus::Connection,
sender: futures::channel::mpsc::UnboundedSender<network_manager::Request>,
active_conns: Vec<ActiveConnectionInfo>,
devices: Vec<network_manager::devices::DeviceInfo>,
}
#[derive(Debug, Default)]
pub struct Page {
nm_task: Option<tokio::sync::oneshot::Sender<()>>,
nm_state: Option<NmState>,
dialog: Option<VpnDialog>,
view_more_popup: Option<ConnectionId>,
known_connections: IndexMap<UUID, VpnConnectionSettings>,
/// Withhold device update if the view more popup is shown.
withheld_devices: Option<Vec<network_manager::devices::DeviceInfo>>,
/// Withhold active connections update if the view more popup is shown.
withheld_active_conns: Option<Vec<ActiveConnectionInfo>>,
}
impl page::AutoBind<crate::pages::Message> for Page {}
impl page::Page<crate::pages::Message> for Page {
fn info(&self) -> cosmic_settings_page::Info {
page::Info::new("vpn", "preferences-vpn-symbolic")
.title(fl!("vpn"))
.description(fl!("connections-and-profiles", variant = "vpn"))
}
fn content(
&self,
sections: &mut slotmap::SlotMap<section::Entity, Section<crate::pages::Message>>,
) -> Option<page::Content> {
Some(vec![sections.insert(devices_view())])
}
fn dialog(&self) -> Option<Element<crate::pages::Message>> {
self.dialog.as_ref().map(|dialog| match dialog {
VpnDialog::Password {
username,
password,
password_hidden,
..
} => {
let username = widget::text_input(fl!("username"), username.as_str())
.on_input(Message::UsernameUpdate);
let password = widget::text_input::secure_input(
fl!("password"),
password.unsecure(),
Some(Message::TogglePasswordVisibility),
*password_hidden,
)
.on_input(|input| Message::PasswordUpdate(SecureString::from(input)))
.on_submit(Message::ConnectWithPassword);
let controls = widget::column::with_capacity(2)
.spacing(12)
.push(username)
.push(password)
.apply(Element::from);
let primary_action = widget::button::suggested(fl!("connect"))
.on_press(Message::ConnectWithPassword);
let secondary_action =
widget::button::standard(fl!("cancel")).on_press(Message::CancelDialog);
widget::dialog(fl!("auth-dialog"))
.icon(icon::from_name("network-vpn-symbolic").size(64))
.body(fl!("auth-dialog", "vpn-description"))
.control(controls)
.primary_action(primary_action)
.secondary_action(secondary_action)
.apply(Element::from)
.map(crate::pages::Message::Vpn)
}
VpnDialog::RemoveProfile(uuid) => {
let primary_action = widget::button::destructive(fl!("remove"))
.on_press(Message::RemoveProfile(uuid.clone()));
let secondary_action =
widget::button::standard(fl!("cancel")).on_press(Message::CancelDialog);
widget::dialog(fl!("remove-connection-dialog"))
.icon(icon::from_name("dialog-information").size(64))
.body(fl!("remove-connection-dialog", "vpn-description"))
.primary_action(primary_action)
.secondary_action(secondary_action)
.apply(Element::from)
.map(crate::pages::Message::Vpn)
}
})
}
fn header_view(&self) -> Option<Element<'_, crate::pages::Message>> {
Some(
widget::button::standard(fl!("add-network"))
.trailing_icon(icon::from_name("window-pop-out-symbolic"))
.on_press(Message::AddNetwork)
.apply(widget::container)
.width(Length::Fill)
.align_x(alignment::Horizontal::Right)
.apply(Element::from)
.map(crate::pages::Message::Vpn),
)
}
fn on_enter(
&mut self,
_page: cosmic_settings_page::Entity,
sender: tokio::sync::mpsc::Sender<crate::pages::Message>,
) -> cosmic::Command<crate::pages::Message> {
if self.nm_task.is_none() {
return cosmic::command::future(async move {
zbus::Connection::system()
.await
.context("failed to create system dbus connection")
.map_or_else(
|why| Message::Error(why.to_string()),
|conn| Message::NetworkManagerConnect((conn, sender.clone())),
)
});
}
Command::none()
}
fn on_leave(&mut self) -> Command<crate::pages::Message> {
self.view_more_popup = None;
self.nm_state = None;
self.withheld_active_conns = None;
self.withheld_devices = None;
self.dialog = None;
if let Some(cancel) = self.nm_task.take() {
_ = cancel.send(());
}
Command::none()
}
}
impl Page {
pub fn update(&mut self, message: Message) -> Command<crate::app::Message> {
match message {
Message::NetworkManager(network_manager::Event::RequestResponse {
req,
state,
success,
}) => {
if !success {
tracing::error!(request = ?req, "network-manager request failed");
}
if let Some(NmState { ref conn, .. }) = self.nm_state {
let conn = conn.clone();
self.update_active_conns(state);
return cosmic::command::batch(vec![
connection_settings(conn.clone()),
update_devices(conn),
]);
}
}
Message::KnownConnections(connections) => {
self.known_connections = connections;
}
Message::UpdateDevices(devices) => {
self.update_devices(devices);
}
Message::UpdateState(state) => {
self.update_active_conns(state);
}
Message::NetworkManager(
network_manager::Event::ActiveConns | network_manager::Event::Devices,
) => {
if let Some(NmState { ref conn, .. }) = self.nm_state {
return cosmic::command::batch(vec![
update_state(conn.clone()),
update_devices(conn.clone()),
connection_settings(conn.clone()),
]);
}
}
Message::NetworkManager(network_manager::Event::Init {
conn,
sender,
state,
}) => {
self.nm_state = Some(NmState {
conn: conn.clone(),
sender,
devices: Vec::new(),
active_conns: state
.active_conns
.into_iter()
.filter(|info| matches!(info, ActiveConnectionInfo::Vpn { .. }))
.collect(),
});
return cosmic::command::batch(vec![
connection_settings(conn.clone()),
update_devices(conn),
]);
}
Message::NetworkManager(_event) => (),
Message::AddNetwork => return add_network(),
Message::Activate(uuid) => {
self.close_popup_and_apply_updates();
if let Some(settings) = self.known_connections.get(&uuid) {
match settings.password_flag() {
Some(PasswordFlag::NotSaved | PasswordFlag::AgentOwned) => {
self.view_more_popup = None;
self.dialog = Some(VpnDialog::Password {
id: settings.id.clone(),
uuid: uuid.clone(),
username: settings.username.clone().unwrap_or_default(),
password: SecureString::from(""),
password_hidden: true,
});
}
_ => {
let connection_name = settings.id.clone();
return cosmic::command::future(async move {
if let Err(why) = nmcli::connect(&connection_name).await {
return Message::Error(format!(
"failed to connect to VPN: {why}"
));
}
Message::Refresh
});
}
}
}
}
Message::Deactivate(uuid) => {
self.close_popup_and_apply_updates();
if let Some(NmState { ref sender, .. }) = self.nm_state {
_ = sender.unbounded_send(network_manager::Request::Deactivate(uuid));
}
}
Message::RemoveProfileRequest(uuid) => {
self.view_more_popup = None;
self.dialog = Some(VpnDialog::RemoveProfile(uuid));
}
Message::RemoveProfile(uuid) => {
self.dialog = None;
self.close_popup_and_apply_updates();
if let Some(NmState { ref sender, .. }) = self.nm_state {
_ = sender.unbounded_send(network_manager::Request::Remove(uuid));
}
}
Message::ViewMore(uuid) => {
self.view_more_popup = uuid;
if self.view_more_popup.is_none() {
self.close_popup_and_apply_updates();
}
}
Message::Settings(uuid) => {
self.close_popup_and_apply_updates();
return cosmic::command::future(async move {
super::nm_edit_connection(uuid.as_ref())
.then(|res| async move {
match res.context("failed to open connection editor") {
Ok(_) => Message::Refresh,
Err(why) => Message::Error(why.to_string()),
}
})
.await
});
}
Message::Refresh => {
if let Some(NmState { ref conn, .. }) = self.nm_state {
return cosmic::command::batch(vec![
update_state(conn.clone()),
update_devices(conn.clone()),
connection_settings(conn.clone()),
]);
}
}
Message::PasswordUpdate(pass) => {
if let Some(VpnDialog::Password {
ref mut password, ..
}) = self.dialog
{
*password = pass;
}
}
Message::ConnectWithPassword => {
let Some(dialog) = self.dialog.take() else {
return Command::none();
};
if let VpnDialog::Password {
id,
username,
password,
..
} = dialog
{
return self
.activate_with_password(id, username, password)
.map(crate::app::Message::from);
}
}
Message::UsernameUpdate(user) => {
if let Some(VpnDialog::Password {
ref mut username, ..
}) = self.dialog
{
*username = user;
}
}
Message::CancelDialog => {
self.dialog = None;
}
Message::TogglePasswordVisibility => {
if let Some(VpnDialog::Password {
ref mut password_hidden,
..
}) = self.dialog
{
*password_hidden = !*password_hidden;
}
}
Message::Error(why) => {
tracing::error!(why, "error in VPN settings page");
}
Message::NetworkManagerConnect((conn, output)) => {
self.connect(conn.clone(), output);
}
}
Command::none()
}
fn activate_with_password(
&mut self,
connection_name: String,
username: String,
password: SecureString,
) -> Command<Message> {
cosmic::command::future(async move {
if let Err(why) = nmcli::set_username(&connection_name, &username).await {
return Message::Error(format!("failed to set VPN username: {why}"));
}
if let Err(why) = nmcli::set_password_flags_none(&connection_name).await {
return Message::Error(format!(
"failed to call nmcli to set VPN password-flags parameter: {why}"
));
}
if let Err(why) = nmcli::set_password(&connection_name, password.unsecure()).await {
return Message::Error(format!("failed to call nmcli to set VPN password: {why}"));
}
if let Err(why) = nmcli::connect(&connection_name).await {
return Message::Error(format!("failed to connect to VPN: {why}"));
}
Message::Refresh
})
}
fn connect(
&mut self,
conn: zbus::Connection,
sender: tokio::sync::mpsc::Sender<crate::pages::Message>,
) {
if self.nm_task.is_none() {
self.nm_task = Some(crate::utils::forward_event_loop(
sender,
|event| crate::pages::Message::Vpn(Message::NetworkManager(event)),
move |tx| async move {
futures::join!(
network_manager::watch(conn.clone(), tx.clone()),
network_manager::active_conns::watch(conn.clone(), tx.clone()),
network_manager::devices::watch(conn, true, tx)
);
},
));
}
}
/// Closes the view more popup and applies any withheld updates.
fn close_popup_and_apply_updates(&mut self) {
self.view_more_popup = None;
if let Some(ref mut nm_state) = self.nm_state {
if let Some(active_conns) = self.withheld_active_conns.take() {
nm_state.active_conns = active_conns;
}
if let Some(devices) = self.withheld_devices.take() {
nm_state.devices = devices;
}
}
}
/// Withholds updates if the view more popup is displayed.
fn update_devices(&mut self, devices: Vec<network_manager::devices::DeviceInfo>) {
if let Some(ref mut nm_state) = self.nm_state {
if self.view_more_popup.is_some() {
self.withheld_devices = Some(devices);
} else {
nm_state.devices = devices;
}
}
}
/// Withholds updates if the view more popup is displayed.
fn update_active_conns(&mut self, state: NetworkManagerState) {
if let Some(ref mut nm_state) = self.nm_state {
let conns = state
.active_conns
.into_iter()
.filter(|info| matches!(info, ActiveConnectionInfo::Vpn { .. }))
.collect();
if self.view_more_popup.is_some() {
self.withheld_active_conns = Some(conns);
} else {
nm_state.active_conns = conns;
}
}
}
}
fn devices_view() -> Section<crate::pages::Message> {
crate::slab!(descriptions {
vpn_conns_txt = fl!("vpn", "connections");
remove_txt = fl!("vpn", "remove");
connect_txt = fl!("connect");
connected_txt = fl!("connected");
settings_txt = fl!("settings");
disconnect_txt = fl!("disconnect");
});
Section::default()
.descriptions(descriptions)
.view::<Page>(move |_binder, page, section| {
let Some(NmState {
ref active_conns, ..
}) = page.nm_state
else {
return cosmic::widget::column().into();
};
let theme = cosmic::theme::active();
let spacing = &theme.cosmic().spacing;
let mut view = widget::column::with_capacity(4);
let vpn_connections =
widget::settings::view_section(&section.descriptions[vpn_conns_txt]);
if page.known_connections.is_empty() {
view = view.push(vpn_connections.add(widget::settings::item_row(vec![
widget::text::body(fl!("no-vpn")).into(),
])));
} else {
let known_networks = page.known_connections.iter().fold(
vpn_connections,
|networks, (uuid, connection)| {
let is_connected = active_conns.iter().any(|conn| match conn {
ActiveConnectionInfo::Vpn { name, .. } => {
name.as_str() == connection.id.as_str()
}
_ => false,
});
let (connect_txt, connect_msg) = if is_connected {
(&section.descriptions[connected_txt], None)
} else {
(
&section.descriptions[connect_txt],
Some(Message::Activate(uuid.clone())),
)
};
let identifier =
widget::text::body(connection.id.as_str()).wrap(Wrap::Glyph);
let connect: Element<'_, Message> = if let Some(msg) = connect_msg {
widget::button::text(connect_txt).on_press(msg).into()
} else {
widget::text::body(connect_txt)
.vertical_alignment(alignment::Vertical::Center)
.into()
};
let view_more_button =
widget::button::icon(widget::icon::from_name("view-more-symbolic"));
let view_more: Option<Element<_>> = if page
.view_more_popup
.as_deref()
.map_or(false, |id| id == uuid.as_ref())
{
widget::popover(view_more_button.on_press(Message::ViewMore(None)))
.position(widget::popover::Position::Bottom)
.on_close(Message::ViewMore(None))
.popup({
widget::column()
.push_maybe(is_connected.then(|| {
popup_button(
Message::Deactivate(uuid.clone()),
&section.descriptions[disconnect_txt],
)
}))
.push(popup_button(
Message::Settings(uuid.clone()),
&section.descriptions[settings_txt],
))
.push(popup_button(
Message::RemoveProfileRequest(uuid.clone()),
&section.descriptions[remove_txt],
))
.width(Length::Fixed(200.0))
.apply(widget::container)
.style(cosmic::style::Container::Dialog)
})
.apply(|e| Some(Element::from(e)))
} else {
view_more_button
.on_press(Message::ViewMore(Some(uuid.clone())))
.apply(|e| Some(Element::from(e)))
};
let controls = widget::row::with_capacity(2)
.push(connect)
.push_maybe(view_more)
.align_items(alignment::Alignment::Center)
.spacing(spacing.space_xxs);
let widget = widget::settings::item_row(vec![
identifier.into(),
widget::horizontal_space(Length::Fill).into(),
controls.into(),
]);
networks.add(widget)
},
);
view = view.push(known_networks);
}
view.spacing(spacing.space_l)
.apply(Element::from)
.map(crate::pages::Message::Vpn)
})
}
fn popup_button<'a>(message: Message, text: &'a str) -> Element<'a, Message> {
widget::text::body(text)
.vertical_alignment(alignment::Vertical::Center)
.apply(widget::button)
.padding([4, 16])
.width(Length::Fill)
.style(cosmic::theme::Button::MenuItem)
.on_press(message)
.into()
}
fn update_state(conn: zbus::Connection) -> Command<crate::app::Message> {
cosmic::command::future(async move {
match NetworkManagerState::new(&conn).await {
Ok(state) => Message::UpdateState(state),
Err(why) => Message::Error(why.to_string()),
}
})
}
fn update_devices(conn: zbus::Connection) -> Command<crate::app::Message> {
cosmic::command::future(async move {
let filter =
|device_type| matches!(device_type, network_manager::devices::DeviceType::WireGuard);
match network_manager::devices::list(&conn, filter).await {
Ok(devices) => Message::UpdateDevices(devices),
Err(why) => Message::Error(why.to_string()),
}
})
}
fn add_network() -> Command<crate::app::Message> {
let Some(dir) = dirs::download_dir().or_else(dirs::home_dir) else {
return Command::none();
};
cosmic::dialog::file_chooser::open::Dialog::new()
.directory(dir)
.title(fl!("vpn", "select-file"))
.filter(
FileFilter::new("OpenVPN")
.mimetype("application/x-openvpn-profile")
.glob("*.ovpn"),
)
.open_file()
.then(|result| async move {
match result {
Ok(response) => {
_ = super::nm_add_vpn_file("openvpn", response.url().path()).await;
Message::Refresh
}
Err(why) => {
return Message::Error(why.to_string());
}
}
})
.apply(cosmic::command::future)
}
fn connection_settings(conn: zbus::Connection) -> Command<crate::app::Message> {
let settings = async move {
let settings = network_manager::dbus::settings::NetworkManagerSettings::new(&conn).await?;
_ = settings.load_connections(&[]).await;
let settings = settings
// Get a list of known connections.
.list_connections()
.await?
// Prepare for wrapping in a concurrent stream.
.into_iter()
.map(|conn| async move { conn })
// Create a concurrent stream for each connection.
.apply(futures::stream::FuturesOrdered::from_iter)
// Concurrently fetch settings for each connection, and filter for VPN.
.filter_map(|conn| async move {
let settings = conn.get_settings().await.ok()?;
let (connection, vpn) = settings.get("connection").zip(settings.get("vpn"))?;
if connection.get("type")?.downcast_ref::<String>().ok()? != "vpn" {
return None;
}
let id = connection.get("id")?.downcast_ref::<String>().ok()?;
let uuid = connection.get("uuid")?.downcast_ref::<String>().ok()?;
let (username, connection_type, password_flag) = vpn
.get("data")
.and_then(|data| data.downcast_ref::<zbus::zvariant::Dict>().ok())
.map(|dict| {
let (mut connection_type, mut password_flag) = (None, None);
let username = dict
.get::<String, String>(&String::from("username"))
.ok()
.flatten()
.filter(|value| !value.is_empty());
if let Some("password") = dict
.get::<String, String>(&String::from("connection-type"))
.ok()
.flatten()
.as_deref()
{
connection_type = Some(ConnectionType::Password);
password_flag = dict
.get::<String, String>(&String::from("password-flags"))
.ok()
.flatten()
.and_then(|value| match value.as_str() {
"0" => Some(PasswordFlag::None),
"1" => Some(PasswordFlag::AgentOwned),
"2" => Some(PasswordFlag::NotSaved),
"4" => Some(PasswordFlag::NotRequired),
_ => None,
});
}
(username, connection_type, password_flag)
})
.unwrap_or_default();
Some((
Arc::from(uuid),
VpnConnectionSettings {
id,
connection_type,
password_flag,
username,
},
))
})
// Reduce the settings list into
.fold(IndexMap::new(), |mut set, (uuid, data)| async move {
set.insert(uuid, data);
set
})
.await;
Ok::<_, zbus::Error>(settings)
};
cosmic::command::future(async move {
settings
.await
.context("failed to get connection settings")
.map_or_else(
|why| Message::Error(why.to_string()),
Message::KnownConnections,
)
})
}

View file

@ -0,0 +1,49 @@
// Copyright 2024 System76 <info@system76.com>
// SPDX-License-Identifier: GPL-3.0-only
use as_result::IntoResult;
use std::io;
pub async fn set_username(connection_name: &str, username: &str) -> io::Result<()> {
tokio::process::Command::new("nmcli")
.args(&["con", "mod", connection_name, "vpn.user-name", username])
.status()
.await
.and_then(IntoResult::into_result)
}
pub async fn set_password_flags_none(connection_name: &str) -> io::Result<()> {
tokio::process::Command::new("nmcli")
.args(&[
"con",
"mod",
connection_name,
"+vpn.data",
"password-flags=0",
])
.status()
.await
.and_then(IntoResult::into_result)
}
pub async fn set_password(connection_name: &str, password: &str) -> io::Result<()> {
tokio::process::Command::new("nmcli")
.args(&[
"con",
"mod",
&connection_name,
"vpn.secrets",
&format!("password={password}"),
])
.status()
.await
.and_then(IntoResult::into_result)
}
pub async fn connect(connection_name: &str) -> io::Result<()> {
tokio::process::Command::new("nmcli")
.args(&["con", "up", &connection_name])
.status()
.await
.and_then(IntoResult::into_result)
}

View file

@ -0,0 +1,787 @@
// Copyright 2024 System76 <info@system76.com>
// SPDX-License-Identifier: GPL-3.0-only
use std::collections::{BTreeMap, BTreeSet};
use anyhow::Context;
use cosmic::{
iced::{alignment, Length},
iced_core::text::Wrap,
prelude::CollectionWidget,
widget::{self, icon},
Apply, Command, Element,
};
use cosmic_settings_page::{self as page, section, Section};
use cosmic_settings_subscriptions::network_manager::{
self, available_wifi::AccessPoint, current_networks::ActiveConnectionInfo, NetworkManagerState,
};
use futures::StreamExt;
use secure_string::SecureString;
use slab::Slab;
#[derive(Clone, Debug)]
pub enum Message {
/// Add a network connection with nm-connection-editor
AddNetwork,
/// Cancels a dialog.
CancelDialog,
/// Connect to a WiFi network access point.
Connect(network_manager::SSID),
/// Connect with a password
ConnectWithPassword,
/// Settings for known connections.
ConnectionSettings(BTreeMap<Box<str>, Box<str>>),
/// Disconnect from an access point.
Disconnect(network_manager::SSID),
/// An error occurred.
Error(String),
/// Create a dialog to ask for confirmation on forgetting a connection.
ForgetRequest(network_manager::SSID),
/// Forget a known access point.
Forget(network_manager::SSID),
/// An update from the network manager daemon
NetworkManager(network_manager::Event),
/// Successfully connected to the system dbus.
NetworkManagerConnect(
(
zbus::Connection,
tokio::sync::mpsc::Sender<crate::pages::Message>,
),
),
/// Request an auth dialog
PasswordRequest(network_manager::SSID),
/// Update the password from the dialog
PasswordUpdate(SecureString),
/// Opens settings page for the access point.
Settings(network_manager::SSID),
/// Toggles visibility of the password input
TogglePasswordVisibility,
/// Update NetworkManagerState
UpdateState(NetworkManagerState),
/// Update the devices lists
UpdateDevices(Vec<network_manager::devices::DeviceInfo>),
/// Display more options for an access point
ViewMore(Option<network_manager::SSID>),
/// Toggle WiFi access
WiFiEnable(bool),
}
impl From<Message> for crate::app::Message {
fn from(message: Message) -> Self {
crate::pages::Message::WiFi(message).into()
}
}
impl From<Message> for crate::pages::Message {
fn from(message: Message) -> Self {
crate::pages::Message::WiFi(message)
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
enum WiFiDialog {
Forget(network_manager::SSID),
Password {
ssid: network_manager::SSID,
password: SecureString,
password_hidden: bool,
},
}
#[derive(Debug, Default)]
pub struct Page {
nm_task: Option<tokio::sync::oneshot::Sender<()>>,
nm_state: Option<NmState>,
dialog: Option<WiFiDialog>,
view_more_popup: Option<network_manager::SSID>,
connecting: BTreeSet<network_manager::SSID>,
ssid_to_uuid: BTreeMap<Box<str>, Box<str>>,
/// Withhold device update if the view more popup is shown.
withheld_devices: Option<Vec<network_manager::devices::DeviceInfo>>,
/// Withhold state update if the view more popup is shown.
withheld_state: Option<NetworkManagerState>,
}
#[derive(Debug)]
pub struct NmState {
conn: zbus::Connection,
sender: futures::channel::mpsc::UnboundedSender<network_manager::Request>,
state: network_manager::NetworkManagerState,
devices: Vec<network_manager::devices::DeviceInfo>,
}
impl page::AutoBind<crate::pages::Message> for Page {}
impl page::Page<crate::pages::Message> for Page {
fn info(&self) -> cosmic_settings_page::Info {
page::Info::new("wifi", "preferences-wireless-symbolic")
.title(fl!("wifi"))
.description(fl!("connections-and-profiles", variant = "wifi"))
}
fn content(
&self,
sections: &mut slotmap::SlotMap<section::Entity, Section<crate::pages::Message>>,
) -> Option<page::Content> {
Some(vec![sections.insert(devices_view())])
}
fn dialog(&self) -> Option<Element<crate::pages::Message>> {
self.dialog.as_ref().map(|dialog| match dialog {
WiFiDialog::Password {
password,
password_hidden,
..
} => {
let password = widget::text_input::secure_input(
fl!("password"),
password.unsecure(),
Some(Message::TogglePasswordVisibility),
*password_hidden,
)
.on_input(|input| Message::PasswordUpdate(SecureString::from(input)))
.on_submit(Message::ConnectWithPassword);
let primary_action = widget::button::suggested(fl!("connect"))
.on_press(Message::ConnectWithPassword);
let secondary_action =
widget::button::standard(fl!("cancel")).on_press(Message::CancelDialog);
widget::dialog(fl!("auth-dialog"))
.icon(icon::from_name("preferences-wireless-symbolic").size(64))
.body(fl!("auth-dialog", "wifi-description"))
.control(password)
.primary_action(primary_action)
.secondary_action(secondary_action)
.apply(Element::from)
.map(crate::pages::Message::WiFi)
}
WiFiDialog::Forget(ssid) => {
let primary_action = widget::button::destructive(fl!("forget"))
.on_press(Message::Forget(ssid.clone()));
let secondary_action =
widget::button::standard(fl!("cancel")).on_press(Message::CancelDialog);
widget::dialog(fl!("forget-dialog"))
.icon(icon::from_name("dialog-information").size(64))
.body(fl!("forget-dialog", "description"))
.primary_action(primary_action)
.secondary_action(secondary_action)
.apply(Element::from)
.map(crate::pages::Message::WiFi)
}
})
}
fn header_view(&self) -> Option<cosmic::Element<'_, crate::pages::Message>> {
Some(
widget::button::standard(fl!("add-network"))
.trailing_icon(icon::from_name("window-pop-out-symbolic"))
.on_press(Message::AddNetwork)
.apply(widget::container)
.width(Length::Fill)
.align_x(alignment::Horizontal::Right)
.apply(Element::from)
.map(crate::pages::Message::WiFi),
)
}
fn on_enter(
&mut self,
_page: cosmic_settings_page::Entity,
sender: tokio::sync::mpsc::Sender<crate::pages::Message>,
) -> cosmic::Command<crate::pages::Message> {
if self.nm_task.is_none() {
return cosmic::command::future(async move {
zbus::Connection::system()
.await
.context("failed to create system dbus connection")
.map_or_else(
|why| Message::Error(why.to_string()),
|conn| Message::NetworkManagerConnect((conn, sender.clone())),
)
.apply(crate::pages::Message::WiFi)
});
}
Command::none()
}
fn on_leave(&mut self) -> Command<crate::pages::Message> {
self.view_more_popup = None;
self.nm_state = None;
self.ssid_to_uuid.clear();
self.connecting.clear();
self.withheld_state = None;
self.withheld_devices = None;
if let Some(cancel) = self.nm_task.take() {
_ = cancel.send(());
}
Command::none()
}
}
impl Page {
pub fn update(&mut self, message: Message) -> Command<crate::app::Message> {
match message {
Message::NetworkManager(network_manager::Event::RequestResponse {
req,
state,
success,
}) => {
if !success {
tracing::error!(request = ?req, "network-manager request failed");
}
match req {
network_manager::Request::Password(ssid, _) => {
if success {
self.connecting.remove(&ssid);
} else {
// Request to retry
self.dialog = Some(WiFiDialog::Password {
ssid,
password: SecureString::from(""),
password_hidden: true,
});
}
}
network_manager::Request::SelectAccessPoint(ssid) => {
self.connecting.remove(&ssid);
}
_ => (),
}
self.update_state(state);
if let Some(NmState { ref conn, .. }) = self.nm_state {
return update_devices(conn.clone());
}
}
Message::UpdateDevices(devices) => {
self.update_devices(devices);
}
Message::UpdateState(state) => {
self.update_state(state);
if let Some(NmState { ref conn, .. }) = self.nm_state {
return connection_settings(conn.clone());
}
}
Message::NetworkManager(
network_manager::Event::ActiveConns
| network_manager::Event::Devices
| network_manager::Event::WiFiEnabled(_)
| network_manager::Event::WirelessAccessPoints,
) => {
if let Some(NmState { ref conn, .. }) = self.nm_state {
return cosmic::command::batch(vec![
update_state(conn.clone()),
update_devices(conn.clone()),
]);
}
}
Message::ConnectionSettings(settings) => {
self.ssid_to_uuid = settings;
}
Message::NetworkManager(network_manager::Event::Init {
conn,
sender,
state,
}) => {
self.nm_state = Some(NmState {
conn: conn.clone(),
sender,
state,
devices: Vec::new(),
});
return update_devices(conn);
}
Message::AddNetwork => {
tokio::task::spawn(super::nm_add_wifi());
}
Message::Connect(ssid) => {
if let Some(nm) = self.nm_state.as_mut() {
self.connecting.insert(ssid.clone());
_ = nm
.sender
.unbounded_send(network_manager::Request::SelectAccessPoint(ssid));
}
}
Message::PasswordRequest(ssid) => {
self.dialog = Some(WiFiDialog::Password {
ssid,
password: SecureString::from(""),
password_hidden: true,
});
}
Message::PasswordUpdate(pass) => {
if let Some(WiFiDialog::Password {
ref mut password, ..
}) = self.dialog
{
*password = pass;
}
}
Message::ConnectWithPassword => {
let Some(dialog) = self.dialog.take() else {
return Command::none();
};
if let WiFiDialog::Password { ssid, password, .. } = dialog {
if let Some(nm) = self.nm_state.as_mut() {
self.connecting.insert(ssid.clone());
_ = nm
.sender
.unbounded_send(network_manager::Request::Password(ssid, password));
}
}
}
Message::TogglePasswordVisibility => {
if let Some(WiFiDialog::Password {
ref mut password_hidden,
..
}) = self.dialog
{
*password_hidden = !*password_hidden;
}
}
Message::ViewMore(ssid) => {
self.view_more_popup = ssid;
if self.view_more_popup.is_none() {
self.close_popup_and_apply_updates();
}
}
Message::Disconnect(ssid) => {
self.close_popup_and_apply_updates();
if let Some(nm) = self.nm_state.as_mut() {
_ = nm
.sender
.unbounded_send(network_manager::Request::Disconnect(ssid));
}
}
Message::ForgetRequest(ssid) => {
self.dialog = Some(WiFiDialog::Forget(ssid));
self.view_more_popup = None;
}
Message::Forget(ssid) => {
self.dialog = None;
self.close_popup_and_apply_updates();
if let Some(nm) = self.nm_state.as_mut() {
_ = nm
.sender
.unbounded_send(network_manager::Request::Forget(ssid));
}
}
Message::Settings(ssid) => {
self.close_popup_and_apply_updates();
if let Some(uuid) = self.ssid_to_uuid.get(ssid.as_ref()).cloned() {
tokio::task::spawn(
async move { super::nm_edit_connection(uuid.as_ref()).await },
);
}
}
Message::WiFiEnable(enable) => {
if let Some(nm) = self.nm_state.as_mut() {
_ = nm
.sender
.unbounded_send(network_manager::Request::SetWiFi(enable));
_ = nm.sender.unbounded_send(network_manager::Request::Reload);
}
}
Message::CancelDialog => {
self.dialog = None;
}
Message::Error(why) => {
tracing::error!(why, "error in wifi settings page");
}
Message::NetworkManagerConnect((conn, output)) => {
self.connect(conn.clone(), output);
return connection_settings(conn);
}
}
Command::none()
}
fn connect(
&mut self,
conn: zbus::Connection,
sender: tokio::sync::mpsc::Sender<crate::pages::Message>,
) {
if self.nm_task.is_none() {
self.nm_task = Some(crate::utils::forward_event_loop(
sender,
|event| crate::pages::Message::WiFi(Message::NetworkManager(event)),
move |tx| async move {
futures::join!(
network_manager::watch(conn.clone(), tx.clone()),
network_manager::active_conns::watch(conn.clone(), tx.clone()),
network_manager::wireless_enabled::watch(conn.clone(), tx.clone()),
network_manager::watch_connections_changed(conn, tx)
);
},
));
}
}
/// Closes the view more popup and applies any withheld updates.
fn close_popup_and_apply_updates(&mut self) {
self.view_more_popup = None;
if let Some(ref mut nm_state) = self.nm_state {
if let Some(state) = self.withheld_state.take() {
nm_state.state = state;
}
if let Some(devices) = self.withheld_devices.take() {
nm_state.devices = devices;
}
}
}
/// Withholds updates if the view more popup is displayed.
fn update_devices(&mut self, devices: Vec<network_manager::devices::DeviceInfo>) {
if let Some(ref mut nm_state) = self.nm_state {
if self.view_more_popup.is_some() {
self.withheld_devices = Some(devices);
} else {
nm_state.devices = devices;
}
}
}
/// Withholds updates if the view more popup is displayed.
fn update_state(&mut self, state: NetworkManagerState) {
if let Some(ref mut nm_state) = self.nm_state {
if self.view_more_popup.is_some() {
self.withheld_state = Some(state);
} else {
nm_state.state = state;
}
}
}
}
fn devices_view() -> Section<crate::pages::Message> {
crate::slab!(descriptions {
airplane_mode_txt = fl!("airplane-on");
connect_txt = fl!("connect");
connected_txt = fl!("connected");
connecting_txt = fl!("connecting");
disconnect_txt = fl!("disconnect");
forget_txt = fl!("wifi", "forget");
known_networks_txt = fl!("known-networks");
no_networks_txt = fl!("no-networks");
settings_txt = fl!("settings");
visible_networks_txt = fl!("visible-networks");
wifi_txt = fl!("wifi");
});
Section::default()
.descriptions(descriptions)
.view::<Page>(move |_binder, page, section| {
let Some(NmState { ref state, .. }) = page.nm_state else {
return cosmic::widget::column().into();
};
let theme = cosmic::theme::active();
let spacing = &theme.cosmic().spacing;
let wifi_enable =
widget::settings::item::builder(&section.descriptions[wifi_txt]).control(
widget::toggler(None, state.wifi_enabled, Message::WiFiEnable),
);
let mut view = widget::column::with_capacity(4)
.push(widget::list_column().add(wifi_enable))
.push_maybe(state.airplane_mode.then(|| {
widget::row::with_capacity(2)
.push(icon::from_name("airplane-mode-symbolic"))
.push(widget::text::body(&section.descriptions[airplane_mode_txt]))
.spacing(8)
.align_items(alignment::Alignment::Center)
.apply(widget::container)
.width(Length::Fill)
.align_x(alignment::Horizontal::Center)
}));
if !state.airplane_mode
&& state.known_access_points.is_empty()
&& state.wireless_access_points.is_empty()
{
let no_networks_found =
widget::container(widget::text::body(&section.descriptions[no_networks_txt]))
.align_x(alignment::Horizontal::Center)
.width(Length::Fill);
view = view.push(no_networks_found);
} else {
let mut has_known = false;
let mut has_visible = false;
// Create separate sections for known and visible networks.
let (known_networks, visible_networks) = state.wireless_access_points.iter().fold(
(
widget::settings::view_section(&section.descriptions[known_networks_txt]),
widget::settings::view_section(&section.descriptions[visible_networks_txt]),
),
|(mut known_networks, mut visible_networks), network| {
let is_connected = is_connected(state, network);
let is_known = state
.known_access_points
.iter()
.map(|known| known.ssid.as_ref())
.chain(state.active_conns.iter().filter_map(|active| {
if let ActiveConnectionInfo::WiFi { name, .. } = active {
Some(name.as_str())
} else {
None
}
}))
.any(|known| known == network.ssid.as_ref());
// TODO: detect if access point is secured or not.
let is_encrypted = true;
let (connect_txt, connect_msg) = if is_connected {
(&section.descriptions[connected_txt], None)
} else if page.connecting.contains(&network.ssid) {
(&section.descriptions[connecting_txt], None)
} else {
(
&section.descriptions[connect_txt],
Some(if is_known || !is_encrypted {
Message::Connect(network.ssid.clone())
} else {
Message::PasswordRequest(network.ssid.clone())
}),
)
};
let identifier = widget::row::with_capacity(3)
.push(widget::icon::from_name("network-wireless-good-symbolic"))
.push_maybe(
is_encrypted
.then(|| widget::icon::from_name("connection-secure-symbolic")),
)
.push(widget::text::body(network.ssid.as_ref()).wrap(Wrap::Glyph))
.spacing(spacing.space_xxs);
let connect: Element<'_, Message> = if let Some(msg) = connect_msg {
widget::button::text(connect_txt).on_press(msg).into()
} else {
widget::text::body(connect_txt)
.vertical_alignment(alignment::Vertical::Center)
.into()
};
let view_more_button =
widget::button::icon(widget::icon::from_name("view-more-symbolic"));
let view_more: Option<Element<_>> = if page
.view_more_popup
.as_deref()
.map_or(false, |id| id == network.ssid.as_ref())
{
widget::popover(view_more_button.on_press(Message::ViewMore(None)))
.position(widget::popover::Position::Bottom)
.on_close(Message::ViewMore(None))
.popup({
widget::column()
.push_maybe(is_connected.then(|| {
popup_button(
Message::Disconnect(network.ssid.clone()),
&section.descriptions[disconnect_txt],
)
}))
.push(popup_button(
Message::Settings(network.ssid.clone()),
&section.descriptions[settings_txt],
))
.push_maybe(is_known.then(|| {
popup_button(
Message::ForgetRequest(network.ssid.clone()),
&section.descriptions[forget_txt],
)
}))
.width(Length::Fixed(170.0))
.apply(widget::container)
.style(cosmic::style::Container::Dialog)
})
.apply(|e| Some(Element::from(e)))
} else if is_known {
view_more_button
.on_press(Message::ViewMore(Some(network.ssid.clone())))
.apply(|e| Some(Element::from(e)))
} else {
None
};
let controls = widget::row::with_capacity(2)
.push(connect)
.push_maybe(view_more)
.align_items(alignment::Alignment::Center)
.spacing(spacing.space_xxs);
let widget = widget::settings::item_row(vec![
identifier.into(),
widget::horizontal_space(Length::Fill).into(),
controls.into(),
]);
if is_known {
has_known = true;
known_networks = known_networks.add(widget);
} else {
has_visible = true;
visible_networks = visible_networks.add(widget);
}
(known_networks, visible_networks)
},
);
if has_known {
view = view.push(known_networks);
}
if has_visible {
view = view.push(visible_networks);
}
};
view.spacing(spacing.space_l)
.apply(Element::from)
.map(crate::pages::Message::WiFi)
})
}
fn is_connected(state: &NetworkManagerState, network: &AccessPoint) -> bool {
state.active_conns.iter().any(|active| {
if let ActiveConnectionInfo::WiFi { ref name, .. } = active {
*name == network.ssid.as_ref()
} else {
false
}
})
}
fn popup_button<'a>(message: Message, text: &'a str) -> Element<'a, Message> {
widget::text::body(text)
.vertical_alignment(alignment::Vertical::Center)
.apply(widget::button)
.padding([4, 16])
.width(Length::Fill)
.style(cosmic::theme::Button::MenuItem)
.on_press(message)
.into()
}
fn connection_settings(conn: zbus::Connection) -> Command<crate::app::Message> {
let settings = async move {
let settings = network_manager::dbus::settings::NetworkManagerSettings::new(&conn).await?;
_ = settings.load_connections(&[]).await;
let settings = settings
// Get a list of known connections.
.list_connections()
.await?
// Prepare for wrapping in a concurrent stream.
.into_iter()
.map(|conn| async move { conn })
// Create a concurrent stream for each connection.
.apply(futures::stream::FuturesOrdered::from_iter)
// Concurrently fetch settings for each connection.
.filter_map(|conn| async move {
conn.get_settings()
.await
.map(network_manager::Settings::new)
.ok()
})
// Reduce the settings list into a SSID->UUID map.
.fold(BTreeMap::new(), |mut set, settings| async move {
if let Some(ref wifi) = settings.wifi {
if let Some(ssid) = wifi
.ssid
.clone()
.and_then(|ssid| String::from_utf8(ssid).ok())
{
if let Some(ref connection) = settings.connection {
if let Some(uuid) = connection.uuid.clone() {
set.insert(ssid.into(), uuid.into());
return set;
}
}
}
}
set
})
.await;
Ok::<_, zbus::Error>(settings)
};
cosmic::command::future(async move {
settings
.await
.context("failed to get connection settings")
.map_or_else(
|why| Message::Error(why.to_string()),
Message::ConnectionSettings,
)
.apply(crate::pages::Message::WiFi)
})
}
pub fn update_state(conn: zbus::Connection) -> Command<crate::app::Message> {
cosmic::command::future(async move {
match NetworkManagerState::new(&conn).await {
Ok(state) => Message::UpdateState(state),
Err(why) => Message::Error(why.to_string()),
}
})
}
pub fn update_devices(conn: zbus::Connection) -> Command<crate::app::Message> {
cosmic::command::future(async move {
let filter =
|device_type| matches!(device_type, network_manager::devices::DeviceType::Wifi);
match network_manager::devices::list(&conn, filter).await {
Ok(devices) => Message::UpdateDevices(devices),
Err(why) => Message::Error(why.to_string()),
}
})
}

View file

@ -1,10 +1,690 @@
// Copyright 2023 System76 <info@system76.com>
// Copyright 2024 System76 <info@system76.com>
// SPDX-License-Identifier: GPL-3.0-only
use cosmic_settings_page as page;
use std::{collections::BTreeSet, sync::Arc};
pub fn info() -> page::Info {
page::Info::new("wired", "network-workgroup-symbolic")
.title(fl!("wired"))
.description(fl!("wired", "desc"))
use anyhow::Context;
use cosmic::{
iced::{alignment, Length},
iced_core::text::Wrap,
prelude::CollectionWidget,
widget::{self, icon},
Apply, Command, Element,
};
use cosmic_settings_page::{self as page, section, Section};
use cosmic_settings_subscriptions::network_manager::{
self, current_networks::ActiveConnectionInfo, devices::DeviceState, NetworkManagerState,
};
use slab::Slab;
pub type ConnectionId = Arc<str>;
#[derive(Clone, Debug)]
pub enum Message {
/// Activate a connection
Activate(ConnectionId),
/// Add a network connection with nm-connection-editor
AddNetwork,
/// Cancels an active dialog.
CancelDialog,
/// Deactivate a connection.
Deactivate(ConnectionId),
/// An error occurred.
Error(String),
/// An update from the network manager daemon
NetworkManager(network_manager::Event),
/// Successfully connected to the system dbus.
NetworkManagerConnect(
(
zbus::Connection,
tokio::sync::mpsc::Sender<crate::pages::Message>,
),
),
/// Refresh devices and their connection profiles
Refresh,
/// Create a dialog to ask for confirmation of removal.
RemoveProfileRequest(ConnectionId),
/// Remove a connection profile
RemoveProfile(ConnectionId),
/// Selects a device to display connections from
SelectDevice(Arc<network_manager::devices::DeviceInfo>),
/// Opens settings page for the access point.
Settings(ConnectionId),
/// Update NetworkManagerState
UpdateState(NetworkManagerState),
/// Update the devices lists
UpdateDevices(Vec<network_manager::devices::DeviceInfo>),
/// Display more options for an access point
ViewMore(Option<ConnectionId>),
}
impl From<Message> for crate::app::Message {
fn from(message: Message) -> Self {
crate::pages::Message::Wired(message).into()
}
}
impl From<Message> for crate::pages::Message {
fn from(message: Message) -> Self {
crate::pages::Message::Wired(message)
}
}
pub type InterfaceId = String;
#[derive(Clone, Debug, Eq, PartialEq)]
enum WiredDialog {
RemoveProfile(ConnectionId),
}
#[derive(Debug, Default)]
pub struct Page {
nm_task: Option<tokio::sync::oneshot::Sender<()>>,
nm_state: Option<NmState>,
dialog: Option<WiredDialog>,
/// When defined, displays connections for the specific device.
active_device: Option<Arc<network_manager::devices::DeviceInfo>>,
/// Tracks which connections are in the act of connecting.
connecting: BTreeSet<ConnectionId>,
/// Displays a popup when set.
view_more_popup: Option<ConnectionId>,
/// Withhold device update if the view more popup is shown.
withheld_devices: Option<Vec<Arc<network_manager::devices::DeviceInfo>>>,
/// Withhold active connections update if the view more popup is shown.
withheld_active_conns: Option<Vec<ActiveConnectionInfo>>,
}
#[derive(Debug)]
pub struct NmState {
conn: zbus::Connection,
sender: futures::channel::mpsc::UnboundedSender<network_manager::Request>,
active_conns: Vec<ActiveConnectionInfo>,
devices: Vec<Arc<network_manager::devices::DeviceInfo>>,
}
impl page::AutoBind<crate::pages::Message> for Page {}
impl page::Page<crate::pages::Message> for Page {
fn info(&self) -> cosmic_settings_page::Info {
page::Info::new("wired", "preferences-wired-symbolic")
.title(fl!("wired"))
.description(fl!("connections-and-profiles", variant = "wired"))
}
fn content(
&self,
sections: &mut slotmap::SlotMap<section::Entity, Section<crate::pages::Message>>,
) -> Option<page::Content> {
Some(vec![sections.insert(devices_view())])
}
fn dialog(&self) -> Option<Element<crate::pages::Message>> {
self.dialog.as_ref().map(|dialog| match dialog {
WiredDialog::RemoveProfile(uuid) => {
let primary_action = widget::button::destructive(fl!("remove"))
.on_press(Message::RemoveProfile(uuid.clone()));
let secondary_action =
widget::button::standard(fl!("cancel")).on_press(Message::CancelDialog);
widget::dialog(fl!("remove-connection-dialog"))
.icon(icon::from_name("dialog-information").size(64))
.body(fl!("remove-connection-dialog", "wired-description"))
.primary_action(primary_action)
.secondary_action(secondary_action)
.apply(Element::from)
.map(crate::pages::Message::Wired)
}
})
}
fn header_view(&self) -> Option<cosmic::Element<'_, crate::pages::Message>> {
Some(
widget::button::standard(fl!("add-network"))
.trailing_icon(icon::from_name("window-pop-out-symbolic"))
.on_press(Message::AddNetwork)
.apply(widget::container)
.width(Length::Fill)
.align_x(alignment::Horizontal::Right)
.apply(Element::from)
.map(crate::pages::Message::Wired),
)
}
fn on_enter(
&mut self,
_page: cosmic_settings_page::Entity,
sender: tokio::sync::mpsc::Sender<crate::pages::Message>,
) -> cosmic::Command<crate::pages::Message> {
if self.nm_task.is_none() {
return cosmic::command::future(async move {
zbus::Connection::system()
.await
.context("failed to create system dbus connection")
.map_or_else(
|why| Message::Error(why.to_string()),
|conn| Message::NetworkManagerConnect((conn, sender.clone())),
)
.apply(crate::pages::Message::Wired)
});
}
Command::none()
}
fn on_leave(&mut self) -> Command<crate::pages::Message> {
self.active_device = None;
self.view_more_popup = None;
self.nm_state = None;
self.withheld_active_conns = None;
self.withheld_devices = None;
self.connecting.clear();
if let Some(cancel) = self.nm_task.take() {
_ = cancel.send(());
}
Command::none()
}
fn title(&self) -> Option<&str> {
self.active_device
.as_ref()
.map(|device| device.interface.as_str())
}
}
impl Page {
pub fn update(&mut self, message: Message) -> Command<crate::app::Message> {
match message {
Message::NetworkManager(network_manager::Event::RequestResponse {
req,
state,
success,
}) => {
if !success {
tracing::error!(request = ?req, "network-manager request failed");
}
if let Some(NmState { ref conn, .. }) = self.nm_state {
let conn = conn.clone();
self.update_active_conns(state);
return update_devices(conn);
}
}
Message::UpdateDevices(devices) => {
self.update_devices(devices);
}
Message::UpdateState(state) => {
self.update_active_conns(state);
}
Message::SelectDevice(device) => {
self.active_device = Some(device);
}
Message::NetworkManager(
network_manager::Event::ActiveConns | network_manager::Event::Devices,
) => {
if let Some(NmState { ref conn, .. }) = self.nm_state {
return cosmic::command::batch(vec![
update_state(conn.clone()),
update_devices(conn.clone()),
]);
}
}
Message::NetworkManager(network_manager::Event::Init {
conn,
sender,
state,
}) => {
self.nm_state = Some(NmState {
conn: conn.clone(),
sender,
devices: Vec::new(),
active_conns: state
.active_conns
.into_iter()
.filter(|info| matches!(info, ActiveConnectionInfo::Wired { .. }))
.collect(),
});
return update_devices(conn);
}
Message::NetworkManager(_event) => (),
Message::AddNetwork => {
return cosmic::command::future(async move {
_ = super::nm_add_wired().await;
// TODO: Update when iced is rebased to use then method.
Message::Refresh
});
}
Message::Activate(uuid) => {
self.close_popup_and_apply_updates();
if let Some(NmState {
ref devices,
ref sender,
..
}) = self.nm_state
{
for device in devices {
let device_conn = device
.available_connections
.iter()
.find(|conn| conn.uuid.as_ref() == uuid.as_ref());
if let Some(device_conn) = device_conn {
let device_path = device.path.clone();
let conn_path = device_conn.path.clone();
_ = sender.unbounded_send(network_manager::Request::Activate(
device_path,
conn_path,
));
break;
}
}
}
}
Message::Deactivate(uuid) => {
self.close_popup_and_apply_updates();
if let Some(NmState { ref sender, .. }) = self.nm_state {
_ = sender.unbounded_send(network_manager::Request::Deactivate(uuid));
}
}
Message::RemoveProfileRequest(uuid) => {
self.view_more_popup = None;
self.dialog = Some(WiredDialog::RemoveProfile(uuid));
}
Message::RemoveProfile(uuid) => {
self.dialog = None;
self.close_popup_and_apply_updates();
if let Some(NmState { ref sender, .. }) = self.nm_state {
_ = sender.unbounded_send(network_manager::Request::Remove(uuid));
}
}
Message::ViewMore(uuid) => {
self.view_more_popup = uuid;
if self.view_more_popup.is_none() {
self.close_popup_and_apply_updates();
}
}
Message::Settings(uuid) => {
self.close_popup_and_apply_updates();
return cosmic::command::future(async move {
_ = super::nm_edit_connection(uuid.as_ref()).await;
// TODO: Update when iced is rebased to use then method.
Message::Refresh
});
}
Message::Refresh => {
if let Some(NmState { ref conn, .. }) = self.nm_state {
return cosmic::command::batch(vec![
update_state(conn.clone()),
update_devices(conn.clone()),
]);
}
}
Message::CancelDialog => {
self.dialog = None;
}
Message::Error(why) => {
tracing::error!(why, "error in wired settings page");
}
Message::NetworkManagerConnect((conn, output)) => {
self.connect(conn.clone(), output);
}
}
Command::none()
}
fn connect(
&mut self,
conn: zbus::Connection,
sender: tokio::sync::mpsc::Sender<crate::pages::Message>,
) {
if self.nm_task.is_none() {
self.nm_task = Some(crate::utils::forward_event_loop(
sender,
|event| crate::pages::Message::Wired(Message::NetworkManager(event)),
move |tx| async move {
futures::join!(
network_manager::watch(conn.clone(), tx.clone()),
network_manager::active_conns::watch(conn.clone(), tx.clone()),
network_manager::devices::watch(conn, true, tx)
);
},
));
}
}
/// Closes the view more popup and applies any withheld updates.
fn close_popup_and_apply_updates(&mut self) {
self.view_more_popup = None;
if let Some(ref mut nm_state) = self.nm_state {
if let Some(active_conns) = self.withheld_active_conns.take() {
nm_state.active_conns = active_conns;
}
if let Some(devices) = self.withheld_devices.take() {
nm_state.devices = devices;
}
}
self.update_active_device();
}
fn update_active_device(&mut self) {
if let Some((nm_state, active)) = self.nm_state.as_ref().zip(self.active_device.as_ref()) {
self.active_device = nm_state
.devices
.iter()
.find(|device| device.path == active.path)
.map(Arc::clone);
}
}
/// Withholds updates if the view more popup is displayed.
fn update_devices(&mut self, devices: Vec<network_manager::devices::DeviceInfo>) {
if let Some(ref mut nm_state) = self.nm_state {
let devices = devices.into_iter().map(Arc::new).collect();
if self.view_more_popup.is_some() {
self.withheld_devices = Some(devices);
} else {
nm_state.devices = devices;
}
}
self.update_active_device();
}
/// Withholds updates if the view more popup is displayed.
fn update_active_conns(&mut self, state: NetworkManagerState) {
if let Some(ref mut nm_state) = self.nm_state {
let conns = state
.active_conns
.into_iter()
.filter(|info| matches!(info, ActiveConnectionInfo::Wired { .. }))
.collect();
if self.view_more_popup.is_some() {
self.withheld_active_conns = Some(conns);
} else {
nm_state.active_conns = conns;
}
}
}
fn device_view<'a>(
&'a self,
spacing: &cosmic::cosmic_theme::Spacing,
nm_state: &'a NmState,
connect_txt: &'a str,
connected_txt: &'a str,
disconnect_txt: &'a str,
remove_txt: &'a str,
settings_txt: &'a str,
wired_conns_txt: &'a str,
device: &'a network_manager::devices::DeviceInfo,
) -> Element<'a, Message> {
let has_multiple_connection_profiles = device.available_connections.len() > 1;
let header_txt = format!("{}", wired_conns_txt);
device
.available_connections
.iter()
.fold(
widget::settings::view_section(header_txt),
|networks, connection| {
let is_connected = nm_state.active_conns.iter().any(|conn| match conn {
ActiveConnectionInfo::Wired { name, .. } => {
name.as_str() == connection.id.as_str()
}
_ => false,
});
let (connect_txt, connect_msg) = if is_connected {
(connected_txt, None)
} else {
(
connect_txt,
Some(Message::Activate(connection.uuid.clone())),
)
};
let identifier = widget::text::body(&connection.id).wrap(Wrap::Glyph);
let connect: Element<'_, Message> = if let Some(msg) = connect_msg {
widget::button::text(connect_txt).on_press(msg).into()
} else {
widget::text::body(connect_txt)
.vertical_alignment(alignment::Vertical::Center)
.into()
};
let view_more_button =
widget::button::icon(widget::icon::from_name("view-more-symbolic"));
let view_more: Option<Element<_>> = if self
.view_more_popup
.as_deref()
.map_or(false, |id| id == connection.uuid.as_ref())
{
widget::popover(view_more_button.on_press(Message::ViewMore(None)))
.position(widget::popover::Position::Bottom)
.on_close(Message::ViewMore(None))
.popup({
widget::column()
.push_maybe(is_connected.then(|| {
popup_button(
Message::Deactivate(connection.uuid.clone()),
&disconnect_txt,
)
}))
.push(popup_button(
Message::Settings(connection.uuid.clone()),
&settings_txt,
))
.push_maybe(has_multiple_connection_profiles.then(|| {
popup_button(
Message::RemoveProfileRequest(connection.uuid.clone()),
&remove_txt,
)
}))
.width(Length::Fixed(200.0))
.apply(widget::container)
.style(cosmic::style::Container::Dialog)
})
.apply(|e| Some(Element::from(e)))
} else {
view_more_button
.on_press(Message::ViewMore(Some(connection.uuid.clone())))
.apply(|e| Some(Element::from(e)))
};
let controls = widget::row::with_capacity(2)
.push(connect)
.push_maybe(view_more)
.align_items(alignment::Alignment::Center)
.spacing(spacing.space_xxs);
let widget = widget::settings::item_row(vec![
identifier.into(),
widget::horizontal_space(Length::Fill).into(),
controls.into(),
]);
networks.add(widget)
},
)
.into()
}
fn device_list_view<'a>(
&'a self,
_spacing: &cosmic::cosmic_theme::Spacing,
nm_state: &'a NmState,
devices_txt: &'a str,
) -> Element<'a, Message> {
nm_state
.devices
.iter()
.fold(
widget::settings::view_section(devices_txt),
|section, device| {
let is_unplugged = matches!(device.state, DeviceState::Unavailable);
let device_list =
cosmic::widget::settings::item::builder(device.interface.as_str())
.description(match device.state {
DeviceState::Activated => fl!("network-device-state", "activated"),
DeviceState::Config => fl!("network-device-state", "config"),
DeviceState::Deactivating => {
fl!("network-device-state", "deactivating")
}
DeviceState::Disconnected => {
fl!("network-device-state", "disconnected")
}
DeviceState::Failed => fl!("network-device-state", "failed"),
DeviceState::IpCheck => fl!("network-device-state", "ip-check"),
DeviceState::IpConfig => fl!("network-device-state", "ip-config"),
DeviceState::NeedAuth => fl!("network-device-state", "need-auth"),
DeviceState::Prepare => fl!("network-device-state", "prepare"),
DeviceState::Secondaries => {
fl!("network-device-state", "secondaries")
}
DeviceState::Unavailable => {
fl!("network-device-state", "unplugged")
}
DeviceState::Unknown => fl!("network-device-state", "unknown"),
DeviceState::Unmanaged => fl!("network-device-state", "unmanaged"),
})
.icon(icon::from_name("network-wired-symbolic").size(32))
.control(icon::from_name("go-next-symbolic").size(20))
.spacing(16)
.apply(widget::container)
.padding([16, 14])
.style(cosmic::theme::Container::List)
.apply(widget::button)
.padding(0)
.style(cosmic::theme::Button::Transparent)
.on_press_maybe(if is_unplugged {
None
} else {
Some(Message::SelectDevice(device.clone()))
});
section.add(device_list)
},
)
.into()
}
}
fn devices_view() -> Section<crate::pages::Message> {
crate::slab!(descriptions {
wired_conns_txt = fl!("wired", "connections");
wired_devices_txt = fl!("wired", "devices");
remove_txt = fl!("wired", "remove");
connect_txt = fl!("connect");
connected_txt = fl!("connected");
settings_txt = fl!("settings");
disconnect_txt = fl!("disconnect");
});
Section::default()
.descriptions(descriptions)
.view::<Page>(move |_binder, page, section| {
let Some(ref nm_state) = page.nm_state else {
return cosmic::widget::column().into();
};
let theme = cosmic::theme::active();
let spacing = &theme.cosmic().spacing;
let mut view = widget::column::with_capacity(4);
// Displays device connections if a device is selected, or only device exists.
let active_device = page
.active_device
.as_ref()
.or_else(|| (nm_state.devices.len() == 1).then(|| nm_state.devices.get(0))?)
.filter(|device| !matches!(device.state, DeviceState::Unavailable));
view = match active_device {
Some(device) => view.push(page.device_view(
spacing,
nm_state,
&section.descriptions[connect_txt],
&section.descriptions[connected_txt],
&section.descriptions[disconnect_txt],
&section.descriptions[remove_txt],
&section.descriptions[settings_txt],
&section.descriptions[wired_conns_txt],
device,
)),
None => view.push(page.device_list_view(
spacing,
nm_state,
&section.descriptions[wired_devices_txt],
)),
};
view.spacing(spacing.space_l)
.apply(Element::from)
.map(crate::pages::Message::Wired)
})
}
fn popup_button<'a>(message: Message, text: &'a str) -> Element<'a, Message> {
widget::text::body(text)
.vertical_alignment(alignment::Vertical::Center)
.apply(widget::button)
.padding([4, 16])
.width(Length::Fill)
.style(cosmic::theme::Button::MenuItem)
.on_press(message)
.into()
}
fn update_state(conn: zbus::Connection) -> Command<crate::app::Message> {
cosmic::command::future(async move {
match NetworkManagerState::new(&conn).await {
Ok(state) => Message::UpdateState(state),
Err(why) => Message::Error(why.to_string()),
}
})
}
fn update_devices(conn: zbus::Connection) -> Command<crate::app::Message> {
cosmic::command::future(async move {
let filter =
|device_type| matches!(device_type, network_manager::devices::DeviceType::Ethernet);
match network_manager::devices::list(&conn, filter).await {
Ok(devices) => Message::UpdateDevices(devices),
Err(why) => Message::Error(why.to_string()),
}
})
}

View file

@ -46,3 +46,15 @@ pub fn forward_event_loop<M: 'static + Send, T: Future<Output = ()> + Send + 'st
cancel_tx
}
/// Creates a slab with predefined items
#[macro_export]
macro_rules! slab {
( $descriptions:ident { $( $txt_id:ident = $txt_expr:expr; )+ } ) => {
let mut $descriptions = Slab::new();
$(
let $txt_id = $descriptions.insert($txt_expr);
)+
}
}