From a3431cab5ba76cb21844c23749b6381a62394484 Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Thu, 1 Oct 2026 15:28:16 +0100 Subject: [PATCH] Add Wi-Fi and Bluetooth settings and compact controls --- Cargo.lock | 2 + crates/ferese-theme/src/controls.rs | 44 +- docs/configuration.md | 16 + shell/ferese-settings/Cargo.toml | 2 + .../ferese-settings/src/connections/agent.rs | 375 ++++++++++++ .../src/connections/bluetooth.rs | 250 ++++++++ .../src/connections/bluetooth_ui.rs | 191 ++++++ .../src/connections/contracts.rs | 572 ++++++++++++++++++ .../src/connections/controller.rs | 322 ++++++++++ shell/ferese-settings/src/connections/mod.rs | 256 ++++++++ .../src/connections/pairing_ui.rs | 67 ++ shell/ferese-settings/src/connections/ui.rs | 190 ++++++ shell/ferese-settings/src/connections/wifi.rs | 474 +++++++++++++++ .../src/connections/wifi_ui.rs | 249 ++++++++ shell/ferese-settings/src/displays.rs | 245 ++++++++ shell/ferese-settings/src/displays_ui.rs | 173 ++++++ shell/ferese-settings/src/form_controls.rs | 147 +++-- shell/ferese-settings/src/main.rs | 136 ++++- shell/ferese-settings/src/pages.rs | 165 +++-- shell/ferese-settings/src/schema.rs | 10 +- shell/ferese-settings/src/theme_controls.rs | 84 +-- 21 files changed, 3745 insertions(+), 225 deletions(-) create mode 100644 shell/ferese-settings/src/connections/agent.rs create mode 100644 shell/ferese-settings/src/connections/bluetooth.rs create mode 100644 shell/ferese-settings/src/connections/bluetooth_ui.rs create mode 100644 shell/ferese-settings/src/connections/contracts.rs create mode 100644 shell/ferese-settings/src/connections/controller.rs create mode 100644 shell/ferese-settings/src/connections/mod.rs create mode 100644 shell/ferese-settings/src/connections/pairing_ui.rs create mode 100644 shell/ferese-settings/src/connections/ui.rs create mode 100644 shell/ferese-settings/src/connections/wifi.rs create mode 100644 shell/ferese-settings/src/connections/wifi_ui.rs create mode 100644 shell/ferese-settings/src/displays_ui.rs diff --git a/Cargo.lock b/Cargo.lock index c5d764e..7555f0c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1772,6 +1772,8 @@ dependencies = [ "shlex 1.3.0", "tempfile", "tokio", + "zbus", + "zeroize", ] [[package]] diff --git a/crates/ferese-theme/src/controls.rs b/crates/ferese-theme/src/controls.rs index 788722d..26b0e76 100644 --- a/crates/ferese-theme/src/controls.rs +++ b/crates/ferese-theme/src/controls.rs @@ -70,11 +70,15 @@ pub fn navigation_style(p: Palette, selected: bool) -> theme::Button { pub fn settings_input(p: Palette) -> theme::TextInput { let appearance = move |focused: bool, hovered: bool| cosmic::widget::text_input::Appearance { - background: mix(p.sidebar, p.card, if hovered { 0.65 } else { 0.4 }).into(), + background: mix(p.card, p.text, if hovered { 0.06 } else { 0.035 }).into(), border_radius: p.radius.min(7.).into(), - border_width: if focused { 1. } else { 0. }, + border_width: 1., border_offset: None, - border_color: p.accent, + border_color: if focused { + p.accent + } else { + mix(p.card, p.text, if hovered { 0.26 } else { 0.18 }) + }, icon_color: Some(p.muted), text_color: Some(p.text), placeholder_color: p.muted, @@ -91,16 +95,36 @@ pub fn settings_input(p: Palette) -> theme::TextInput { } } +/// Give transparent native selectors the same resting surface as other controls. +pub fn select<'a, M: 'a>( + content: impl Into>, + p: Palette, +) -> container::Container<'a, M, cosmic::Theme> { + widget::container(content).class(theme::Container::custom(move |_| container::Style { + background: Some(mix(p.card, p.text, 0.035).into()), + border: Border { + width: 1., + color: mix(p.card, p.text, 0.18), + radius: p.radius.min(7.).into(), + }, + ..Default::default() + })) +} + fn styled_button(p: Palette, selected: bool, navigation: bool) -> theme::Button { let style = move |hover: bool, focused: bool| { let background = if selected { mix(p.sidebar, p.accent, if hover { 0.24 } else { 0.17 }) } else if hover { - mix(p.card, crate::surface_shade(p.sidebar), 0.06) + if navigation { + mix(p.card, crate::surface_shade(p.sidebar), 0.06) + } else { + mix(p.card, p.text, 0.08) + } } else if navigation { p.sidebar } else { - p.card + mix(p.card, p.text, 0.035) }; let (background, on) = if selected { crate::accent_pair(background, p.text) @@ -113,8 +137,14 @@ fn styled_button(p: Palette, selected: bool, navigation: bool) -> theme::Button text_color: Some(on), icon_color: Some(on), border_radius: p.radius.min(9.).into(), - border_width: if selected { 1. } else { 0. }, - border_color: if selected { p.accent } else { Color::TRANSPARENT }, + border_width: if selected || !navigation { 1. } else { 0. }, + border_color: if selected { + p.accent + } else if !navigation { + mix(p.card, p.text, 0.18) + } else { + Color::TRANSPARENT + }, outline_width: if focused { 2. } else { 0. }, outline_color: p.accent, overlay: None, diff --git a/docs/configuration.md b/docs/configuration.md index 9b49b89..3b6dcd6 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -65,8 +65,24 @@ bindings, rules, layouts, displays, widgets and login items update live. feresectl reload-config # reload with error feedback ferese --check-config PATH # validate without applying ferese-settings --config PATH # edit a separate preview config +ferese-settings --page wifi # open Wi-Fi controls +ferese-settings --page bluetooth # open Bluetooth controls ``` +**Settings → Connections** manages Wi-Fi through NetworkManager and Bluetooth +through BlueZ. Wi-Fi supports scanning, joining open/WPA/WPA2/WPA3 networks, +hidden networks, changing saved passwords, disconnecting, and forgetting saved +networks. Enterprise and legacy Wi-Fi connections use an existing NetworkManager +profile; their certificate and authentication setup is not edited here. + +Bluetooth supports adapter selection, power, discovery, pairing with PIN/passkey +or confirmation prompts, connecting, disconnecting, trust, and forgetting devices. +Discovery runs only when requested and stops after 30 seconds, when leaving +Connections, or when Settings closes. Leaving the page or closing Settings also +cancels pending pairing. Passwords are sent to NetworkManager, not saved in KDL; +pairing keys are managed by BlueZ. These controls affect the system even when +Settings is opened with a separate preview config. + Defaults below are built-in defaults, not your personal or packaged overrides. Dimensions are logical pixels unless noted; numbers must be finite. Optional values are omitted, not `null`. Colors use `#RRGGBB` or `#RRGGBBAA`. diff --git a/shell/ferese-settings/Cargo.toml b/shell/ferese-settings/Cargo.toml index f9dd099..647104f 100644 --- a/shell/ferese-settings/Cargo.toml +++ b/shell/ferese-settings/Cargo.toml @@ -15,4 +15,6 @@ tempfile.workspace = true shlex = "1.3.0" notify = "8.2.0" tokio = { version = "1", features = ["sync", "time", "rt"] } +zbus = "5.19.0" +zeroize = "1.9" image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "webp"] } diff --git a/shell/ferese-settings/src/connections/agent.rs b/shell/ferese-settings/src/connections/agent.rs new file mode 100644 index 0000000..338fb67 --- /dev/null +++ b/shell/ferese-settings/src/connections/agent.rs @@ -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, + owner: Option, + serial: u64, + generation: u64, + prompt: Option, + reply: Option>>>, +} + +#[derive(Clone, Default)] +pub(crate) struct Pairing(Arc>); + +#[derive(Debug, zbus::DBusError)] +#[zbus(prefix = "org.bluez.Error")] +enum Error { + Rejected(String), + Canceled(String), +} + +impl Pairing { + fn begin(&self, target: &str) -> Result { + 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 { + 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 { + self.0.lock().unwrap().prompt.clone() + } + + pub fn reply(&self, id: u64, value: Option>) { + 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, 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 { + 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 { + 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::() + .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")); + } +} diff --git a/shell/ferese-settings/src/connections/bluetooth.rs b/shell/ferese-settings/src/connections/bluetooth.rs new file mode 100644 index 0000000..e8945d6 --- /dev/null +++ b/shell/ferese-settings/src/connections/bluetooth.rs @@ -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, +} + +#[derive(Clone, Debug, Default)] +pub(crate) struct Bluetooth { + pub adapters: Vec, + pub devices: Vec, +} + +pub(crate) async fn read(connection: &Connection) -> Result { + 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, key: &str) -> String { + properties + .get(key) + .and_then(|v| <&str>::try_from(v).ok()) + .unwrap_or_default() + .to_owned() +} + +fn boolean(properties: &HashMap, 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)); + } +} diff --git a/shell/ferese-settings/src/connections/bluetooth_ui.rs b/shell/ferese-settings/src/connections/bluetooth_ui.rs new file mode 100644 index 0000000..335c738 --- /dev/null +++ b/shell/ferese-settings/src/connections/bluetooth_ui.rs @@ -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, + ) -> 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() + } +} diff --git a/shell/ferese-settings/src/connections/contracts.rs b/shell/ferese-settings/src/connections/contracts.rs new file mode 100644 index 0000000..93d4c53 --- /dev/null +++ b/shell/ferese-settings/src/connections/contracts.rs @@ -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>; + +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, +} + +struct Manager(Arc>); + +#[zbus::interface(name = "org.freedesktop.NetworkManager")] +impl Manager { + fn get_devices(&self) -> Vec { + 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>); + +#[zbus::interface(name = "org.freedesktop.NetworkManager.Device.Wireless")] +impl Wireless { + fn get_all_access_points(&self) -> Vec { + vec![path("/ap")] + } + + fn request_scan(&self, options: HashMap) { + 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>); + +#[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 { + 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>); + +#[zbus::interface(name = "org.freedesktop.NetworkManager.Settings")] +impl Profiles { + fn list_connections(&self) -> Vec { + if self.0.lock().unwrap().deleted { + vec![] + } else { + vec![path("/profile")] + } + } +} + +struct Profile(Arc>); + +#[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>) -> 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>); + +#[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>); + +#[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>); + +#[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>) -> 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); +} diff --git a/shell/ferese-settings/src/connections/controller.rs b/shell/ferese-settings/src/connections/controller.rs new file mode 100644 index 0000000..5112c03 --- /dev/null +++ b/shell/ferese-settings/src/connections/controller.rs @@ -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 { + 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, String>) -> Task { + 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 { + 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 { + 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 { + 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 { + 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); + } +} diff --git a/shell/ferese-settings/src/connections/mod.rs b/shell/ferese-settings/src/connections/mod.rs new file mode 100644 index 0000000..a2fa124 --- /dev/null +++ b/shell/ferese-settings/src/connections/mod.rs @@ -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, + pub bluetooth: Result, +} + +pub(crate) struct State { + pub client: Option>, + pub snapshot: Option, + pub tab: Tab, + pub loading: bool, + pub busy: Option<&'static str>, + pub error: Option, + pub selected: Option, + pub password: Zeroizing, + pub hidden_name: String, + pub hidden: bool, + pub hidden_security: Security, + pub adapter: Option, + pub prompt: Option, + pub pairing_input: Zeroizing, + pub forget: Option, +} + +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), + 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>, + discovery: Arc>>, +} + +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, 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 { + 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 { + self.agent.prompt() + } + + pub fn reply(&self, id: u64, value: Option>) { + 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(future: impl std::future::Future>) -> Result { + 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, String> { + Proxy::new(connection, destination, path, interface) + .await + .map_err(|e| e.to_string()) +} diff --git a/shell/ferese-settings/src/connections/pairing_ui.rs b/shell/ferese-settings/src/connections/pairing_ui.rs new file mode 100644 index 0000000..6800d77 --- /dev/null +++ b/shell/ferese-settings/src/connections/pairing_ui.rs @@ -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)) + } +} diff --git a/shell/ferese-settings/src/connections/ui.rs b/shell/ferese-settings/src/connections/ui.rs new file mode 100644 index 0000000..d304e89 --- /dev/null +++ b/shell/ferese-settings/src/connections/ui.rs @@ -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), + SubmitWifi, + CancelWifi, + Hidden(bool), + HiddenName(String), + HiddenSecurity(Security), + PairingValue(u64, Zeroizing), + 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> { + 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() + } +} diff --git a/shell/ferese-settings/src/connections/wifi.rs b/shell/ferese-settings/src/connections/wifi.rs new file mode 100644 index 0000000..396757c --- /dev/null +++ b/shell/ferese-settings/src/connections/wifi.rs @@ -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>; + +#[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, + pub name: String, + pub security: Security, + pub strength: u8, + pub device: String, + pub ap: String, + pub saved: Option, + 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, + pub networks: Vec, + pub saved: Vec, +} + +#[derive(Clone, Debug)] +pub(crate) struct Saved { + pub path: String, + pub name: String, + pub ssid: Vec, + 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 { + 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 = 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::("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 = 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 = 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, 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, String> { + let settings = proxy(connection, NM, "/org/freedesktop/NetworkManager/Settings", SETTINGS).await?; + let paths: Vec = 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::::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 = 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::("DeviceType").await.unwrap_or(0) != 2 { + continue; + } + found = true; + proxy(connection, NM, path.as_str(), WIRELESS) + .await? + .call::<_, _, ()>("RequestScan", &(HashMap::::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>>, 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>> = 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::("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()); + } +} diff --git a/shell/ferese-settings/src/connections/wifi_ui.rs b/shell/ferese-settings/src/connections/wifi_ui.rs new file mode 100644 index 0000000..c56a2d1 --- /dev/null +++ b/shell/ferese-settings/src/connections/wifi_ui.rs @@ -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) -> 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) + } +} diff --git a/shell/ferese-settings/src/displays.rs b/shell/ferese-settings/src/displays.rs index ed2034e..e8f4e39 100644 --- a/shell/ferese-settings/src/displays.rs +++ b/shell/ferese-settings/src/displays.rs @@ -88,6 +88,171 @@ pub fn edits(prefix: &str, mode: Mode, automatic: bool) -> Vec { ] } +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct Target { + pub key: String, + pub matcher: String, + pub title: String, + pub profile: Option, + pub prefix: Option, + pub active: bool, + pub connected: bool, +} + +impl std::fmt::Display for Target { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{} · {}{}", + self.title, + self.profile.as_deref().unwrap_or("Automatic"), + if self.connected { "" } else { " (offline)" } + ) + } +} + +pub(super) fn targets(snapshot: &crate::store::Snapshot, displays: &[Display]) -> Vec { + let mut targets = Vec::new(); + for profile in 0..snapshot.records("output_profiles") { + let prefix = format!("output_profiles.{profile}"); + let name = snapshot.string(&format!("{prefix}.name"), "Display profile"); + for output in 0..snapshot.records(&format!("{prefix}.outputs")) { + let prefix = format!("{prefix}.outputs.{output}"); + let matcher = snapshot.string(&format!("{prefix}.match"), ""); + let live = displays + .iter() + .find(|d| d.connector == matcher || d.identity == matcher); + targets.push(Target { + key: format!("{name}\0{matcher}"), + title: live.map(|d| d.connector.clone()).unwrap_or_else(|| matcher.clone()), + matcher, + profile: Some(name.clone()), + prefix: Some(prefix), + active: live.is_some_and(|d| d.profile.as_deref() == Some(&name)), + connected: live.is_some(), + }); + } + } + for display in displays { + if !targets + .iter() + .any(|t| t.matcher == display.connector || t.matcher == display.identity) + { + targets.push(Target { + key: format!("automatic\0{}", display.connector), + matcher: display.connector.clone(), + title: display.connector.clone(), + profile: None, + prefix: None, + active: true, + connected: true, + }); + } + } + targets +} + +pub(super) fn selected<'a>(targets: &'a [Target], key: Option<&str>) -> Option<&'a Target> { + targets + .iter() + .find(|t| Some(t.key.as_str()) == key) + .or_else(|| targets.iter().find(|t| t.active)) + .or_else(|| targets.iter().find(|t| t.connected)) + .or_else(|| targets.first()) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) struct Resolution { + pub width: u32, + pub height: u32, +} + +impl std::fmt::Display for Resolution { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} × {}", self.width, self.height) + } +} + +pub(super) fn resolutions(display: &Display) -> Vec { + let mut values: Vec<_> = display + .modes + .iter() + .filter(|m| m.width > 0 && m.height > 0) + .map(|m| Resolution { + width: m.width, + height: m.height, + }) + .collect(); + values.sort_by_key(|r| { + ( + std::cmp::Reverse(u64::from(r.width) * u64::from(r.height)), + r.width, + r.height, + ) + }); + values.dedup(); + values +} + +pub(super) fn resolution(configured: &str, display: &Display) -> Option { + configured + .split('@') + .next() + .and_then(|v| v.split_once('x')) + .and_then(|(w, h)| { + Some(Resolution { + width: w.parse().ok()?, + height: h.parse().ok()?, + }) + }) + .or_else(|| { + display.current.map(|m| Resolution { + width: m.width, + height: m.height, + }) + }) +} + +pub(super) fn change_resolution( + display: &Display, + configured: &str, + resolution: Resolution, + automatic: bool, +) -> Option<(Mode, bool)> { + let modes = choices(display, &format!("{}x{}", resolution.width, resolution.height)); + let auto = automatic && modes.iter().any(|m| (59_000..=61_000).contains(&m.refresh)); + let previous = configured + .split('@') + .nth(1) + .and_then(|r| r.parse::().ok()) + .map(|r| r * 1000.) + .or_else(|| display.current.map(|m| f64::from(m.refresh))); + let mode = if auto { + modes.last() + } else { + modes + .iter() + .find(|m| previous.is_some_and(|r| (r - f64::from(m.refresh)).abs() < 1.)) + .or_else(|| modes.last()) + }?; + Some((*mode, auto)) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum Refresh { + Manual(Mode), + Automatic(Mode), +} + +impl std::fmt::Display for Refresh { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Manual(mode) => f.write_str(&mode.label()), + Self::Automatic(_) => f.write_str("Auto (battery)"), + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -169,4 +334,84 @@ mod tests { assert!(snapshot.boolean(&format!("{prefix}.auto_refresh"), false)); assert_eq!(snapshot.string(&format!("{prefix}.mode"), ""), "2880x1800@120.000"); } + + #[test] + fn selected_display_prefers_active_profile_and_survives_profile_reordering() { + let snapshot = + Snapshot::parse("output-profile dock { output HDMI-A-1; }; output-profile laptop { output eDP-1; }".into()) + .unwrap(); + let live = vec![Display { + connector: "eDP-1".into(), + identity: "panel".into(), + profile: Some("laptop".into()), + current: None, + modes: vec![], + }]; + let values = targets(&snapshot, &live); + assert_eq!(selected(&values, None).unwrap().profile.as_deref(), Some("laptop")); + let dock = selected(&values, Some("dock\0HDMI-A-1")).unwrap(); + assert!(!dock.connected); + let key = dock.key.clone(); + let reordered = + Snapshot::parse("output-profile laptop { output eDP-1; }; output-profile dock { output HDMI-A-1; }".into()) + .unwrap(); + let values = targets(&reordered, &live); + assert_eq!( + selected(&values, Some(&key)).unwrap().prefix.as_deref(), + Some("output_profiles.1.outputs.0") + ); + } + + #[test] + fn automatically_managed_outputs_are_still_visible() { + let snapshot = Snapshot::parse(String::new()).unwrap(); + let live = vec![Display { + connector: "eDP-1".into(), + identity: "panel".into(), + profile: None, + current: None, + modes: vec![], + }]; + let values = targets(&snapshot, &live); + assert_eq!(values.len(), 1); + assert!(selected(&values, None).unwrap().prefix.is_none()); + } + + #[test] + fn resolution_changes_preserve_refresh_and_only_keep_valid_auto_policy() { + let mode = |width, height, refresh| Mode { width, height, refresh }; + let display = Display { + connector: "eDP-1".into(), + identity: "panel".into(), + profile: None, + current: Some(mode(2880, 1800, 60000)), + modes: vec![ + mode(2880, 1800, 60000), + mode(2880, 1800, 120000), + mode(2560, 1440, 90000), + ], + }; + assert_eq!(resolutions(&display).len(), 2); + let high = Resolution { + width: 2880, + height: 1800, + }; + assert_eq!( + change_resolution(&display, "2880x1800@120", high, false), + Some((mode(2880, 1800, 120000), false)) + ); + assert_eq!( + change_resolution(&display, "2880x1800@120", high, true), + Some((mode(2880, 1800, 120000), true)) + ); + let other = Resolution { + width: 2560, + height: 1440, + }; + assert_eq!( + change_resolution(&display, "2880x1800@120", other, true), + Some((mode(2560, 1440, 90000), false)) + ); + assert!(change_resolution(&display, "", Resolution { width: 1, height: 1 }, true).is_none()); + } } diff --git a/shell/ferese-settings/src/displays_ui.rs b/shell/ferese-settings/src/displays_ui.rs new file mode 100644 index 0000000..02f5d1b --- /dev/null +++ b/shell/ferese-settings/src/displays_ui.rs @@ -0,0 +1,173 @@ +use cosmic::Element; +use cosmic::iced::{Alignment, Length}; +use cosmic::widget::{column, container, row}; + +use crate::displays::{self, Refresh}; +use crate::{App, Message, schema, visuals}; + +impl App { + pub(super) fn displays_view(&self) -> Element<'_, Message> { + let palette = visuals::Palette::from_resolved(&self.resolved.presented); + let targets = displays::targets(&self.draft, &self.displays); + let selected = displays::selected(&targets, self.display_selection.as_deref()).cloned(); + let mut panel = column([]).spacing(6); + panel = panel.push( + row([]) + .spacing(8) + .align_y(Alignment::Center) + .push(visuals::action_icon("M3 4h18v13H3z M12 17v4 M8 21h8", palette.accent)) + .push( + ferese_theme::controls::select( + cosmic::iced::widget::pick_list(targets.clone(), selected.clone(), |target| { + Message::SelectDisplay(target.key) + }) + .placeholder("Select display") + .font(self.font) + .text_size(12) + .padding([4, 8]) + .width(Length::Fill), + palette, + ) + .width(Length::Fill), + ) + .push(self.settings_icon_button( + "Refresh displays", + "M20 7V3l-3 3a8 8 0 1 0 3 6 M20 3h-5", + Some(Message::RefreshDisplays), + )), + ); + if let Some(error) = &self.display_error { + panel = panel.push(cosmic::widget::tooltip( + self.note("Live display information unavailable"), + self.label(error.clone(), 11.), + cosmic::widget::tooltip::Position::Bottom, + )); + } + if let Some(target) = selected { + let live = self + .displays + .iter() + .find(|d| d.connector == target.matcher || d.identity == target.matcher); + if let Some(display) = live { + let status = display + .current + .map(|mode| format!("{} × {} · {}", mode.width, mode.height, mode.label())) + .unwrap_or_else(|| "Off".into()); + panel = panel.push(self.note(&status)); + } + if let Some(prefix) = target.prefix { + let configured = self.draft.string(&format!("{prefix}.mode"), ""); + let automatic = self.draft.boolean(&format!("{prefix}.auto_refresh"), false); + if let Some(display) = live { + let resolutions = displays::resolutions(display); + if !resolutions.is_empty() { + let path = prefix.clone(); + panel = panel.push( + self.display_control( + "Resolution", + ferese_theme::controls::select( + cosmic::iced::widget::pick_list( + resolutions, + displays::resolution(&configured, display), + move |size| Message::DisplayResolution(path.clone(), size), + ) + .font(self.font) + .text_size(12) + .padding([4, 8]) + .width(225), + palette, + ) + .width(225) + .into(), + ), + ); + } else { + panel = panel.push(self.display_mode_field(&prefix)); + } + let modes = displays::choices(display, &configured); + let mut refresh: Vec<_> = modes.iter().copied().map(Refresh::Manual).collect(); + if modes.iter().any(|mode| (59_000..=61_000).contains(&mode.refresh)) + && let Some(high) = modes.last() + { + refresh.push(Refresh::Automatic(*high)); + } + if !refresh.is_empty() { + let configured_rate = configured + .split('@') + .nth(1) + .and_then(|r| r.parse::().ok()) + .map(|r| r * 1000.) + .or_else(|| display.current.map(|m| f64::from(m.refresh))); + let selected = refresh.iter().copied().find(|value| match value { + Refresh::Automatic(_) => automatic, + Refresh::Manual(mode) => { + !automatic && configured_rate.is_some_and(|r| (r - f64::from(mode.refresh)).abs() < 1.) + } + }); + let path = prefix.clone(); + panel = panel.push( + self.display_control( + "Refresh rate", + ferese_theme::controls::select( + cosmic::iced::widget::pick_list(refresh, selected, move |value| match value { + Refresh::Manual(mode) => Message::RefreshRate(path.clone(), mode, false), + Refresh::Automatic(mode) => Message::RefreshRate(path.clone(), mode, true), + }) + .font(self.font) + .text_size(12) + .padding([4, 8]) + .width(225), + palette, + ) + .width(225) + .into(), + ), + ); + } + } else { + panel = panel.push(self.display_mode_field(&prefix)); + } + panel = panel.push(self.field(schema::range( + format!("{prefix}.scale"), + "Scale", + "", + schema::RangeSpec { + default: 1., + min: 0.75, + max: 3., + step: 0.25, + suffix: "×", + integer: false, + }, + ))); + panel = panel.push(row([]).spacing(5).align_y(Alignment::Center) + .push(cosmic::widget::tooltip(visuals::action_icon("M12 3a9 9 0 1 0 0 18a9 9 0 0 0 0-18 M12 11v6 M12 7h.01", palette.muted), + self.label("Auto uses 60 Hz below 30% battery and returns to normal on AC or at 35%. Applying a display mode can briefly blank the screen.", 11.), cosmic::widget::tooltip::Position::Top)) + .push(self.note(if target.active { "Changes apply live" } else { "Saved profile · applies when active" }))); + } else { + panel = panel.push(self.note("Managed automatically · no saved display profile")); + } + } else { + panel = panel.push(self.note("No displays or saved profiles found")); + } + container(panel) + .padding([7, 10]) + .width(Length::Fill) + .class(visuals::surface(palette.card, 14.)) + .into() + } + + fn display_control<'a>(&self, label: &str, control: Element<'a, Message>) -> Element<'a, Message> { + row([]) + .spacing(10) + .align_y(Alignment::Center) + .padding([7, 10]) + .push(self.label(label.to_owned(), 13.).width(Length::Fill)) + .push(control) + .into() + } + + fn display_mode_field(&self, prefix: &str) -> Element<'static, Message> { + self.field(schema::text(format!("{prefix}.mode"), "Resolution / refresh", "", "")) + } +} diff --git a/shell/ferese-settings/src/form_controls.rs b/shell/ferese-settings/src/form_controls.rs index e06f85d..16f87f8 100644 --- a/shell/ferese-settings/src/form_controls.rs +++ b/shell/ferese-settings/src/form_controls.rs @@ -1,8 +1,8 @@ use cosmic::widget::column; use super::{ - Alignment, App, Element, Field, Kind, Length, Message, button, container, displays, fonts, row, set, slider, - text_input, visuals, widget, + Alignment, App, Element, Field, Kind, Length, Message, button, container, fonts, row, set, slider, text_input, + visuals, widget, }; impl App { @@ -16,11 +16,52 @@ impl App { pub(super) fn note(&self, text: &str) -> Element<'static, Message> { let palette = visuals::Palette::from_resolved(&self.resolved.presented); - self.label(text.to_owned(), 12.) + self.label(text.to_owned(), 11.) .class(cosmic::theme::Text::Color(palette.muted)) .into() } + pub(super) fn settings_button( + &self, + label: &str, + icon: &str, + message: Option, + selected: bool, + ) -> Element<'static, Message> { + let palette = visuals::Palette::from_resolved(&self.resolved.presented); + button::custom( + row([]) + .spacing(5) + .align_y(Alignment::Center) + .push(visuals::action_icon(icon, palette.text)) + .push(self.label(label.to_owned(), 12.)), + ) + .name(label.to_owned()) + .padding([4, 8]) + .class(visuals::button_style(palette, selected)) + .on_press_maybe(message) + .into() + } + + pub(super) fn settings_icon_button( + &self, + label: &str, + icon: &str, + message: Option, + ) -> Element<'static, Message> { + let palette = visuals::Palette::from_resolved(&self.resolved.presented); + widget::tooltip( + button::custom(visuals::action_icon(icon, palette.text)) + .name(label.to_owned()) + .padding(4) + .class(visuals::button_style(palette, false)) + .on_press_maybe(message), + self.label(label.to_owned(), 11.), + widget::tooltip::Position::Top, + ) + .into() + } + pub(super) fn field(&self, mut field: Field) -> Element<'static, Message> { if field.path == "theme.material.opacity" { let appearance = self.resolved.theme.appearance; @@ -35,7 +76,7 @@ impl App { } } let palette = visuals::Palette::from_resolved(&self.resolved.presented); - let mut labels = column([]).spacing(3).push(self.label(field.label.clone(), 13.)); + let mut labels = column([]).spacing(2).push(self.label(field.label.clone(), 13.)); if !field.description.is_empty() { labels = labels.push( self.label(field.description.clone(), 11.) @@ -60,27 +101,32 @@ impl App { let selection_path = path.clone(); let commit = field.clone(); column([]) - .spacing(6) + .spacing(4) .push( - cosmic::iced::widget::pick_list( - families, - selected.map(|index| families[index].clone()), - move |family: String| { - let value = if family == "Default font" { - String::new() - } else { - family - }; - Message::SelectFont(selection_path.clone(), value) - }, + ferese_theme::controls::select( + cosmic::iced::widget::pick_list( + families, + selected.map(|index| families[index].clone()), + move |family: String| { + let value = if family == "Default font" { + String::new() + } else { + family + }; + Message::SelectFont(selection_path.clone(), value) + }, + ) + .font(self.font) + .text_size(12) + .width(225), + palette, ) - .font(self.font) - .text_size(12) .width(225), ) .push( text_input("Default font", value) .font(self.font) + .padding([4, 8]) .style(visuals::input_style(palette)) .on_input(move |value| Message::Draft(path.clone(), value)) .on_submit(move |_| Message::Commit(field.clone())) @@ -118,7 +164,7 @@ impl App { let release = field.clone(); row([]) .align_y(Alignment::Center) - .spacing(14) + .spacing(8) .push( slider(min..=max, value, move |value| Message::Range(field.clone(), value)) .step(step) @@ -135,7 +181,7 @@ impl App { for (key, label) in choices { options = options.push( button::custom(self.label(*label, 12.)) - .padding([6, 9]) + .padding([4, 8]) .class(visuals::button_style(palette, value == *key)) .on_press(Message::Change(set(&path, *key))), ); @@ -158,6 +204,7 @@ impl App { let swatch = visuals::color(&value, palette.accent); let input = text_input(default, value) .font(self.font) + .padding([4, 8]) .style(visuals::input_style(palette)) .on_input(move |value| Message::Draft(path.clone(), value)) .on_submit(move |_| Message::Commit(field.clone())) @@ -178,70 +225,14 @@ impl App { }; container( row([]) - .spacing(16) + .spacing(10) .align_y(Alignment::Center) .push(labels) .push(control), ) - .padding([12, 12]) + .padding([7, 10]) .width(Length::Fill) .class(visuals::surface(palette.card, 0.)) .into() } - - pub(super) fn refresh_controls(&self, prefix: &str) -> Element<'_, Message> { - let matcher = self.draft.string(&format!("{prefix}.match"), ""); - let configured = self.draft.string(&format!("{prefix}.mode"), ""); - let automatic = self.draft.boolean(&format!("{prefix}.auto_refresh"), false); - let Some(display) = self - .displays - .iter() - .find(|display| display.connector == matcher || display.identity == matcher) - else { - return self.note("Connect this display to choose a supported refresh rate."); - }; - let modes = displays::choices(display, &configured); - let mut controls = row([]).spacing(8).align_y(Alignment::Center); - for mode in &modes { - let selected = !automatic - && configured - .split('@') - .nth(1) - .and_then(|rate| rate.parse::().ok()) - .is_some_and(|rate| (rate * 1000. - f64::from(mode.refresh)).abs() < 1.); - controls = controls.push( - ferese_theme::controls::text_button( - mode.label(), - self.font, - visuals::Palette::from_resolved(&self.resolved.presented), - selected, - ) - .on_press(Message::RefreshRate(prefix.to_owned(), *mode, false)), - ); - } - let supports_auto = modes.iter().any(|mode| (59_000..=61_000).contains(&mode.refresh)); - if supports_auto && let Some(mode) = modes.last() { - controls = controls.push( - ferese_theme::controls::text_button( - "Auto", - self.font, - visuals::Palette::from_resolved(&self.resolved.presented), - automatic, - ) - .on_press(Message::RefreshRate(prefix.to_owned(), *mode, true)), - ); - } - let current = display.current.map(|mode| mode.label()).unwrap_or_else(|| "Off".into()); - let profile = display.profile.as_deref().unwrap_or("automatic configuration"); - column![ - self.label("Refresh rate", 14.), - self.note(&format!("Current: {current} · active profile: {profile}")), - controls, - self.note(if supports_auto { - "Auto uses 60 Hz below 30% on battery. Normal refresh returns on AC or at 35%. Switching may briefly blank the display. Choosing a rate turns Auto off." - } else { - "This resolution has no supported 60 Hz mode for automatic switching." - }), - ].spacing(8).into() - } } diff --git a/shell/ferese-settings/src/main.rs b/shell/ferese-settings/src/main.rs index 2dd1e09..0fbaf4a 100644 --- a/shell/ferese-settings/src/main.rs +++ b/shell/ferese-settings/src/main.rs @@ -1,3 +1,4 @@ +mod connections; mod form_controls; mod pages; mod theme_controls; @@ -5,6 +6,7 @@ mod theme_controls; use theme_controls::*; mod displays; +mod displays_ui; mod fonts; mod schema; mod store; @@ -24,14 +26,33 @@ use store::{Edit, Snapshot, set}; fn main() -> cosmic::iced::Result { let mut args = std::env::args_os().skip(1); - let path = match args.next() { - Some(arg) if arg == "--config" => PathBuf::from(args.next().expect("--config requires a path")), - Some(_) => { - eprintln!("Usage: ferese-settings [--config PATH]"); - return Ok(()); + let mut path = store::config_path(); + let mut connection_tab = None; + while let Some(arg) = args.next() { + match arg.to_str() { + Some("--config") => { + let Some(value) = args.next() else { + eprintln!("--config requires a path"); + return Ok(()); + }; + path = PathBuf::from(value); + } + Some("--page") => { + connection_tab = match args.next().as_deref().and_then(|s| s.to_str()) { + Some("connections" | "wifi") => Some(connections::Tab::Wifi), + Some("bluetooth") => Some(connections::Tab::Bluetooth), + _ => { + eprintln!("--page accepts connections, wifi, or bluetooth"); + return Ok(()); + } + }; + } + _ => { + eprintln!("Usage: ferese-settings [--config PATH] [--page connections|wifi|bluetooth]"); + return Ok(()); + } } - None => store::config_path(), - }; + } let path = path.canonicalize().unwrap_or(path); fonts::families(); let initial = Snapshot::read(&path); @@ -43,12 +64,19 @@ fn main() -> cosmic::iced::Result { .is_daemon(false) .antialiasing(true) .default_text_size(14.), - (path, initial), + (path, initial, connection_tab), ) } #[derive(Clone, Debug)] enum Message { + Connections(connections::Input), + ConnectionsReady(Result, String>), + ConnectionsRefreshed(Box), + ConnectionFinished(Result<(), String>), + ConnectionsLeft, + SelectDisplay(String), + DisplayResolution(String, displays::Resolution), RefreshDisplays, DisplaysLoaded(Result, String>), RefreshRate(String, displays::Mode, bool), @@ -92,7 +120,10 @@ enum Message { } struct App { + connections: connections::State, displays: Vec, + display_selection: Option, + display_error: Option, note_editors: HashMap, core: Core, path: PathBuf, @@ -122,6 +153,14 @@ struct App { thumbnail_error: Option, } +impl Drop for App { + fn drop(&mut self) { + if let Some(client) = &self.connections.client { + client.shutdown(); + } + } +} + struct NoteEditor { content: widget::text_editor::Content, revision: u64, @@ -130,7 +169,7 @@ struct NoteEditor { impl cosmic::Application for App { type Executor = cosmic::executor::Default; - type Flags = (PathBuf, Result); + type Flags = (PathBuf, Result, Option); type Message = Message; const APP_ID: &'static str = "dev.ferese.Settings"; @@ -142,7 +181,7 @@ impl cosmic::Application for App { &mut self.core } - fn init(mut core: Core, (path, initial): Self::Flags) -> (Self, Task) { + fn init(mut core: Core, (path, initial, connection_tab): Self::Flags) -> (Self, Task) { core.window.show_headerbar = false; let status = if path == store::config_path() { @@ -157,7 +196,13 @@ impl cosmic::Application for App { let font = ferese_theme::font(Some(&resolved.presented.tokens.typography.font_family)); let native_palette = visuals::Palette::from_resolved(&resolved.presented); let mut app = Self { + connections: connections::State { + tab: connection_tab.unwrap_or_default(), + ..connections::State::default() + }, displays: vec![], + display_selection: None, + display_error: None, note_editors: HashMap::new(), core, path, @@ -169,7 +214,11 @@ impl cosmic::Application for App { saving_previous: String::new(), error, status, - page: Page::Appearance, + page: if connection_tab.is_some() { + Page::Connections + } else { + Page::Appearance + }, search: String::new(), inputs: HashMap::new(), ranges: HashMap::new(), @@ -193,15 +242,44 @@ impl cosmic::Application for App { .unwrap_or_else(Task::none); app.sync_notes(); - (app, task) + let connections = if app.page == Page::Connections { + app.refresh_connections() + } else { + Task::none() + }; + (app, Task::batch([task, connections])) } fn update(&mut self, message: Message) -> Task { match message { - Message::RefreshDisplays => return self.load_displays(), - Message::DisplaysLoaded(result) => { - self.displays = result.unwrap_or_default(); + Message::Connections(input) => return self.connections_input(input), + Message::ConnectionsReady(result) => return self.connections_ready(result), + Message::ConnectionsRefreshed(snapshot) => self.connections_refreshed(*snapshot), + Message::ConnectionFinished(result) => return self.connection_finished(result), + Message::ConnectionsLeft => {} + Message::SelectDisplay(key) => self.display_selection = Some(key), + Message::DisplayResolution(prefix, size) => { + let matcher = self.draft.string(&format!("{prefix}.match"), ""); + let configured = self.draft.string(&format!("{prefix}.mode"), ""); + let automatic = self.draft.boolean(&format!("{prefix}.auto_refresh"), false); + if let Some(display) = self + .displays + .iter() + .find(|d| d.connector == matcher || d.identity == matcher) + && let Some((mode, automatic)) = displays::change_resolution(display, &configured, size, automatic) + { + self.inputs.remove(&format!("{prefix}.mode")); + return self.edit_many(displays::edits(&prefix, mode, automatic)); + } } + Message::RefreshDisplays => return self.load_displays(), + Message::DisplaysLoaded(result) => match result { + Ok(displays) => { + self.displays = displays; + self.display_error = None; + } + Err(error) => self.display_error = Some(error), + }, Message::RefreshRate(prefix, mode, automatic) => { self.inputs.remove(&format!("{prefix}.mode")); return self.edit_many(displays::edits(&prefix, mode, automatic)); @@ -318,8 +396,21 @@ impl cosmic::Application for App { self.error = Some(error); } Message::Page(page) => { + let leaving = self.page == Page::Connections && page != Page::Connections; self.page = page; self.search.clear(); + if leaving { + let cleanup = self.leave_connections(); + let load = match page { + Page::Displays => self.load_displays(), + Page::Wallpaper => self.load_thumbnail(), + _ => Task::none(), + }; + return Task::batch([cleanup, load]); + } + if page == Page::Connections { + return self.refresh_connections(); + } if page == Page::Displays { return self.load_displays(); } @@ -603,6 +694,18 @@ impl cosmic::Application for App { } else { cosmic::iced::Subscription::none() }, + if self.page == Page::Connections { + cosmic::iced::time::every(std::time::Duration::from_secs(3)) + .map(|_| Message::Connections(connections::Input::Refresh)) + } else { + cosmic::iced::Subscription::none() + }, + if self.connections.busy == Some("Pairing…") { + cosmic::iced::time::every(std::time::Duration::from_millis(250)) + .map(|_| Message::Connections(connections::Input::PollPrompt)) + } else { + cosmic::iced::Subscription::none() + }, ]) } @@ -792,6 +895,7 @@ mod tests { ( PathBuf::from("/unused/settings-test.kdl"), Snapshot::parse(String::new()), + None, ), ) .0 @@ -904,7 +1008,7 @@ mod tests { assert!(app.status.contains("externally")); let _ = app.update(Message::ExternalConfig(Err("invalid KDL".into()))); assert_eq!(app.current.source, external.source); - assert!(app.error.unwrap().contains("invalid KDL")); + assert!(app.error.as_ref().unwrap().contains("invalid KDL")); } #[test] diff --git a/shell/ferese-settings/src/pages.rs b/shell/ferese-settings/src/pages.rs index b76c841..be48bc1 100644 --- a/shell/ferese-settings/src/pages.rs +++ b/shell/ferese-settings/src/pages.rs @@ -6,12 +6,12 @@ use super::{ impl App { pub(super) fn page_view(&self) -> Element<'_, Message> { let palette = visuals::Palette::from_resolved(&self.resolved.presented); - let mut sidebar = column([]) + let sidebar = column([]) .spacing(3) .push( widget::mouse_area( row([]) - .spacing(10) + .spacing(8) .align_y(Alignment::Center) .push(visuals::brand_icon()) .push(self.label("Ferese", 16.)) @@ -29,11 +29,12 @@ impl App { .size(12), ) .push(widget::Space::new().height(8)); + let mut navigation = column([]).spacing(3); for page in Page::ALL { - sidebar = sidebar.push( + navigation = navigation.push( button::custom( row([]) - .spacing(10) + .spacing(8) .align_y(Alignment::Center) .push(visuals::icon( page, @@ -51,21 +52,21 @@ impl App { .on_press(Message::Page(page)), ); } - let sidebar = container(sidebar.push(widget::Space::new().height(Length::Fill))) + let sidebar = container(sidebar.push(scrollable(navigation).height(Length::Fill))) .width(204) .height(Length::Fill) .padding([20, 10]) .class(visuals::surface(palette.sidebar, 0.)); - let mut heading = row([]).align_y(Alignment::Center).spacing(20).push( + let mut heading = row([]).align_y(Alignment::Center).spacing(12).push( column([]) - .spacing(5) + .spacing(3) .push(self.label( if self.search.is_empty() { self.page.title() } else { "Search" }, - 24., + 20., )) .push( self.label( @@ -122,15 +123,18 @@ impl App { "Auto follows the GTK/GNOME appearance preference".to_owned() }; heading = heading.push(widget::tooltip( - ferese_theme::controls::text_button(label, self.font, palette, self.auto_details) - .name("Automatic appearance settings") - .on_press(Message::AutoDetails(!self.auto_details)), + self.settings_button( + &label, + "M12 3a9 9 0 1 0 0 18a9 9 0 0 0 0-18 M12 7v5l3 2", + Some(Message::AutoDetails(!self.auto_details)), + self.auto_details, + ), self.label(explanation, 12.), widget::tooltip::Position::Bottom, )); } - let mut body = column([]).spacing(12); + let mut body = column([]).spacing(6); if !self.search.is_empty() { if !Page::ALL.into_iter().any(|p| p.matches(&self.search)) { @@ -140,12 +144,12 @@ impl App { body = body.push( button::custom( column([]) - .spacing(5) + .spacing(3) .push(self.label(page.title(), 16.)) .push(self.label(page.subtitle(), 12.)), ) .width(Length::Fill) - .padding(20) + .padding(10) .class(visuals::button_style(palette, false)) .on_press(Message::Page(page)), ); @@ -179,7 +183,7 @@ impl App { body = body.push( widget::image(handle.clone()) .width(Length::Fill) - .height(190) + .height(160) .content_fit(cosmic::iced::ContentFit::Contain), ); } else { @@ -191,7 +195,12 @@ impl App { .unwrap_or("Choose an image to preview your wallpaper.") })); } - body = body.push(button::standard("Choose image…").on_press(Message::PickWallpaper)); + body = body.push(self.settings_button( + "Choose image…", + "M3 4h18v16H3z M3 15l5-5 5 5 3-3 5 5 M15 8h.01", + Some(Message::PickWallpaper), + false, + )); } let fields = schema::fields(self.page); @@ -231,7 +240,7 @@ impl App { } else { "Expand advanced customization" }) - .padding(12) + .padding([7, 10]) .width(Length::Fill) .class(visuals::button_style(palette, false)) .on_press(Message::AdvancedTheme(!self.advanced_theme)), @@ -240,18 +249,24 @@ impl App { group = group.push(self.theme_file_picker()); } } - body = body.push(container(group).padding(8).class(visuals::surface(palette.card, 14.))); + body = body.push(container(group).padding(4).class(visuals::surface(palette.card, 14.))); } match self.page { + Page::Connections => body = body.push(self.connections_view()), Page::LockScreen => { body = body.push(self.note("Uses your wallpaper, shell colors, font and corner radius from Appearance. Changes apply when the locker or preview opens.")); - body = body.push(button::standard("Preview lock screen").on_press(Message::PreviewLock)); + body = body.push(self.settings_button( + "Preview lock screen", + "M7 10V7a5 5 0 0 1 10 0v3 M5 10h14v11H5z M12 14v3", + Some(Message::PreviewLock), + false, + )); body = body.push(self.note("The preview is an ordinary window and does not lock your session. Automatic locking is configured separately in Login items.")); } Page::Desktop => { let can_change_list = !self.saving && !self.note_editors.values().any(|e| e.dirty); - body = body.push(self.label("Sticky notes", 16.)); + body = body.push(self.label("Sticky notes", 14.)); body = body.push(self.note("Edit here; notes save after you pause typing. Desktop cards stay behind windows and are click-through.")); for index in 0..self.draft.records("desktop_widgets.notes") { let id = self.draft.string(&format!("desktop_widgets.notes.{index}.id"), "note"); @@ -267,19 +282,28 @@ impl App { widget::TextEditor::new(&editor.content) .height(160) .font(self.font) - .size(14.) + .size(13.) .on_action(move |action| Message::NoteAction(id.clone(), action)), ); } - group = group.push(button::destructive("Remove note").on_press_maybe( + group = group.push(self.settings_icon_button( + "Remove note", + "M4 6h16 M9 6V3h6v3 M6 6l1 15h10l1-15 M10 10v7 M14 10v7", can_change_list.then_some(Message::Remove("desktop_widgets.notes".into(), index)), )); - body = body.push(container(group).padding(12).class(visuals::surface(palette.card, 14.))); + body = body.push( + container(group) + .padding([7, 10]) + .class(visuals::surface(palette.card, 14.)), + ); } body = body.push( - button::standard("Add note").on_press_maybe( + self.settings_button( + "Add note", + "M12 5v14 M5 12h14", (can_change_list && self.draft.records("desktop_widgets.notes") < 32) .then_some(Message::AddNote), + false, ), ); } @@ -312,22 +336,31 @@ impl App { Kind::Toggle(true), ))) .push( - container(button::destructive("Remove item").on_press_maybe( + container(self.settings_icon_button( + "Remove login item", + "M4 6h16 M9 6V3h6v3 M6 6l1 15h10l1-15 M10 10v7 M14 10v7", (!self.saving).then_some(Message::Remove("autostart".into(), index)), )) - .padding(12), + .padding([7, 10]), ); - body = body.push(container(group).padding(8).class(visuals::surface(palette.card, 14.))); + body = body.push(container(group).padding(4).class(visuals::surface(palette.card, 14.))); } body = body.push( row([]) - .spacing(10) + .spacing(8) .push( text_input("Program and arguments", self.new_command.clone()) + .font(self.font) + .size(12) + .padding([4, 8]) + .style(visuals::input_style(palette)) .on_input(Message::NewCommand), ) - .push(button::standard("Add login item").on_press_maybe( + .push(self.settings_button( + "Add login item", + "M12 5v14 M5 12h14", (!self.saving && !self.new_command.trim().is_empty()).then_some(Message::AddCommand), + false, )), ); } @@ -347,10 +380,16 @@ impl App { }); if !exists { - gestures = gestures.push( - button::standard(label) - .on_press_maybe((!self.saving).then_some(Message::AddSwipe(keys))), - ); + gestures = gestures.push(self.settings_icon_button( + label, + match keys { + "Swipe3Up" => "M12 20V4 M5 11l7-7 7 7", + "Swipe3Down" => "M12 4v16 M5 13l7 7 7-7", + "Swipe3Left" => "M20 12H4 M11 5l-7 7 7 7", + _ => "M4 12h16 M13 5l7 7-7 7", + }, + (!self.saving).then_some(Message::AddSwipe(keys)), + )); } } @@ -378,64 +417,18 @@ impl App { "Command name, direction, or action argument.", "", ))); - body = body.push(container(group).padding(8).class(visuals::surface(palette.card, 14.))); + body = body.push(container(group).padding(4).class(visuals::surface(palette.card, 14.))); } if self.draft.records("bindings") == 0 { body = body.push(self.note("You are using the built-in shortcuts. Add custom bindings in config.kdl; they will appear here after Reload.")); } } - Page::Displays => { - body = body.push(self.note("Display profiles update connected outputs live. Keep your config open for adding profiles or changing display positions.")); - for profile in 0..self.draft.records("output_profiles") { - let prefix = format!("output_profiles.{profile}"); - body = - body.push(self.label(self.draft.string(&format!("{prefix}.name"), "Display profile"), 17.)); - - for output in 0..self.draft.records(&format!("{prefix}.outputs")) { - let prefix = format!("{prefix}.outputs.{output}"); - let group = column([]) - .spacing(1) - .push(self.refresh_controls(&prefix)) - .push(self.field(schema::text( - format!("{prefix}.match"), - "Display", - "Output name or matching pattern.", - "", - ))) - .push(self.field(schema::text( - format!("{prefix}.mode"), - "Resolution", - "For example: 2560x1440@60. Leave unchanged to retain automatic selection.", - "", - ))) - .push(self.field(schema::range( - format!("{prefix}.scale"), - "Scale", - "Logical size of text and controls.", - schema::RangeSpec { - default: 1., - min: 0.75, - max: 3., - step: 0.25, - suffix: "×", - integer: false, - }, - ))); - body = body.push(container(group).padding(8).class(visuals::surface(palette.card, 14.))); - } - } - if self.draft.records("output_profiles") == 0 { - body = - body.push(self.note( - "No saved profiles. Ferese currently configures connected displays automatically.", - )); - } - } + Page::Displays => body = body.push(self.displays_view()), _ => {} } } - let mut content = column([]).spacing(16).push( + let mut content = column([]).spacing(8).push( widget::mouse_area(heading.width(Length::Fill)) .on_press(Message::DragWindow) .interaction(cosmic::iced::mouse::Interaction::Grab), @@ -444,7 +437,7 @@ impl App { if let Some(error) = &self.error { content = content.push( container(self.label(error.clone(), 12.)) - .padding(12) + .padding([7, 10]) .width(Length::Fill) .class(visuals::surface(palette.error, 10.)), ); @@ -461,6 +454,7 @@ impl App { }) .width(Length::Fill), ) + .id(widget::Id::new("settings-content")) .direction(cosmic::iced::widget::scrollable::Direction::Vertical( cosmic::iced::widget::scrollable::Scrollbar::new() .width(10) @@ -508,6 +502,11 @@ impl App { self.label("Reload configuration", 11.), widget::tooltip::Position::Top, )); + let footer: Element<'_, Message> = if self.page == Page::Connections { + self.note("Connections are managed by the system. Passwords are not saved in your Ferese config.") + } else { + footer.into() + }; row([]) .push(sidebar) .push( diff --git a/shell/ferese-settings/src/schema.rs b/shell/ferese-settings/src/schema.rs index ecaa933..0a89037 100644 --- a/shell/ferese-settings/src/schema.rs +++ b/shell/ferese-settings/src/schema.rs @@ -13,10 +13,11 @@ pub enum Page { Shortcuts, Startup, Displays, + Connections, } impl Page { - pub const ALL: [Self; 13] = [ + pub const ALL: [Self; 14] = [ Self::Appearance, Self::Accessibility, Self::Wallpaper, @@ -30,6 +31,7 @@ impl Page { Self::Shortcuts, Self::Startup, Self::Displays, + Self::Connections, ]; pub fn title(self) -> &'static str { @@ -47,6 +49,7 @@ impl Page { Self::Shortcuts => "Shortcuts", Self::Startup => "Login items", Self::Displays => "Displays", + Self::Connections => "Connections", } } @@ -65,6 +68,7 @@ impl Page { Self::Shortcuts => "Your most-used actions, a keystroke away.", Self::Startup => "Ready when you sign in.", Self::Displays => "A place for every screen.", + Self::Connections => "Wi-Fi networks and Bluetooth devices.", } } @@ -89,12 +93,14 @@ impl Page { } Self::Startup => "M12 3v9 M7 5a9 9 0 1 0 10 0", Self::Displays => "M3 4h18v13H3z M12 17v4 M8 21h8", + Self::Connections => "M2 8a16 16 0 0 1 20 0 M5 12a11 11 0 0 1 14 0 M8 16a6 6 0 0 1 8 0 M12 20h.01", } } pub fn matches(self, query: &str) -> bool { let query = query.to_lowercase(); self.title().to_lowercase().contains(&query) + || self.subtitle().to_lowercase().contains(&query) || fields(self).iter().any(|f| { format!("{} {} {}", f.label, f.description, f.path) .to_lowercase() @@ -1112,6 +1118,6 @@ pub fn fields(page: Page) -> Vec { "Keep the shortcut guide until you are comfortable with Ferese.", true, )], - Page::Startup | Page::Displays => vec![], + Page::Startup | Page::Displays | Page::Connections => vec![], } } diff --git a/shell/ferese-settings/src/theme_controls.rs b/shell/ferese-settings/src/theme_controls.rs index 91cab3c..fd9cf69 100644 --- a/shell/ferese-settings/src/theme_controls.rs +++ b/shell/ferese-settings/src/theme_controls.rs @@ -18,7 +18,7 @@ impl App { .into_owned() }; let labels = column([]) - .spacing(3) + .spacing(2) .width(Length::Fill) .push(self.label("Theme overrides", 13.)) .push(widget::tooltip( @@ -39,32 +39,29 @@ impl App { scopes = scopes.push( button::custom(self.label(label, 12.)) .name(format!("Theme file: {label}")) - .padding([6, 8]) + .padding([4, 8]) .class(visuals::button_style(palette, index == self.theme_file_target)) .on_press(Message::ThemeFileTarget(index)), ); } let mut controls = row([]) - .spacing(8) + .spacing(6) .align_y(Alignment::Center) .push( container(scopes) .padding(3) .class(visuals::surface(palette.sidebar, 10.)), ) - .push( - ferese_theme::controls::text_button( - if file.is_empty() { "Choose…" } else { "Replace…" }, - self.font, - palette, - false, - ) - .on_press(Message::PickThemeFile(path)), - ); + .push(self.settings_button( + if file.is_empty() { "Choose…" } else { "Replace…" }, + "M3 7V5h6l2 2h10v13H3z", + Some(Message::PickThemeFile(path)), + false, + )); if !file.is_empty() { controls = controls.push(widget::tooltip( button::custom(visuals::action_icon("M6 6l12 12M6 18L18 6", palette.muted)) - .padding(5) + .padding(4) .on_press(Message::Change(Edit::Unset(path.into()))), self.label("Remove override", 11.), widget::tooltip::Position::Bottom, @@ -72,12 +69,12 @@ impl App { } container( row([]) - .spacing(16) + .spacing(6) .align_y(Alignment::Center) .push(labels) .push(controls), ) - .padding([12, 12]) + .padding([7, 10]) .width(Length::Fill) .class(visuals::surface(palette.card, 0.)) .into() @@ -88,24 +85,29 @@ impl App { let scheduled = self.draft.string("theme.schedule.source", "schedule") == "schedule"; let mut sources = row([]).spacing(4); for (source, label) in [("system", "System"), ("schedule", "Schedule")] { - sources = sources.push( - ferese_theme::controls::text_button(label, self.font, palette, scheduled == (source == "schedule")) - .name(format!("Auto source: {label}")) - .on_press(Message::Change(set("theme.schedule.source", source))), - ); + sources = sources.push(self.settings_button( + label, + if source == "schedule" { + "M12 3a9 9 0 1 0 0 18a9 9 0 0 0 0-18 M12 7v5l3 2" + } else { + "M3 4h18v13H3z M12 17v4 M8 21h8" + }, + Some(Message::Change(set("theme.schedule.source", source))), + scheduled == (source == "schedule"), + )); } let description = if scheduled { "Switch between light and dark at set times." } else { "Follow the GTK/GNOME appearance preference." }; - let mut options = column([]).spacing(8).push( + let mut options = column([]).spacing(6).push( row([]) - .spacing(12) + .spacing(6) .align_y(Alignment::Center) .push( column([]) - .spacing(3) + .spacing(2) .width(Length::Fill) .push(self.label("Automatic appearance", 13.)) .push(self.note(description)), @@ -113,7 +115,7 @@ impl App { .push(sources), ); if scheduled { - let mut times = row([]).spacing(16).align_y(Alignment::Center); + let mut times = row([]).spacing(6).align_y(Alignment::Center); for field in schema::fields(Page::Appearance) .into_iter() .filter(|field| field.path.starts_with("theme.schedule.")) @@ -130,6 +132,7 @@ impl App { let submit = field.clone(); let input = text_input(*default, value) .font(self.font) + .padding([4, 8]) .size(12) .style(visuals::input_style(palette)) .on_input(move |value| Message::Draft(path.clone(), value)) @@ -138,7 +141,7 @@ impl App { .width(if field.path.ends_with("timezone") { 150 } else { 75 }); times = times.push( row([]) - .spacing(8) + .spacing(6) .align_y(Alignment::Center) .push(self.label(field.label, 12.)) .push(input), @@ -147,7 +150,7 @@ impl App { options = options.push(times); } container(options) - .padding(12) + .padding([7, 10]) .width(Length::Fill) .class(visuals::surface(palette.card, 0.)) .into() @@ -162,32 +165,35 @@ impl App { let selected_id = visuals::family_selection(&self.draft, appearance); let choices = self.resolved.families.clone(); let ids: Vec<_> = choices.iter().map(|family| family.id.clone()).collect(); - let mut heading = row([]).align_y(Alignment::Center).spacing(12).push( + let mut heading = row([]).align_y(Alignment::Center).spacing(6).push( self.label( match appearance { Some(Light) => "Light theme", Some(Dark) => "Dark theme", None => "Theme", }, - 15., + 14., ) .width(Length::Fill), ); if appearance != Some(Dark) { if self.undo.is_some() { - heading = heading.push( - ferese_theme::controls::text_button("Undo", self.font, palette, false) - .on_press_maybe((!self.saving).then_some(Message::Undo)), - ); + heading = heading.push(self.settings_icon_button( + "Undo", + "M4 10h8 M4 10V3 M4 10c3-7 16-6 16 3a7 7 0 0 1-7 7", + (!self.saving).then_some(Message::Undo), + )); } - heading = heading.push( - ferese_theme::controls::text_button("Import…", self.font, palette, false) - .on_press(Message::ImportTheme), - ); + heading = heading.push(self.settings_button( + "Import…", + "M3 7V5h6l2 2h10v13H3z M12 10v7 M9 14l3 3 3-3", + Some(Message::ImportTheme), + false, + )); } - let mut gallery = column([]).spacing(10).push(heading); + let mut gallery = column([]).spacing(6).push(heading); for (chunk, families) in choices.chunks(3).enumerate() { - let mut tiles = row([]).spacing(12); + let mut tiles = row([]).spacing(6); for (offset, family) in families.iter().enumerate() { let selected = family.id == selected_id; let index = chunk * 3 + offset; @@ -220,7 +226,7 @@ impl App { gallery = gallery.push(tiles); } gallery::group( - container(gallery).width(Length::Fill).max_width(640), + container(gallery).width(Length::Fill), cosmic::iced::advanced::widget::Id::new(format!("theme-gallery-{appearance:?}")), match appearance { Some(Light) => "Light theme",