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",
"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"

View file

@ -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]

View file

@ -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<network_manager::devices::DeviceInfo>),
/// 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 {
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<String>,
security_type: NetworkType,
}
#[derive(Debug, Default)]
pub struct Page {
entity: page::Entity,
@ -111,6 +122,10 @@ pub struct Page {
withheld_devices: Option<Vec<network_manager::devices::DeviceInfo>>,
/// Withhold state update if the view more popup is shown.
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)]
@ -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>> {
Some(
widget::button::standard(fl!("add-network"))
@ -253,6 +312,12 @@ impl page::Page<crate::pages::Message> for Page {
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 {
@ -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:<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 => {
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::pages::Message> {
crate::slab!(descriptions {
airplane_mode_txt = fl!("airplane-on");
@ -617,6 +737,7 @@ fn devices_view() -> Section<crate::pages::Message> {
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<crate::pages::Message> {
Message::Settings(network.ssid.clone()),
&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(|| {
popup_button(
Message::ForgetRequest(network.ssid.clone()),

View file

@ -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

View file

@ -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::<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 => {
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<String>,
security_type: NetworkType,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]