Add Wi-Fi and Bluetooth settings and compact controls

This commit is contained in:
Samuel Onoja committed 2026-10-01 15:28:16 +01:00
1 parent 94afc6eb5c
commit a3431cab5b
21 files changed
+3745 -225

No files matched your search

@@ -0,0 +1,375 @@
//! A caller-owned BlueZ agent. Only the device being paired may ask for input.
use std::fmt;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::sync::oneshot;
use zbus::message::Header;
use zbus::zvariant::OwnedObjectPath;
use zeroize::Zeroizing;
use super::{Client, bluetooth, proxy};
const PATH: &str = "/dev/ferese/Settings/BluetoothAgent";
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum Kind {
Pin,
Passkey,
Confirm(u32),
Authorize,
Service(String),
Display(String),
}
pub(super) fn valid_answer(kind: &Kind, value: &str) -> bool {
match kind {
Kind::Pin => !value.is_empty() && value.chars().count() <= 16,
Kind::Passkey => !value.is_empty() && value.len() <= 6 && value.bytes().all(|b| b.is_ascii_digit()),
Kind::Display(_) => false,
_ => true,
}
}
#[derive(Clone, PartialEq, Eq)]
pub(crate) struct Prompt {
pub id: u64,
pub device: String,
pub kind: Kind,
}
impl fmt::Debug for Prompt {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("BluetoothPairingPrompt")
}
}
#[derive(Default)]
struct Pending {
target: Option<String>,
owner: Option<String>,
serial: u64,
generation: u64,
prompt: Option<Prompt>,
reply: Option<oneshot::Sender<Option<Zeroizing<String>>>>,
}
#[derive(Clone, Default)]
pub(crate) struct Pairing(Arc<Mutex<Pending>>);
#[derive(Debug, zbus::DBusError)]
#[zbus(prefix = "org.bluez.Error")]
enum Error {
Rejected(String),
Canceled(String),
}
impl Pairing {
fn begin(&self, target: &str) -> Result<u64, String> {
let mut state = self.0.lock().unwrap();
if state.target.is_some() {
return Err("Another device is already pairing.".into());
}
state.generation = state.generation.wrapping_add(1);
state.target = Some(target.to_owned());
Ok(state.generation)
}
fn finish(&self, generation: u64) {
let mut state = self.0.lock().unwrap();
if state.generation == generation {
state.target = None;
state.prompt = None;
state.reply = None;
}
}
pub fn cancel_local(&self) -> Option<String> {
let mut state = self.0.lock().unwrap();
state.generation = state.generation.wrapping_add(1);
state.prompt = None;
state.reply = None;
state.target.take()
}
pub fn prompt(&self) -> Option<Prompt> {
self.0.lock().unwrap().prompt.clone()
}
pub fn reply(&self, id: u64, value: Option<Zeroizing<String>>) {
let mut state = self.0.lock().unwrap();
if state.prompt.as_ref().is_some_and(|p| p.id == id) {
if let Some(reply) = state.reply.take() {
let _ = reply.send(value);
}
state.prompt = None;
}
}
fn check(state: &Pending, device: &str, sender: Option<&str>) -> Result<(), Error> {
if state.target.as_deref() != Some(device) || sender.is_none() || state.owner.as_deref() != sender {
Err(Error::Rejected("No matching pairing request".into()))
} else {
Ok(())
}
}
async fn ask(&self, device: OwnedObjectPath, kind: Kind, sender: Option<&str>) -> Result<Zeroizing<String>, Error> {
let (id, rx) = {
let mut state = self.0.lock().unwrap();
Self::check(&state, device.as_str(), sender)?;
if state.reply.is_some() {
return Err(Error::Rejected("A pairing question is already pending".into()));
}
state.serial = state.serial.wrapping_add(1);
let id = state.serial;
let (tx, rx) = oneshot::channel();
state.prompt = Some(Prompt {
id,
device: device.to_string(),
kind,
});
state.reply = Some(tx);
(id, rx)
};
let answer = tokio::time::timeout(Duration::from_secs(90), rx).await;
self.reply(id, None);
answer
.ok()
.and_then(Result::ok)
.flatten()
.ok_or_else(|| Error::Canceled("Pairing canceled or timed out".into()))
}
fn display(&self, device: OwnedObjectPath, text: String, sender: Option<&str>) -> Result<(), Error> {
let mut state = self.0.lock().unwrap();
Self::check(&state, device.as_str(), sender)?;
state.serial = state.serial.wrapping_add(1);
state.prompt = Some(Prompt {
id: state.serial,
device: device.to_string(),
kind: Kind::Display(text),
});
Ok(())
}
fn dismiss(&self, sender: Option<&str>) -> Result<(), Error> {
let mut state = self.0.lock().unwrap();
if sender.is_none() || state.owner.as_deref() != sender {
return Err(Error::Rejected("Unknown sender".into()));
}
state.prompt = None;
state.reply = None;
Ok(())
}
}
#[zbus::interface(name = "org.bluez.Agent1")]
impl Pairing {
async fn request_pin_code(
&self,
device: OwnedObjectPath,
#[zbus(header)] header: Header<'_>,
) -> Result<String, Error> {
let answer = self.ask(device, Kind::Pin, header.sender().map(|s| s.as_str())).await?;
if !valid_answer(&Kind::Pin, &answer) {
return Err(Error::Rejected("PIN must have 1–16 characters".into()));
}
Ok(answer.to_string())
}
async fn request_passkey(&self, device: OwnedObjectPath, #[zbus(header)] header: Header<'_>) -> Result<u32, Error> {
let answer = self
.ask(device, Kind::Passkey, header.sender().map(|s| s.as_str()))
.await?;
if !valid_answer(&Kind::Passkey, &answer) {
return Err(Error::Rejected("Passkey must have up to six digits".into()));
}
answer
.parse::<u32>()
.ok()
.filter(|v| *v <= 999_999)
.ok_or_else(|| Error::Rejected("Invalid passkey".into()))
}
async fn request_confirmation(
&self,
device: OwnedObjectPath,
passkey: u32,
#[zbus(header)] header: Header<'_>,
) -> Result<(), Error> {
self.ask(device, Kind::Confirm(passkey), header.sender().map(|s| s.as_str()))
.await
.map(|_| ())
}
async fn request_authorization(
&self,
device: OwnedObjectPath,
#[zbus(header)] header: Header<'_>,
) -> Result<(), Error> {
self.ask(device, Kind::Authorize, header.sender().map(|s| s.as_str()))
.await
.map(|_| ())
}
async fn authorize_service(
&self,
device: OwnedObjectPath,
uuid: String,
#[zbus(header)] header: Header<'_>,
) -> Result<(), Error> {
self.ask(device, Kind::Service(uuid), header.sender().map(|s| s.as_str()))
.await
.map(|_| ())
}
fn display_pin_code(
&self,
device: OwnedObjectPath,
pincode: String,
#[zbus(header)] header: Header<'_>,
) -> Result<(), Error> {
self.display(
device,
format!("Type {pincode} on the device, then press Enter."),
header.sender().map(|s| s.as_str()),
)
}
fn display_passkey(
&self,
device: OwnedObjectPath,
passkey: u32,
entered: u16,
#[zbus(header)] header: Header<'_>,
) -> Result<(), Error> {
self.display(
device,
format!("Type {passkey:06} on the device, then press Enter. {entered} digits entered."),
header.sender().map(|s| s.as_str()),
)
}
fn cancel(&self, #[zbus(header)] header: Header<'_>) -> Result<(), Error> {
self.dismiss(header.sender().map(|s| s.as_str()))
}
fn release(&self, #[zbus(header)] header: Header<'_>) -> Result<(), Error> {
self.dismiss(header.sender().map(|s| s.as_str()))?;
self.cancel_local();
self.0.lock().unwrap().owner = None;
Ok(())
}
}
impl Client {
async fn register_agent(&self) -> Result<(), String> {
let owner: String = proxy(
&self.connection,
"org.freedesktop.DBus",
"/org/freedesktop/DBus",
"org.freedesktop.DBus",
)
.await?
.call("GetNameOwner", &("org.bluez",))
.await
.map_err(|e| e.to_string())?;
let mut registered = self.registered.lock().await;
if registered.as_ref() == Some(&owner) && self.agent.0.lock().unwrap().owner.as_ref() == Some(&owner) {
return Ok(());
}
self.agent.0.lock().unwrap().owner = Some(owner.clone());
self.connection
.object_server()
.at(PATH, self.agent.clone())
.await
.map_err(|e| e.to_string())?;
let path = OwnedObjectPath::try_from(PATH).unwrap();
proxy(&self.connection, "org.bluez", "/org/bluez", "org.bluez.AgentManager1")
.await?
.call::<_, _, ()>("RegisterAgent", &(path, "KeyboardDisplay"))
.await
.map_err(|e| e.to_string())?;
*registered = Some(owner);
Ok(())
}
pub(crate) async fn pair(&self, path: &str, epoch: u64) -> Result<(), String> {
let generation = self.agent.begin(path)?;
if self.closed.load(std::sync::atomic::Ordering::SeqCst) || self.epoch() != epoch {
self.agent.finish(generation);
return Err("Pairing canceled.".into());
}
let result = async {
self.register_agent().await?;
if self.agent.0.lock().unwrap().generation != generation {
return Err("Pairing canceled.".into());
}
bluetooth::device_call(&self.connection, path, "Pair").await
}
.await;
let still_active = self.agent.0.lock().unwrap().generation == generation;
self.agent.finish(generation);
if !still_active {
return Err("Pairing canceled.".into());
}
result?;
bluetooth::trust(&self.connection, path, true).await?;
bluetooth::device_call(&self.connection, path, "Connect").await
}
pub(crate) async fn cancel_pairing(&self) -> Result<(), String> {
if let Some(path) = self.agent.cancel_local() {
bluetooth::device_call(&self.connection, &path, "CancelPairing").await
} else {
Ok(())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn stale_answers_and_unknown_devices_cannot_approve_pairing() {
let pairing = Pairing::default();
pairing.0.lock().unwrap().owner = Some(":1.2".into());
let generation = pairing.begin("/device/1").unwrap();
let path = OwnedObjectPath::try_from("/device/2").unwrap();
assert!(pairing.ask(path, Kind::Authorize, Some(":1.2")).await.is_err());
let path = OwnedObjectPath::try_from("/device/1").unwrap();
assert!(pairing.ask(path.clone(), Kind::Authorize, Some(":1.9")).await.is_err());
let other = pairing.clone();
let task = tokio::spawn(async move { other.ask(path, Kind::Confirm(123456), Some(":1.2")).await });
tokio::task::yield_now().await;
let id = pairing.prompt().unwrap().id;
pairing.reply(id.wrapping_sub(1), Some(Zeroizing::new(String::new())));
assert!(pairing.prompt().is_some());
pairing.reply(id, Some(Zeroizing::new(String::new())));
assert!(task.await.unwrap().is_ok());
pairing.finish(generation);
assert!(pairing.prompt().is_none());
}
#[tokio::test]
async fn cancel_closes_the_question_and_old_completion_cannot_clear_new_pairing() {
let pairing = Pairing::default();
pairing.0.lock().unwrap().owner = Some(":1.2".into());
let old = pairing.begin("/device/1").unwrap();
let other = pairing.clone();
let task = tokio::spawn(async move {
other
.ask(OwnedObjectPath::try_from("/device/1").unwrap(), Kind::Pin, Some(":1.2"))
.await
});
tokio::task::yield_now().await;
assert!(pairing.prompt().is_some());
pairing.cancel_local();
assert!(task.await.unwrap().is_err());
pairing.begin("/device/2").unwrap();
pairing.finish(old);
assert_eq!(pairing.0.lock().unwrap().target.as_deref(), Some("/device/2"));
}
}
@@ -0,0 +1,250 @@
use std::collections::HashMap;
use std::time::Duration;
use zbus::Connection;
use zbus::fdo::ManagedObjects;
use zbus::zvariant::{OwnedObjectPath, OwnedValue};
use super::{Client, proxy};
const BLUEZ: &str = "org.bluez";
const ADAPTER: &str = "org.bluez.Adapter1";
const DEVICE: &str = "org.bluez.Device1";
#[derive(Clone, Debug)]
pub(crate) struct Adapter {
pub path: String,
pub name: String,
pub powered: bool,
pub discovering: bool,
}
#[derive(Clone, Debug)]
pub(crate) struct Device {
pub path: String,
pub adapter: String,
pub name: String,
pub address: String,
pub icon: String,
pub connected: bool,
pub paired: bool,
pub trusted: bool,
pub battery: Option<u8>,
}
#[derive(Clone, Debug, Default)]
pub(crate) struct Bluetooth {
pub adapters: Vec<Adapter>,
pub devices: Vec<Device>,
}
pub(crate) async fn read(connection: &Connection) -> Result<Bluetooth, String> {
let objects: ManagedObjects = proxy(connection, BLUEZ, "/", "org.freedesktop.DBus.ObjectManager")
.await?
.call("GetManagedObjects", &())
.await
.map_err(|e| e.to_string())?;
Ok(snapshot(&objects))
}
fn string(properties: &HashMap<String, OwnedValue>, key: &str) -> String {
properties
.get(key)
.and_then(|v| <&str>::try_from(v).ok())
.unwrap_or_default()
.to_owned()
}
fn boolean(properties: &HashMap<String, OwnedValue>, key: &str) -> bool {
properties
.get(key)
.and_then(|v| bool::try_from(v).ok())
.unwrap_or(false)
}
fn snapshot(objects: &ManagedObjects) -> Bluetooth {
let mut result = Bluetooth::default();
for (path, interfaces) in objects {
if let Some(p) = interfaces.get(ADAPTER) {
result.adapters.push(Adapter {
path: path.to_string(),
name: string(p, "Alias"),
powered: boolean(p, "Powered"),
discovering: boolean(p, "Discovering"),
});
}
if let Some(p) = interfaces.get(DEVICE) {
let Some(adapter) = p
.get("Adapter")
.and_then(|v| <&zbus::zvariant::ObjectPath>::try_from(v).ok())
else {
continue;
};
let address = string(p, "Address");
let alias = string(p, "Alias");
result.devices.push(Device {
path: path.to_string(),
adapter: adapter.to_string(),
name: if alias.is_empty() { address.clone() } else { alias },
address,
icon: string(p, "Icon"),
connected: boolean(p, "Connected"),
paired: boolean(p, "Paired"),
trusted: boolean(p, "Trusted"),
battery: interfaces
.get("org.bluez.Battery1")
.and_then(|p| p.get("Percentage"))
.and_then(|v| u8::try_from(v).ok()),
});
}
}
result.adapters.sort_by(|a, b| a.path.cmp(&b.path));
result.devices.sort_by(|a, b| {
b.connected
.cmp(&a.connected)
.then(b.paired.cmp(&a.paired))
.then(a.name.cmp(&b.name))
});
result
}
pub(crate) async fn power(connection: &Connection, path: &str, on: bool) -> Result<(), String> {
proxy(connection, BLUEZ, path, ADAPTER)
.await?
.set_property("Powered", on)
.await
.map_err(|e| e.to_string())
}
pub(crate) async fn adapter_call(connection: &Connection, path: &str, method: &str) -> Result<(), String> {
proxy(connection, BLUEZ, path, ADAPTER)
.await?
.call::<_, _, ()>(method, &())
.await
.map_err(|e| e.to_string())
}
pub(crate) async fn device_call(connection: &Connection, path: &str, method: &str) -> Result<(), String> {
proxy(connection, BLUEZ, path, DEVICE)
.await?
.call::<_, _, ()>(method, &())
.await
.map_err(|e| e.to_string())
}
pub(crate) async fn trust(connection: &Connection, path: &str, on: bool) -> Result<(), String> {
proxy(connection, BLUEZ, path, DEVICE)
.await?
.set_property("Trusted", on)
.await
.map_err(|e| e.to_string())
}
pub(crate) async fn forget(connection: &Connection, adapter: &str, path: &str) -> Result<(), String> {
let path = OwnedObjectPath::try_from(path).map_err(|e| e.to_string())?;
proxy(connection, BLUEZ, adapter, ADAPTER)
.await?
.call::<_, _, ()>("RemoveDevice", &(path,))
.await
.map_err(|e| e.to_string())
}
impl Client {
pub(crate) async fn discover(&self, path: String, epoch: u64) -> Result<(), String> {
// BlueZ discovery is reference counted per D-Bus caller. Never stop
// discovery owned by another app, and never acquire a lease twice.
if self.discovery.lock().unwrap().contains_key(&path) {
return Ok(());
}
adapter_call(&self.connection, &path, "StartDiscovery").await?;
if self.closed.load(std::sync::atomic::Ordering::SeqCst) || self.epoch() != epoch {
let _ = adapter_call(&self.connection, &path, "StopDiscovery").await;
return Err("Discovery canceled.".into());
}
let lease = std::time::Instant::now();
self.discovery.lock().unwrap().insert(path.clone(), lease);
let leases = self.discovery.clone();
let connection = self.connection.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(30)).await;
let owned = {
let mut leases = leases.lock().unwrap();
if leases.get(&path) == Some(&lease) {
leases.remove(&path);
true
} else {
false
}
};
if owned {
let _ = adapter_call(&connection, &path, "StopDiscovery").await;
}
});
Ok(())
}
pub(crate) async fn stop_discovery(&self) -> Result<(), String> {
let paths = std::mem::take(&mut *self.discovery.lock().unwrap());
let mut result = Ok(());
for (path, _) in paths {
if let Err(error) = adapter_call(&self.connection, &path, "StopDiscovery").await {
result = Err(error);
}
}
result
}
pub(crate) fn discovering(&self, path: &str) -> bool {
self.discovery.lock().unwrap().contains_key(path)
}
}
#[cfg(test)]
mod tests {
use zbus::zvariant::Str;
use super::*;
#[test]
fn parse_devices_and_keep_adapter_identity() {
let adapter = OwnedObjectPath::try_from("/org/bluez/hci0").unwrap();
let objects = HashMap::from([
(
adapter.clone(),
HashMap::from([(
zbus::names::OwnedInterfaceName::try_from(ADAPTER).unwrap(),
HashMap::from([
("Alias".into(), OwnedValue::from(Str::from("Laptop"))),
("Powered".into(), OwnedValue::from(true)),
]),
)]),
),
(
OwnedObjectPath::try_from("/org/bluez/hci0/dev_1").unwrap(),
HashMap::from([
(
zbus::names::OwnedInterfaceName::try_from(DEVICE).unwrap(),
HashMap::from([
(
"Adapter".into(),
OwnedValue::try_from(zbus::zvariant::Value::from(adapter)).unwrap(),
),
("Alias".into(), OwnedValue::from(Str::from("Headphones"))),
("Connected".into(), OwnedValue::from(true)),
("Paired".into(), OwnedValue::from(true)),
]),
),
(
zbus::names::OwnedInterfaceName::try_from("org.bluez.Battery1").unwrap(),
HashMap::from([("Percentage".into(), OwnedValue::from(80u8))]),
),
]),
),
]);
let snapshot = snapshot(&objects);
assert!(snapshot.adapters[0].powered);
assert!(snapshot.devices[0].connected);
assert_eq!(snapshot.devices[0].adapter, "/org/bluez/hci0");
assert_eq!(snapshot.devices[0].battery, Some(80));
}
}
@@ -0,0 +1,191 @@
use cosmic::Element;
use cosmic::iced::{Alignment, Length};
use cosmic::widget::{column, row};
use super::Command;
use super::ui::{Input, message};
use crate::{App, Message, visuals};
impl App {
pub(super) fn bluetooth_view(
&self,
result: &Result<super::bluetooth::Bluetooth, String>,
) -> Element<'static, Message> {
let s = &self.connections;
let p = visuals::Palette::from_resolved(&self.resolved.presented);
let ready = s.busy.is_none();
let Ok(bluetooth) = result else {
return self.connection_card(
column([])
.spacing(8)
.push(self.label("Bluetooth unavailable", 14.))
.push(self.connection_note(result.as_ref().unwrap_err())),
);
};
let mut body = column([]).spacing(6);
if bluetooth.adapters.len() > 1 {
let mut choices = row([]).spacing(6);
for adapter in &bluetooth.adapters {
choices = choices.push(self.connection_button(
&adapter.name,
Input::Adapter(adapter.path.clone()),
ready,
s.adapter.as_ref() == Some(&adapter.path),
));
}
body = body.push(choices);
}
let Some(adapter) = bluetooth.adapters.iter().find(|a| Some(&a.path) == s.adapter.as_ref()) else {
return body
.push(self.connection_note("No Bluetooth adapter is available."))
.into();
};
body = body.push(
self.connection_card(
row([])
.spacing(8)
.align_y(Alignment::Center)
.push(
column([])
.spacing(2)
.width(Length::Fill)
.push(self.label("Bluetooth", 15.))
.push(self.connection_note(&format!(
"{} · {}",
adapter.name,
if adapter.powered { "On" } else { "Off" }
))),
)
.push(
ferese_theme::controls::switch(adapter.powered, p)
.name("Bluetooth power")
.on_press_maybe(ready.then(|| {
message(Input::Run(Command::BluetoothPower(
adapter.path.clone(),
!adapter.powered,
)))
})),
),
),
);
if !adapter.powered {
return body.into();
}
let scanning = s.client.as_ref().is_some_and(|c| c.discovering(&adapter.path));
body = body.push(
row([])
.align_y(Alignment::Center)
.push(
self.label(
if scanning {
"Searching for devices…"
} else {
"Devices"
},
14.,
)
.width(Length::Fill),
)
.push(self.connection_button(
if scanning { "Stop scan" } else { "Find devices" },
Input::Run(if scanning {
Command::BluetoothStop
} else {
Command::BluetoothScan(adapter.path.clone())
}),
ready,
false,
)),
);
body = body.push(self.connection_note(if adapter.discovering {
"Put your device in pairing mode. Discovery stops automatically after 30 seconds."
} else {
"Choose Find devices to discover nearby devices in pairing mode."
}));
let devices: Vec<_> = bluetooth.devices.iter().filter(|d| d.adapter == adapter.path).collect();
for paired in [true, false] {
body = body.push(self.label(if paired { "Paired devices" } else { "Nearby devices" }, 14.));
if !devices.iter().any(|d| d.paired == paired) {
body = body.push(self.connection_note(if paired {
"No paired devices yet."
} else {
"No nearby devices found."
}));
}
for device in devices.iter().filter(|d| d.paired == paired) {
let state = if device.connected {
"Connected"
} else if device.paired {
"Disconnected"
} else {
"Not paired"
};
let detail = format!(
"{state} · {}{}{}",
device.address,
if device.trusted { " · Trusted" } else { "" },
device.battery.map(|b| format!(" · {b}% battery")).unwrap_or_default()
);
let mut controls = row([]).spacing(6);
let action = if device.connected {
Command::BluetoothDisconnect(device.path.clone())
} else if device.paired {
Command::BluetoothConnect(device.path.clone())
} else {
Command::BluetoothPair(device.path.clone())
};
controls = controls.push(self.connection_button(
if device.connected {
"Disconnect"
} else if device.paired {
"Connect"
} else {
"Pair"
},
Input::Run(action),
ready,
false,
));
if device.paired {
controls = controls.push(self.connection_button(
if device.trusted { "Untrust" } else { "Trust" },
Input::Run(Command::BluetoothTrust(device.path.clone(), !device.trusted)),
ready,
false,
));
controls = controls.push(self.connection_button(
"Forget",
Input::Forget(Command::BluetoothForget(device.adapter.clone(), device.path.clone())),
ready,
false,
));
}
let icon = if device.icon.contains("audio") {
"M3 9v6h4l5 4V5L7 9z M16 8a6 6 0 0 1 0 8"
} else {
"M12 3l6 5-12 8 6 5V3 M6 8l12 8-6 5"
};
body = body.push(
self.connection_card(
row([])
.spacing(8)
.align_y(Alignment::Center)
.push(visuals::action_icon(
icon,
if device.connected { p.accent } else { p.muted },
))
.push(
column([])
.spacing(2)
.width(Length::Fill)
.push(self.label(device.name.clone(), 13.))
.push(self.connection_note(&detail)),
)
.push(controls),
),
);
}
}
body.into()
}
}
@@ -0,0 +1,572 @@
//! Private-bus contracts exercise serialized method/property signatures without hardware.
use std::collections::HashMap;
use std::io::{BufRead, BufReader};
use std::process::{Child, Command, Stdio};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use zbus::Connection;
use zbus::message::Header;
use zbus::zvariant::{OwnedObjectPath, OwnedValue, Str};
use zeroize::Zeroizing;
use super::agent::Kind;
use super::{Client, Command as Action, proxy};
const NM: &str = "org.freedesktop.NetworkManager";
const ROOT: &str = "/org/freedesktop/NetworkManager";
const ADAPTER: &str = "/org/bluez/hci0";
const DEVICE: &str = "/org/bluez/hci0/dev_1";
type Settings = HashMap<String, HashMap<String, OwnedValue>>;
fn path(value: &str) -> OwnedObjectPath {
OwnedObjectPath::try_from(value).unwrap()
}
struct Bus {
child: Child,
address: String,
}
impl Bus {
fn new() -> Self {
let mut child = Command::new("dbus-daemon")
.args(["--session", "--nofork", "--print-address=1"])
.stdout(Stdio::piped())
.spawn()
.unwrap();
let mut address = String::new();
BufReader::new(child.stdout.take().unwrap())
.read_line(&mut address)
.unwrap();
assert!(
!address.trim().is_empty(),
"dbus-daemon did not provide an address; check whether Unix sockets are permitted"
);
Self {
child,
address: address.trim().to_owned(),
}
}
async fn connect(&self) -> Connection {
zbus::connection::Builder::address(self.address.as_str())
.unwrap()
.method_timeout(Duration::from_secs(5))
.build()
.await
.unwrap()
}
}
impl Drop for Bus {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
#[derive(Default)]
struct NetworkState {
enabled: bool,
scans: usize,
activated: bool,
deleted: bool,
updates: usize,
received: Option<Settings>,
}
struct Manager(Arc<Mutex<NetworkState>>);
#[zbus::interface(name = "org.freedesktop.NetworkManager")]
impl Manager {
fn get_devices(&self) -> Vec<OwnedObjectPath> {
vec![path("/device")]
}
#[zbus(property)]
fn wireless_enabled(&self) -> bool {
self.0.lock().unwrap().enabled
}
#[zbus(property)]
fn set_wireless_enabled(&self, enabled: bool) {
self.0.lock().unwrap().enabled = enabled;
}
#[zbus(property)]
fn wireless_hardware_enabled(&self) -> bool {
true
}
fn activate_connection(
&self,
connection: OwnedObjectPath,
device: OwnedObjectPath,
ap: OwnedObjectPath,
) -> OwnedObjectPath {
assert_eq!(connection.as_str(), "/profile");
assert_eq!(device.as_str(), "/device");
assert_eq!(ap.as_str(), "/ap");
self.0.lock().unwrap().activated = true;
path("/active")
}
fn add_and_activate_connection(
&self,
settings: Settings,
device: OwnedObjectPath,
ap: OwnedObjectPath,
) -> (OwnedObjectPath, OwnedObjectPath) {
assert_eq!(device.as_str(), "/device");
assert_eq!(ap.as_str(), "/ap");
let mut state = self.0.lock().unwrap();
state.received = Some(settings);
state.activated = true;
(path("/profile"), path("/active"))
}
}
struct Wireless(Arc<Mutex<NetworkState>>);
#[zbus::interface(name = "org.freedesktop.NetworkManager.Device.Wireless")]
impl Wireless {
fn get_all_access_points(&self) -> Vec<OwnedObjectPath> {
vec![path("/ap")]
}
fn request_scan(&self, options: HashMap<String, OwnedValue>) {
assert!(options.is_empty());
self.0.lock().unwrap().scans += 1;
}
#[zbus(property)]
fn active_access_point(&self) -> OwnedObjectPath {
path(if self.0.lock().unwrap().activated { "/ap" } else { "/" })
}
}
struct NetworkDevice(Arc<Mutex<NetworkState>>);
#[zbus::interface(name = "org.freedesktop.NetworkManager.Device")]
impl NetworkDevice {
#[zbus(property)]
fn device_type(&self) -> u32 {
2
}
#[zbus(property)]
fn interface(&self) -> &str {
"wlan0"
}
#[zbus(property)]
fn state(&self) -> u32 {
if self.0.lock().unwrap().activated { 100 } else { 30 }
}
fn disconnect(&self) {
self.0.lock().unwrap().activated = false;
}
}
struct AccessPoint;
#[zbus::interface(name = "org.freedesktop.NetworkManager.AccessPoint")]
impl AccessPoint {
#[zbus(property)]
fn ssid(&self) -> Vec<u8> {
b"Test network".to_vec()
}
#[zbus(property)]
fn strength(&self) -> u8 {
75
}
#[zbus(property)]
fn flags(&self) -> u32 {
1
}
#[zbus(property)]
fn wpa_flags(&self) -> u32 {
0
}
#[zbus(property)]
fn rsn_flags(&self) -> u32 {
0x100
}
}
struct Profiles(Arc<Mutex<NetworkState>>);
#[zbus::interface(name = "org.freedesktop.NetworkManager.Settings")]
impl Profiles {
fn list_connections(&self) -> Vec<OwnedObjectPath> {
if self.0.lock().unwrap().deleted {
vec![]
} else {
vec![path("/profile")]
}
}
}
struct Profile(Arc<Mutex<NetworkState>>);
#[zbus::interface(name = "org.freedesktop.NetworkManager.Settings.Connection")]
impl Profile {
fn get_settings(&self) -> Settings {
HashMap::from([
(
"connection".into(),
HashMap::from([("id".into(), OwnedValue::from(Str::from("Test network")))]),
),
(
"802-11-wireless".into(),
HashMap::from([(
"ssid".into(),
OwnedValue::try_from(zbus::zvariant::Value::from(b"Test network".as_slice())).unwrap(),
)]),
),
(
"802-11-wireless-security".into(),
HashMap::from([("key-mgmt".into(), OwnedValue::from(Str::from("wpa-psk")))]),
),
])
}
fn update(&self, settings: Settings) {
let mut state = self.0.lock().unwrap();
state.updates += 1;
state.received = Some(settings);
}
fn delete(&self) {
self.0.lock().unwrap().deleted = true;
}
}
struct Active;
#[zbus::interface(name = "org.freedesktop.NetworkManager.Connection.Active")]
impl Active {
#[zbus(property)]
fn state(&self) -> u32 {
2
}
}
async fn network_service(bus: &Bus, state: &Arc<Mutex<NetworkState>>) -> Connection {
let server = bus.connect().await;
server.request_name(NM).await.unwrap();
let objects = server.object_server();
objects.at(ROOT, Manager(state.clone())).await.unwrap();
objects.at("/device", Wireless(state.clone())).await.unwrap();
objects.at("/device", NetworkDevice(state.clone())).await.unwrap();
objects.at("/ap", AccessPoint).await.unwrap();
objects
.at("/org/freedesktop/NetworkManager/Settings", Profiles(state.clone()))
.await
.unwrap();
objects.at("/profile", Profile(state.clone())).await.unwrap();
objects.at("/active", Active).await.unwrap();
server
}
#[tokio::test]
#[ignore = "requires dbus-daemon; uses a private bus, never the system bus"]
async fn wifi_controls_and_missing_bluetooth_are_independent() {
let bus = Bus::new();
let state = Arc::new(Mutex::new(NetworkState::default()));
let _server = network_service(&bus, &state).await;
let client = Client::with_connection(bus.connect().await);
let initial = client.snapshot().await;
assert!(initial.bluetooth.is_err());
assert!(!initial.wifi.unwrap().enabled);
client.execute(Action::WifiPower(true)).await.unwrap();
client.execute(Action::WifiScan).await.unwrap();
let wifi = client.snapshot().await.wifi.unwrap();
assert!(wifi.enabled);
assert_eq!(wifi.networks[0].name, "Test network");
assert_eq!(wifi.networks[0].saved.as_deref(), Some("/profile"));
client
.execute(Action::WifiConnect(
wifi.networks[0].clone(),
Zeroizing::new("updated-password".into()),
))
.await
.unwrap();
{
let state = state.lock().unwrap();
assert_eq!(state.updates, 1);
assert_eq!(state.scans, 1);
assert_eq!(
<&str>::try_from(&state.received.as_ref().unwrap()["802-11-wireless-security"]["psk"]).unwrap(),
"updated-password"
);
}
assert!(client.snapshot().await.wifi.unwrap().networks[0].connected);
client.execute(Action::WifiDisconnect("/device".into())).await.unwrap();
assert!(!client.snapshot().await.wifi.unwrap().networks[0].connected);
let mut unsaved = wifi.networks[0].clone();
unsaved.saved = None;
client
.execute(Action::WifiConnect(unsaved, Zeroizing::new("new-password".into())))
.await
.unwrap();
client.execute(Action::WifiForget("/profile".into())).await.unwrap();
assert!(client.snapshot().await.wifi.unwrap().saved.is_empty());
}
#[derive(Default)]
struct BluetoothState {
powered: bool,
discovering: bool,
paired: bool,
connected: bool,
trusted: bool,
starts: usize,
stops: usize,
removed: bool,
agent: Option<(String, OwnedObjectPath)>,
}
struct Adapter(Arc<Mutex<BluetoothState>>);
#[zbus::interface(name = "org.bluez.Adapter1")]
impl Adapter {
#[zbus(property)]
fn alias(&self) -> &str {
"Laptop"
}
#[zbus(property)]
fn powered(&self) -> bool {
self.0.lock().unwrap().powered
}
#[zbus(property)]
fn set_powered(&self, powered: bool) {
self.0.lock().unwrap().powered = powered;
}
#[zbus(property)]
fn discovering(&self) -> bool {
self.0.lock().unwrap().discovering
}
fn start_discovery(&self) {
let mut s = self.0.lock().unwrap();
s.discovering = true;
s.starts += 1;
}
fn stop_discovery(&self) {
let mut s = self.0.lock().unwrap();
s.discovering = false;
s.stops += 1;
}
fn remove_device(&self, device: OwnedObjectPath) {
assert_eq!(device.as_str(), DEVICE);
self.0.lock().unwrap().removed = true;
}
}
struct BluetoothDevice(Arc<Mutex<BluetoothState>>);
#[zbus::interface(name = "org.bluez.Device1")]
impl BluetoothDevice {
#[zbus(property)]
fn adapter(&self) -> OwnedObjectPath {
path(ADAPTER)
}
#[zbus(property)]
fn alias(&self) -> &str {
"Headphones"
}
#[zbus(property)]
fn address(&self) -> &str {
"AA:BB:CC:DD:EE:FF"
}
#[zbus(property)]
fn icon(&self) -> &str {
"audio-headset"
}
#[zbus(property)]
fn connected(&self) -> bool {
self.0.lock().unwrap().connected
}
#[zbus(property)]
fn paired(&self) -> bool {
self.0.lock().unwrap().paired
}
#[zbus(property)]
fn trusted(&self) -> bool {
self.0.lock().unwrap().trusted
}
#[zbus(property)]
fn set_trusted(&self, trusted: bool) {
self.0.lock().unwrap().trusted = trusted;
}
async fn pair(&self, #[zbus(connection)] connection: &Connection) -> zbus::fdo::Result<()> {
let (owner, agent) = self.0.lock().unwrap().agent.clone().unwrap();
proxy(connection, &owner, agent.as_str(), "org.bluez.Agent1")
.await
.map_err(zbus::fdo::Error::Failed)?
.call::<_, _, ()>("RequestConfirmation", &(path(DEVICE), 123456u32))
.await
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?;
self.0.lock().unwrap().paired = true;
Ok(())
}
fn connect(&self) {
self.0.lock().unwrap().connected = true;
}
fn disconnect(&self) {
self.0.lock().unwrap().connected = false;
}
fn cancel_pairing(&self) {}
}
struct AgentManager(Arc<Mutex<BluetoothState>>);
#[zbus::interface(name = "org.bluez.AgentManager1")]
impl AgentManager {
fn register_agent(&self, agent: OwnedObjectPath, capability: String, #[zbus(header)] header: Header<'_>) {
assert_eq!(capability, "KeyboardDisplay");
self.0.lock().unwrap().agent = Some((header.sender().unwrap().to_string(), agent));
}
}
async fn bluetooth_service(bus: &Bus, state: &Arc<Mutex<BluetoothState>>) -> Connection {
let server = bus.connect().await;
server.request_name("org.bluez").await.unwrap();
let objects = server.object_server();
objects.at("/", zbus::fdo::ObjectManager).await.unwrap();
objects.at("/org/bluez", AgentManager(state.clone())).await.unwrap();
objects.at(ADAPTER, Adapter(state.clone())).await.unwrap();
objects.at(DEVICE, BluetoothDevice(state.clone())).await.unwrap();
server
}
async fn wait_for_prompt(client: &Client) -> super::agent::Prompt {
tokio::time::timeout(Duration::from_secs(3), async {
loop {
if let Some(prompt) = client.prompt() {
break prompt;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.unwrap()
}
#[tokio::test]
#[ignore = "requires dbus-daemon; uses a private bus, never the system bus"]
async fn bluetooth_pairing_controls_and_close_cleanup() {
let bus = Bus::new();
let state = Arc::new(Mutex::new(BluetoothState::default()));
let _server = bluetooth_service(&bus, &state).await;
let client = Client::with_connection(bus.connect().await);
let epoch = client.epoch();
client.leave_now();
assert!(
client
.execute_checked(Action::BluetoothPower(ADAPTER.into(), true), epoch)
.await
.is_err()
);
assert!(!state.lock().unwrap().powered);
client
.execute(Action::BluetoothPower(ADAPTER.into(), true))
.await
.unwrap();
client.execute(Action::BluetoothScan(ADAPTER.into())).await.unwrap();
client.execute(Action::BluetoothScan(ADAPTER.into())).await.unwrap();
assert_eq!(state.lock().unwrap().starts, 1);
let snapshot = client.snapshot().await;
assert!(snapshot.wifi.is_err());
let bluetooth = snapshot.bluetooth.unwrap();
assert!(bluetooth.adapters[0].powered);
assert_eq!(bluetooth.devices[0].name, "Headphones");
let other = client.clone();
let pairing = tokio::spawn(async move { other.execute(Action::BluetoothPair(DEVICE.into())).await });
let prompt = wait_for_prompt(&client).await;
assert_eq!(prompt.kind, Kind::Confirm(123456));
client.reply(prompt.id, Some(Zeroizing::new(String::new())));
pairing.await.unwrap().unwrap();
{
let state = state.lock().unwrap();
assert!(state.paired && state.connected && state.trusted);
}
client
.execute(Action::BluetoothDisconnect(DEVICE.into()))
.await
.unwrap();
client
.execute(Action::BluetoothTrust(DEVICE.into(), false))
.await
.unwrap();
assert!(!state.lock().unwrap().trusted);
client.execute(Action::BluetoothConnect(DEVICE.into())).await.unwrap();
client
.execute(Action::BluetoothForget(ADAPTER.into(), DEVICE.into()))
.await
.unwrap();
assert!(state.lock().unwrap().removed);
let other = client.clone();
let pairing = tokio::spawn(async move { other.execute(Action::BluetoothPair(DEVICE.into())).await });
wait_for_prompt(&client).await;
// Window destruction happens on a UI thread outside Tokio's context.
let closing = client.clone();
std::thread::spawn(move || closing.shutdown()).join().unwrap();
assert!(pairing.await.unwrap().is_err());
assert!(client.execute(Action::BluetoothConnect(DEVICE.into())).await.is_err());
assert!(client.prompt().is_none());
tokio::time::timeout(Duration::from_secs(3), async {
while state.lock().unwrap().discovering {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.unwrap();
assert_eq!(state.lock().unwrap().stops, 1);
}
#[tokio::test]
#[ignore = "requires dbus-daemon; uses a private bus, never the system bus"]
async fn bluetooth_agent_recovers_after_service_restart() {
let bus = Bus::new();
let state = Arc::new(Mutex::new(BluetoothState::default()));
let old_server = bluetooth_service(&bus, &state).await;
let client = Client::with_connection(bus.connect().await);
let other = client.clone();
let pairing = tokio::spawn(async move { other.execute(Action::BluetoothPair(DEVICE.into())).await });
let prompt = wait_for_prompt(&client).await;
client.reply(prompt.id, Some(Zeroizing::new(String::new())));
pairing.await.unwrap().unwrap();
old_server.release_name("org.bluez").await.unwrap();
let new_state = Arc::new(Mutex::new(BluetoothState::default()));
let _server = bluetooth_service(&bus, &new_state).await;
let other = client.clone();
let pairing = tokio::spawn(async move { other.execute(Action::BluetoothPair(DEVICE.into())).await });
let prompt = wait_for_prompt(&client).await;
client.reply(prompt.id, Some(Zeroizing::new(String::new())));
pairing.await.unwrap().unwrap();
assert!(new_state.lock().unwrap().paired);
}
@@ -0,0 +1,322 @@
use std::sync::Arc;
use cosmic::app::Task;
use zeroize::{Zeroize, Zeroizing};
use super::ui::Input;
use super::{Client, Command, Network, Snapshot};
use crate::{App, Message};
impl App {
pub(crate) fn refresh_connections(&mut self) -> Task<Message> {
if self.connections.loading {
return Task::none();
}
self.connections.loading = true;
if let Some(client) = self.connections.client.clone() {
Task::perform(async move { Box::new(client.snapshot().await) }, |snapshot| {
cosmic::Action::App(Message::ConnectionsRefreshed(snapshot))
})
} else {
Task::perform(Client::new(), |client| {
cosmic::Action::App(Message::ConnectionsReady(client))
})
}
}
pub(crate) fn connections_ready(&mut self, result: Result<Arc<Client>, String>) -> Task<Message> {
self.connections.loading = false;
match result {
Ok(client) => {
self.connections.client = Some(client);
self.connections.error = None;
self.refresh_connections()
}
Err(error) => {
self.connections.error = Some(error);
Task::none()
}
}
}
pub(crate) fn connections_refreshed(&mut self, snapshot: Snapshot) {
self.connections.loading = false;
if let Ok(bluetooth) = &snapshot.bluetooth
&& !bluetooth
.adapters
.iter()
.any(|a| Some(&a.path) == self.connections.adapter.as_ref())
{
self.connections.adapter = bluetooth.adapters.first().map(|a| a.path.clone());
}
self.connections.snapshot = Some(snapshot);
}
pub(crate) fn connection_finished(&mut self, result: Result<(), String>) -> Task<Message> {
self.connections.busy = None;
self.connections.error = result.err();
self.connections.prompt = None;
self.connections.pairing_input.zeroize();
self.refresh_connections()
}
pub(crate) fn leave_connections(&mut self) -> Task<Message> {
self.connections.password.zeroize();
self.connections.pairing_input.zeroize();
self.connections.selected = None;
self.connections.hidden = false;
self.connections.prompt = None;
self.connections.forget = None;
if let Some(client) = &self.connections.client {
client.leave_now();
}
Task::none()
}
pub(crate) fn connections_input(&mut self, input: Input) -> Task<Message> {
match input {
Input::Refresh => return self.refresh_connections(),
Input::PollPrompt => {
let prompt = self.connections.client.as_ref().and_then(|c| c.prompt());
if prompt != self.connections.prompt {
self.connections.pairing_input.zeroize();
let needs_input = prompt
.as_ref()
.is_some_and(|p| matches!(p.kind, super::agent::Kind::Pin | super::agent::Kind::Passkey));
self.connections.prompt = prompt;
let scroll = cosmic::iced::widget::scrollable::snap_to(
cosmic::widget::Id::new("settings-content"),
cosmic::iced::widget::scrollable::RelativeOffset { x: None, y: Some(0.) },
);
return if needs_input {
Task::batch([
scroll,
cosmic::widget::text_input::focus(cosmic::widget::Id::new("bluetooth-passkey")),
])
} else {
scroll
};
}
}
Input::Tab(tab) => {
self.connections.tab = tab;
self.connections.password.zeroize();
self.connections.selected = None;
self.connections.hidden = false;
}
Input::Adapter(path) => self.connections.adapter = Some(path),
Input::Password(value) => self.connections.password = value,
Input::HiddenName(name) => self.connections.hidden_name = name,
Input::HiddenSecurity(security) => self.connections.hidden_security = security,
Input::Select(network) => {
self.connections.password.zeroize();
self.connections.hidden = false;
self.connections.selected = Some(network);
self.connections.error = None;
return cosmic::widget::text_input::focus(cosmic::widget::Id::new("wifi-password"));
}
Input::Hidden(hidden) => {
self.connections.hidden = hidden;
self.connections.selected = None;
self.connections.password.zeroize();
self.connections.hidden_name.clear();
}
Input::CancelWifi => {
self.connections.password.zeroize();
self.connections.selected = None;
self.connections.hidden = false;
}
Input::SubmitWifi => {
if let Some(network) = self.wifi_target()
&& self.connections.busy.is_none()
&& network.security.can_create()
&& super::wifi::valid_password(network.security, &self.connections.password)
{
let password = std::mem::replace(&mut self.connections.password, Zeroizing::new(String::new()));
return self.connections_input(Input::Run(Command::WifiConnect(network, password)));
}
}
Input::PairingValue(id, value) => {
if self.connections.prompt.as_ref().is_some_and(|p| p.id == id) {
self.connections.pairing_input = value;
}
}
Input::Answer(id, accepted) => {
if !self.connections.prompt.as_ref().is_some_and(|p| p.id == id) {
return Task::none();
}
if accepted
&& !super::agent::valid_answer(
&self.connections.prompt.as_ref().unwrap().kind,
&self.connections.pairing_input,
)
{
return Task::none();
}
if let Some(client) = &self.connections.client {
let value = accepted
.then(|| std::mem::replace(&mut self.connections.pairing_input, Zeroizing::new(String::new())));
client.reply(id, value);
}
self.connections.pairing_input.zeroize();
self.connections.prompt = None;
}
Input::Forget(command) => {
self.connections.forget = Some(command);
return cosmic::iced::widget::scrollable::snap_to(
cosmic::widget::Id::new("settings-content"),
cosmic::iced::widget::scrollable::RelativeOffset { x: None, y: Some(0.) },
);
}
Input::CancelForget => self.connections.forget = None,
Input::ConfirmForget => {
if let Some(command) = self.connections.forget.take() {
return self.connections_input(Input::Run(command));
}
}
Input::Run(command) => {
if self.connections.busy.is_some() && !matches!(command, Command::CancelPairing) {
return Task::none();
}
let Some(client) = self.connections.client.clone() else {
return Task::none();
};
if matches!(command, Command::CancelPairing) {
// The original Pair task still owns completion and busy state.
return Task::perform(
async move {
let _ = client.cancel_pairing().await;
},
|_| cosmic::Action::App(Message::ConnectionsLeft),
);
}
self.connections.busy = Some(command.label());
self.connections.error = None;
self.connections.selected = None;
self.connections.hidden = false;
let epoch = client.epoch();
return Task::perform(async move { client.execute_checked(command, epoch).await }, |result| {
cosmic::Action::App(Message::ConnectionFinished(result))
});
}
}
Task::none()
}
pub(super) fn wifi_target(&self) -> Option<Network> {
if self.connections.hidden {
let wifi = self.connections.snapshot.as_ref()?.wifi.as_ref().ok()?;
let radio = wifi.radios.first()?;
let name = &self.connections.hidden_name;
if name.is_empty() || name.len() > 32 {
return None;
}
Some(Network {
ssid: name.as_bytes().to_vec(),
name: name.clone(),
security: self.connections.hidden_security,
strength: 0,
device: radio.path.clone(),
ap: "/".into(),
saved: None,
connected: false,
connecting: false,
hidden: true,
})
} else {
self.connections.selected.clone()
}
}
}
#[cfg(test)]
mod tests {
use cosmic::Application;
use super::*;
fn app() -> App {
App::init(
cosmic::app::Core::default(),
(
"/unused/connections-test.kdl".into(),
crate::store::Snapshot::parse(String::new()),
None,
),
)
.0
}
#[test]
fn switching_tabs_and_leaving_clear_credentials_without_config_edits() {
let mut app = app();
let original = app.draft.source.clone();
let _ = app.connections_input(Input::Password(Zeroizing::new("secret".into())));
let _ = app.connections_input(Input::Tab(super::super::Tab::Bluetooth));
assert!(app.connections.password.is_empty());
let _ = app.connections_input(Input::Password(Zeroizing::new("another-secret".into())));
let _ = app.leave_connections();
assert!(app.connections.password.is_empty());
assert!(app.connections.pairing_input.is_empty());
assert!(app.pending.is_empty());
assert_eq!(app.draft.source, original);
}
#[test]
fn stale_pairing_input_and_submit_do_not_touch_the_current_question() {
let mut app = app();
app.connections.prompt = Some(super::super::agent::Prompt {
id: 2,
device: "/device/2".into(),
kind: super::super::agent::Kind::Passkey,
});
app.connections.pairing_input = Zeroizing::new("123456".into());
let _ = app.connections_input(Input::PairingValue(1, Zeroizing::new("old-value".into())));
let _ = app.connections_input(Input::Answer(1, true));
assert_eq!(app.connections.pairing_input.as_str(), "123456");
assert_eq!(app.connections.prompt.as_ref().unwrap().id, 2);
let _ = app.connections_input(Input::PairingValue(2, Zeroizing::new("bad-value".into())));
let _ = app.connections_input(Input::Answer(2, true));
assert!(app.connections.prompt.is_some());
}
#[test]
fn debug_messages_redact_network_and_pairing_secrets() {
let network = Network {
ssid: b"test".to_vec(),
name: "test".into(),
security: super::super::Security::Personal,
strength: 0,
device: "/device".into(),
ap: "/ap".into(),
saved: None,
connected: false,
connecting: false,
hidden: false,
};
for message in [
Input::Run(Command::WifiConnect(network, Zeroizing::new("never-log-this".into()))),
Input::Password(Zeroizing::new("never-log-this".into())),
Input::PairingValue(1, Zeroizing::new("never-log-this".into())),
] {
assert!(!format!("{message:?}").contains("never-log-this"));
}
}
#[test]
fn connection_page_search_and_startup_tab() {
assert!(crate::schema::Page::Connections.matches("bluetooth"));
assert!(crate::schema::Page::Connections.matches("wi-fi"));
let (app, _) = App::init(
cosmic::app::Core::default(),
(
"/unused/connections-test.kdl".into(),
crate::store::Snapshot::parse(String::new()),
Some(super::super::Tab::Bluetooth),
),
);
assert_eq!(app.page, crate::schema::Page::Connections);
assert_eq!(app.connections.tab, super::super::Tab::Bluetooth);
assert!(app.connections.loading);
}
}
@@ -0,0 +1,256 @@
//! Connections are managed by NetworkManager and BlueZ, never by the KDL store.
mod agent;
mod bluetooth;
mod bluetooth_ui;
#[cfg(test)]
mod contracts;
mod controller;
mod pairing_ui;
mod ui;
mod wifi;
mod wifi_ui;
use std::fmt;
use std::sync::Arc;
use std::time::Duration;
pub(crate) use ui::Input;
pub(crate) use wifi::{Network, Security};
use zbus::{Connection, Proxy};
use zeroize::Zeroizing;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) enum Tab {
#[default]
Wifi,
Bluetooth,
}
#[derive(Clone, Debug)]
pub(crate) struct Snapshot {
pub wifi: Result<wifi::Wifi, String>,
pub bluetooth: Result<bluetooth::Bluetooth, String>,
}
pub(crate) struct State {
pub client: Option<Arc<Client>>,
pub snapshot: Option<Snapshot>,
pub tab: Tab,
pub loading: bool,
pub busy: Option<&'static str>,
pub error: Option<String>,
pub selected: Option<Network>,
pub password: Zeroizing<String>,
pub hidden_name: String,
pub hidden: bool,
pub hidden_security: Security,
pub adapter: Option<String>,
pub prompt: Option<agent::Prompt>,
pub pairing_input: Zeroizing<String>,
pub forget: Option<Command>,
}
impl Default for State {
fn default() -> Self {
Self {
client: None,
snapshot: None,
tab: Tab::Wifi,
loading: false,
busy: None,
error: None,
selected: None,
password: Zeroizing::new(String::new()),
hidden_name: String::new(),
hidden: false,
hidden_security: Security::Personal,
adapter: None,
prompt: None,
pairing_input: Zeroizing::new(String::new()),
forget: None,
}
}
}
/// Debug deliberately omits credentials, including messages logged by the UI.
#[derive(Clone)]
pub(crate) enum Command {
WifiPower(bool),
WifiScan,
WifiConnect(Network, Zeroizing<String>),
WifiDisconnect(String),
WifiForget(String),
BluetoothPower(String, bool),
BluetoothScan(String),
BluetoothStop,
BluetoothPair(String),
BluetoothConnect(String),
BluetoothDisconnect(String),
BluetoothTrust(String, bool),
BluetoothForget(String, String),
CancelPairing,
}
impl Command {
pub fn label(&self) -> &'static str {
match self {
Self::WifiPower(_) | Self::BluetoothPower(..) => "Updating radio…",
Self::WifiScan => "Scanning for networks…",
Self::WifiConnect(..) | Self::BluetoothConnect(_) => "Connecting…",
Self::WifiDisconnect(_) | Self::BluetoothDisconnect(_) => "Disconnecting…",
Self::WifiForget(_) | Self::BluetoothForget(..) => "Forgetting…",
Self::BluetoothScan(_) => "Starting discovery…",
Self::BluetoothStop => "Stopping discovery…",
Self::BluetoothPair(_) => "Pairing…",
Self::BluetoothTrust(..) => "Updating device…",
Self::CancelPairing => "Canceling pairing…",
}
}
}
impl fmt::Debug for Command {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.label())
}
}
pub(crate) struct Client {
connection: Connection,
runtime: tokio::runtime::Handle,
epoch: std::sync::atomic::AtomicU64,
closed: std::sync::atomic::AtomicBool,
agent: agent::Pairing,
registered: tokio::sync::Mutex<Option<String>>,
discovery: Arc<std::sync::Mutex<std::collections::BTreeMap<String, std::time::Instant>>>,
}
impl fmt::Debug for Client {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("ConnectionsClient")
}
}
impl Client {
pub async fn new() -> Result<Arc<Self>, String> {
let connection = zbus::connection::Builder::system()
.map_err(|e| e.to_string())?
.method_timeout(Duration::from_secs(120))
.build()
.await
.map_err(|e| e.to_string())?;
Ok(Self::with_connection(connection))
}
fn with_connection(connection: Connection) -> Arc<Self> {
Arc::new(Self {
connection,
runtime: tokio::runtime::Handle::current(),
epoch: std::sync::atomic::AtomicU64::new(0),
closed: std::sync::atomic::AtomicBool::new(false),
agent: agent::Pairing::default(),
registered: tokio::sync::Mutex::new(None),
discovery: Arc::default(),
})
}
pub async fn snapshot(&self) -> Snapshot {
// Isolate missing services: a Bluetooth failure must not hide Wi-Fi.
let (wifi, bluetooth) = tokio::join!(
bounded(wifi::read(&self.connection)),
bounded(bluetooth::read(&self.connection)),
);
Snapshot { wifi, bluetooth }
}
pub fn prompt(&self) -> Option<agent::Prompt> {
self.agent.prompt()
}
pub fn reply(&self, id: u64, value: Option<Zeroizing<String>>) {
self.agent.reply(id, value);
}
pub fn epoch(&self) -> u64 {
self.epoch.load(std::sync::atomic::Ordering::SeqCst)
}
#[cfg(test)]
pub async fn execute(&self, command: Command) -> Result<(), String> {
self.execute_checked(command, self.epoch()).await
}
pub async fn execute_checked(&self, command: Command, epoch: u64) -> Result<(), String> {
if self.closed.load(std::sync::atomic::Ordering::SeqCst) || self.epoch() != epoch {
return Err("This connection request was canceled.".into());
}
match command {
Command::WifiPower(on) => wifi::power(&self.connection, on).await,
Command::WifiScan => wifi::scan(&self.connection).await,
Command::WifiConnect(network, password) => wifi::connect(&self.connection, &network, &password).await,
Command::WifiDisconnect(path) => wifi::disconnect(&self.connection, &path).await,
Command::WifiForget(path) => wifi::forget(&self.connection, &path).await,
Command::BluetoothPower(path, on) => {
if !on {
self.stop_discovery().await?;
}
bluetooth::power(&self.connection, &path, on).await
}
Command::BluetoothScan(path) => self.discover(path, epoch).await,
Command::BluetoothStop => self.stop_discovery().await,
Command::BluetoothPair(path) => self.pair(&path, epoch).await,
Command::BluetoothConnect(path) => bluetooth::device_call(&self.connection, &path, "Connect").await,
Command::BluetoothDisconnect(path) => bluetooth::device_call(&self.connection, &path, "Disconnect").await,
Command::BluetoothTrust(path, on) => bluetooth::trust(&self.connection, &path, on).await,
Command::BluetoothForget(adapter, path) => bluetooth::forget(&self.connection, &adapter, &path).await,
Command::CancelPairing => self.cancel_pairing().await,
}
}
pub fn leave_now(&self) {
self.epoch.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
self.cleanup();
}
pub fn shutdown(&self) {
self.closed.store(true, std::sync::atomic::Ordering::SeqCst);
self.leave_now();
}
fn cleanup(&self) {
let target = self.agent.cancel_local();
let paths = std::mem::take(&mut *self.discovery.lock().unwrap());
let connection = self.connection.clone();
self.runtime.spawn(async move {
if let Some(path) = target {
let _ = bluetooth::device_call(&connection, &path, "CancelPairing").await;
}
for (path, _) in paths {
let _ = bluetooth::adapter_call(&connection, &path, "StopDiscovery").await;
}
});
}
}
impl Drop for Client {
fn drop(&mut self) {
self.shutdown();
}
}
async fn bounded<T>(future: impl std::future::Future<Output = Result<T, String>>) -> Result<T, String> {
tokio::time::timeout(Duration::from_secs(10), future)
.await
.map_err(|_| "The connection service did not respond. Try again.".to_owned())?
}
async fn proxy<'a>(
connection: &'a Connection,
destination: &'a str,
path: &'a str,
interface: &'a str,
) -> Result<Proxy<'a>, String> {
Proxy::new(connection, destination, path, interface)
.await
.map_err(|e| e.to_string())
}
@@ -0,0 +1,67 @@
use cosmic::Element;
use cosmic::widget::{column, row, text_input};
use zeroize::{Zeroize, Zeroizing};
use super::ui::{Input, message};
use super::{Command, agent};
use crate::{App, Message, visuals};
impl App {
pub(super) fn pairing_view(&self, prompt: &agent::Prompt) -> Element<'_, Message> {
let s = &self.connections;
let p = visuals::Palette::from_resolved(&self.resolved.presented);
let device = s
.snapshot
.as_ref()
.and_then(|s| s.bluetooth.as_ref().ok())
.and_then(|b| b.devices.iter().find(|d| d.path == prompt.device))
.map(|d| d.name.as_str())
.unwrap_or("device");
let explanation = match &prompt.kind {
agent::Kind::Pin => "Enter the PIN shown on your device.".into(),
agent::Kind::Passkey => "Enter the six-digit passkey shown on your device.".into(),
agent::Kind::Confirm(key) => format!("Does {key:06} match the number on your device?"),
agent::Kind::Authorize => "Allow this device to pair?".into(),
agent::Kind::Service(uuid) => format!("Allow the device to use service {uuid}?"),
agent::Kind::Display(text) => text.clone(),
};
let mut body = column([])
.spacing(8)
.push(self.label(format!("Pair with {device}"), 15.))
.push(self.connection_note(&explanation));
let id = prompt.id;
let input = matches!(prompt.kind, agent::Kind::Pin | agent::Kind::Passkey);
if input {
body = body.push(
text_input("PIN or passkey", s.pairing_input.as_str())
.id(cosmic::widget::Id::new("bluetooth-passkey"))
.password()
.font(self.font)
.size(13)
.padding([5, 8])
.style(visuals::input_style(p))
.on_input(move |value| message(Input::PairingValue(id, Zeroizing::new(value))))
.on_submit(move |mut value| {
value.zeroize();
message(Input::Answer(id, true))
}),
);
}
let valid = agent::valid_answer(&prompt.kind, &s.pairing_input);
let mut controls = row([]).spacing(8).push(self.connection_button(
"Cancel pairing",
Input::Run(Command::CancelPairing),
true,
false,
));
if !matches!(prompt.kind, agent::Kind::Display(_)) {
controls = controls.push(self.connection_button(
if input { "Submit" } else { "Confirm" },
Input::Answer(id, true),
valid,
true,
));
}
self.connection_card(body.push(controls))
}
}
+190
View File
@@ -0,0 +1,190 @@
use std::fmt;
use cosmic::Element;
use cosmic::iced::{Alignment, Length};
use cosmic::widget::{self, button, column, container, row};
use zeroize::Zeroizing;
use super::{Command, Network, Security, Tab};
use crate::{App, Message, visuals};
#[derive(Clone)]
pub(crate) enum Input {
Refresh,
PollPrompt,
Tab(Tab),
Adapter(String),
Run(Command),
Select(Network),
Password(Zeroizing<String>),
SubmitWifi,
CancelWifi,
Hidden(bool),
HiddenName(String),
HiddenSecurity(Security),
PairingValue(u64, Zeroizing<String>),
Answer(u64, bool),
Forget(Command),
ConfirmForget,
CancelForget,
}
impl fmt::Debug for Input {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("ConnectionsInput")
}
}
pub(super) fn message(input: Input) -> Message {
Message::Connections(input)
}
impl App {
pub(super) fn connection_button(
&self,
label: &str,
input: Input,
enabled: bool,
selected: bool,
) -> Element<'static, Message> {
let p = visuals::Palette::from_resolved(&self.resolved.presented);
let (icon, icon_only) = match &input {
Input::Refresh => (Some("M20 7V3l-3 3a8 8 0 1 0 3 6 M20 3h-5"), true),
Input::Forget(_) | Input::ConfirmForget => (
Some("M4 6h16 M9 6V3h6v3 M6 6l1 15h10l1-15 M10 10v7 M14 10v7"),
matches!(input, Input::Forget(_)),
),
Input::Select(network) if network.saved.is_some() => {
(Some("M8 15a5 5 0 1 1 4-4l9-9 M16 7l3 3 M13 10l3 3"), true)
}
Input::Run(Command::BluetoothTrust(_, on)) => (
Some(if *on {
"M12 3l8 3v6c0 5-8 9-8 9s-8-4-8-9V6z"
} else {
"M12 3l8 3v6c0 5-8 9-8 9s-8-4-8-9V6z M8 12l3 3 5-6"
}),
true,
),
Input::Tab(Tab::Wifi) => (Some(crate::schema::Page::Connections.icon()), false),
Input::Tab(Tab::Bluetooth) => (Some("M12 3l6 5-12 8 6 5V3 M6 8l12 8-6 5"), false),
Input::Run(Command::WifiScan | Command::BluetoothScan(_)) => {
(Some("M10 3a7 7 0 1 0 0 14a7 7 0 0 0 0-14 M15 15l6 6"), false)
}
Input::Run(Command::BluetoothStop) => (Some("M6 6h12v12H6z"), false),
Input::Run(Command::WifiConnect(..) | Command::BluetoothConnect(_) | Command::BluetoothPair(_))
| Input::Select(_)
| Input::SubmitWifi => (Some("M4 12h15 M14 7l5 5-5 5"), false),
Input::Run(Command::WifiDisconnect(_) | Command::BluetoothDisconnect(_)) => {
(Some("M7 7l10 10 M17 7L7 17"), false)
}
Input::Hidden(_) => (Some("M12 5v14 M5 12h14"), false),
Input::Answer(..) => (Some("M5 12l4 4L19 6"), false),
_ => (None, false),
};
let mut content = row([]).spacing(5).align_y(Alignment::Center);
if let Some(icon) = icon {
content = content.push(visuals::action_icon(icon, if selected { p.accent } else { p.text }));
}
if !icon_only {
content = content.push(self.label(label.to_owned(), 12.));
}
let control = button::custom(content)
.name(label.to_owned())
.padding(if icon_only { [4, 4] } else { [4, 8] })
.class(visuals::button_style(p, selected))
.on_press_maybe(enabled.then(|| message(input)));
if icon_only {
widget::tooltip(
control,
self.label(label.to_owned(), 11.),
widget::tooltip::Position::Top,
)
.into()
} else {
control.into()
}
}
pub(super) fn connection_note(&self, text: &str) -> Element<'static, Message> {
let p = visuals::Palette::from_resolved(&self.resolved.presented);
self.label(text.to_owned(), 11.)
.class(cosmic::theme::Text::Color(p.muted))
.into()
}
pub(super) fn connection_card<'a>(&self, body: impl Into<Element<'a, Message>>) -> Element<'a, Message> {
let p = visuals::Palette::from_resolved(&self.resolved.presented);
container(body)
.padding([7, 10])
.width(Length::Fill)
.class(visuals::surface(p.card, p.radius))
.into()
}
pub(crate) fn connections_view(&self) -> Element<'_, Message> {
let s = &self.connections;
let p = visuals::Palette::from_resolved(&self.resolved.presented);
let mut body = column([]).spacing(6);
body = body.push(
row([])
.spacing(6)
.push(self.connection_button("Wi-Fi", Input::Tab(Tab::Wifi), true, s.tab == Tab::Wifi))
.push(self.connection_button("Bluetooth", Input::Tab(Tab::Bluetooth), true, s.tab == Tab::Bluetooth))
.push(widget::Space::new().width(Length::Fill))
.push(self.connection_button("Refresh", Input::Refresh, !s.loading, false)),
);
if let Some(error) = &s.error {
body = body.push(
container(self.label(error.clone(), 12.))
.padding(8)
.width(Length::Fill)
.class(visuals::surface(p.error, 10.)),
);
}
if let Some(busy) = s.busy {
let mut status = row([])
.align_y(Alignment::Center)
.spacing(6)
.push(self.label(busy, 12.));
if busy == "Pairing…" {
status = status.push(self.connection_button("Cancel", Input::Run(Command::CancelPairing), true, false));
}
body = body.push(status);
}
// Pairing stays visible even if the user switches between the two tabs.
if let Some(prompt) = &s.prompt {
body = body.push(self.pairing_view(prompt));
}
if s.forget.is_some() {
body = body.push(
self.connection_card(
column([])
.spacing(6)
.push(self.label("Forget this connection?", 14.))
.push(self.connection_note(
"Saved credentials or pairing keys will be removed. You will need to connect again.",
))
.push(
row([])
.spacing(8)
.push(self.connection_button("Cancel", Input::CancelForget, true, false))
.push(self.connection_button("Forget", Input::ConfirmForget, s.busy.is_none(), false)),
),
),
);
}
if let Some(snapshot) = &s.snapshot {
body = body.push(match s.tab {
Tab::Wifi => self.wifi_view(&snapshot.wifi),
Tab::Bluetooth => self.bluetooth_view(&snapshot.bluetooth),
});
} else {
body = body.push(self.connection_note(if s.loading {
"Loading connection services…"
} else {
"Connection services are unavailable. Use Refresh to try again."
}));
}
body.into()
}
}
@@ -0,0 +1,474 @@
use std::collections::{BTreeMap, HashMap};
use zbus::Connection;
use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value};
use super::proxy;
const NM: &str = "org.freedesktop.NetworkManager";
const ROOT: &str = "/org/freedesktop/NetworkManager";
const DEVICE: &str = "org.freedesktop.NetworkManager.Device";
const WIRELESS: &str = "org.freedesktop.NetworkManager.Device.Wireless";
const AP: &str = "org.freedesktop.NetworkManager.AccessPoint";
const SETTINGS: &str = "org.freedesktop.NetworkManager.Settings";
const PROFILE: &str = "org.freedesktop.NetworkManager.Settings.Connection";
type Settings = HashMap<String, HashMap<String, OwnedValue>>;
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum Security {
Open,
Personal,
Sae,
Owe,
Enterprise,
Unsupported,
}
impl Security {
pub fn label(self) -> &'static str {
match self {
Self::Open => "Open",
Self::Personal => "WPA / WPA2",
Self::Sae => "WPA3",
Self::Owe => "Enhanced open",
Self::Enterprise => "Enterprise",
Self::Unsupported => "Legacy security",
}
}
pub fn password(self) -> bool {
matches!(self, Self::Personal | Self::Sae)
}
pub fn can_create(self) -> bool {
!matches!(self, Self::Enterprise | Self::Unsupported)
}
}
#[derive(Clone, Debug)]
pub(crate) struct Network {
pub ssid: Vec<u8>,
pub name: String,
pub security: Security,
pub strength: u8,
pub device: String,
pub ap: String,
pub saved: Option<String>,
pub connected: bool,
pub connecting: bool,
pub hidden: bool,
}
#[derive(Clone, Debug)]
pub(crate) struct Radio {
pub path: String,
pub name: String,
}
#[derive(Clone, Debug, Default)]
pub(crate) struct Wifi {
pub enabled: bool,
pub hardware_enabled: bool,
pub radios: Vec<Radio>,
pub networks: Vec<Network>,
pub saved: Vec<Saved>,
}
#[derive(Clone, Debug)]
pub(crate) struct Saved {
pub path: String,
pub name: String,
pub ssid: Vec<u8>,
pub security: Security,
}
fn security(flags: u32, wpa: u32, rsn: u32) -> Security {
let auth = wpa | rsn;
if auth & (0x200 | 0x2000) != 0 {
Security::Enterprise
} else if auth & 0x100 != 0 {
Security::Personal
} else if auth & 0x400 != 0 {
Security::Sae
} else if auth & (0x800 | 0x1000) != 0 {
Security::Owe
} else if flags & 1 != 0 {
Security::Unsupported
} else {
Security::Open
}
}
pub(crate) async fn read(connection: &Connection) -> Result<Wifi, String> {
let root = proxy(connection, NM, ROOT, NM).await?;
let enabled = root.get_property("WirelessEnabled").await.map_err(|e| e.to_string())?;
let hardware_enabled = root
.get_property("WirelessHardwareEnabled")
.await
.map_err(|e| e.to_string())?;
let devices: Vec<OwnedObjectPath> = root.call("GetDevices", &()).await.map_err(|e| e.to_string())?;
let saved = profiles(connection).await?;
let mut result = Wifi {
enabled,
hardware_enabled,
saved,
..Wifi::default()
};
let mut networks = BTreeMap::new();
for path in devices {
let device = proxy(connection, NM, path.as_str(), DEVICE).await?;
if device.get_property::<u32>("DeviceType").await.unwrap_or(0) != 2 {
continue;
}
let name: String = device.get_property("Interface").await.map_err(|e| e.to_string())?;
result.radios.push(Radio {
path: path.to_string(),
name,
});
if !enabled {
continue;
}
let state: u32 = device.get_property("State").await.map_err(|e| e.to_string())?;
let wireless = proxy(connection, NM, path.as_str(), WIRELESS).await?;
let active: OwnedObjectPath = wireless
.get_property("ActiveAccessPoint")
.await
.map_err(|e| e.to_string())?;
let aps: Vec<OwnedObjectPath> = wireless
.call("GetAllAccessPoints", &())
.await
.map_err(|e| e.to_string())?;
for ap_path in aps {
let ap = proxy(connection, NM, ap_path.as_str(), AP).await?;
let ssid: Vec<u8> = ap.get_property("Ssid").await.map_err(|e| e.to_string())?;
if ssid.is_empty() {
continue;
}
let strength = ap.get_property("Strength").await.map_err(|e| e.to_string())?;
let flags: u32 = ap.get_property("Flags").await.map_err(|e| e.to_string())?;
let wpa: u32 = ap.get_property("WpaFlags").await.map_err(|e| e.to_string())?;
let rsn: u32 = ap.get_property("RsnFlags").await.map_err(|e| e.to_string())?;
let auth = security(flags, wpa, rsn);
let saved = result
.saved
.iter()
.find(|s| s.ssid == ssid && compatible(s.security, auth, wpa | rsn));
let network = Network {
name: String::from_utf8_lossy(&ssid).into_owned(),
saved: saved.map(|s| s.path.clone()),
ssid,
security: auth,
strength,
device: path.to_string(),
ap: ap_path.to_string(),
connected: active == ap_path && state == 100,
connecting: active == ap_path && (40..100).contains(&state),
hidden: false,
};
insert_network(&mut networks, network);
}
}
result.networks = networks.into_values().collect();
result.networks.sort_by(|a, b| {
b.connected
.cmp(&a.connected)
.then(b.connecting.cmp(&a.connecting))
.then(b.strength.cmp(&a.strength))
.then(a.name.cmp(&b.name))
});
Ok(result)
}
fn compatible(saved: Security, advertised: Security, auth: u32) -> bool {
saved == advertised
|| saved == Security::Personal && advertised == Security::Sae
|| saved == Security::Sae && advertised == Security::Personal && auth & 0x400 != 0
}
fn insert_network(networks: &mut BTreeMap<(Vec<u8>, Security, String), Network>, network: Network) {
// Keep adapters separate, and prefer the active AP over a stronger same-SSID AP.
let key = (network.ssid.clone(), network.security, network.device.clone());
if networks.get(&key).is_none_or(|old| {
(network.connected, network.connecting, network.strength) > (old.connected, old.connecting, old.strength)
}) {
networks.insert(key, network);
}
}
async fn profiles(connection: &Connection) -> Result<Vec<Saved>, String> {
let settings = proxy(connection, NM, "/org/freedesktop/NetworkManager/Settings", SETTINGS).await?;
let paths: Vec<OwnedObjectPath> = settings.call("ListConnections", &()).await.map_err(|e| e.to_string())?;
let mut saved = Vec::new();
for path in paths {
let profile = proxy(connection, NM, path.as_str(), PROFILE).await?;
let Ok(values) = profile.call::<_, _, Settings>("GetSettings", &()).await else {
continue;
};
let Some(wireless) = values.get("802-11-wireless") else {
continue;
};
let Some(ssid) = wireless
.get("ssid")
.and_then(|v| Vec::<u8>::try_from(v.try_clone().ok()?).ok())
else {
continue;
};
let key = values
.get("802-11-wireless-security")
.and_then(|s| s.get("key-mgmt"))
.and_then(|v| <&str>::try_from(v).ok());
let security = match key {
None => Security::Open,
Some("wpa-psk") => Security::Personal,
Some("sae") => Security::Sae,
Some("owe") => Security::Owe,
Some("wpa-eap" | "wpa-eap-suite-b-192" | "ieee8021x") => Security::Enterprise,
_ => Security::Unsupported,
};
let name = values
.get("connection")
.and_then(|s| s.get("id"))
.and_then(|v| <&str>::try_from(v).ok())
.map(str::to_owned)
.unwrap_or_else(|| String::from_utf8_lossy(&ssid).into_owned());
saved.push(Saved {
path: path.to_string(),
name,
ssid,
security,
});
}
saved.sort_by(|a, b| a.name.cmp(&b.name));
Ok(saved)
}
pub(crate) async fn power(connection: &Connection, enabled: bool) -> Result<(), String> {
proxy(connection, NM, ROOT, NM)
.await?
.set_property("WirelessEnabled", enabled)
.await
.map_err(|e| e.to_string())
}
pub(crate) async fn scan(connection: &Connection) -> Result<(), String> {
let root = proxy(connection, NM, ROOT, NM).await?;
let devices: Vec<OwnedObjectPath> = root.call("GetDevices", &()).await.map_err(|e| e.to_string())?;
let mut found = false;
for path in devices {
let device = proxy(connection, NM, path.as_str(), DEVICE).await?;
if device.get_property::<u32>("DeviceType").await.unwrap_or(0) != 2 {
continue;
}
found = true;
proxy(connection, NM, path.as_str(), WIRELESS)
.await?
.call::<_, _, ()>("RequestScan", &(HashMap::<String, OwnedValue>::new(),))
.await
.map_err(|e| e.to_string())?;
}
if found {
Ok(())
} else {
Err("No Wi-Fi adapter is available.".into())
}
}
pub(crate) fn valid_password(security: Security, password: &str) -> bool {
match security {
Security::Personal => {
(8..=63).contains(&password.len())
|| (password.len() == 64 && password.bytes().all(|b| b.is_ascii_hexdigit()))
}
Security::Sae => !password.is_empty(),
_ => true,
}
}
fn settings<'a>(
network: &'a Network,
password: &'a str,
) -> Result<HashMap<&'static str, HashMap<&'static str, Value<'a>>>, String> {
if !network.security.can_create() {
return Err(
"This network needs an existing NetworkManager profile with its enterprise or legacy security settings."
.into(),
);
}
if !valid_password(network.security, password) {
return Err("WPA passwords need 8–63 characters, or 64 hexadecimal digits.".into());
}
let mut settings = HashMap::from([
(
"connection",
HashMap::from([
("id", Value::from(network.name.as_str())),
("type", Value::from("802-11-wireless")),
]),
),
(
"802-11-wireless",
HashMap::from([
("ssid", Value::from(network.ssid.as_slice())),
("hidden", Value::from(network.hidden)),
]),
),
("ipv4", HashMap::from([("method", Value::from("auto"))])),
("ipv6", HashMap::from([("method", Value::from("auto"))])),
]);
let key = match network.security {
Security::Personal => Some("wpa-psk"),
Security::Sae => Some("sae"),
Security::Owe => Some("owe"),
_ => None,
};
if let Some(key) = key {
let mut security = HashMap::from([("key-mgmt", Value::from(key))]);
if network.security.password() {
security.insert("psk", Value::from(password));
}
settings.insert("802-11-wireless-security", security);
}
Ok(settings)
}
pub(crate) async fn connect(connection: &Connection, network: &Network, password: &str) -> Result<(), String> {
let root = proxy(connection, NM, ROOT, NM).await?;
let device = OwnedObjectPath::try_from(network.device.as_str()).map_err(|e| e.to_string())?;
let ap = OwnedObjectPath::try_from(network.ap.as_str()).map_err(|e| e.to_string())?;
let active: OwnedObjectPath = if let Some(saved) = &network.saved {
if !password.is_empty() && network.security.password() {
if !valid_password(network.security, password) {
return Err("Invalid network password.".into());
}
let profile = proxy(connection, NM, saved, PROFILE).await?;
let previous: Settings = profile.call("GetSettings", &()).await.map_err(|e| e.to_string())?;
let mut values: HashMap<String, HashMap<String, Value<'_>>> = previous
.into_iter()
.map(|(section, values)| {
(
section,
values
.into_iter()
.map(|(key, value)| (key, Value::from(value)))
.collect(),
)
})
.collect();
let security = values.entry("802-11-wireless-security".into()).or_default();
security.insert("psk".into(), Value::from(password));
security.insert("psk-flags".into(), Value::from(0u32));
profile
.call::<_, _, ()>("Update", &(values,))
.await
.map_err(|e| e.to_string())?;
}
let saved = OwnedObjectPath::try_from(saved.as_str()).map_err(|e| e.to_string())?;
root.call("ActivateConnection", &(saved, &device, &ap))
.await
.map_err(|e| e.to_string())?
} else {
let values = settings(network, password)?;
let (_, active): (OwnedObjectPath, OwnedObjectPath) = root
.call("AddAndActivateConnection", &(values, &device, &ap))
.await
.map_err(|e| e.to_string())?;
active
};
// Activation's reply only means accepted. Wait for its actual result.
let active = proxy(
connection,
NM,
active.as_str(),
"org.freedesktop.NetworkManager.Connection.Active",
)
.await?;
for _ in 0..90 {
match active.get_property::<u32>("State").await.map_err(|e| e.to_string())? {
2 => return Ok(()),
3 | 4 => return Err("The network connection failed. Check the password and try again.".into()),
_ => tokio::time::sleep(std::time::Duration::from_secs(1)).await,
}
}
Err("The network did not finish connecting. Check its status before trying again.".into())
}
pub(crate) async fn disconnect(connection: &Connection, device: &str) -> Result<(), String> {
proxy(connection, NM, device, DEVICE)
.await?
.call::<_, _, ()>("Disconnect", &())
.await
.map_err(|e| e.to_string())
}
pub(crate) async fn forget(connection: &Connection, profile: &str) -> Result<(), String> {
proxy(connection, NM, profile, PROFILE)
.await?
.call::<_, _, ()>("Delete", &())
.await
.map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn network() -> Network {
Network {
ssid: b"test".to_vec(),
name: "test".into(),
security: Security::Personal,
strength: 10,
device: "/device/1".into(),
ap: "/ap/1".into(),
saved: None,
connected: false,
connecting: false,
hidden: false,
}
}
#[test]
fn security_and_credentials() {
assert_eq!(security(0, 0, 0), Security::Open);
assert_eq!(security(1, 0x100, 0x500), Security::Personal);
assert_eq!(security(1, 0, 0x400), Security::Sae);
assert_eq!(security(1, 0, 0x200), Security::Enterprise);
assert_eq!(security(0, 0, 0x800), Security::Owe);
assert!(!valid_password(Security::Personal, "short"));
assert!(valid_password(Security::Personal, &"a".repeat(64)));
assert!(!valid_password(Security::Personal, &"z".repeat(64)));
assert!(valid_password(Security::Sae, "short"));
assert!(compatible(Security::Sae, Security::Personal, 0x500));
assert!(!compatible(Security::Sae, Security::Personal, 0x100));
assert!(compatible(Security::Personal, Security::Sae, 0x400));
assert_eq!(security(1, 0, 0x2000), Security::Enterprise);
}
#[test]
fn active_ap_wins_deduplication() {
let mut networks = BTreeMap::new();
let mut active = network();
active.connected = true;
insert_network(&mut networks, active);
let mut strong = network();
strong.strength = 99;
insert_network(&mut networks, strong.clone());
assert!(networks.values().next().unwrap().connected);
strong.security = Security::Open;
insert_network(&mut networks, strong);
assert_eq!(networks.len(), 2);
}
#[test]
fn secrets_only_go_to_secure_profiles() {
let mut network = network();
network.hidden = true;
let values = settings(&network, "password").unwrap();
assert_eq!(values["802-11-wireless-security"]["psk"], Value::from("password"));
assert_eq!(values["802-11-wireless"]["hidden"], Value::from(true));
network.security = Security::Open;
assert!(!settings(&network, "").unwrap().contains_key("802-11-wireless-security"));
network.security = Security::Enterprise;
assert!(settings(&network, "password").is_err());
}
}
@@ -0,0 +1,249 @@
use cosmic::Element;
use cosmic::iced::{Alignment, Length};
use cosmic::widget::{column, row, text_input};
use zeroize::{Zeroize, Zeroizing};
use super::ui::{Input, message};
use super::{Command, Security};
use crate::{App, Message, visuals};
impl App {
pub(super) fn wifi_view(&self, result: &Result<super::wifi::Wifi, String>) -> Element<'_, Message> {
let s = &self.connections;
let p = visuals::Palette::from_resolved(&self.resolved.presented);
let ready = s.busy.is_none();
let Ok(wifi) = result else {
return self.connection_card(
column([])
.spacing(8)
.push(self.label("Wi-Fi unavailable", 14.))
.push(self.connection_note(result.as_ref().unwrap_err())),
);
};
let mut body = column([]).spacing(6);
let status = if wifi.radios.is_empty() {
"No Wi-Fi adapter"
} else if !wifi.hardware_enabled {
"Blocked by a hardware switch or airplane mode"
} else if wifi.enabled {
"On"
} else {
"Off"
};
body = body.push(
self.connection_card(
row([])
.spacing(8)
.align_y(Alignment::Center)
.push(
column([])
.spacing(2)
.width(Length::Fill)
.push(self.label("Wi-Fi", 15.))
.push(self.connection_note(status)),
)
.push(
ferese_theme::controls::switch(wifi.enabled, p)
.name("Wi-Fi power")
.on_press_maybe(
(ready && (wifi.hardware_enabled || wifi.enabled) && !wifi.radios.is_empty())
.then(|| message(Input::Run(Command::WifiPower(!wifi.enabled)))),
),
),
),
);
if !wifi.enabled || !wifi.hardware_enabled || wifi.radios.is_empty() {
return body.into();
}
body = body.push(
row([])
.align_y(Alignment::Center)
.push(self.label("Networks", 14.).width(Length::Fill))
.push(self.connection_button("Scan", Input::Run(Command::WifiScan), ready, false)),
);
if wifi.networks.is_empty() {
body = body.push(self.connection_note("No networks found. Scan to look for nearby networks."));
}
for network in &wifi.networks {
let radio = wifi
.radios
.iter()
.find(|r| r.path == network.device)
.map(|r| r.name.as_str())
.unwrap_or("");
let state = if network.connected {
"Connected · "
} else if network.connecting {
"Connecting · "
} else if network.saved.is_some() {
"Saved · "
} else {
""
};
let detail = format!(
"{state}{} · {}% signal{}",
network.security.label(),
network.strength,
if wifi.radios.len() > 1 {
format!(" · {radio}")
} else {
String::new()
}
);
let mut controls = row([]).spacing(6);
if network.connected || network.connecting {
controls = controls.push(self.connection_button(
"Disconnect",
Input::Run(Command::WifiDisconnect(network.device.clone())),
ready,
false,
));
} else if network.saved.is_some() || !network.security.password() && network.security.can_create() {
controls = controls.push(self.connection_button(
"Connect",
Input::Run(Command::WifiConnect(network.clone(), Zeroizing::new(String::new()))),
ready,
false,
));
} else {
controls =
controls.push(self.connection_button("Connect…", Input::Select(network.clone()), ready, false));
}
if network.saved.is_some() && network.security.password() {
controls =
controls.push(self.connection_button("Password…", Input::Select(network.clone()), ready, false));
}
body = body.push(
self.connection_card(
row([])
.spacing(8)
.align_y(Alignment::Center)
.push(visuals::action_icon(
super::super::schema::Page::Connections.icon(),
p.accent,
))
.push(
column([])
.spacing(2)
.width(Length::Fill)
.push(self.label(network.name.clone(), 13.))
.push(self.connection_note(&detail)),
)
.push(controls),
),
);
if s.selected.as_ref().is_some_and(|selected| {
selected.ssid == network.ssid
&& selected.device == network.device
&& selected.security == network.security
}) {
body = body.push(self.wifi_credentials());
}
}
body = body.push(self.connection_button("Join a hidden network…", Input::Hidden(true), ready, false));
if s.hidden {
body = body.push(self.wifi_credentials());
}
if !wifi.saved.is_empty() {
body = body.push(self.label("Saved networks", 14.));
for saved in &wifi.saved {
body = body.push(
self.connection_card(
row([])
.spacing(8)
.align_y(Alignment::Center)
.push(visuals::action_icon(crate::schema::Page::Connections.icon(), p.muted))
.push(
column([])
.spacing(2)
.width(Length::Fill)
.push(self.label(saved.name.clone(), 13.))
.push(self.connection_note(saved.security.label())),
)
.push(self.connection_button(
"Forget",
Input::Forget(Command::WifiForget(saved.path.clone())),
ready,
false,
)),
),
);
}
}
body.into()
}
pub(super) fn wifi_credentials(&self) -> Element<'_, Message> {
let s = &self.connections;
let p = visuals::Palette::from_resolved(&self.resolved.presented);
let target = self.wifi_target();
let mut body = column([]).spacing(6).push(self.label(
if s.hidden {
"Hidden network".into()
} else {
format!(
"Join {}",
s.selected.as_ref().map(|n| n.name.as_str()).unwrap_or("network")
)
},
16.,
));
if s.hidden {
body = body.push(
text_input("Network name (SSID)", s.hidden_name.clone())
.font(self.font)
.size(13)
.padding([5, 8])
.style(visuals::input_style(p))
.on_input(|name| message(Input::HiddenName(name))),
);
let mut choices = row([]).spacing(6);
for security in [Security::Personal, Security::Sae, Security::Open, Security::Owe] {
choices = choices.push(self.connection_button(
security.label(),
Input::HiddenSecurity(security),
true,
s.hidden_security == security,
));
}
body = body.push(choices);
}
let security = target.as_ref().map(|n| n.security).unwrap_or(s.hidden_security);
if security.password() {
body = body.push(
text_input("Network password", s.password.as_str())
.id(cosmic::widget::Id::new("wifi-password"))
.password()
.font(self.font)
.size(13)
.padding([5, 8])
.style(visuals::input_style(p))
.on_input(|value| message(Input::Password(Zeroizing::new(value))))
.on_submit(|mut value| {
value.zeroize();
message(Input::SubmitWifi)
}),
);
}
if !security.can_create() {
body = body.push(self.connection_note(
"Enterprise and legacy networks need a saved NetworkManager profile with their security settings.",
));
}
body = body.push(
row([])
.spacing(8)
.push(self.connection_button("Cancel", Input::CancelWifi, true, false))
.push(self.connection_button(
"Connect",
Input::SubmitWifi,
target.is_some()
&& security.can_create()
&& super::wifi::valid_password(security, &s.password)
&& s.busy.is_none(),
true,
)),
);
self.connection_card(body)
}
}