From b9dfcde7b12221924c29a14f9f391f7c2e8c7915 Mon Sep 17 00:00:00 2001 From: Fred <27208977+FreddyFunk@users.noreply.github.com> Date: Tue, 11 Nov 2025 16:21:45 +0100 Subject: [PATCH] feat(wifi): share public network with QR code --- Cargo.lock | 7 + Cargo.toml | 2 +- cosmic-settings/src/pages/networking/wifi.rs | 129 ++++++++++++++++++- i18n/en/cosmic_settings.ftl | 4 + subscriptions/network-manager/src/lib.rs | 74 +++++++++++ 5 files changed, 214 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 02a0107..04da7a8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3606,6 +3606,7 @@ dependencies = [ "num-traits", "once_cell", "ouroboros", + "qrcode", "rustc-hash 2.1.1", "thiserror 1.0.69", "unicode-segmentation", @@ -6287,6 +6288,12 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "qrcode" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "166f136dfdb199f98186f3649cf7a0536534a61417a1a30221b492b4fb60ce3f" + [[package]] name = "quick-error" version = "2.0.1" diff --git a/Cargo.toml b/Cargo.toml index 9a2182b..cb2ed8c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,7 +11,7 @@ cosmic-randr = { git = "https://github.com/pop-os/cosmic-randr" } tokio = { version = "1.48.0", features = ["macros"] } [workspace.dependencies.libcosmic] -features = ["dbus-config", "multi-window", "winit", "tokio"] +features = ["dbus-config", "multi-window", "winit", "tokio", "qr_code"] git = "https://github.com/pop-os/libcosmic" [workspace.dependencies.cosmic-config] diff --git a/cosmic-settings/src/pages/networking/wifi.rs b/cosmic-settings/src/pages/networking/wifi.rs index 84f1780..5acf724 100644 --- a/cosmic-settings/src/pages/networking/wifi.rs +++ b/cosmic-settings/src/pages/networking/wifi.rs @@ -9,6 +9,7 @@ use std::{ use anyhow::Context; use cosmic::{ Apply, Element, Task, + app::ContextDrawer, iced::{Alignment, Length}, iced_core::text::Wrapping, iced_widget::focus_next, @@ -54,6 +55,8 @@ pub enum Message { PasswordRequest(network_manager::SSID), /// Update the password from the dialog PasswordUpdate(SecureString), + /// Request QR code drawer for sharing WiFi credentials + QRCodeRequest(network_manager::SSID), /// Selects a device to display connections from SelectDevice(Arc), /// Opens settings page for the access point. @@ -84,7 +87,7 @@ impl From for crate::pages::Message { } } -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, PartialEq, Eq)] enum WiFiDialog { Forget(network_manager::SSID), Password { @@ -96,6 +99,14 @@ enum WiFiDialog { }, } +/// QR code sharing context drawer state +#[derive(Clone, Debug, PartialEq, Eq)] +struct QRCodeDrawer { + ssid: network_manager::SSID, + password: Option, + security_type: NetworkType, +} + #[derive(Debug, Default)] pub struct Page { entity: page::Entity, @@ -111,6 +122,10 @@ pub struct Page { withheld_devices: Option>, /// Withhold state update if the view more popup is shown. withheld_state: Option, + /// QR code data for WiFi sharing drawer + qr_code_data: Option, + /// QR code context drawer state + qr_drawer: Option, } #[derive(Debug)] @@ -208,6 +223,50 @@ impl page::Page for Page { }) } + fn context_drawer(&self) -> Option> { + let drawer = self.qr_drawer.as_ref()?; + + let theme = cosmic::theme::active(); + let spacing = &theme.cosmic().spacing; + + let qr_section = if let Some(ref qr_data) = self.qr_code_data { + widget::container(widget::qr_code(qr_data).cell_size(5)).center_x(Length::Fill) + } else { + widget::container(widget::text::body(fl!("qr-code-unavailable"))) + }; + + let description = widget::text::body(fl!("scan-to-connect-description")) + .apply(widget::container) + .center_x(Length::Fill); + + let mut info_items = widget::list_column(); + + info_items = info_items.add(widget::settings::item( + fl!("network-name"), + drawer.ssid.as_ref(), + )); + + if let Some(ref pass) = drawer.password { + info_items = info_items.add(widget::settings::item(fl!("password"), pass.as_str())); + } + + let content = column::column() + .spacing(spacing.space_s) + .push(qr_section) + .push(description) + .push(info_items); + + Some( + cosmic::app::context_drawer( + content + .apply(Element::from) + .map(crate::pages::Message::WiFi), + crate::pages::Message::CloseContextDrawer, + ) + .title(fl!("share")), + ) + } + fn header_view(&self) -> Option> { Some( widget::button::standard(fl!("add-network")) @@ -253,6 +312,12 @@ impl page::Page for Page { Task::none() } + + fn on_context_drawer_close(&mut self) -> Task { + self.qr_drawer = None; + self.qr_code_data = None; + Task::none() + } } impl Page { @@ -354,6 +419,41 @@ impl Page { return update_devices(conn); } + Message::NetworkManager(network_manager::Event::WiFiCredentials { + ssid, + password, + security_type, + }) => { + // Generate QR code data in WiFi format: WIFI:T:;S:;P:;; + // Special characters must be escaped according to WiFi QR code spec + let escaped_ssid = escape_wifi_qr_string(ssid.as_ref()); + let qr_string = if let Some(ref pass) = password { + let security = match security_type { + NetworkType::PSK => "WPA", + NetworkType::EAP => "WPA", + NetworkType::Open => "", + }; + let escaped_password = escape_wifi_qr_string(pass); + format!( + "WIFI:T:{};S:{};P:{};;", + security, escaped_ssid, escaped_password + ) + } else { + format!("WIFI:T:;S:{};;", escaped_ssid) + }; + + self.qr_code_data = widget::qr_code::Data::new(qr_string).ok(); + + self.qr_drawer = Some(QRCodeDrawer { + ssid, + password, + security_type, + }); + + // Open the context drawer + return cosmic::task::message(crate::app::Message::OpenContextDrawer(self.entity)); + } + Message::AddNetwork => { tokio::task::spawn(super::nm_add_wifi()); } @@ -455,6 +555,15 @@ impl Page { } } + Message::QRCodeRequest(ssid) => { + self.view_more_popup = None; + if let Some(nm) = self.nm_state.as_mut() { + _ = nm + .sender + .unbounded_send(network_manager::Request::GetWiFiCredentials(ssid)); + } + } + Message::ViewMore(ssid) => { self.view_more_popup = ssid; if self.view_more_popup.is_none() { @@ -606,6 +715,17 @@ impl Page { } } +/// Escapes special characters in WiFi QR code strings according to the spec +/// Special characters that need escaping: \ ; , : " +/// https://github.com/zxing/zxing/wiki/Barcode-Contents#wi-fi-network-config-android-ios-11 +fn escape_wifi_qr_string(s: &str) -> String { + s.replace('\\', "\\\\") + .replace(';', "\\;") + .replace(',', "\\,") + .replace(':', "\\:") + .replace('"', "\\\"") +} + fn devices_view() -> Section { crate::slab!(descriptions { airplane_mode_txt = fl!("airplane-on"); @@ -617,6 +737,7 @@ fn devices_view() -> Section { known_networks_txt = fl!("known-networks"); no_networks_txt = fl!("no-networks"); settings_txt = fl!("settings"); + share_txt = fl!("share"); visible_networks_txt = fl!("visible-networks"); wifi_txt = fl!("wifi"); }); @@ -742,6 +863,12 @@ fn devices_view() -> Section { Message::Settings(network.ssid.clone()), §ion.descriptions[settings_txt], )) + .push_maybe(is_known.then(|| { + popup_button( + Message::QRCodeRequest(network.ssid.clone()), + §ion.descriptions[share_txt], + ) + })) .push_maybe(is_known.then(|| { popup_button( Message::ForgetRequest(network.ssid.clone()), diff --git a/i18n/en/cosmic_settings.ftl b/i18n/en/cosmic_settings.ftl index 4d89fd6..6aed53b 100644 --- a/i18n/en/cosmic_settings.ftl +++ b/i18n/en/cosmic_settings.ftl @@ -27,12 +27,16 @@ disconnect = Disconnect forget = Forget known-networks = Known networks network-and-wireless = Network & wireless +network-name = Network Name no-networks = No networks have been found. no-vpn = No VPN connections available. password = Password password-confirm = Confirm password +qr-code-unavailable = QR code not available remove = Remove +scan-to-connect-description = Scan the QR code to connect to this network. settings = Settings +share = Share network username = Username visible-networks = Visible networks identity = Identity diff --git a/subscriptions/network-manager/src/lib.rs b/subscriptions/network-manager/src/lib.rs index c5ecb3a..65378dc 100644 --- a/subscriptions/network-manager/src/lib.rs +++ b/subscriptions/network-manager/src/lib.rs @@ -451,6 +451,73 @@ async fn start_listening( .await; } + Some(Request::GetWiFiCredentials(ssid)) => { + let s = match NetworkManagerSettings::new(&conn).await { + Ok(s) => s, + Err(why) => { + tracing::error!(?why, "error getting network manager settings"); + return State::Waiting(conn, rx); + } + }; + + // Determine network type - default to PSK for encrypted networks + let mut security_type = NetworkType::PSK; + + let known_conns = s.list_connections().await.unwrap_or_default(); + for c in known_conns { + let settings = c.get_settings().await.ok().unwrap_or_default(); + let settings_parsed = Settings::new(settings.clone()); + + if let Some(saved_ssid) = settings_parsed + .wifi + .clone() + .and_then(|w| w.ssid) + .and_then(|s| String::from_utf8(s).ok()) + { + if saved_ssid == ssid.as_ref() { + let password = c + .get_secrets("802-11-wireless-security") + .await + .ok() + .and_then(|secrets| { + // Look for PSK password + secrets + .get("802-11-wireless-security") + .and_then(|sec| sec.get("psk")) + .and_then(|v| { + v.downcast_ref::().ok() + }) + .map(|s| s.to_string()) + .or_else(|| { + // Fallback to WEP key + secrets + .get("802-11-wireless-security") + .and_then(|sec| sec.get("wep-key0")) + .and_then(|v| { + v.downcast_ref::().ok() + }) + .map(|s| s.to_string()) + }) + }); + + // If no password found, might be open network + if password.is_none() { + security_type = NetworkType::Open; + } + + _ = output + .send(Event::WiFiCredentials { + ssid: ssid.clone(), + password, + security_type, + }) + .await; + break; + } + } + } + } + None => { return State::Finished; } @@ -540,6 +607,8 @@ pub enum Request { password: SecureString, hw_address: HwAddress, }, + /// Get WiFi credentials for a known access point. + GetWiFiCredentials(SSID), /// Signal to reload the service. Reload, /// Remove a connection profile. @@ -568,6 +637,11 @@ pub enum Event { WiFiEnabled(bool), WirelessAccessPoints, ActiveConns, + WiFiCredentials { + ssid: SSID, + password: Option, + security_type: NetworkType, + }, } #[derive(Debug, Clone, PartialEq, Eq)]