Add Wi-Fi and Bluetooth settings and compact controls

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

No files matched your search

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