Add Wi-Fi and Bluetooth settings and compact controls
This commit is contained in:
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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user