feat(wifi): share public network with QR code

This commit is contained in:
Fred 2025-11-11 16:21:45 +01:00 • committed by GitHub
parent ae3fdd5cf9
commit b9dfcde7b1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 214 additions and 2 deletions

7
Cargo.lock generated
View file

@ -3606,6 +3606,7 @@ dependencies = [
"num-traits", "num-traits",
"once_cell", "once_cell",
"ouroboros", "ouroboros",
"qrcode",
"rustc-hash 2.1.1", "rustc-hash 2.1.1",
"thiserror 1.0.69", "thiserror 1.0.69",
"unicode-segmentation", "unicode-segmentation",
@ -6287,6 +6288,12 @@ dependencies = [
"bytemuck", "bytemuck",
] ]
[[package]]
name = "qrcode"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "166f136dfdb199f98186f3649cf7a0536534a61417a1a30221b492b4fb60ce3f"
[[package]] [[package]]
name = "quick-error" name = "quick-error"
version = "2.0.1" version = "2.0.1"

View file

@ -11,7 +11,7 @@ cosmic-randr = { git = "https://github.com/pop-os/cosmic-randr" }
tokio = { version = "1.48.0", features = ["macros"] } tokio = { version = "1.48.0", features = ["macros"] }
[workspace.dependencies.libcosmic] [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" git = "https://github.com/pop-os/libcosmic"
[workspace.dependencies.cosmic-config] [workspace.dependencies.cosmic-config]

View file

@ -9,6 +9,7 @@ use std::{
use anyhow::Context; use anyhow::Context;
use cosmic::{ use cosmic::{
Apply, Element, Task, Apply, Element, Task,
app::ContextDrawer,
iced::{Alignment, Length}, iced::{Alignment, Length},
iced_core::text::Wrapping, iced_core::text::Wrapping,
iced_widget::focus_next, iced_widget::focus_next,
@ -54,6 +55,8 @@ pub enum Message {
PasswordRequest(network_manager::SSID), PasswordRequest(network_manager::SSID),
/// Update the password from the dialog /// Update the password from the dialog
PasswordUpdate(SecureString), PasswordUpdate(SecureString),
/// Request QR code drawer for sharing WiFi credentials
QRCodeRequest(network_manager::SSID),
/// Selects a device to display connections from /// Selects a device to display connections from
SelectDevice(Arc<network_manager::devices::DeviceInfo>), SelectDevice(Arc<network_manager::devices::DeviceInfo>),
/// Opens settings page for the access point. /// Opens settings page for the access point.
@ -84,7 +87,7 @@ impl From<Message> for crate::pages::Message {
} }
} }
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, PartialEq, Eq)]
enum WiFiDialog { enum WiFiDialog {
Forget(network_manager::SSID), Forget(network_manager::SSID),
Password { 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<String>,
security_type: NetworkType,
}
#[derive(Debug, Default)] #[derive(Debug, Default)]
pub struct Page { pub struct Page {
entity: page::Entity, entity: page::Entity,
@ -111,6 +122,10 @@ pub struct Page {
withheld_devices: Option<Vec<network_manager::devices::DeviceInfo>>, withheld_devices: Option<Vec<network_manager::devices::DeviceInfo>>,
/// Withhold state update if the view more popup is shown. /// Withhold state update if the view more popup is shown.
withheld_state: Option<NetworkManagerState>, withheld_state: Option<NetworkManagerState>,
/// QR code data for WiFi sharing drawer
qr_code_data: Option<widget::qr_code::Data>,
/// QR code context drawer state
qr_drawer: Option<QRCodeDrawer>,
} }
#[derive(Debug)] #[derive(Debug)]
@ -208,6 +223,50 @@ impl page::Page<crate::pages::Message> for Page {
}) })
} }
fn context_drawer(&self) -> Option<ContextDrawer<'_, crate::pages::Message>> {
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<cosmic::Element<'_, crate::pages::Message>> { fn header_view(&self) -> Option<cosmic::Element<'_, crate::pages::Message>> {
Some( Some(
widget::button::standard(fl!("add-network")) widget::button::standard(fl!("add-network"))
@ -253,6 +312,12 @@ impl page::Page<crate::pages::Message> for Page {
Task::none() Task::none()
} }
fn on_context_drawer_close(&mut self) -> Task<crate::pages::Message> {
self.qr_drawer = None;
self.qr_code_data = None;
Task::none()
}
} }
impl Page { impl Page {
@ -354,6 +419,41 @@ impl Page {
return update_devices(conn); return update_devices(conn);
} }
Message::NetworkManager(network_manager::Event::WiFiCredentials {
ssid,
password,
security_type,
}) => {
// Generate QR code data in WiFi format: WIFI:T:<type>;S:<ssid>;P:<password>;;
// 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 => { Message::AddNetwork => {
tokio::task::spawn(super::nm_add_wifi()); 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) => { Message::ViewMore(ssid) => {
self.view_more_popup = ssid; self.view_more_popup = ssid;
if self.view_more_popup.is_none() { 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::pages::Message> { fn devices_view() -> Section<crate::pages::Message> {
crate::slab!(descriptions { crate::slab!(descriptions {
airplane_mode_txt = fl!("airplane-on"); airplane_mode_txt = fl!("airplane-on");
@ -617,6 +737,7 @@ fn devices_view() -> Section<crate::pages::Message> {
known_networks_txt = fl!("known-networks"); known_networks_txt = fl!("known-networks");
no_networks_txt = fl!("no-networks"); no_networks_txt = fl!("no-networks");
settings_txt = fl!("settings"); settings_txt = fl!("settings");
share_txt = fl!("share");
visible_networks_txt = fl!("visible-networks"); visible_networks_txt = fl!("visible-networks");
wifi_txt = fl!("wifi"); wifi_txt = fl!("wifi");
}); });
@ -742,6 +863,12 @@ fn devices_view() -> Section<crate::pages::Message> {
Message::Settings(network.ssid.clone()), Message::Settings(network.ssid.clone()),
&section.descriptions[settings_txt], &section.descriptions[settings_txt],
)) ))
.push_maybe(is_known.then(|| {
popup_button(
Message::QRCodeRequest(network.ssid.clone()),
&section.descriptions[share_txt],
)
}))
.push_maybe(is_known.then(|| { .push_maybe(is_known.then(|| {
popup_button( popup_button(
Message::ForgetRequest(network.ssid.clone()), Message::ForgetRequest(network.ssid.clone()),

View file

@ -27,12 +27,16 @@ disconnect = Disconnect
forget = Forget forget = Forget
known-networks = Known networks known-networks = Known networks
network-and-wireless = Network & wireless network-and-wireless = Network & wireless
network-name = Network Name
no-networks = No networks have been found. no-networks = No networks have been found.
no-vpn = No VPN connections available. no-vpn = No VPN connections available.
password = Password password = Password
password-confirm = Confirm password password-confirm = Confirm password
qr-code-unavailable = QR code not available
remove = Remove remove = Remove
scan-to-connect-description = Scan the QR code to connect to this network.
settings = Settings settings = Settings
share = Share network
username = Username username = Username
visible-networks = Visible networks visible-networks = Visible networks
identity = Identity identity = Identity

View file

@ -451,6 +451,73 @@ async fn start_listening(
.await; .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::<zbus::zvariant::Str>().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::<zbus::zvariant::Str>().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 => { None => {
return State::Finished; return State::Finished;
} }
@ -540,6 +607,8 @@ pub enum Request {
password: SecureString, password: SecureString,
hw_address: HwAddress, hw_address: HwAddress,
}, },
/// Get WiFi credentials for a known access point.
GetWiFiCredentials(SSID),
/// Signal to reload the service. /// Signal to reload the service.
Reload, Reload,
/// Remove a connection profile. /// Remove a connection profile.
@ -568,6 +637,11 @@ pub enum Event {
WiFiEnabled(bool), WiFiEnabled(bool),
WirelessAccessPoints, WirelessAccessPoints,
ActiveConns, ActiveConns,
WiFiCredentials {
ssid: SSID,
password: Option<String>,
security_type: NetworkType,
},
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]