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