Add Wi-Fi and Bluetooth settings and compact controls

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

No files matched your search

Generated
+2
View File
@@ -1772,6 +1772,8 @@ dependencies = [
"shlex 1.3.0", "shlex 1.3.0",
"tempfile", "tempfile",
"tokio", "tokio",
"zbus",
"zeroize",
] ]
[[package]] [[package]]
+36 -6
View File
@@ -70,11 +70,15 @@ pub fn navigation_style(p: Palette, selected: bool) -> theme::Button {
pub fn settings_input(p: Palette) -> theme::TextInput { pub fn settings_input(p: Palette) -> theme::TextInput {
let appearance = move |focused: bool, hovered: bool| cosmic::widget::text_input::Appearance { let appearance = move |focused: bool, hovered: bool| cosmic::widget::text_input::Appearance {
background: mix(p.sidebar, p.card, if hovered { 0.65 } else { 0.4 }).into(), background: mix(p.card, p.text, if hovered { 0.06 } else { 0.035 }).into(),
border_radius: p.radius.min(7.).into(), border_radius: p.radius.min(7.).into(),
border_width: if focused { 1. } else { 0. }, border_width: 1.,
border_offset: None, border_offset: None,
border_color: p.accent, border_color: if focused {
p.accent
} else {
mix(p.card, p.text, if hovered { 0.26 } else { 0.18 })
},
icon_color: Some(p.muted), icon_color: Some(p.muted),
text_color: Some(p.text), text_color: Some(p.text),
placeholder_color: p.muted, placeholder_color: p.muted,
@@ -91,16 +95,36 @@ pub fn settings_input(p: Palette) -> theme::TextInput {
} }
} }
/// Give transparent native selectors the same resting surface as other controls.
pub fn select<'a, M: 'a>(
content: impl Into<cosmic::Element<'a, M>>,
p: Palette,
) -> container::Container<'a, M, cosmic::Theme> {
widget::container(content).class(theme::Container::custom(move |_| container::Style {
background: Some(mix(p.card, p.text, 0.035).into()),
border: Border {
width: 1.,
color: mix(p.card, p.text, 0.18),
radius: p.radius.min(7.).into(),
},
..Default::default()
}))
}
fn styled_button(p: Palette, selected: bool, navigation: bool) -> theme::Button { fn styled_button(p: Palette, selected: bool, navigation: bool) -> theme::Button {
let style = move |hover: bool, focused: bool| { let style = move |hover: bool, focused: bool| {
let background = if selected { let background = if selected {
mix(p.sidebar, p.accent, if hover { 0.24 } else { 0.17 }) mix(p.sidebar, p.accent, if hover { 0.24 } else { 0.17 })
} else if hover { } else if hover {
if navigation {
mix(p.card, crate::surface_shade(p.sidebar), 0.06) mix(p.card, crate::surface_shade(p.sidebar), 0.06)
} else {
mix(p.card, p.text, 0.08)
}
} else if navigation { } else if navigation {
p.sidebar p.sidebar
} else { } else {
p.card mix(p.card, p.text, 0.035)
}; };
let (background, on) = if selected { let (background, on) = if selected {
crate::accent_pair(background, p.text) crate::accent_pair(background, p.text)
@@ -113,8 +137,14 @@ fn styled_button(p: Palette, selected: bool, navigation: bool) -> theme::Button
text_color: Some(on), text_color: Some(on),
icon_color: Some(on), icon_color: Some(on),
border_radius: p.radius.min(9.).into(), border_radius: p.radius.min(9.).into(),
border_width: if selected { 1. } else { 0. }, border_width: if selected || !navigation { 1. } else { 0. },
border_color: if selected { p.accent } else { Color::TRANSPARENT }, border_color: if selected {
p.accent
} else if !navigation {
mix(p.card, p.text, 0.18)
} else {
Color::TRANSPARENT
},
outline_width: if focused { 2. } else { 0. }, outline_width: if focused { 2. } else { 0. },
outline_color: p.accent, outline_color: p.accent,
overlay: None, overlay: None,
+16
View File
@@ -65,8 +65,24 @@ bindings, rules, layouts, displays, widgets and login items update live.
feresectl reload-config # reload with error feedback feresectl reload-config # reload with error feedback
ferese --check-config PATH # validate without applying ferese --check-config PATH # validate without applying
ferese-settings --config PATH # edit a separate preview config ferese-settings --config PATH # edit a separate preview config
ferese-settings --page wifi # open Wi-Fi controls
ferese-settings --page bluetooth # open Bluetooth controls
``` ```
**Settings → Connections** manages Wi-Fi through NetworkManager and Bluetooth
through BlueZ. Wi-Fi supports scanning, joining open/WPA/WPA2/WPA3 networks,
hidden networks, changing saved passwords, disconnecting, and forgetting saved
networks. Enterprise and legacy Wi-Fi connections use an existing NetworkManager
profile; their certificate and authentication setup is not edited here.
Bluetooth supports adapter selection, power, discovery, pairing with PIN/passkey
or confirmation prompts, connecting, disconnecting, trust, and forgetting devices.
Discovery runs only when requested and stops after 30 seconds, when leaving
Connections, or when Settings closes. Leaving the page or closing Settings also
cancels pending pairing. Passwords are sent to NetworkManager, not saved in KDL;
pairing keys are managed by BlueZ. These controls affect the system even when
Settings is opened with a separate preview config.
Defaults below are built-in defaults, not your personal or packaged overrides. Defaults below are built-in defaults, not your personal or packaged overrides.
Dimensions are logical pixels unless noted; numbers must be finite. Optional Dimensions are logical pixels unless noted; numbers must be finite. Optional
values are omitted, not `null`. Colors use `#RRGGBB` or `#RRGGBBAA`. values are omitted, not `null`. Colors use `#RRGGBB` or `#RRGGBBAA`.
+2
View File
@@ -15,4 +15,6 @@ tempfile.workspace = true
shlex = "1.3.0" shlex = "1.3.0"
notify = "8.2.0" notify = "8.2.0"
tokio = { version = "1", features = ["sync", "time", "rt"] } tokio = { version = "1", features = ["sync", "time", "rt"] }
zbus = "5.19.0"
zeroize = "1.9"
image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "webp"] } image = { version = "0.25.10", default-features = false, features = ["png", "jpeg", "webp"] }
@@ -0,0 +1,375 @@
//! A caller-owned BlueZ agent. Only the device being paired may ask for input.
use std::fmt;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::sync::oneshot;
use zbus::message::Header;
use zbus::zvariant::OwnedObjectPath;
use zeroize::Zeroizing;
use super::{Client, bluetooth, proxy};
const PATH: &str = "/dev/ferese/Settings/BluetoothAgent";
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum Kind {
Pin,
Passkey,
Confirm(u32),
Authorize,
Service(String),
Display(String),
}
pub(super) fn valid_answer(kind: &Kind, value: &str) -> bool {
match kind {
Kind::Pin => !value.is_empty() && value.chars().count() <= 16,
Kind::Passkey => !value.is_empty() && value.len() <= 6 && value.bytes().all(|b| b.is_ascii_digit()),
Kind::Display(_) => false,
_ => true,
}
}
#[derive(Clone, PartialEq, Eq)]
pub(crate) struct Prompt {
pub id: u64,
pub device: String,
pub kind: Kind,
}
impl fmt::Debug for Prompt {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("BluetoothPairingPrompt")
}
}
#[derive(Default)]
struct Pending {
target: Option<String>,
owner: Option<String>,
serial: u64,
generation: u64,
prompt: Option<Prompt>,
reply: Option<oneshot::Sender<Option<Zeroizing<String>>>>,
}
#[derive(Clone, Default)]
pub(crate) struct Pairing(Arc<Mutex<Pending>>);
#[derive(Debug, zbus::DBusError)]
#[zbus(prefix = "org.bluez.Error")]
enum Error {
Rejected(String),
Canceled(String),
}
impl Pairing {
fn begin(&self, target: &str) -> Result<u64, String> {
let mut state = self.0.lock().unwrap();
if state.target.is_some() {
return Err("Another device is already pairing.".into());
}
state.generation = state.generation.wrapping_add(1);
state.target = Some(target.to_owned());
Ok(state.generation)
}
fn finish(&self, generation: u64) {
let mut state = self.0.lock().unwrap();
if state.generation == generation {
state.target = None;
state.prompt = None;
state.reply = None;
}
}
pub fn cancel_local(&self) -> Option<String> {
let mut state = self.0.lock().unwrap();
state.generation = state.generation.wrapping_add(1);
state.prompt = None;
state.reply = None;
state.target.take()
}
pub fn prompt(&self) -> Option<Prompt> {
self.0.lock().unwrap().prompt.clone()
}
pub fn reply(&self, id: u64, value: Option<Zeroizing<String>>) {
let mut state = self.0.lock().unwrap();
if state.prompt.as_ref().is_some_and(|p| p.id == id) {
if let Some(reply) = state.reply.take() {
let _ = reply.send(value);
}
state.prompt = None;
}
}
fn check(state: &Pending, device: &str, sender: Option<&str>) -> Result<(), Error> {
if state.target.as_deref() != Some(device) || sender.is_none() || state.owner.as_deref() != sender {
Err(Error::Rejected("No matching pairing request".into()))
} else {
Ok(())
}
}
async fn ask(&self, device: OwnedObjectPath, kind: Kind, sender: Option<&str>) -> Result<Zeroizing<String>, Error> {
let (id, rx) = {
let mut state = self.0.lock().unwrap();
Self::check(&state, device.as_str(), sender)?;
if state.reply.is_some() {
return Err(Error::Rejected("A pairing question is already pending".into()));
}
state.serial = state.serial.wrapping_add(1);
let id = state.serial;
let (tx, rx) = oneshot::channel();
state.prompt = Some(Prompt {
id,
device: device.to_string(),
kind,
});
state.reply = Some(tx);
(id, rx)
};
let answer = tokio::time::timeout(Duration::from_secs(90), rx).await;
self.reply(id, None);
answer
.ok()
.and_then(Result::ok)
.flatten()
.ok_or_else(|| Error::Canceled("Pairing canceled or timed out".into()))
}
fn display(&self, device: OwnedObjectPath, text: String, sender: Option<&str>) -> Result<(), Error> {
let mut state = self.0.lock().unwrap();
Self::check(&state, device.as_str(), sender)?;
state.serial = state.serial.wrapping_add(1);
state.prompt = Some(Prompt {
id: state.serial,
device: device.to_string(),
kind: Kind::Display(text),
});
Ok(())
}
fn dismiss(&self, sender: Option<&str>) -> Result<(), Error> {
let mut state = self.0.lock().unwrap();
if sender.is_none() || state.owner.as_deref() != sender {
return Err(Error::Rejected("Unknown sender".into()));
}
state.prompt = None;
state.reply = None;
Ok(())
}
}
#[zbus::interface(name = "org.bluez.Agent1")]
impl Pairing {
async fn request_pin_code(
&self,
device: OwnedObjectPath,
#[zbus(header)] header: Header<'_>,
) -> Result<String, Error> {
let answer = self.ask(device, Kind::Pin, header.sender().map(|s| s.as_str())).await?;
if !valid_answer(&Kind::Pin, &answer) {
return Err(Error::Rejected("PIN must have 1–16 characters".into()));
}
Ok(answer.to_string())
}
async fn request_passkey(&self, device: OwnedObjectPath, #[zbus(header)] header: Header<'_>) -> Result<u32, Error> {
let answer = self
.ask(device, Kind::Passkey, header.sender().map(|s| s.as_str()))
.await?;
if !valid_answer(&Kind::Passkey, &answer) {
return Err(Error::Rejected("Passkey must have up to six digits".into()));
}
answer
.parse::<u32>()
.ok()
.filter(|v| *v <= 999_999)
.ok_or_else(|| Error::Rejected("Invalid passkey".into()))
}
async fn request_confirmation(
&self,
device: OwnedObjectPath,
passkey: u32,
#[zbus(header)] header: Header<'_>,
) -> Result<(), Error> {
self.ask(device, Kind::Confirm(passkey), header.sender().map(|s| s.as_str()))
.await
.map(|_| ())
}
async fn request_authorization(
&self,
device: OwnedObjectPath,
#[zbus(header)] header: Header<'_>,
) -> Result<(), Error> {
self.ask(device, Kind::Authorize, header.sender().map(|s| s.as_str()))
.await
.map(|_| ())
}
async fn authorize_service(
&self,
device: OwnedObjectPath,
uuid: String,
#[zbus(header)] header: Header<'_>,
) -> Result<(), Error> {
self.ask(device, Kind::Service(uuid), header.sender().map(|s| s.as_str()))
.await
.map(|_| ())
}
fn display_pin_code(
&self,
device: OwnedObjectPath,
pincode: String,
#[zbus(header)] header: Header<'_>,
) -> Result<(), Error> {
self.display(
device,
format!("Type {pincode} on the device, then press Enter."),
header.sender().map(|s| s.as_str()),
)
}
fn display_passkey(
&self,
device: OwnedObjectPath,
passkey: u32,
entered: u16,
#[zbus(header)] header: Header<'_>,
) -> Result<(), Error> {
self.display(
device,
format!("Type {passkey:06} on the device, then press Enter. {entered} digits entered."),
header.sender().map(|s| s.as_str()),
)
}
fn cancel(&self, #[zbus(header)] header: Header<'_>) -> Result<(), Error> {
self.dismiss(header.sender().map(|s| s.as_str()))
}
fn release(&self, #[zbus(header)] header: Header<'_>) -> Result<(), Error> {
self.dismiss(header.sender().map(|s| s.as_str()))?;
self.cancel_local();
self.0.lock().unwrap().owner = None;
Ok(())
}
}
impl Client {
async fn register_agent(&self) -> Result<(), String> {
let owner: String = proxy(
&self.connection,
"org.freedesktop.DBus",
"/org/freedesktop/DBus",
"org.freedesktop.DBus",
)
.await?
.call("GetNameOwner", &("org.bluez",))
.await
.map_err(|e| e.to_string())?;
let mut registered = self.registered.lock().await;
if registered.as_ref() == Some(&owner) && self.agent.0.lock().unwrap().owner.as_ref() == Some(&owner) {
return Ok(());
}
self.agent.0.lock().unwrap().owner = Some(owner.clone());
self.connection
.object_server()
.at(PATH, self.agent.clone())
.await
.map_err(|e| e.to_string())?;
let path = OwnedObjectPath::try_from(PATH).unwrap();
proxy(&self.connection, "org.bluez", "/org/bluez", "org.bluez.AgentManager1")
.await?
.call::<_, _, ()>("RegisterAgent", &(path, "KeyboardDisplay"))
.await
.map_err(|e| e.to_string())?;
*registered = Some(owner);
Ok(())
}
pub(crate) async fn pair(&self, path: &str, epoch: u64) -> Result<(), String> {
let generation = self.agent.begin(path)?;
if self.closed.load(std::sync::atomic::Ordering::SeqCst) || self.epoch() != epoch {
self.agent.finish(generation);
return Err("Pairing canceled.".into());
}
let result = async {
self.register_agent().await?;
if self.agent.0.lock().unwrap().generation != generation {
return Err("Pairing canceled.".into());
}
bluetooth::device_call(&self.connection, path, "Pair").await
}
.await;
let still_active = self.agent.0.lock().unwrap().generation == generation;
self.agent.finish(generation);
if !still_active {
return Err("Pairing canceled.".into());
}
result?;
bluetooth::trust(&self.connection, path, true).await?;
bluetooth::device_call(&self.connection, path, "Connect").await
}
pub(crate) async fn cancel_pairing(&self) -> Result<(), String> {
if let Some(path) = self.agent.cancel_local() {
bluetooth::device_call(&self.connection, &path, "CancelPairing").await
} else {
Ok(())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn stale_answers_and_unknown_devices_cannot_approve_pairing() {
let pairing = Pairing::default();
pairing.0.lock().unwrap().owner = Some(":1.2".into());
let generation = pairing.begin("/device/1").unwrap();
let path = OwnedObjectPath::try_from("/device/2").unwrap();
assert!(pairing.ask(path, Kind::Authorize, Some(":1.2")).await.is_err());
let path = OwnedObjectPath::try_from("/device/1").unwrap();
assert!(pairing.ask(path.clone(), Kind::Authorize, Some(":1.9")).await.is_err());
let other = pairing.clone();
let task = tokio::spawn(async move { other.ask(path, Kind::Confirm(123456), Some(":1.2")).await });
tokio::task::yield_now().await;
let id = pairing.prompt().unwrap().id;
pairing.reply(id.wrapping_sub(1), Some(Zeroizing::new(String::new())));
assert!(pairing.prompt().is_some());
pairing.reply(id, Some(Zeroizing::new(String::new())));
assert!(task.await.unwrap().is_ok());
pairing.finish(generation);
assert!(pairing.prompt().is_none());
}
#[tokio::test]
async fn cancel_closes_the_question_and_old_completion_cannot_clear_new_pairing() {
let pairing = Pairing::default();
pairing.0.lock().unwrap().owner = Some(":1.2".into());
let old = pairing.begin("/device/1").unwrap();
let other = pairing.clone();
let task = tokio::spawn(async move {
other
.ask(OwnedObjectPath::try_from("/device/1").unwrap(), Kind::Pin, Some(":1.2"))
.await
});
tokio::task::yield_now().await;
assert!(pairing.prompt().is_some());
pairing.cancel_local();
assert!(task.await.unwrap().is_err());
pairing.begin("/device/2").unwrap();
pairing.finish(old);
assert_eq!(pairing.0.lock().unwrap().target.as_deref(), Some("/device/2"));
}
}
@@ -0,0 +1,250 @@
use std::collections::HashMap;
use std::time::Duration;
use zbus::Connection;
use zbus::fdo::ManagedObjects;
use zbus::zvariant::{OwnedObjectPath, OwnedValue};
use super::{Client, proxy};
const BLUEZ: &str = "org.bluez";
const ADAPTER: &str = "org.bluez.Adapter1";
const DEVICE: &str = "org.bluez.Device1";
#[derive(Clone, Debug)]
pub(crate) struct Adapter {
pub path: String,
pub name: String,
pub powered: bool,
pub discovering: bool,
}
#[derive(Clone, Debug)]
pub(crate) struct Device {
pub path: String,
pub adapter: String,
pub name: String,
pub address: String,
pub icon: String,
pub connected: bool,
pub paired: bool,
pub trusted: bool,
pub battery: Option<u8>,
}
#[derive(Clone, Debug, Default)]
pub(crate) struct Bluetooth {
pub adapters: Vec<Adapter>,
pub devices: Vec<Device>,
}
pub(crate) async fn read(connection: &Connection) -> Result<Bluetooth, String> {
let objects: ManagedObjects = proxy(connection, BLUEZ, "/", "org.freedesktop.DBus.ObjectManager")
.await?
.call("GetManagedObjects", &())
.await
.map_err(|e| e.to_string())?;
Ok(snapshot(&objects))
}
fn string(properties: &HashMap<String, OwnedValue>, key: &str) -> String {
properties
.get(key)
.and_then(|v| <&str>::try_from(v).ok())
.unwrap_or_default()
.to_owned()
}
fn boolean(properties: &HashMap<String, OwnedValue>, key: &str) -> bool {
properties
.get(key)
.and_then(|v| bool::try_from(v).ok())
.unwrap_or(false)
}
fn snapshot(objects: &ManagedObjects) -> Bluetooth {
let mut result = Bluetooth::default();
for (path, interfaces) in objects {
if let Some(p) = interfaces.get(ADAPTER) {
result.adapters.push(Adapter {
path: path.to_string(),
name: string(p, "Alias"),
powered: boolean(p, "Powered"),
discovering: boolean(p, "Discovering"),
});
}
if let Some(p) = interfaces.get(DEVICE) {
let Some(adapter) = p
.get("Adapter")
.and_then(|v| <&zbus::zvariant::ObjectPath>::try_from(v).ok())
else {
continue;
};
let address = string(p, "Address");
let alias = string(p, "Alias");
result.devices.push(Device {
path: path.to_string(),
adapter: adapter.to_string(),
name: if alias.is_empty() { address.clone() } else { alias },
address,
icon: string(p, "Icon"),
connected: boolean(p, "Connected"),
paired: boolean(p, "Paired"),
trusted: boolean(p, "Trusted"),
battery: interfaces
.get("org.bluez.Battery1")
.and_then(|p| p.get("Percentage"))
.and_then(|v| u8::try_from(v).ok()),
});
}
}
result.adapters.sort_by(|a, b| a.path.cmp(&b.path));
result.devices.sort_by(|a, b| {
b.connected
.cmp(&a.connected)
.then(b.paired.cmp(&a.paired))
.then(a.name.cmp(&b.name))
});
result
}
pub(crate) async fn power(connection: &Connection, path: &str, on: bool) -> Result<(), String> {
proxy(connection, BLUEZ, path, ADAPTER)
.await?
.set_property("Powered", on)
.await
.map_err(|e| e.to_string())
}
pub(crate) async fn adapter_call(connection: &Connection, path: &str, method: &str) -> Result<(), String> {
proxy(connection, BLUEZ, path, ADAPTER)
.await?
.call::<_, _, ()>(method, &())
.await
.map_err(|e| e.to_string())
}
pub(crate) async fn device_call(connection: &Connection, path: &str, method: &str) -> Result<(), String> {
proxy(connection, BLUEZ, path, DEVICE)
.await?
.call::<_, _, ()>(method, &())
.await
.map_err(|e| e.to_string())
}
pub(crate) async fn trust(connection: &Connection, path: &str, on: bool) -> Result<(), String> {
proxy(connection, BLUEZ, path, DEVICE)
.await?
.set_property("Trusted", on)
.await
.map_err(|e| e.to_string())
}
pub(crate) async fn forget(connection: &Connection, adapter: &str, path: &str) -> Result<(), String> {
let path = OwnedObjectPath::try_from(path).map_err(|e| e.to_string())?;
proxy(connection, BLUEZ, adapter, ADAPTER)
.await?
.call::<_, _, ()>("RemoveDevice", &(path,))
.await
.map_err(|e| e.to_string())
}
impl Client {
pub(crate) async fn discover(&self, path: String, epoch: u64) -> Result<(), String> {
// BlueZ discovery is reference counted per D-Bus caller. Never stop
// discovery owned by another app, and never acquire a lease twice.
if self.discovery.lock().unwrap().contains_key(&path) {
return Ok(());
}
adapter_call(&self.connection, &path, "StartDiscovery").await?;
if self.closed.load(std::sync::atomic::Ordering::SeqCst) || self.epoch() != epoch {
let _ = adapter_call(&self.connection, &path, "StopDiscovery").await;
return Err("Discovery canceled.".into());
}
let lease = std::time::Instant::now();
self.discovery.lock().unwrap().insert(path.clone(), lease);
let leases = self.discovery.clone();
let connection = self.connection.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(30)).await;
let owned = {
let mut leases = leases.lock().unwrap();
if leases.get(&path) == Some(&lease) {
leases.remove(&path);
true
} else {
false
}
};
if owned {
let _ = adapter_call(&connection, &path, "StopDiscovery").await;
}
});
Ok(())
}
pub(crate) async fn stop_discovery(&self) -> Result<(), String> {
let paths = std::mem::take(&mut *self.discovery.lock().unwrap());
let mut result = Ok(());
for (path, _) in paths {
if let Err(error) = adapter_call(&self.connection, &path, "StopDiscovery").await {
result = Err(error);
}
}
result
}
pub(crate) fn discovering(&self, path: &str) -> bool {
self.discovery.lock().unwrap().contains_key(path)
}
}
#[cfg(test)]
mod tests {
use zbus::zvariant::Str;
use super::*;
#[test]
fn parse_devices_and_keep_adapter_identity() {
let adapter = OwnedObjectPath::try_from("/org/bluez/hci0").unwrap();
let objects = HashMap::from([
(
adapter.clone(),
HashMap::from([(
zbus::names::OwnedInterfaceName::try_from(ADAPTER).unwrap(),
HashMap::from([
("Alias".into(), OwnedValue::from(Str::from("Laptop"))),
("Powered".into(), OwnedValue::from(true)),
]),
)]),
),
(
OwnedObjectPath::try_from("/org/bluez/hci0/dev_1").unwrap(),
HashMap::from([
(
zbus::names::OwnedInterfaceName::try_from(DEVICE).unwrap(),
HashMap::from([
(
"Adapter".into(),
OwnedValue::try_from(zbus::zvariant::Value::from(adapter)).unwrap(),
),
("Alias".into(), OwnedValue::from(Str::from("Headphones"))),
("Connected".into(), OwnedValue::from(true)),
("Paired".into(), OwnedValue::from(true)),
]),
),
(
zbus::names::OwnedInterfaceName::try_from("org.bluez.Battery1").unwrap(),
HashMap::from([("Percentage".into(), OwnedValue::from(80u8))]),
),
]),
),
]);
let snapshot = snapshot(&objects);
assert!(snapshot.adapters[0].powered);
assert!(snapshot.devices[0].connected);
assert_eq!(snapshot.devices[0].adapter, "/org/bluez/hci0");
assert_eq!(snapshot.devices[0].battery, Some(80));
}
}
@@ -0,0 +1,191 @@
use cosmic::Element;
use cosmic::iced::{Alignment, Length};
use cosmic::widget::{column, row};
use super::Command;
use super::ui::{Input, message};
use crate::{App, Message, visuals};
impl App {
pub(super) fn bluetooth_view(
&self,
result: &Result<super::bluetooth::Bluetooth, String>,
) -> Element<'static, Message> {
let s = &self.connections;
let p = visuals::Palette::from_resolved(&self.resolved.presented);
let ready = s.busy.is_none();
let Ok(bluetooth) = result else {
return self.connection_card(
column([])
.spacing(8)
.push(self.label("Bluetooth unavailable", 14.))
.push(self.connection_note(result.as_ref().unwrap_err())),
);
};
let mut body = column([]).spacing(6);
if bluetooth.adapters.len() > 1 {
let mut choices = row([]).spacing(6);
for adapter in &bluetooth.adapters {
choices = choices.push(self.connection_button(
&adapter.name,
Input::Adapter(adapter.path.clone()),
ready,
s.adapter.as_ref() == Some(&adapter.path),
));
}
body = body.push(choices);
}
let Some(adapter) = bluetooth.adapters.iter().find(|a| Some(&a.path) == s.adapter.as_ref()) else {
return body
.push(self.connection_note("No Bluetooth adapter is available."))
.into();
};
body = body.push(
self.connection_card(
row([])
.spacing(8)
.align_y(Alignment::Center)
.push(
column([])
.spacing(2)
.width(Length::Fill)
.push(self.label("Bluetooth", 15.))
.push(self.connection_note(&format!(
"{} · {}",
adapter.name,
if adapter.powered { "On" } else { "Off" }
))),
)
.push(
ferese_theme::controls::switch(adapter.powered, p)
.name("Bluetooth power")
.on_press_maybe(ready.then(|| {
message(Input::Run(Command::BluetoothPower(
adapter.path.clone(),
!adapter.powered,
)))
})),
),
),
);
if !adapter.powered {
return body.into();
}
let scanning = s.client.as_ref().is_some_and(|c| c.discovering(&adapter.path));
body = body.push(
row([])
.align_y(Alignment::Center)
.push(
self.label(
if scanning {
"Searching for devices…"
} else {
"Devices"
},
14.,
)
.width(Length::Fill),
)
.push(self.connection_button(
if scanning { "Stop scan" } else { "Find devices" },
Input::Run(if scanning {
Command::BluetoothStop
} else {
Command::BluetoothScan(adapter.path.clone())
}),
ready,
false,
)),
);
body = body.push(self.connection_note(if adapter.discovering {
"Put your device in pairing mode. Discovery stops automatically after 30 seconds."
} else {
"Choose Find devices to discover nearby devices in pairing mode."
}));
let devices: Vec<_> = bluetooth.devices.iter().filter(|d| d.adapter == adapter.path).collect();
for paired in [true, false] {
body = body.push(self.label(if paired { "Paired devices" } else { "Nearby devices" }, 14.));
if !devices.iter().any(|d| d.paired == paired) {
body = body.push(self.connection_note(if paired {
"No paired devices yet."
} else {
"No nearby devices found."
}));
}
for device in devices.iter().filter(|d| d.paired == paired) {
let state = if device.connected {
"Connected"
} else if device.paired {
"Disconnected"
} else {
"Not paired"
};
let detail = format!(
"{state} · {}{}{}",
device.address,
if device.trusted { " · Trusted" } else { "" },
device.battery.map(|b| format!(" · {b}% battery")).unwrap_or_default()
);
let mut controls = row([]).spacing(6);
let action = if device.connected {
Command::BluetoothDisconnect(device.path.clone())
} else if device.paired {
Command::BluetoothConnect(device.path.clone())
} else {
Command::BluetoothPair(device.path.clone())
};
controls = controls.push(self.connection_button(
if device.connected {
"Disconnect"
} else if device.paired {
"Connect"
} else {
"Pair"
},
Input::Run(action),
ready,
false,
));
if device.paired {
controls = controls.push(self.connection_button(
if device.trusted { "Untrust" } else { "Trust" },
Input::Run(Command::BluetoothTrust(device.path.clone(), !device.trusted)),
ready,
false,
));
controls = controls.push(self.connection_button(
"Forget",
Input::Forget(Command::BluetoothForget(device.adapter.clone(), device.path.clone())),
ready,
false,
));
}
let icon = if device.icon.contains("audio") {
"M3 9v6h4l5 4V5L7 9z M16 8a6 6 0 0 1 0 8"
} else {
"M12 3l6 5-12 8 6 5V3 M6 8l12 8-6 5"
};
body = body.push(
self.connection_card(
row([])
.spacing(8)
.align_y(Alignment::Center)
.push(visuals::action_icon(
icon,
if device.connected { p.accent } else { p.muted },
))
.push(
column([])
.spacing(2)
.width(Length::Fill)
.push(self.label(device.name.clone(), 13.))
.push(self.connection_note(&detail)),
)
.push(controls),
),
);
}
}
body.into()
}
}
@@ -0,0 +1,572 @@
//! Private-bus contracts exercise serialized method/property signatures without hardware.
use std::collections::HashMap;
use std::io::{BufRead, BufReader};
use std::process::{Child, Command, Stdio};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use zbus::Connection;
use zbus::message::Header;
use zbus::zvariant::{OwnedObjectPath, OwnedValue, Str};
use zeroize::Zeroizing;
use super::agent::Kind;
use super::{Client, Command as Action, proxy};
const NM: &str = "org.freedesktop.NetworkManager";
const ROOT: &str = "/org/freedesktop/NetworkManager";
const ADAPTER: &str = "/org/bluez/hci0";
const DEVICE: &str = "/org/bluez/hci0/dev_1";
type Settings = HashMap<String, HashMap<String, OwnedValue>>;
fn path(value: &str) -> OwnedObjectPath {
OwnedObjectPath::try_from(value).unwrap()
}
struct Bus {
child: Child,
address: String,
}
impl Bus {
fn new() -> Self {
let mut child = Command::new("dbus-daemon")
.args(["--session", "--nofork", "--print-address=1"])
.stdout(Stdio::piped())
.spawn()
.unwrap();
let mut address = String::new();
BufReader::new(child.stdout.take().unwrap())
.read_line(&mut address)
.unwrap();
assert!(
!address.trim().is_empty(),
"dbus-daemon did not provide an address; check whether Unix sockets are permitted"
);
Self {
child,
address: address.trim().to_owned(),
}
}
async fn connect(&self) -> Connection {
zbus::connection::Builder::address(self.address.as_str())
.unwrap()
.method_timeout(Duration::from_secs(5))
.build()
.await
.unwrap()
}
}
impl Drop for Bus {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
#[derive(Default)]
struct NetworkState {
enabled: bool,
scans: usize,
activated: bool,
deleted: bool,
updates: usize,
received: Option<Settings>,
}
struct Manager(Arc<Mutex<NetworkState>>);
#[zbus::interface(name = "org.freedesktop.NetworkManager")]
impl Manager {
fn get_devices(&self) -> Vec<OwnedObjectPath> {
vec![path("/device")]
}
#[zbus(property)]
fn wireless_enabled(&self) -> bool {
self.0.lock().unwrap().enabled
}
#[zbus(property)]
fn set_wireless_enabled(&self, enabled: bool) {
self.0.lock().unwrap().enabled = enabled;
}
#[zbus(property)]
fn wireless_hardware_enabled(&self) -> bool {
true
}
fn activate_connection(
&self,
connection: OwnedObjectPath,
device: OwnedObjectPath,
ap: OwnedObjectPath,
) -> OwnedObjectPath {
assert_eq!(connection.as_str(), "/profile");
assert_eq!(device.as_str(), "/device");
assert_eq!(ap.as_str(), "/ap");
self.0.lock().unwrap().activated = true;
path("/active")
}
fn add_and_activate_connection(
&self,
settings: Settings,
device: OwnedObjectPath,
ap: OwnedObjectPath,
) -> (OwnedObjectPath, OwnedObjectPath) {
assert_eq!(device.as_str(), "/device");
assert_eq!(ap.as_str(), "/ap");
let mut state = self.0.lock().unwrap();
state.received = Some(settings);
state.activated = true;
(path("/profile"), path("/active"))
}
}
struct Wireless(Arc<Mutex<NetworkState>>);
#[zbus::interface(name = "org.freedesktop.NetworkManager.Device.Wireless")]
impl Wireless {
fn get_all_access_points(&self) -> Vec<OwnedObjectPath> {
vec![path("/ap")]
}
fn request_scan(&self, options: HashMap<String, OwnedValue>) {
assert!(options.is_empty());
self.0.lock().unwrap().scans += 1;
}
#[zbus(property)]
fn active_access_point(&self) -> OwnedObjectPath {
path(if self.0.lock().unwrap().activated { "/ap" } else { "/" })
}
}
struct NetworkDevice(Arc<Mutex<NetworkState>>);
#[zbus::interface(name = "org.freedesktop.NetworkManager.Device")]
impl NetworkDevice {
#[zbus(property)]
fn device_type(&self) -> u32 {
2
}
#[zbus(property)]
fn interface(&self) -> &str {
"wlan0"
}
#[zbus(property)]
fn state(&self) -> u32 {
if self.0.lock().unwrap().activated { 100 } else { 30 }
}
fn disconnect(&self) {
self.0.lock().unwrap().activated = false;
}
}
struct AccessPoint;
#[zbus::interface(name = "org.freedesktop.NetworkManager.AccessPoint")]
impl AccessPoint {
#[zbus(property)]
fn ssid(&self) -> Vec<u8> {
b"Test network".to_vec()
}
#[zbus(property)]
fn strength(&self) -> u8 {
75
}
#[zbus(property)]
fn flags(&self) -> u32 {
1
}
#[zbus(property)]
fn wpa_flags(&self) -> u32 {
0
}
#[zbus(property)]
fn rsn_flags(&self) -> u32 {
0x100
}
}
struct Profiles(Arc<Mutex<NetworkState>>);
#[zbus::interface(name = "org.freedesktop.NetworkManager.Settings")]
impl Profiles {
fn list_connections(&self) -> Vec<OwnedObjectPath> {
if self.0.lock().unwrap().deleted {
vec![]
} else {
vec![path("/profile")]
}
}
}
struct Profile(Arc<Mutex<NetworkState>>);
#[zbus::interface(name = "org.freedesktop.NetworkManager.Settings.Connection")]
impl Profile {
fn get_settings(&self) -> Settings {
HashMap::from([
(
"connection".into(),
HashMap::from([("id".into(), OwnedValue::from(Str::from("Test network")))]),
),
(
"802-11-wireless".into(),
HashMap::from([(
"ssid".into(),
OwnedValue::try_from(zbus::zvariant::Value::from(b"Test network".as_slice())).unwrap(),
)]),
),
(
"802-11-wireless-security".into(),
HashMap::from([("key-mgmt".into(), OwnedValue::from(Str::from("wpa-psk")))]),
),
])
}
fn update(&self, settings: Settings) {
let mut state = self.0.lock().unwrap();
state.updates += 1;
state.received = Some(settings);
}
fn delete(&self) {
self.0.lock().unwrap().deleted = true;
}
}
struct Active;
#[zbus::interface(name = "org.freedesktop.NetworkManager.Connection.Active")]
impl Active {
#[zbus(property)]
fn state(&self) -> u32 {
2
}
}
async fn network_service(bus: &Bus, state: &Arc<Mutex<NetworkState>>) -> Connection {
let server = bus.connect().await;
server.request_name(NM).await.unwrap();
let objects = server.object_server();
objects.at(ROOT, Manager(state.clone())).await.unwrap();
objects.at("/device", Wireless(state.clone())).await.unwrap();
objects.at("/device", NetworkDevice(state.clone())).await.unwrap();
objects.at("/ap", AccessPoint).await.unwrap();
objects
.at("/org/freedesktop/NetworkManager/Settings", Profiles(state.clone()))
.await
.unwrap();
objects.at("/profile", Profile(state.clone())).await.unwrap();
objects.at("/active", Active).await.unwrap();
server
}
#[tokio::test]
#[ignore = "requires dbus-daemon; uses a private bus, never the system bus"]
async fn wifi_controls_and_missing_bluetooth_are_independent() {
let bus = Bus::new();
let state = Arc::new(Mutex::new(NetworkState::default()));
let _server = network_service(&bus, &state).await;
let client = Client::with_connection(bus.connect().await);
let initial = client.snapshot().await;
assert!(initial.bluetooth.is_err());
assert!(!initial.wifi.unwrap().enabled);
client.execute(Action::WifiPower(true)).await.unwrap();
client.execute(Action::WifiScan).await.unwrap();
let wifi = client.snapshot().await.wifi.unwrap();
assert!(wifi.enabled);
assert_eq!(wifi.networks[0].name, "Test network");
assert_eq!(wifi.networks[0].saved.as_deref(), Some("/profile"));
client
.execute(Action::WifiConnect(
wifi.networks[0].clone(),
Zeroizing::new("updated-password".into()),
))
.await
.unwrap();
{
let state = state.lock().unwrap();
assert_eq!(state.updates, 1);
assert_eq!(state.scans, 1);
assert_eq!(
<&str>::try_from(&state.received.as_ref().unwrap()["802-11-wireless-security"]["psk"]).unwrap(),
"updated-password"
);
}
assert!(client.snapshot().await.wifi.unwrap().networks[0].connected);
client.execute(Action::WifiDisconnect("/device".into())).await.unwrap();
assert!(!client.snapshot().await.wifi.unwrap().networks[0].connected);
let mut unsaved = wifi.networks[0].clone();
unsaved.saved = None;
client
.execute(Action::WifiConnect(unsaved, Zeroizing::new("new-password".into())))
.await
.unwrap();
client.execute(Action::WifiForget("/profile".into())).await.unwrap();
assert!(client.snapshot().await.wifi.unwrap().saved.is_empty());
}
#[derive(Default)]
struct BluetoothState {
powered: bool,
discovering: bool,
paired: bool,
connected: bool,
trusted: bool,
starts: usize,
stops: usize,
removed: bool,
agent: Option<(String, OwnedObjectPath)>,
}
struct Adapter(Arc<Mutex<BluetoothState>>);
#[zbus::interface(name = "org.bluez.Adapter1")]
impl Adapter {
#[zbus(property)]
fn alias(&self) -> &str {
"Laptop"
}
#[zbus(property)]
fn powered(&self) -> bool {
self.0.lock().unwrap().powered
}
#[zbus(property)]
fn set_powered(&self, powered: bool) {
self.0.lock().unwrap().powered = powered;
}
#[zbus(property)]
fn discovering(&self) -> bool {
self.0.lock().unwrap().discovering
}
fn start_discovery(&self) {
let mut s = self.0.lock().unwrap();
s.discovering = true;
s.starts += 1;
}
fn stop_discovery(&self) {
let mut s = self.0.lock().unwrap();
s.discovering = false;
s.stops += 1;
}
fn remove_device(&self, device: OwnedObjectPath) {
assert_eq!(device.as_str(), DEVICE);
self.0.lock().unwrap().removed = true;
}
}
struct BluetoothDevice(Arc<Mutex<BluetoothState>>);
#[zbus::interface(name = "org.bluez.Device1")]
impl BluetoothDevice {
#[zbus(property)]
fn adapter(&self) -> OwnedObjectPath {
path(ADAPTER)
}
#[zbus(property)]
fn alias(&self) -> &str {
"Headphones"
}
#[zbus(property)]
fn address(&self) -> &str {
"AA:BB:CC:DD:EE:FF"
}
#[zbus(property)]
fn icon(&self) -> &str {
"audio-headset"
}
#[zbus(property)]
fn connected(&self) -> bool {
self.0.lock().unwrap().connected
}
#[zbus(property)]
fn paired(&self) -> bool {
self.0.lock().unwrap().paired
}
#[zbus(property)]
fn trusted(&self) -> bool {
self.0.lock().unwrap().trusted
}
#[zbus(property)]
fn set_trusted(&self, trusted: bool) {
self.0.lock().unwrap().trusted = trusted;
}
async fn pair(&self, #[zbus(connection)] connection: &Connection) -> zbus::fdo::Result<()> {
let (owner, agent) = self.0.lock().unwrap().agent.clone().unwrap();
proxy(connection, &owner, agent.as_str(), "org.bluez.Agent1")
.await
.map_err(zbus::fdo::Error::Failed)?
.call::<_, _, ()>("RequestConfirmation", &(path(DEVICE), 123456u32))
.await
.map_err(|e| zbus::fdo::Error::Failed(e.to_string()))?;
self.0.lock().unwrap().paired = true;
Ok(())
}
fn connect(&self) {
self.0.lock().unwrap().connected = true;
}
fn disconnect(&self) {
self.0.lock().unwrap().connected = false;
}
fn cancel_pairing(&self) {}
}
struct AgentManager(Arc<Mutex<BluetoothState>>);
#[zbus::interface(name = "org.bluez.AgentManager1")]
impl AgentManager {
fn register_agent(&self, agent: OwnedObjectPath, capability: String, #[zbus(header)] header: Header<'_>) {
assert_eq!(capability, "KeyboardDisplay");
self.0.lock().unwrap().agent = Some((header.sender().unwrap().to_string(), agent));
}
}
async fn bluetooth_service(bus: &Bus, state: &Arc<Mutex<BluetoothState>>) -> Connection {
let server = bus.connect().await;
server.request_name("org.bluez").await.unwrap();
let objects = server.object_server();
objects.at("/", zbus::fdo::ObjectManager).await.unwrap();
objects.at("/org/bluez", AgentManager(state.clone())).await.unwrap();
objects.at(ADAPTER, Adapter(state.clone())).await.unwrap();
objects.at(DEVICE, BluetoothDevice(state.clone())).await.unwrap();
server
}
async fn wait_for_prompt(client: &Client) -> super::agent::Prompt {
tokio::time::timeout(Duration::from_secs(3), async {
loop {
if let Some(prompt) = client.prompt() {
break prompt;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.unwrap()
}
#[tokio::test]
#[ignore = "requires dbus-daemon; uses a private bus, never the system bus"]
async fn bluetooth_pairing_controls_and_close_cleanup() {
let bus = Bus::new();
let state = Arc::new(Mutex::new(BluetoothState::default()));
let _server = bluetooth_service(&bus, &state).await;
let client = Client::with_connection(bus.connect().await);
let epoch = client.epoch();
client.leave_now();
assert!(
client
.execute_checked(Action::BluetoothPower(ADAPTER.into(), true), epoch)
.await
.is_err()
);
assert!(!state.lock().unwrap().powered);
client
.execute(Action::BluetoothPower(ADAPTER.into(), true))
.await
.unwrap();
client.execute(Action::BluetoothScan(ADAPTER.into())).await.unwrap();
client.execute(Action::BluetoothScan(ADAPTER.into())).await.unwrap();
assert_eq!(state.lock().unwrap().starts, 1);
let snapshot = client.snapshot().await;
assert!(snapshot.wifi.is_err());
let bluetooth = snapshot.bluetooth.unwrap();
assert!(bluetooth.adapters[0].powered);
assert_eq!(bluetooth.devices[0].name, "Headphones");
let other = client.clone();
let pairing = tokio::spawn(async move { other.execute(Action::BluetoothPair(DEVICE.into())).await });
let prompt = wait_for_prompt(&client).await;
assert_eq!(prompt.kind, Kind::Confirm(123456));
client.reply(prompt.id, Some(Zeroizing::new(String::new())));
pairing.await.unwrap().unwrap();
{
let state = state.lock().unwrap();
assert!(state.paired && state.connected && state.trusted);
}
client
.execute(Action::BluetoothDisconnect(DEVICE.into()))
.await
.unwrap();
client
.execute(Action::BluetoothTrust(DEVICE.into(), false))
.await
.unwrap();
assert!(!state.lock().unwrap().trusted);
client.execute(Action::BluetoothConnect(DEVICE.into())).await.unwrap();
client
.execute(Action::BluetoothForget(ADAPTER.into(), DEVICE.into()))
.await
.unwrap();
assert!(state.lock().unwrap().removed);
let other = client.clone();
let pairing = tokio::spawn(async move { other.execute(Action::BluetoothPair(DEVICE.into())).await });
wait_for_prompt(&client).await;
// Window destruction happens on a UI thread outside Tokio's context.
let closing = client.clone();
std::thread::spawn(move || closing.shutdown()).join().unwrap();
assert!(pairing.await.unwrap().is_err());
assert!(client.execute(Action::BluetoothConnect(DEVICE.into())).await.is_err());
assert!(client.prompt().is_none());
tokio::time::timeout(Duration::from_secs(3), async {
while state.lock().unwrap().discovering {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.unwrap();
assert_eq!(state.lock().unwrap().stops, 1);
}
#[tokio::test]
#[ignore = "requires dbus-daemon; uses a private bus, never the system bus"]
async fn bluetooth_agent_recovers_after_service_restart() {
let bus = Bus::new();
let state = Arc::new(Mutex::new(BluetoothState::default()));
let old_server = bluetooth_service(&bus, &state).await;
let client = Client::with_connection(bus.connect().await);
let other = client.clone();
let pairing = tokio::spawn(async move { other.execute(Action::BluetoothPair(DEVICE.into())).await });
let prompt = wait_for_prompt(&client).await;
client.reply(prompt.id, Some(Zeroizing::new(String::new())));
pairing.await.unwrap().unwrap();
old_server.release_name("org.bluez").await.unwrap();
let new_state = Arc::new(Mutex::new(BluetoothState::default()));
let _server = bluetooth_service(&bus, &new_state).await;
let other = client.clone();
let pairing = tokio::spawn(async move { other.execute(Action::BluetoothPair(DEVICE.into())).await });
let prompt = wait_for_prompt(&client).await;
client.reply(prompt.id, Some(Zeroizing::new(String::new())));
pairing.await.unwrap().unwrap();
assert!(new_state.lock().unwrap().paired);
}
@@ -0,0 +1,322 @@
use std::sync::Arc;
use cosmic::app::Task;
use zeroize::{Zeroize, Zeroizing};
use super::ui::Input;
use super::{Client, Command, Network, Snapshot};
use crate::{App, Message};
impl App {
pub(crate) fn refresh_connections(&mut self) -> Task<Message> {
if self.connections.loading {
return Task::none();
}
self.connections.loading = true;
if let Some(client) = self.connections.client.clone() {
Task::perform(async move { Box::new(client.snapshot().await) }, |snapshot| {
cosmic::Action::App(Message::ConnectionsRefreshed(snapshot))
})
} else {
Task::perform(Client::new(), |client| {
cosmic::Action::App(Message::ConnectionsReady(client))
})
}
}
pub(crate) fn connections_ready(&mut self, result: Result<Arc<Client>, String>) -> Task<Message> {
self.connections.loading = false;
match result {
Ok(client) => {
self.connections.client = Some(client);
self.connections.error = None;
self.refresh_connections()
}
Err(error) => {
self.connections.error = Some(error);
Task::none()
}
}
}
pub(crate) fn connections_refreshed(&mut self, snapshot: Snapshot) {
self.connections.loading = false;
if let Ok(bluetooth) = &snapshot.bluetooth
&& !bluetooth
.adapters
.iter()
.any(|a| Some(&a.path) == self.connections.adapter.as_ref())
{
self.connections.adapter = bluetooth.adapters.first().map(|a| a.path.clone());
}
self.connections.snapshot = Some(snapshot);
}
pub(crate) fn connection_finished(&mut self, result: Result<(), String>) -> Task<Message> {
self.connections.busy = None;
self.connections.error = result.err();
self.connections.prompt = None;
self.connections.pairing_input.zeroize();
self.refresh_connections()
}
pub(crate) fn leave_connections(&mut self) -> Task<Message> {
self.connections.password.zeroize();
self.connections.pairing_input.zeroize();
self.connections.selected = None;
self.connections.hidden = false;
self.connections.prompt = None;
self.connections.forget = None;
if let Some(client) = &self.connections.client {
client.leave_now();
}
Task::none()
}
pub(crate) fn connections_input(&mut self, input: Input) -> Task<Message> {
match input {
Input::Refresh => return self.refresh_connections(),
Input::PollPrompt => {
let prompt = self.connections.client.as_ref().and_then(|c| c.prompt());
if prompt != self.connections.prompt {
self.connections.pairing_input.zeroize();
let needs_input = prompt
.as_ref()
.is_some_and(|p| matches!(p.kind, super::agent::Kind::Pin | super::agent::Kind::Passkey));
self.connections.prompt = prompt;
let scroll = cosmic::iced::widget::scrollable::snap_to(
cosmic::widget::Id::new("settings-content"),
cosmic::iced::widget::scrollable::RelativeOffset { x: None, y: Some(0.) },
);
return if needs_input {
Task::batch([
scroll,
cosmic::widget::text_input::focus(cosmic::widget::Id::new("bluetooth-passkey")),
])
} else {
scroll
};
}
}
Input::Tab(tab) => {
self.connections.tab = tab;
self.connections.password.zeroize();
self.connections.selected = None;
self.connections.hidden = false;
}
Input::Adapter(path) => self.connections.adapter = Some(path),
Input::Password(value) => self.connections.password = value,
Input::HiddenName(name) => self.connections.hidden_name = name,
Input::HiddenSecurity(security) => self.connections.hidden_security = security,
Input::Select(network) => {
self.connections.password.zeroize();
self.connections.hidden = false;
self.connections.selected = Some(network);
self.connections.error = None;
return cosmic::widget::text_input::focus(cosmic::widget::Id::new("wifi-password"));
}
Input::Hidden(hidden) => {
self.connections.hidden = hidden;
self.connections.selected = None;
self.connections.password.zeroize();
self.connections.hidden_name.clear();
}
Input::CancelWifi => {
self.connections.password.zeroize();
self.connections.selected = None;
self.connections.hidden = false;
}
Input::SubmitWifi => {
if let Some(network) = self.wifi_target()
&& self.connections.busy.is_none()
&& network.security.can_create()
&& super::wifi::valid_password(network.security, &self.connections.password)
{
let password = std::mem::replace(&mut self.connections.password, Zeroizing::new(String::new()));
return self.connections_input(Input::Run(Command::WifiConnect(network, password)));
}
}
Input::PairingValue(id, value) => {
if self.connections.prompt.as_ref().is_some_and(|p| p.id == id) {
self.connections.pairing_input = value;
}
}
Input::Answer(id, accepted) => {
if !self.connections.prompt.as_ref().is_some_and(|p| p.id == id) {
return Task::none();
}
if accepted
&& !super::agent::valid_answer(
&self.connections.prompt.as_ref().unwrap().kind,
&self.connections.pairing_input,
)
{
return Task::none();
}
if let Some(client) = &self.connections.client {
let value = accepted
.then(|| std::mem::replace(&mut self.connections.pairing_input, Zeroizing::new(String::new())));
client.reply(id, value);
}
self.connections.pairing_input.zeroize();
self.connections.prompt = None;
}
Input::Forget(command) => {
self.connections.forget = Some(command);
return cosmic::iced::widget::scrollable::snap_to(
cosmic::widget::Id::new("settings-content"),
cosmic::iced::widget::scrollable::RelativeOffset { x: None, y: Some(0.) },
);
}
Input::CancelForget => self.connections.forget = None,
Input::ConfirmForget => {
if let Some(command) = self.connections.forget.take() {
return self.connections_input(Input::Run(command));
}
}
Input::Run(command) => {
if self.connections.busy.is_some() && !matches!(command, Command::CancelPairing) {
return Task::none();
}
let Some(client) = self.connections.client.clone() else {
return Task::none();
};
if matches!(command, Command::CancelPairing) {
// The original Pair task still owns completion and busy state.
return Task::perform(
async move {
let _ = client.cancel_pairing().await;
},
|_| cosmic::Action::App(Message::ConnectionsLeft),
);
}
self.connections.busy = Some(command.label());
self.connections.error = None;
self.connections.selected = None;
self.connections.hidden = false;
let epoch = client.epoch();
return Task::perform(async move { client.execute_checked(command, epoch).await }, |result| {
cosmic::Action::App(Message::ConnectionFinished(result))
});
}
}
Task::none()
}
pub(super) fn wifi_target(&self) -> Option<Network> {
if self.connections.hidden {
let wifi = self.connections.snapshot.as_ref()?.wifi.as_ref().ok()?;
let radio = wifi.radios.first()?;
let name = &self.connections.hidden_name;
if name.is_empty() || name.len() > 32 {
return None;
}
Some(Network {
ssid: name.as_bytes().to_vec(),
name: name.clone(),
security: self.connections.hidden_security,
strength: 0,
device: radio.path.clone(),
ap: "/".into(),
saved: None,
connected: false,
connecting: false,
hidden: true,
})
} else {
self.connections.selected.clone()
}
}
}
#[cfg(test)]
mod tests {
use cosmic::Application;
use super::*;
fn app() -> App {
App::init(
cosmic::app::Core::default(),
(
"/unused/connections-test.kdl".into(),
crate::store::Snapshot::parse(String::new()),
None,
),
)
.0
}
#[test]
fn switching_tabs_and_leaving_clear_credentials_without_config_edits() {
let mut app = app();
let original = app.draft.source.clone();
let _ = app.connections_input(Input::Password(Zeroizing::new("secret".into())));
let _ = app.connections_input(Input::Tab(super::super::Tab::Bluetooth));
assert!(app.connections.password.is_empty());
let _ = app.connections_input(Input::Password(Zeroizing::new("another-secret".into())));
let _ = app.leave_connections();
assert!(app.connections.password.is_empty());
assert!(app.connections.pairing_input.is_empty());
assert!(app.pending.is_empty());
assert_eq!(app.draft.source, original);
}
#[test]
fn stale_pairing_input_and_submit_do_not_touch_the_current_question() {
let mut app = app();
app.connections.prompt = Some(super::super::agent::Prompt {
id: 2,
device: "/device/2".into(),
kind: super::super::agent::Kind::Passkey,
});
app.connections.pairing_input = Zeroizing::new("123456".into());
let _ = app.connections_input(Input::PairingValue(1, Zeroizing::new("old-value".into())));
let _ = app.connections_input(Input::Answer(1, true));
assert_eq!(app.connections.pairing_input.as_str(), "123456");
assert_eq!(app.connections.prompt.as_ref().unwrap().id, 2);
let _ = app.connections_input(Input::PairingValue(2, Zeroizing::new("bad-value".into())));
let _ = app.connections_input(Input::Answer(2, true));
assert!(app.connections.prompt.is_some());
}
#[test]
fn debug_messages_redact_network_and_pairing_secrets() {
let network = Network {
ssid: b"test".to_vec(),
name: "test".into(),
security: super::super::Security::Personal,
strength: 0,
device: "/device".into(),
ap: "/ap".into(),
saved: None,
connected: false,
connecting: false,
hidden: false,
};
for message in [
Input::Run(Command::WifiConnect(network, Zeroizing::new("never-log-this".into()))),
Input::Password(Zeroizing::new("never-log-this".into())),
Input::PairingValue(1, Zeroizing::new("never-log-this".into())),
] {
assert!(!format!("{message:?}").contains("never-log-this"));
}
}
#[test]
fn connection_page_search_and_startup_tab() {
assert!(crate::schema::Page::Connections.matches("bluetooth"));
assert!(crate::schema::Page::Connections.matches("wi-fi"));
let (app, _) = App::init(
cosmic::app::Core::default(),
(
"/unused/connections-test.kdl".into(),
crate::store::Snapshot::parse(String::new()),
Some(super::super::Tab::Bluetooth),
),
);
assert_eq!(app.page, crate::schema::Page::Connections);
assert_eq!(app.connections.tab, super::super::Tab::Bluetooth);
assert!(app.connections.loading);
}
}
@@ -0,0 +1,256 @@
//! Connections are managed by NetworkManager and BlueZ, never by the KDL store.
mod agent;
mod bluetooth;
mod bluetooth_ui;
#[cfg(test)]
mod contracts;
mod controller;
mod pairing_ui;
mod ui;
mod wifi;
mod wifi_ui;
use std::fmt;
use std::sync::Arc;
use std::time::Duration;
pub(crate) use ui::Input;
pub(crate) use wifi::{Network, Security};
use zbus::{Connection, Proxy};
use zeroize::Zeroizing;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) enum Tab {
#[default]
Wifi,
Bluetooth,
}
#[derive(Clone, Debug)]
pub(crate) struct Snapshot {
pub wifi: Result<wifi::Wifi, String>,
pub bluetooth: Result<bluetooth::Bluetooth, String>,
}
pub(crate) struct State {
pub client: Option<Arc<Client>>,
pub snapshot: Option<Snapshot>,
pub tab: Tab,
pub loading: bool,
pub busy: Option<&'static str>,
pub error: Option<String>,
pub selected: Option<Network>,
pub password: Zeroizing<String>,
pub hidden_name: String,
pub hidden: bool,
pub hidden_security: Security,
pub adapter: Option<String>,
pub prompt: Option<agent::Prompt>,
pub pairing_input: Zeroizing<String>,
pub forget: Option<Command>,
}
impl Default for State {
fn default() -> Self {
Self {
client: None,
snapshot: None,
tab: Tab::Wifi,
loading: false,
busy: None,
error: None,
selected: None,
password: Zeroizing::new(String::new()),
hidden_name: String::new(),
hidden: false,
hidden_security: Security::Personal,
adapter: None,
prompt: None,
pairing_input: Zeroizing::new(String::new()),
forget: None,
}
}
}
/// Debug deliberately omits credentials, including messages logged by the UI.
#[derive(Clone)]
pub(crate) enum Command {
WifiPower(bool),
WifiScan,
WifiConnect(Network, Zeroizing<String>),
WifiDisconnect(String),
WifiForget(String),
BluetoothPower(String, bool),
BluetoothScan(String),
BluetoothStop,
BluetoothPair(String),
BluetoothConnect(String),
BluetoothDisconnect(String),
BluetoothTrust(String, bool),
BluetoothForget(String, String),
CancelPairing,
}
impl Command {
pub fn label(&self) -> &'static str {
match self {
Self::WifiPower(_) | Self::BluetoothPower(..) => "Updating radio…",
Self::WifiScan => "Scanning for networks…",
Self::WifiConnect(..) | Self::BluetoothConnect(_) => "Connecting…",
Self::WifiDisconnect(_) | Self::BluetoothDisconnect(_) => "Disconnecting…",
Self::WifiForget(_) | Self::BluetoothForget(..) => "Forgetting…",
Self::BluetoothScan(_) => "Starting discovery…",
Self::BluetoothStop => "Stopping discovery…",
Self::BluetoothPair(_) => "Pairing…",
Self::BluetoothTrust(..) => "Updating device…",
Self::CancelPairing => "Canceling pairing…",
}
}
}
impl fmt::Debug for Command {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.label())
}
}
pub(crate) struct Client {
connection: Connection,
runtime: tokio::runtime::Handle,
epoch: std::sync::atomic::AtomicU64,
closed: std::sync::atomic::AtomicBool,
agent: agent::Pairing,
registered: tokio::sync::Mutex<Option<String>>,
discovery: Arc<std::sync::Mutex<std::collections::BTreeMap<String, std::time::Instant>>>,
}
impl fmt::Debug for Client {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("ConnectionsClient")
}
}
impl Client {
pub async fn new() -> Result<Arc<Self>, String> {
let connection = zbus::connection::Builder::system()
.map_err(|e| e.to_string())?
.method_timeout(Duration::from_secs(120))
.build()
.await
.map_err(|e| e.to_string())?;
Ok(Self::with_connection(connection))
}
fn with_connection(connection: Connection) -> Arc<Self> {
Arc::new(Self {
connection,
runtime: tokio::runtime::Handle::current(),
epoch: std::sync::atomic::AtomicU64::new(0),
closed: std::sync::atomic::AtomicBool::new(false),
agent: agent::Pairing::default(),
registered: tokio::sync::Mutex::new(None),
discovery: Arc::default(),
})
}
pub async fn snapshot(&self) -> Snapshot {
// Isolate missing services: a Bluetooth failure must not hide Wi-Fi.
let (wifi, bluetooth) = tokio::join!(
bounded(wifi::read(&self.connection)),
bounded(bluetooth::read(&self.connection)),
);
Snapshot { wifi, bluetooth }
}
pub fn prompt(&self) -> Option<agent::Prompt> {
self.agent.prompt()
}
pub fn reply(&self, id: u64, value: Option<Zeroizing<String>>) {
self.agent.reply(id, value);
}
pub fn epoch(&self) -> u64 {
self.epoch.load(std::sync::atomic::Ordering::SeqCst)
}
#[cfg(test)]
pub async fn execute(&self, command: Command) -> Result<(), String> {
self.execute_checked(command, self.epoch()).await
}
pub async fn execute_checked(&self, command: Command, epoch: u64) -> Result<(), String> {
if self.closed.load(std::sync::atomic::Ordering::SeqCst) || self.epoch() != epoch {
return Err("This connection request was canceled.".into());
}
match command {
Command::WifiPower(on) => wifi::power(&self.connection, on).await,
Command::WifiScan => wifi::scan(&self.connection).await,
Command::WifiConnect(network, password) => wifi::connect(&self.connection, &network, &password).await,
Command::WifiDisconnect(path) => wifi::disconnect(&self.connection, &path).await,
Command::WifiForget(path) => wifi::forget(&self.connection, &path).await,
Command::BluetoothPower(path, on) => {
if !on {
self.stop_discovery().await?;
}
bluetooth::power(&self.connection, &path, on).await
}
Command::BluetoothScan(path) => self.discover(path, epoch).await,
Command::BluetoothStop => self.stop_discovery().await,
Command::BluetoothPair(path) => self.pair(&path, epoch).await,
Command::BluetoothConnect(path) => bluetooth::device_call(&self.connection, &path, "Connect").await,
Command::BluetoothDisconnect(path) => bluetooth::device_call(&self.connection, &path, "Disconnect").await,
Command::BluetoothTrust(path, on) => bluetooth::trust(&self.connection, &path, on).await,
Command::BluetoothForget(adapter, path) => bluetooth::forget(&self.connection, &adapter, &path).await,
Command::CancelPairing => self.cancel_pairing().await,
}
}
pub fn leave_now(&self) {
self.epoch.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
self.cleanup();
}
pub fn shutdown(&self) {
self.closed.store(true, std::sync::atomic::Ordering::SeqCst);
self.leave_now();
}
fn cleanup(&self) {
let target = self.agent.cancel_local();
let paths = std::mem::take(&mut *self.discovery.lock().unwrap());
let connection = self.connection.clone();
self.runtime.spawn(async move {
if let Some(path) = target {
let _ = bluetooth::device_call(&connection, &path, "CancelPairing").await;
}
for (path, _) in paths {
let _ = bluetooth::adapter_call(&connection, &path, "StopDiscovery").await;
}
});
}
}
impl Drop for Client {
fn drop(&mut self) {
self.shutdown();
}
}
async fn bounded<T>(future: impl std::future::Future<Output = Result<T, String>>) -> Result<T, String> {
tokio::time::timeout(Duration::from_secs(10), future)
.await
.map_err(|_| "The connection service did not respond. Try again.".to_owned())?
}
async fn proxy<'a>(
connection: &'a Connection,
destination: &'a str,
path: &'a str,
interface: &'a str,
) -> Result<Proxy<'a>, String> {
Proxy::new(connection, destination, path, interface)
.await
.map_err(|e| e.to_string())
}
@@ -0,0 +1,67 @@
use cosmic::Element;
use cosmic::widget::{column, row, text_input};
use zeroize::{Zeroize, Zeroizing};
use super::ui::{Input, message};
use super::{Command, agent};
use crate::{App, Message, visuals};
impl App {
pub(super) fn pairing_view(&self, prompt: &agent::Prompt) -> Element<'_, Message> {
let s = &self.connections;
let p = visuals::Palette::from_resolved(&self.resolved.presented);
let device = s
.snapshot
.as_ref()
.and_then(|s| s.bluetooth.as_ref().ok())
.and_then(|b| b.devices.iter().find(|d| d.path == prompt.device))
.map(|d| d.name.as_str())
.unwrap_or("device");
let explanation = match &prompt.kind {
agent::Kind::Pin => "Enter the PIN shown on your device.".into(),
agent::Kind::Passkey => "Enter the six-digit passkey shown on your device.".into(),
agent::Kind::Confirm(key) => format!("Does {key:06} match the number on your device?"),
agent::Kind::Authorize => "Allow this device to pair?".into(),
agent::Kind::Service(uuid) => format!("Allow the device to use service {uuid}?"),
agent::Kind::Display(text) => text.clone(),
};
let mut body = column([])
.spacing(8)
.push(self.label(format!("Pair with {device}"), 15.))
.push(self.connection_note(&explanation));
let id = prompt.id;
let input = matches!(prompt.kind, agent::Kind::Pin | agent::Kind::Passkey);
if input {
body = body.push(
text_input("PIN or passkey", s.pairing_input.as_str())
.id(cosmic::widget::Id::new("bluetooth-passkey"))
.password()
.font(self.font)
.size(13)
.padding([5, 8])
.style(visuals::input_style(p))
.on_input(move |value| message(Input::PairingValue(id, Zeroizing::new(value))))
.on_submit(move |mut value| {
value.zeroize();
message(Input::Answer(id, true))
}),
);
}
let valid = agent::valid_answer(&prompt.kind, &s.pairing_input);
let mut controls = row([]).spacing(8).push(self.connection_button(
"Cancel pairing",
Input::Run(Command::CancelPairing),
true,
false,
));
if !matches!(prompt.kind, agent::Kind::Display(_)) {
controls = controls.push(self.connection_button(
if input { "Submit" } else { "Confirm" },
Input::Answer(id, true),
valid,
true,
));
}
self.connection_card(body.push(controls))
}
}
+190
View File
@@ -0,0 +1,190 @@
use std::fmt;
use cosmic::Element;
use cosmic::iced::{Alignment, Length};
use cosmic::widget::{self, button, column, container, row};
use zeroize::Zeroizing;
use super::{Command, Network, Security, Tab};
use crate::{App, Message, visuals};
#[derive(Clone)]
pub(crate) enum Input {
Refresh,
PollPrompt,
Tab(Tab),
Adapter(String),
Run(Command),
Select(Network),
Password(Zeroizing<String>),
SubmitWifi,
CancelWifi,
Hidden(bool),
HiddenName(String),
HiddenSecurity(Security),
PairingValue(u64, Zeroizing<String>),
Answer(u64, bool),
Forget(Command),
ConfirmForget,
CancelForget,
}
impl fmt::Debug for Input {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("ConnectionsInput")
}
}
pub(super) fn message(input: Input) -> Message {
Message::Connections(input)
}
impl App {
pub(super) fn connection_button(
&self,
label: &str,
input: Input,
enabled: bool,
selected: bool,
) -> Element<'static, Message> {
let p = visuals::Palette::from_resolved(&self.resolved.presented);
let (icon, icon_only) = match &input {
Input::Refresh => (Some("M20 7V3l-3 3a8 8 0 1 0 3 6 M20 3h-5"), true),
Input::Forget(_) | Input::ConfirmForget => (
Some("M4 6h16 M9 6V3h6v3 M6 6l1 15h10l1-15 M10 10v7 M14 10v7"),
matches!(input, Input::Forget(_)),
),
Input::Select(network) if network.saved.is_some() => {
(Some("M8 15a5 5 0 1 1 4-4l9-9 M16 7l3 3 M13 10l3 3"), true)
}
Input::Run(Command::BluetoothTrust(_, on)) => (
Some(if *on {
"M12 3l8 3v6c0 5-8 9-8 9s-8-4-8-9V6z"
} else {
"M12 3l8 3v6c0 5-8 9-8 9s-8-4-8-9V6z M8 12l3 3 5-6"
}),
true,
),
Input::Tab(Tab::Wifi) => (Some(crate::schema::Page::Connections.icon()), false),
Input::Tab(Tab::Bluetooth) => (Some("M12 3l6 5-12 8 6 5V3 M6 8l12 8-6 5"), false),
Input::Run(Command::WifiScan | Command::BluetoothScan(_)) => {
(Some("M10 3a7 7 0 1 0 0 14a7 7 0 0 0 0-14 M15 15l6 6"), false)
}
Input::Run(Command::BluetoothStop) => (Some("M6 6h12v12H6z"), false),
Input::Run(Command::WifiConnect(..) | Command::BluetoothConnect(_) | Command::BluetoothPair(_))
| Input::Select(_)
| Input::SubmitWifi => (Some("M4 12h15 M14 7l5 5-5 5"), false),
Input::Run(Command::WifiDisconnect(_) | Command::BluetoothDisconnect(_)) => {
(Some("M7 7l10 10 M17 7L7 17"), false)
}
Input::Hidden(_) => (Some("M12 5v14 M5 12h14"), false),
Input::Answer(..) => (Some("M5 12l4 4L19 6"), false),
_ => (None, false),
};
let mut content = row([]).spacing(5).align_y(Alignment::Center);
if let Some(icon) = icon {
content = content.push(visuals::action_icon(icon, if selected { p.accent } else { p.text }));
}
if !icon_only {
content = content.push(self.label(label.to_owned(), 12.));
}
let control = button::custom(content)
.name(label.to_owned())
.padding(if icon_only { [4, 4] } else { [4, 8] })
.class(visuals::button_style(p, selected))
.on_press_maybe(enabled.then(|| message(input)));
if icon_only {
widget::tooltip(
control,
self.label(label.to_owned(), 11.),
widget::tooltip::Position::Top,
)
.into()
} else {
control.into()
}
}
pub(super) fn connection_note(&self, text: &str) -> Element<'static, Message> {
let p = visuals::Palette::from_resolved(&self.resolved.presented);
self.label(text.to_owned(), 11.)
.class(cosmic::theme::Text::Color(p.muted))
.into()
}
pub(super) fn connection_card<'a>(&self, body: impl Into<Element<'a, Message>>) -> Element<'a, Message> {
let p = visuals::Palette::from_resolved(&self.resolved.presented);
container(body)
.padding([7, 10])
.width(Length::Fill)
.class(visuals::surface(p.card, p.radius))
.into()
}
pub(crate) fn connections_view(&self) -> Element<'_, Message> {
let s = &self.connections;
let p = visuals::Palette::from_resolved(&self.resolved.presented);
let mut body = column([]).spacing(6);
body = body.push(
row([])
.spacing(6)
.push(self.connection_button("Wi-Fi", Input::Tab(Tab::Wifi), true, s.tab == Tab::Wifi))
.push(self.connection_button("Bluetooth", Input::Tab(Tab::Bluetooth), true, s.tab == Tab::Bluetooth))
.push(widget::Space::new().width(Length::Fill))
.push(self.connection_button("Refresh", Input::Refresh, !s.loading, false)),
);
if let Some(error) = &s.error {
body = body.push(
container(self.label(error.clone(), 12.))
.padding(8)
.width(Length::Fill)
.class(visuals::surface(p.error, 10.)),
);
}
if let Some(busy) = s.busy {
let mut status = row([])
.align_y(Alignment::Center)
.spacing(6)
.push(self.label(busy, 12.));
if busy == "Pairing…" {
status = status.push(self.connection_button("Cancel", Input::Run(Command::CancelPairing), true, false));
}
body = body.push(status);
}
// Pairing stays visible even if the user switches between the two tabs.
if let Some(prompt) = &s.prompt {
body = body.push(self.pairing_view(prompt));
}
if s.forget.is_some() {
body = body.push(
self.connection_card(
column([])
.spacing(6)
.push(self.label("Forget this connection?", 14.))
.push(self.connection_note(
"Saved credentials or pairing keys will be removed. You will need to connect again.",
))
.push(
row([])
.spacing(8)
.push(self.connection_button("Cancel", Input::CancelForget, true, false))
.push(self.connection_button("Forget", Input::ConfirmForget, s.busy.is_none(), false)),
),
),
);
}
if let Some(snapshot) = &s.snapshot {
body = body.push(match s.tab {
Tab::Wifi => self.wifi_view(&snapshot.wifi),
Tab::Bluetooth => self.bluetooth_view(&snapshot.bluetooth),
});
} else {
body = body.push(self.connection_note(if s.loading {
"Loading connection services…"
} else {
"Connection services are unavailable. Use Refresh to try again."
}));
}
body.into()
}
}
@@ -0,0 +1,474 @@
use std::collections::{BTreeMap, HashMap};
use zbus::Connection;
use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value};
use super::proxy;
const NM: &str = "org.freedesktop.NetworkManager";
const ROOT: &str = "/org/freedesktop/NetworkManager";
const DEVICE: &str = "org.freedesktop.NetworkManager.Device";
const WIRELESS: &str = "org.freedesktop.NetworkManager.Device.Wireless";
const AP: &str = "org.freedesktop.NetworkManager.AccessPoint";
const SETTINGS: &str = "org.freedesktop.NetworkManager.Settings";
const PROFILE: &str = "org.freedesktop.NetworkManager.Settings.Connection";
type Settings = HashMap<String, HashMap<String, OwnedValue>>;
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum Security {
Open,
Personal,
Sae,
Owe,
Enterprise,
Unsupported,
}
impl Security {
pub fn label(self) -> &'static str {
match self {
Self::Open => "Open",
Self::Personal => "WPA / WPA2",
Self::Sae => "WPA3",
Self::Owe => "Enhanced open",
Self::Enterprise => "Enterprise",
Self::Unsupported => "Legacy security",
}
}
pub fn password(self) -> bool {
matches!(self, Self::Personal | Self::Sae)
}
pub fn can_create(self) -> bool {
!matches!(self, Self::Enterprise | Self::Unsupported)
}
}
#[derive(Clone, Debug)]
pub(crate) struct Network {
pub ssid: Vec<u8>,
pub name: String,
pub security: Security,
pub strength: u8,
pub device: String,
pub ap: String,
pub saved: Option<String>,
pub connected: bool,
pub connecting: bool,
pub hidden: bool,
}
#[derive(Clone, Debug)]
pub(crate) struct Radio {
pub path: String,
pub name: String,
}
#[derive(Clone, Debug, Default)]
pub(crate) struct Wifi {
pub enabled: bool,
pub hardware_enabled: bool,
pub radios: Vec<Radio>,
pub networks: Vec<Network>,
pub saved: Vec<Saved>,
}
#[derive(Clone, Debug)]
pub(crate) struct Saved {
pub path: String,
pub name: String,
pub ssid: Vec<u8>,
pub security: Security,
}
fn security(flags: u32, wpa: u32, rsn: u32) -> Security {
let auth = wpa | rsn;
if auth & (0x200 | 0x2000) != 0 {
Security::Enterprise
} else if auth & 0x100 != 0 {
Security::Personal
} else if auth & 0x400 != 0 {
Security::Sae
} else if auth & (0x800 | 0x1000) != 0 {
Security::Owe
} else if flags & 1 != 0 {
Security::Unsupported
} else {
Security::Open
}
}
pub(crate) async fn read(connection: &Connection) -> Result<Wifi, String> {
let root = proxy(connection, NM, ROOT, NM).await?;
let enabled = root.get_property("WirelessEnabled").await.map_err(|e| e.to_string())?;
let hardware_enabled = root
.get_property("WirelessHardwareEnabled")
.await
.map_err(|e| e.to_string())?;
let devices: Vec<OwnedObjectPath> = root.call("GetDevices", &()).await.map_err(|e| e.to_string())?;
let saved = profiles(connection).await?;
let mut result = Wifi {
enabled,
hardware_enabled,
saved,
..Wifi::default()
};
let mut networks = BTreeMap::new();
for path in devices {
let device = proxy(connection, NM, path.as_str(), DEVICE).await?;
if device.get_property::<u32>("DeviceType").await.unwrap_or(0) != 2 {
continue;
}
let name: String = device.get_property("Interface").await.map_err(|e| e.to_string())?;
result.radios.push(Radio {
path: path.to_string(),
name,
});
if !enabled {
continue;
}
let state: u32 = device.get_property("State").await.map_err(|e| e.to_string())?;
let wireless = proxy(connection, NM, path.as_str(), WIRELESS).await?;
let active: OwnedObjectPath = wireless
.get_property("ActiveAccessPoint")
.await
.map_err(|e| e.to_string())?;
let aps: Vec<OwnedObjectPath> = wireless
.call("GetAllAccessPoints", &())
.await
.map_err(|e| e.to_string())?;
for ap_path in aps {
let ap = proxy(connection, NM, ap_path.as_str(), AP).await?;
let ssid: Vec<u8> = ap.get_property("Ssid").await.map_err(|e| e.to_string())?;
if ssid.is_empty() {
continue;
}
let strength = ap.get_property("Strength").await.map_err(|e| e.to_string())?;
let flags: u32 = ap.get_property("Flags").await.map_err(|e| e.to_string())?;
let wpa: u32 = ap.get_property("WpaFlags").await.map_err(|e| e.to_string())?;
let rsn: u32 = ap.get_property("RsnFlags").await.map_err(|e| e.to_string())?;
let auth = security(flags, wpa, rsn);
let saved = result
.saved
.iter()
.find(|s| s.ssid == ssid && compatible(s.security, auth, wpa | rsn));
let network = Network {
name: String::from_utf8_lossy(&ssid).into_owned(),
saved: saved.map(|s| s.path.clone()),
ssid,
security: auth,
strength,
device: path.to_string(),
ap: ap_path.to_string(),
connected: active == ap_path && state == 100,
connecting: active == ap_path && (40..100).contains(&state),
hidden: false,
};
insert_network(&mut networks, network);
}
}
result.networks = networks.into_values().collect();
result.networks.sort_by(|a, b| {
b.connected
.cmp(&a.connected)
.then(b.connecting.cmp(&a.connecting))
.then(b.strength.cmp(&a.strength))
.then(a.name.cmp(&b.name))
});
Ok(result)
}
fn compatible(saved: Security, advertised: Security, auth: u32) -> bool {
saved == advertised
|| saved == Security::Personal && advertised == Security::Sae
|| saved == Security::Sae && advertised == Security::Personal && auth & 0x400 != 0
}
fn insert_network(networks: &mut BTreeMap<(Vec<u8>, Security, String), Network>, network: Network) {
// Keep adapters separate, and prefer the active AP over a stronger same-SSID AP.
let key = (network.ssid.clone(), network.security, network.device.clone());
if networks.get(&key).is_none_or(|old| {
(network.connected, network.connecting, network.strength) > (old.connected, old.connecting, old.strength)
}) {
networks.insert(key, network);
}
}
async fn profiles(connection: &Connection) -> Result<Vec<Saved>, String> {
let settings = proxy(connection, NM, "/org/freedesktop/NetworkManager/Settings", SETTINGS).await?;
let paths: Vec<OwnedObjectPath> = settings.call("ListConnections", &()).await.map_err(|e| e.to_string())?;
let mut saved = Vec::new();
for path in paths {
let profile = proxy(connection, NM, path.as_str(), PROFILE).await?;
let Ok(values) = profile.call::<_, _, Settings>("GetSettings", &()).await else {
continue;
};
let Some(wireless) = values.get("802-11-wireless") else {
continue;
};
let Some(ssid) = wireless
.get("ssid")
.and_then(|v| Vec::<u8>::try_from(v.try_clone().ok()?).ok())
else {
continue;
};
let key = values
.get("802-11-wireless-security")
.and_then(|s| s.get("key-mgmt"))
.and_then(|v| <&str>::try_from(v).ok());
let security = match key {
None => Security::Open,
Some("wpa-psk") => Security::Personal,
Some("sae") => Security::Sae,
Some("owe") => Security::Owe,
Some("wpa-eap" | "wpa-eap-suite-b-192" | "ieee8021x") => Security::Enterprise,
_ => Security::Unsupported,
};
let name = values
.get("connection")
.and_then(|s| s.get("id"))
.and_then(|v| <&str>::try_from(v).ok())
.map(str::to_owned)
.unwrap_or_else(|| String::from_utf8_lossy(&ssid).into_owned());
saved.push(Saved {
path: path.to_string(),
name,
ssid,
security,
});
}
saved.sort_by(|a, b| a.name.cmp(&b.name));
Ok(saved)
}
pub(crate) async fn power(connection: &Connection, enabled: bool) -> Result<(), String> {
proxy(connection, NM, ROOT, NM)
.await?
.set_property("WirelessEnabled", enabled)
.await
.map_err(|e| e.to_string())
}
pub(crate) async fn scan(connection: &Connection) -> Result<(), String> {
let root = proxy(connection, NM, ROOT, NM).await?;
let devices: Vec<OwnedObjectPath> = root.call("GetDevices", &()).await.map_err(|e| e.to_string())?;
let mut found = false;
for path in devices {
let device = proxy(connection, NM, path.as_str(), DEVICE).await?;
if device.get_property::<u32>("DeviceType").await.unwrap_or(0) != 2 {
continue;
}
found = true;
proxy(connection, NM, path.as_str(), WIRELESS)
.await?
.call::<_, _, ()>("RequestScan", &(HashMap::<String, OwnedValue>::new(),))
.await
.map_err(|e| e.to_string())?;
}
if found {
Ok(())
} else {
Err("No Wi-Fi adapter is available.".into())
}
}
pub(crate) fn valid_password(security: Security, password: &str) -> bool {
match security {
Security::Personal => {
(8..=63).contains(&password.len())
|| (password.len() == 64 && password.bytes().all(|b| b.is_ascii_hexdigit()))
}
Security::Sae => !password.is_empty(),
_ => true,
}
}
fn settings<'a>(
network: &'a Network,
password: &'a str,
) -> Result<HashMap<&'static str, HashMap<&'static str, Value<'a>>>, String> {
if !network.security.can_create() {
return Err(
"This network needs an existing NetworkManager profile with its enterprise or legacy security settings."
.into(),
);
}
if !valid_password(network.security, password) {
return Err("WPA passwords need 8–63 characters, or 64 hexadecimal digits.".into());
}
let mut settings = HashMap::from([
(
"connection",
HashMap::from([
("id", Value::from(network.name.as_str())),
("type", Value::from("802-11-wireless")),
]),
),
(
"802-11-wireless",
HashMap::from([
("ssid", Value::from(network.ssid.as_slice())),
("hidden", Value::from(network.hidden)),
]),
),
("ipv4", HashMap::from([("method", Value::from("auto"))])),
("ipv6", HashMap::from([("method", Value::from("auto"))])),
]);
let key = match network.security {
Security::Personal => Some("wpa-psk"),
Security::Sae => Some("sae"),
Security::Owe => Some("owe"),
_ => None,
};
if let Some(key) = key {
let mut security = HashMap::from([("key-mgmt", Value::from(key))]);
if network.security.password() {
security.insert("psk", Value::from(password));
}
settings.insert("802-11-wireless-security", security);
}
Ok(settings)
}
pub(crate) async fn connect(connection: &Connection, network: &Network, password: &str) -> Result<(), String> {
let root = proxy(connection, NM, ROOT, NM).await?;
let device = OwnedObjectPath::try_from(network.device.as_str()).map_err(|e| e.to_string())?;
let ap = OwnedObjectPath::try_from(network.ap.as_str()).map_err(|e| e.to_string())?;
let active: OwnedObjectPath = if let Some(saved) = &network.saved {
if !password.is_empty() && network.security.password() {
if !valid_password(network.security, password) {
return Err("Invalid network password.".into());
}
let profile = proxy(connection, NM, saved, PROFILE).await?;
let previous: Settings = profile.call("GetSettings", &()).await.map_err(|e| e.to_string())?;
let mut values: HashMap<String, HashMap<String, Value<'_>>> = previous
.into_iter()
.map(|(section, values)| {
(
section,
values
.into_iter()
.map(|(key, value)| (key, Value::from(value)))
.collect(),
)
})
.collect();
let security = values.entry("802-11-wireless-security".into()).or_default();
security.insert("psk".into(), Value::from(password));
security.insert("psk-flags".into(), Value::from(0u32));
profile
.call::<_, _, ()>("Update", &(values,))
.await
.map_err(|e| e.to_string())?;
}
let saved = OwnedObjectPath::try_from(saved.as_str()).map_err(|e| e.to_string())?;
root.call("ActivateConnection", &(saved, &device, &ap))
.await
.map_err(|e| e.to_string())?
} else {
let values = settings(network, password)?;
let (_, active): (OwnedObjectPath, OwnedObjectPath) = root
.call("AddAndActivateConnection", &(values, &device, &ap))
.await
.map_err(|e| e.to_string())?;
active
};
// Activation's reply only means accepted. Wait for its actual result.
let active = proxy(
connection,
NM,
active.as_str(),
"org.freedesktop.NetworkManager.Connection.Active",
)
.await?;
for _ in 0..90 {
match active.get_property::<u32>("State").await.map_err(|e| e.to_string())? {
2 => return Ok(()),
3 | 4 => return Err("The network connection failed. Check the password and try again.".into()),
_ => tokio::time::sleep(std::time::Duration::from_secs(1)).await,
}
}
Err("The network did not finish connecting. Check its status before trying again.".into())
}
pub(crate) async fn disconnect(connection: &Connection, device: &str) -> Result<(), String> {
proxy(connection, NM, device, DEVICE)
.await?
.call::<_, _, ()>("Disconnect", &())
.await
.map_err(|e| e.to_string())
}
pub(crate) async fn forget(connection: &Connection, profile: &str) -> Result<(), String> {
proxy(connection, NM, profile, PROFILE)
.await?
.call::<_, _, ()>("Delete", &())
.await
.map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn network() -> Network {
Network {
ssid: b"test".to_vec(),
name: "test".into(),
security: Security::Personal,
strength: 10,
device: "/device/1".into(),
ap: "/ap/1".into(),
saved: None,
connected: false,
connecting: false,
hidden: false,
}
}
#[test]
fn security_and_credentials() {
assert_eq!(security(0, 0, 0), Security::Open);
assert_eq!(security(1, 0x100, 0x500), Security::Personal);
assert_eq!(security(1, 0, 0x400), Security::Sae);
assert_eq!(security(1, 0, 0x200), Security::Enterprise);
assert_eq!(security(0, 0, 0x800), Security::Owe);
assert!(!valid_password(Security::Personal, "short"));
assert!(valid_password(Security::Personal, &"a".repeat(64)));
assert!(!valid_password(Security::Personal, &"z".repeat(64)));
assert!(valid_password(Security::Sae, "short"));
assert!(compatible(Security::Sae, Security::Personal, 0x500));
assert!(!compatible(Security::Sae, Security::Personal, 0x100));
assert!(compatible(Security::Personal, Security::Sae, 0x400));
assert_eq!(security(1, 0, 0x2000), Security::Enterprise);
}
#[test]
fn active_ap_wins_deduplication() {
let mut networks = BTreeMap::new();
let mut active = network();
active.connected = true;
insert_network(&mut networks, active);
let mut strong = network();
strong.strength = 99;
insert_network(&mut networks, strong.clone());
assert!(networks.values().next().unwrap().connected);
strong.security = Security::Open;
insert_network(&mut networks, strong);
assert_eq!(networks.len(), 2);
}
#[test]
fn secrets_only_go_to_secure_profiles() {
let mut network = network();
network.hidden = true;
let values = settings(&network, "password").unwrap();
assert_eq!(values["802-11-wireless-security"]["psk"], Value::from("password"));
assert_eq!(values["802-11-wireless"]["hidden"], Value::from(true));
network.security = Security::Open;
assert!(!settings(&network, "").unwrap().contains_key("802-11-wireless-security"));
network.security = Security::Enterprise;
assert!(settings(&network, "password").is_err());
}
}
@@ -0,0 +1,249 @@
use cosmic::Element;
use cosmic::iced::{Alignment, Length};
use cosmic::widget::{column, row, text_input};
use zeroize::{Zeroize, Zeroizing};
use super::ui::{Input, message};
use super::{Command, Security};
use crate::{App, Message, visuals};
impl App {
pub(super) fn wifi_view(&self, result: &Result<super::wifi::Wifi, String>) -> Element<'_, Message> {
let s = &self.connections;
let p = visuals::Palette::from_resolved(&self.resolved.presented);
let ready = s.busy.is_none();
let Ok(wifi) = result else {
return self.connection_card(
column([])
.spacing(8)
.push(self.label("Wi-Fi unavailable", 14.))
.push(self.connection_note(result.as_ref().unwrap_err())),
);
};
let mut body = column([]).spacing(6);
let status = if wifi.radios.is_empty() {
"No Wi-Fi adapter"
} else if !wifi.hardware_enabled {
"Blocked by a hardware switch or airplane mode"
} else if wifi.enabled {
"On"
} else {
"Off"
};
body = body.push(
self.connection_card(
row([])
.spacing(8)
.align_y(Alignment::Center)
.push(
column([])
.spacing(2)
.width(Length::Fill)
.push(self.label("Wi-Fi", 15.))
.push(self.connection_note(status)),
)
.push(
ferese_theme::controls::switch(wifi.enabled, p)
.name("Wi-Fi power")
.on_press_maybe(
(ready && (wifi.hardware_enabled || wifi.enabled) && !wifi.radios.is_empty())
.then(|| message(Input::Run(Command::WifiPower(!wifi.enabled)))),
),
),
),
);
if !wifi.enabled || !wifi.hardware_enabled || wifi.radios.is_empty() {
return body.into();
}
body = body.push(
row([])
.align_y(Alignment::Center)
.push(self.label("Networks", 14.).width(Length::Fill))
.push(self.connection_button("Scan", Input::Run(Command::WifiScan), ready, false)),
);
if wifi.networks.is_empty() {
body = body.push(self.connection_note("No networks found. Scan to look for nearby networks."));
}
for network in &wifi.networks {
let radio = wifi
.radios
.iter()
.find(|r| r.path == network.device)
.map(|r| r.name.as_str())
.unwrap_or("");
let state = if network.connected {
"Connected · "
} else if network.connecting {
"Connecting · "
} else if network.saved.is_some() {
"Saved · "
} else {
""
};
let detail = format!(
"{state}{} · {}% signal{}",
network.security.label(),
network.strength,
if wifi.radios.len() > 1 {
format!(" · {radio}")
} else {
String::new()
}
);
let mut controls = row([]).spacing(6);
if network.connected || network.connecting {
controls = controls.push(self.connection_button(
"Disconnect",
Input::Run(Command::WifiDisconnect(network.device.clone())),
ready,
false,
));
} else if network.saved.is_some() || !network.security.password() && network.security.can_create() {
controls = controls.push(self.connection_button(
"Connect",
Input::Run(Command::WifiConnect(network.clone(), Zeroizing::new(String::new()))),
ready,
false,
));
} else {
controls =
controls.push(self.connection_button("Connect…", Input::Select(network.clone()), ready, false));
}
if network.saved.is_some() && network.security.password() {
controls =
controls.push(self.connection_button("Password…", Input::Select(network.clone()), ready, false));
}
body = body.push(
self.connection_card(
row([])
.spacing(8)
.align_y(Alignment::Center)
.push(visuals::action_icon(
super::super::schema::Page::Connections.icon(),
p.accent,
))
.push(
column([])
.spacing(2)
.width(Length::Fill)
.push(self.label(network.name.clone(), 13.))
.push(self.connection_note(&detail)),
)
.push(controls),
),
);
if s.selected.as_ref().is_some_and(|selected| {
selected.ssid == network.ssid
&& selected.device == network.device
&& selected.security == network.security
}) {
body = body.push(self.wifi_credentials());
}
}
body = body.push(self.connection_button("Join a hidden network…", Input::Hidden(true), ready, false));
if s.hidden {
body = body.push(self.wifi_credentials());
}
if !wifi.saved.is_empty() {
body = body.push(self.label("Saved networks", 14.));
for saved in &wifi.saved {
body = body.push(
self.connection_card(
row([])
.spacing(8)
.align_y(Alignment::Center)
.push(visuals::action_icon(crate::schema::Page::Connections.icon(), p.muted))
.push(
column([])
.spacing(2)
.width(Length::Fill)
.push(self.label(saved.name.clone(), 13.))
.push(self.connection_note(saved.security.label())),
)
.push(self.connection_button(
"Forget",
Input::Forget(Command::WifiForget(saved.path.clone())),
ready,
false,
)),
),
);
}
}
body.into()
}
pub(super) fn wifi_credentials(&self) -> Element<'_, Message> {
let s = &self.connections;
let p = visuals::Palette::from_resolved(&self.resolved.presented);
let target = self.wifi_target();
let mut body = column([]).spacing(6).push(self.label(
if s.hidden {
"Hidden network".into()
} else {
format!(
"Join {}",
s.selected.as_ref().map(|n| n.name.as_str()).unwrap_or("network")
)
},
16.,
));
if s.hidden {
body = body.push(
text_input("Network name (SSID)", s.hidden_name.clone())
.font(self.font)
.size(13)
.padding([5, 8])
.style(visuals::input_style(p))
.on_input(|name| message(Input::HiddenName(name))),
);
let mut choices = row([]).spacing(6);
for security in [Security::Personal, Security::Sae, Security::Open, Security::Owe] {
choices = choices.push(self.connection_button(
security.label(),
Input::HiddenSecurity(security),
true,
s.hidden_security == security,
));
}
body = body.push(choices);
}
let security = target.as_ref().map(|n| n.security).unwrap_or(s.hidden_security);
if security.password() {
body = body.push(
text_input("Network password", s.password.as_str())
.id(cosmic::widget::Id::new("wifi-password"))
.password()
.font(self.font)
.size(13)
.padding([5, 8])
.style(visuals::input_style(p))
.on_input(|value| message(Input::Password(Zeroizing::new(value))))
.on_submit(|mut value| {
value.zeroize();
message(Input::SubmitWifi)
}),
);
}
if !security.can_create() {
body = body.push(self.connection_note(
"Enterprise and legacy networks need a saved NetworkManager profile with their security settings.",
));
}
body = body.push(
row([])
.spacing(8)
.push(self.connection_button("Cancel", Input::CancelWifi, true, false))
.push(self.connection_button(
"Connect",
Input::SubmitWifi,
target.is_some()
&& security.can_create()
&& super::wifi::valid_password(security, &s.password)
&& s.busy.is_none(),
true,
)),
);
self.connection_card(body)
}
}
+245
View File
@@ -88,6 +88,171 @@ pub fn edits(prefix: &str, mode: Mode, automatic: bool) -> Vec<Edit> {
] ]
} }
#[derive(Clone, Debug, PartialEq, Eq)]
pub(super) struct Target {
pub key: String,
pub matcher: String,
pub title: String,
pub profile: Option<String>,
pub prefix: Option<String>,
pub active: bool,
pub connected: bool,
}
impl std::fmt::Display for Target {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{} · {}{}",
self.title,
self.profile.as_deref().unwrap_or("Automatic"),
if self.connected { "" } else { " (offline)" }
)
}
}
pub(super) fn targets(snapshot: &crate::store::Snapshot, displays: &[Display]) -> Vec<Target> {
let mut targets = Vec::new();
for profile in 0..snapshot.records("output_profiles") {
let prefix = format!("output_profiles.{profile}");
let name = snapshot.string(&format!("{prefix}.name"), "Display profile");
for output in 0..snapshot.records(&format!("{prefix}.outputs")) {
let prefix = format!("{prefix}.outputs.{output}");
let matcher = snapshot.string(&format!("{prefix}.match"), "");
let live = displays
.iter()
.find(|d| d.connector == matcher || d.identity == matcher);
targets.push(Target {
key: format!("{name}\0{matcher}"),
title: live.map(|d| d.connector.clone()).unwrap_or_else(|| matcher.clone()),
matcher,
profile: Some(name.clone()),
prefix: Some(prefix),
active: live.is_some_and(|d| d.profile.as_deref() == Some(&name)),
connected: live.is_some(),
});
}
}
for display in displays {
if !targets
.iter()
.any(|t| t.matcher == display.connector || t.matcher == display.identity)
{
targets.push(Target {
key: format!("automatic\0{}", display.connector),
matcher: display.connector.clone(),
title: display.connector.clone(),
profile: None,
prefix: None,
active: true,
connected: true,
});
}
}
targets
}
pub(super) fn selected<'a>(targets: &'a [Target], key: Option<&str>) -> Option<&'a Target> {
targets
.iter()
.find(|t| Some(t.key.as_str()) == key)
.or_else(|| targets.iter().find(|t| t.active))
.or_else(|| targets.iter().find(|t| t.connected))
.or_else(|| targets.first())
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) struct Resolution {
pub width: u32,
pub height: u32,
}
impl std::fmt::Display for Resolution {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} × {}", self.width, self.height)
}
}
pub(super) fn resolutions(display: &Display) -> Vec<Resolution> {
let mut values: Vec<_> = display
.modes
.iter()
.filter(|m| m.width > 0 && m.height > 0)
.map(|m| Resolution {
width: m.width,
height: m.height,
})
.collect();
values.sort_by_key(|r| {
(
std::cmp::Reverse(u64::from(r.width) * u64::from(r.height)),
r.width,
r.height,
)
});
values.dedup();
values
}
pub(super) fn resolution(configured: &str, display: &Display) -> Option<Resolution> {
configured
.split('@')
.next()
.and_then(|v| v.split_once('x'))
.and_then(|(w, h)| {
Some(Resolution {
width: w.parse().ok()?,
height: h.parse().ok()?,
})
})
.or_else(|| {
display.current.map(|m| Resolution {
width: m.width,
height: m.height,
})
})
}
pub(super) fn change_resolution(
display: &Display,
configured: &str,
resolution: Resolution,
automatic: bool,
) -> Option<(Mode, bool)> {
let modes = choices(display, &format!("{}x{}", resolution.width, resolution.height));
let auto = automatic && modes.iter().any(|m| (59_000..=61_000).contains(&m.refresh));
let previous = configured
.split('@')
.nth(1)
.and_then(|r| r.parse::<f64>().ok())
.map(|r| r * 1000.)
.or_else(|| display.current.map(|m| f64::from(m.refresh)));
let mode = if auto {
modes.last()
} else {
modes
.iter()
.find(|m| previous.is_some_and(|r| (r - f64::from(m.refresh)).abs() < 1.))
.or_else(|| modes.last())
}?;
Some((*mode, auto))
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum Refresh {
Manual(Mode),
Automatic(Mode),
}
impl std::fmt::Display for Refresh {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Manual(mode) => f.write_str(&mode.label()),
Self::Automatic(_) => f.write_str("Auto (battery)"),
}
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -169,4 +334,84 @@ mod tests {
assert!(snapshot.boolean(&format!("{prefix}.auto_refresh"), false)); assert!(snapshot.boolean(&format!("{prefix}.auto_refresh"), false));
assert_eq!(snapshot.string(&format!("{prefix}.mode"), ""), "[email protected]"); assert_eq!(snapshot.string(&format!("{prefix}.mode"), ""), "[email protected]");
} }
#[test]
fn selected_display_prefers_active_profile_and_survives_profile_reordering() {
let snapshot =
Snapshot::parse("output-profile dock { output HDMI-A-1; }; output-profile laptop { output eDP-1; }".into())
.unwrap();
let live = vec![Display {
connector: "eDP-1".into(),
identity: "panel".into(),
profile: Some("laptop".into()),
current: None,
modes: vec![],
}];
let values = targets(&snapshot, &live);
assert_eq!(selected(&values, None).unwrap().profile.as_deref(), Some("laptop"));
let dock = selected(&values, Some("dock\0HDMI-A-1")).unwrap();
assert!(!dock.connected);
let key = dock.key.clone();
let reordered =
Snapshot::parse("output-profile laptop { output eDP-1; }; output-profile dock { output HDMI-A-1; }".into())
.unwrap();
let values = targets(&reordered, &live);
assert_eq!(
selected(&values, Some(&key)).unwrap().prefix.as_deref(),
Some("output_profiles.1.outputs.0")
);
}
#[test]
fn automatically_managed_outputs_are_still_visible() {
let snapshot = Snapshot::parse(String::new()).unwrap();
let live = vec![Display {
connector: "eDP-1".into(),
identity: "panel".into(),
profile: None,
current: None,
modes: vec![],
}];
let values = targets(&snapshot, &live);
assert_eq!(values.len(), 1);
assert!(selected(&values, None).unwrap().prefix.is_none());
}
#[test]
fn resolution_changes_preserve_refresh_and_only_keep_valid_auto_policy() {
let mode = |width, height, refresh| Mode { width, height, refresh };
let display = Display {
connector: "eDP-1".into(),
identity: "panel".into(),
profile: None,
current: Some(mode(2880, 1800, 60000)),
modes: vec![
mode(2880, 1800, 60000),
mode(2880, 1800, 120000),
mode(2560, 1440, 90000),
],
};
assert_eq!(resolutions(&display).len(), 2);
let high = Resolution {
width: 2880,
height: 1800,
};
assert_eq!(
change_resolution(&display, "2880x1800@120", high, false),
Some((mode(2880, 1800, 120000), false))
);
assert_eq!(
change_resolution(&display, "2880x1800@120", high, true),
Some((mode(2880, 1800, 120000), true))
);
let other = Resolution {
width: 2560,
height: 1440,
};
assert_eq!(
change_resolution(&display, "2880x1800@120", other, true),
Some((mode(2560, 1440, 90000), false))
);
assert!(change_resolution(&display, "", Resolution { width: 1, height: 1 }, true).is_none());
}
} }
+173
View File
@@ -0,0 +1,173 @@
use cosmic::Element;
use cosmic::iced::{Alignment, Length};
use cosmic::widget::{column, container, row};
use crate::displays::{self, Refresh};
use crate::{App, Message, schema, visuals};
impl App {
pub(super) fn displays_view(&self) -> Element<'_, Message> {
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
let targets = displays::targets(&self.draft, &self.displays);
let selected = displays::selected(&targets, self.display_selection.as_deref()).cloned();
let mut panel = column([]).spacing(6);
panel = panel.push(
row([])
.spacing(8)
.align_y(Alignment::Center)
.push(visuals::action_icon("M3 4h18v13H3z M12 17v4 M8 21h8", palette.accent))
.push(
ferese_theme::controls::select(
cosmic::iced::widget::pick_list(targets.clone(), selected.clone(), |target| {
Message::SelectDisplay(target.key)
})
.placeholder("Select display")
.font(self.font)
.text_size(12)
.padding([4, 8])
.width(Length::Fill),
palette,
)
.width(Length::Fill),
)
.push(self.settings_icon_button(
"Refresh displays",
"M20 7V3l-3 3a8 8 0 1 0 3 6 M20 3h-5",
Some(Message::RefreshDisplays),
)),
);
if let Some(error) = &self.display_error {
panel = panel.push(cosmic::widget::tooltip(
self.note("Live display information unavailable"),
self.label(error.clone(), 11.),
cosmic::widget::tooltip::Position::Bottom,
));
}
if let Some(target) = selected {
let live = self
.displays
.iter()
.find(|d| d.connector == target.matcher || d.identity == target.matcher);
if let Some(display) = live {
let status = display
.current
.map(|mode| format!("{} × {} · {}", mode.width, mode.height, mode.label()))
.unwrap_or_else(|| "Off".into());
panel = panel.push(self.note(&status));
}
if let Some(prefix) = target.prefix {
let configured = self.draft.string(&format!("{prefix}.mode"), "");
let automatic = self.draft.boolean(&format!("{prefix}.auto_refresh"), false);
if let Some(display) = live {
let resolutions = displays::resolutions(display);
if !resolutions.is_empty() {
let path = prefix.clone();
panel = panel.push(
self.display_control(
"Resolution",
ferese_theme::controls::select(
cosmic::iced::widget::pick_list(
resolutions,
displays::resolution(&configured, display),
move |size| Message::DisplayResolution(path.clone(), size),
)
.font(self.font)
.text_size(12)
.padding([4, 8])
.width(225),
palette,
)
.width(225)
.into(),
),
);
} else {
panel = panel.push(self.display_mode_field(&prefix));
}
let modes = displays::choices(display, &configured);
let mut refresh: Vec<_> = modes.iter().copied().map(Refresh::Manual).collect();
if modes.iter().any(|mode| (59_000..=61_000).contains(&mode.refresh))
&& let Some(high) = modes.last()
{
refresh.push(Refresh::Automatic(*high));
}
if !refresh.is_empty() {
let configured_rate = configured
.split('@')
.nth(1)
.and_then(|r| r.parse::<f64>().ok())
.map(|r| r * 1000.)
.or_else(|| display.current.map(|m| f64::from(m.refresh)));
let selected = refresh.iter().copied().find(|value| match value {
Refresh::Automatic(_) => automatic,
Refresh::Manual(mode) => {
!automatic && configured_rate.is_some_and(|r| (r - f64::from(mode.refresh)).abs() < 1.)
}
});
let path = prefix.clone();
panel = panel.push(
self.display_control(
"Refresh rate",
ferese_theme::controls::select(
cosmic::iced::widget::pick_list(refresh, selected, move |value| match value {
Refresh::Manual(mode) => Message::RefreshRate(path.clone(), mode, false),
Refresh::Automatic(mode) => Message::RefreshRate(path.clone(), mode, true),
})
.font(self.font)
.text_size(12)
.padding([4, 8])
.width(225),
palette,
)
.width(225)
.into(),
),
);
}
} else {
panel = panel.push(self.display_mode_field(&prefix));
}
panel = panel.push(self.field(schema::range(
format!("{prefix}.scale"),
"Scale",
"",
schema::RangeSpec {
default: 1.,
min: 0.75,
max: 3.,
step: 0.25,
suffix: "×",
integer: false,
},
)));
panel = panel.push(row([]).spacing(5).align_y(Alignment::Center)
.push(cosmic::widget::tooltip(visuals::action_icon("M12 3a9 9 0 1 0 0 18a9 9 0 0 0 0-18 M12 11v6 M12 7h.01", palette.muted),
self.label("Auto uses 60 Hz below 30% battery and returns to normal on AC or at 35%. Applying a display mode can briefly blank the screen.", 11.), cosmic::widget::tooltip::Position::Top))
.push(self.note(if target.active { "Changes apply live" } else { "Saved profile · applies when active" })));
} else {
panel = panel.push(self.note("Managed automatically · no saved display profile"));
}
} else {
panel = panel.push(self.note("No displays or saved profiles found"));
}
container(panel)
.padding([7, 10])
.width(Length::Fill)
.class(visuals::surface(palette.card, 14.))
.into()
}
fn display_control<'a>(&self, label: &str, control: Element<'a, Message>) -> Element<'a, Message> {
row([])
.spacing(10)
.align_y(Alignment::Center)
.padding([7, 10])
.push(self.label(label.to_owned(), 13.).width(Length::Fill))
.push(control)
.into()
}
fn display_mode_field(&self, prefix: &str) -> Element<'static, Message> {
self.field(schema::text(format!("{prefix}.mode"), "Resolution / refresh", "", ""))
}
}
+56 -65
View File
@@ -1,8 +1,8 @@
use cosmic::widget::column; use cosmic::widget::column;
use super::{ use super::{
Alignment, App, Element, Field, Kind, Length, Message, button, container, displays, fonts, row, set, slider, Alignment, App, Element, Field, Kind, Length, Message, button, container, fonts, row, set, slider, text_input,
text_input, visuals, widget, visuals, widget,
}; };
impl App { impl App {
@@ -16,11 +16,52 @@ impl App {
pub(super) fn note(&self, text: &str) -> Element<'static, Message> { pub(super) fn note(&self, text: &str) -> Element<'static, Message> {
let palette = visuals::Palette::from_resolved(&self.resolved.presented); 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)) .class(cosmic::theme::Text::Color(palette.muted))
.into() .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> { pub(super) fn field(&self, mut field: Field) -> Element<'static, Message> {
if field.path == "theme.material.opacity" { if field.path == "theme.material.opacity" {
let appearance = self.resolved.theme.appearance; let appearance = self.resolved.theme.appearance;
@@ -35,7 +76,7 @@ impl App {
} }
} }
let palette = visuals::Palette::from_resolved(&self.resolved.presented); 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() { if !field.description.is_empty() {
labels = labels.push( labels = labels.push(
self.label(field.description.clone(), 11.) self.label(field.description.clone(), 11.)
@@ -60,8 +101,9 @@ impl App {
let selection_path = path.clone(); let selection_path = path.clone();
let commit = field.clone(); let commit = field.clone();
column([]) column([])
.spacing(6) .spacing(4)
.push( .push(
ferese_theme::controls::select(
cosmic::iced::widget::pick_list( cosmic::iced::widget::pick_list(
families, families,
selected.map(|index| families[index].clone()), selected.map(|index| families[index].clone()),
@@ -77,10 +119,14 @@ impl App {
.font(self.font) .font(self.font)
.text_size(12) .text_size(12)
.width(225), .width(225),
palette,
)
.width(225),
) )
.push( .push(
text_input("Default font", value) text_input("Default font", value)
.font(self.font) .font(self.font)
.padding([4, 8])
.style(visuals::input_style(palette)) .style(visuals::input_style(palette))
.on_input(move |value| Message::Draft(path.clone(), value)) .on_input(move |value| Message::Draft(path.clone(), value))
.on_submit(move |_| Message::Commit(field.clone())) .on_submit(move |_| Message::Commit(field.clone()))
@@ -118,7 +164,7 @@ impl App {
let release = field.clone(); let release = field.clone();
row([]) row([])
.align_y(Alignment::Center) .align_y(Alignment::Center)
.spacing(14) .spacing(8)
.push( .push(
slider(min..=max, value, move |value| Message::Range(field.clone(), value)) slider(min..=max, value, move |value| Message::Range(field.clone(), value))
.step(step) .step(step)
@@ -135,7 +181,7 @@ impl App {
for (key, label) in choices { for (key, label) in choices {
options = options.push( options = options.push(
button::custom(self.label(*label, 12.)) button::custom(self.label(*label, 12.))
.padding([6, 9]) .padding([4, 8])
.class(visuals::button_style(palette, value == *key)) .class(visuals::button_style(palette, value == *key))
.on_press(Message::Change(set(&path, *key))), .on_press(Message::Change(set(&path, *key))),
); );
@@ -158,6 +204,7 @@ impl App {
let swatch = visuals::color(&value, palette.accent); let swatch = visuals::color(&value, palette.accent);
let input = text_input(default, value) let input = text_input(default, value)
.font(self.font) .font(self.font)
.padding([4, 8])
.style(visuals::input_style(palette)) .style(visuals::input_style(palette))
.on_input(move |value| Message::Draft(path.clone(), value)) .on_input(move |value| Message::Draft(path.clone(), value))
.on_submit(move |_| Message::Commit(field.clone())) .on_submit(move |_| Message::Commit(field.clone()))
@@ -178,70 +225,14 @@ impl App {
}; };
container( container(
row([]) row([])
.spacing(16) .spacing(10)
.align_y(Alignment::Center) .align_y(Alignment::Center)
.push(labels) .push(labels)
.push(control), .push(control),
) )
.padding([12, 12]) .padding([7, 10])
.width(Length::Fill) .width(Length::Fill)
.class(visuals::surface(palette.card, 0.)) .class(visuals::surface(palette.card, 0.))
.into() .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()
}
} }
+118 -14
View File
@@ -1,3 +1,4 @@
mod connections;
mod form_controls; mod form_controls;
mod pages; mod pages;
mod theme_controls; mod theme_controls;
@@ -5,6 +6,7 @@ mod theme_controls;
use theme_controls::*; use theme_controls::*;
mod displays; mod displays;
mod displays_ui;
mod fonts; mod fonts;
mod schema; mod schema;
mod store; mod store;
@@ -24,14 +26,33 @@ use store::{Edit, Snapshot, set};
fn main() -> cosmic::iced::Result { fn main() -> cosmic::iced::Result {
let mut args = std::env::args_os().skip(1); let mut args = std::env::args_os().skip(1);
let path = match args.next() { let mut path = store::config_path();
Some(arg) if arg == "--config" => PathBuf::from(args.next().expect("--config requires a path")), let mut connection_tab = None;
Some(_) => { while let Some(arg) = args.next() {
eprintln!("Usage: ferese-settings [--config PATH]"); 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(()); return Ok(());
} }
None => store::config_path(),
}; };
}
_ => {
eprintln!("Usage: ferese-settings [--config PATH] [--page connections|wifi|bluetooth]");
return Ok(());
}
}
}
let path = path.canonicalize().unwrap_or(path); let path = path.canonicalize().unwrap_or(path);
fonts::families(); fonts::families();
let initial = Snapshot::read(&path); let initial = Snapshot::read(&path);
@@ -43,12 +64,19 @@ fn main() -> cosmic::iced::Result {
.is_daemon(false) .is_daemon(false)
.antialiasing(true) .antialiasing(true)
.default_text_size(14.), .default_text_size(14.),
(path, initial), (path, initial, connection_tab),
) )
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
enum Message { 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, RefreshDisplays,
DisplaysLoaded(Result<Vec<displays::Display>, String>), DisplaysLoaded(Result<Vec<displays::Display>, String>),
RefreshRate(String, displays::Mode, bool), RefreshRate(String, displays::Mode, bool),
@@ -92,7 +120,10 @@ enum Message {
} }
struct App { struct App {
connections: connections::State,
displays: Vec<displays::Display>, displays: Vec<displays::Display>,
display_selection: Option<String>,
display_error: Option<String>,
note_editors: HashMap<String, NoteEditor>, note_editors: HashMap<String, NoteEditor>,
core: Core, core: Core,
path: PathBuf, path: PathBuf,
@@ -122,6 +153,14 @@ struct App {
thumbnail_error: Option<String>, thumbnail_error: Option<String>,
} }
impl Drop for App {
fn drop(&mut self) {
if let Some(client) = &self.connections.client {
client.shutdown();
}
}
}
struct NoteEditor { struct NoteEditor {
content: widget::text_editor::Content<cosmic::Renderer>, content: widget::text_editor::Content<cosmic::Renderer>,
revision: u64, revision: u64,
@@ -130,7 +169,7 @@ struct NoteEditor {
impl cosmic::Application for App { impl cosmic::Application for App {
type Executor = cosmic::executor::Default; type Executor = cosmic::executor::Default;
type Flags = (PathBuf, Result<Snapshot, String>); type Flags = (PathBuf, Result<Snapshot, String>, Option<connections::Tab>);
type Message = Message; type Message = Message;
const APP_ID: &'static str = "dev.ferese.Settings"; const APP_ID: &'static str = "dev.ferese.Settings";
@@ -142,7 +181,7 @@ impl cosmic::Application for App {
&mut self.core &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; core.window.show_headerbar = false;
let status = if path == store::config_path() { 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 font = ferese_theme::font(Some(&resolved.presented.tokens.typography.font_family));
let native_palette = visuals::Palette::from_resolved(&resolved.presented); let native_palette = visuals::Palette::from_resolved(&resolved.presented);
let mut app = Self { let mut app = Self {
connections: connections::State {
tab: connection_tab.unwrap_or_default(),
..connections::State::default()
},
displays: vec![], displays: vec![],
display_selection: None,
display_error: None,
note_editors: HashMap::new(), note_editors: HashMap::new(),
core, core,
path, path,
@@ -169,7 +214,11 @@ impl cosmic::Application for App {
saving_previous: String::new(), saving_previous: String::new(),
error, error,
status, status,
page: Page::Appearance, page: if connection_tab.is_some() {
Page::Connections
} else {
Page::Appearance
},
search: String::new(), search: String::new(),
inputs: HashMap::new(), inputs: HashMap::new(),
ranges: HashMap::new(), ranges: HashMap::new(),
@@ -193,15 +242,44 @@ impl cosmic::Application for App {
.unwrap_or_else(Task::none); .unwrap_or_else(Task::none);
app.sync_notes(); 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> { fn update(&mut self, message: Message) -> Task<Message> {
match message { match message {
Message::RefreshDisplays => return self.load_displays(), Message::Connections(input) => return self.connections_input(input),
Message::DisplaysLoaded(result) => { Message::ConnectionsReady(result) => return self.connections_ready(result),
self.displays = result.unwrap_or_default(); 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) => { Message::RefreshRate(prefix, mode, automatic) => {
self.inputs.remove(&format!("{prefix}.mode")); self.inputs.remove(&format!("{prefix}.mode"));
return self.edit_many(displays::edits(&prefix, mode, automatic)); return self.edit_many(displays::edits(&prefix, mode, automatic));
@@ -318,8 +396,21 @@ impl cosmic::Application for App {
self.error = Some(error); self.error = Some(error);
} }
Message::Page(page) => { Message::Page(page) => {
let leaving = self.page == Page::Connections && page != Page::Connections;
self.page = page; self.page = page;
self.search.clear(); 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 { if page == Page::Displays {
return self.load_displays(); return self.load_displays();
} }
@@ -603,6 +694,18 @@ impl cosmic::Application for App {
} else { } else {
cosmic::iced::Subscription::none() 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"), PathBuf::from("/unused/settings-test.kdl"),
Snapshot::parse(String::new()), Snapshot::parse(String::new()),
None,
), ),
) )
.0 .0
@@ -904,7 +1008,7 @@ mod tests {
assert!(app.status.contains("externally")); assert!(app.status.contains("externally"));
let _ = app.update(Message::ExternalConfig(Err("invalid KDL".into()))); let _ = app.update(Message::ExternalConfig(Err("invalid KDL".into())));
assert_eq!(app.current.source, external.source); assert_eq!(app.current.source, external.source);
assert!(app.error.unwrap().contains("invalid KDL")); assert!(app.error.as_ref().unwrap().contains("invalid KDL"));
} }
#[test] #[test]
+82 -83
View File
@@ -6,12 +6,12 @@ use super::{
impl App { impl App {
pub(super) fn page_view(&self) -> Element<'_, Message> { pub(super) fn page_view(&self) -> Element<'_, Message> {
let palette = visuals::Palette::from_resolved(&self.resolved.presented); let palette = visuals::Palette::from_resolved(&self.resolved.presented);
let mut sidebar = column([]) let sidebar = column([])
.spacing(3) .spacing(3)
.push( .push(
widget::mouse_area( widget::mouse_area(
row([]) row([])
.spacing(10) .spacing(8)
.align_y(Alignment::Center) .align_y(Alignment::Center)
.push(visuals::brand_icon()) .push(visuals::brand_icon())
.push(self.label("Ferese", 16.)) .push(self.label("Ferese", 16.))
@@ -29,11 +29,12 @@ impl App {
.size(12), .size(12),
) )
.push(widget::Space::new().height(8)); .push(widget::Space::new().height(8));
let mut navigation = column([]).spacing(3);
for page in Page::ALL { for page in Page::ALL {
sidebar = sidebar.push( navigation = navigation.push(
button::custom( button::custom(
row([]) row([])
.spacing(10) .spacing(8)
.align_y(Alignment::Center) .align_y(Alignment::Center)
.push(visuals::icon( .push(visuals::icon(
page, page,
@@ -51,21 +52,21 @@ impl App {
.on_press(Message::Page(page)), .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) .width(204)
.height(Length::Fill) .height(Length::Fill)
.padding([20, 10]) .padding([20, 10])
.class(visuals::surface(palette.sidebar, 0.)); .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([]) column([])
.spacing(5) .spacing(3)
.push(self.label( .push(self.label(
if self.search.is_empty() { if self.search.is_empty() {
self.page.title() self.page.title()
} else { } else {
"Search" "Search"
}, },
24., 20.,
)) ))
.push( .push(
self.label( self.label(
@@ -122,15 +123,18 @@ impl App {
"Auto follows the GTK/GNOME appearance preference".to_owned() "Auto follows the GTK/GNOME appearance preference".to_owned()
}; };
heading = heading.push(widget::tooltip( heading = heading.push(widget::tooltip(
ferese_theme::controls::text_button(label, self.font, palette, self.auto_details) self.settings_button(
.name("Automatic appearance settings") &label,
.on_press(Message::AutoDetails(!self.auto_details)), "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.), self.label(explanation, 12.),
widget::tooltip::Position::Bottom, widget::tooltip::Position::Bottom,
)); ));
} }
let mut body = column([]).spacing(12); let mut body = column([]).spacing(6);
if !self.search.is_empty() { if !self.search.is_empty() {
if !Page::ALL.into_iter().any(|p| p.matches(&self.search)) { if !Page::ALL.into_iter().any(|p| p.matches(&self.search)) {
@@ -140,12 +144,12 @@ impl App {
body = body.push( body = body.push(
button::custom( button::custom(
column([]) column([])
.spacing(5) .spacing(3)
.push(self.label(page.title(), 16.)) .push(self.label(page.title(), 16.))
.push(self.label(page.subtitle(), 12.)), .push(self.label(page.subtitle(), 12.)),
) )
.width(Length::Fill) .width(Length::Fill)
.padding(20) .padding(10)
.class(visuals::button_style(palette, false)) .class(visuals::button_style(palette, false))
.on_press(Message::Page(page)), .on_press(Message::Page(page)),
); );
@@ -179,7 +183,7 @@ impl App {
body = body.push( body = body.push(
widget::image(handle.clone()) widget::image(handle.clone())
.width(Length::Fill) .width(Length::Fill)
.height(190) .height(160)
.content_fit(cosmic::iced::ContentFit::Contain), .content_fit(cosmic::iced::ContentFit::Contain),
); );
} else { } else {
@@ -191,7 +195,12 @@ impl App {
.unwrap_or("Choose an image to preview your wallpaper.") .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); let fields = schema::fields(self.page);
@@ -231,7 +240,7 @@ impl App {
} else { } else {
"Expand advanced customization" "Expand advanced customization"
}) })
.padding(12) .padding([7, 10])
.width(Length::Fill) .width(Length::Fill)
.class(visuals::button_style(palette, false)) .class(visuals::button_style(palette, false))
.on_press(Message::AdvancedTheme(!self.advanced_theme)), .on_press(Message::AdvancedTheme(!self.advanced_theme)),
@@ -240,18 +249,24 @@ impl App {
group = group.push(self.theme_file_picker()); 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 { match self.page {
Page::Connections => body = body.push(self.connections_view()),
Page::LockScreen => { 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(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.")); 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 => { Page::Desktop => {
let can_change_list = !self.saving && !self.note_editors.values().any(|e| e.dirty); 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.")); 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") { for index in 0..self.draft.records("desktop_widgets.notes") {
let id = self.draft.string(&format!("desktop_widgets.notes.{index}.id"), "note"); let id = self.draft.string(&format!("desktop_widgets.notes.{index}.id"), "note");
@@ -267,19 +282,28 @@ impl App {
widget::TextEditor::new(&editor.content) widget::TextEditor::new(&editor.content)
.height(160) .height(160)
.font(self.font) .font(self.font)
.size(14.) .size(13.)
.on_action(move |action| Message::NoteAction(id.clone(), action)), .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)), 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( 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) (can_change_list && self.draft.records("desktop_widgets.notes") < 32)
.then_some(Message::AddNote), .then_some(Message::AddNote),
false,
), ),
); );
} }
@@ -312,22 +336,31 @@ impl App {
Kind::Toggle(true), Kind::Toggle(true),
))) )))
.push( .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)), (!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( body = body.push(
row([]) row([])
.spacing(10) .spacing(8)
.push( .push(
text_input("Program and arguments", self.new_command.clone()) 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), .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), (!self.saving && !self.new_command.trim().is_empty()).then_some(Message::AddCommand),
false,
)), )),
); );
} }
@@ -347,10 +380,16 @@ impl App {
}); });
if !exists { if !exists {
gestures = gestures.push( gestures = gestures.push(self.settings_icon_button(
button::standard(label) label,
.on_press_maybe((!self.saving).then_some(Message::AddSwipe(keys))), 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.", "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 { 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.")); 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 => { Page::Displays => body = body.push(self.displays_view()),
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.",
));
}
}
_ => {} _ => {}
} }
} }
let mut content = column([]).spacing(16).push( let mut content = column([]).spacing(8).push(
widget::mouse_area(heading.width(Length::Fill)) widget::mouse_area(heading.width(Length::Fill))
.on_press(Message::DragWindow) .on_press(Message::DragWindow)
.interaction(cosmic::iced::mouse::Interaction::Grab), .interaction(cosmic::iced::mouse::Interaction::Grab),
@@ -444,7 +437,7 @@ impl App {
if let Some(error) = &self.error { if let Some(error) = &self.error {
content = content.push( content = content.push(
container(self.label(error.clone(), 12.)) container(self.label(error.clone(), 12.))
.padding(12) .padding([7, 10])
.width(Length::Fill) .width(Length::Fill)
.class(visuals::surface(palette.error, 10.)), .class(visuals::surface(palette.error, 10.)),
); );
@@ -461,6 +454,7 @@ impl App {
}) })
.width(Length::Fill), .width(Length::Fill),
) )
.id(widget::Id::new("settings-content"))
.direction(cosmic::iced::widget::scrollable::Direction::Vertical( .direction(cosmic::iced::widget::scrollable::Direction::Vertical(
cosmic::iced::widget::scrollable::Scrollbar::new() cosmic::iced::widget::scrollable::Scrollbar::new()
.width(10) .width(10)
@@ -508,6 +502,11 @@ impl App {
self.label("Reload configuration", 11.), self.label("Reload configuration", 11.),
widget::tooltip::Position::Top, 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([]) row([])
.push(sidebar) .push(sidebar)
.push( .push(
+8 -2
View File
@@ -13,10 +13,11 @@ pub enum Page {
Shortcuts, Shortcuts,
Startup, Startup,
Displays, Displays,
Connections,
} }
impl Page { impl Page {
pub const ALL: [Self; 13] = [ pub const ALL: [Self; 14] = [
Self::Appearance, Self::Appearance,
Self::Accessibility, Self::Accessibility,
Self::Wallpaper, Self::Wallpaper,
@@ -30,6 +31,7 @@ impl Page {
Self::Shortcuts, Self::Shortcuts,
Self::Startup, Self::Startup,
Self::Displays, Self::Displays,
Self::Connections,
]; ];
pub fn title(self) -> &'static str { pub fn title(self) -> &'static str {
@@ -47,6 +49,7 @@ impl Page {
Self::Shortcuts => "Shortcuts", Self::Shortcuts => "Shortcuts",
Self::Startup => "Login items", Self::Startup => "Login items",
Self::Displays => "Displays", Self::Displays => "Displays",
Self::Connections => "Connections",
} }
} }
@@ -65,6 +68,7 @@ impl Page {
Self::Shortcuts => "Your most-used actions, a keystroke away.", Self::Shortcuts => "Your most-used actions, a keystroke away.",
Self::Startup => "Ready when you sign in.", Self::Startup => "Ready when you sign in.",
Self::Displays => "A place for every screen.", 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::Startup => "M12 3v9 M7 5a9 9 0 1 0 10 0",
Self::Displays => "M3 4h18v13H3z M12 17v4 M8 21h8", 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 { pub fn matches(self, query: &str) -> bool {
let query = query.to_lowercase(); let query = query.to_lowercase();
self.title().to_lowercase().contains(&query) self.title().to_lowercase().contains(&query)
|| self.subtitle().to_lowercase().contains(&query)
|| fields(self).iter().any(|f| { || fields(self).iter().any(|f| {
format!("{} {} {}", f.label, f.description, f.path) format!("{} {} {}", f.label, f.description, f.path)
.to_lowercase() .to_lowercase()
@@ -1112,6 +1118,6 @@ pub fn fields(page: Page) -> Vec<Field> {
"Keep the shortcut guide until you are comfortable with Ferese.", "Keep the shortcut guide until you are comfortable with Ferese.",
true, true,
)], )],
Page::Startup | Page::Displays => vec![], Page::Startup | Page::Displays | Page::Connections => vec![],
} }
} }
+43 -37
View File
@@ -18,7 +18,7 @@ impl App {
.into_owned() .into_owned()
}; };
let labels = column([]) let labels = column([])
.spacing(3) .spacing(2)
.width(Length::Fill) .width(Length::Fill)
.push(self.label("Theme overrides", 13.)) .push(self.label("Theme overrides", 13.))
.push(widget::tooltip( .push(widget::tooltip(
@@ -39,32 +39,29 @@ impl App {
scopes = scopes.push( scopes = scopes.push(
button::custom(self.label(label, 12.)) button::custom(self.label(label, 12.))
.name(format!("Theme file: {label}")) .name(format!("Theme file: {label}"))
.padding([6, 8]) .padding([4, 8])
.class(visuals::button_style(palette, index == self.theme_file_target)) .class(visuals::button_style(palette, index == self.theme_file_target))
.on_press(Message::ThemeFileTarget(index)), .on_press(Message::ThemeFileTarget(index)),
); );
} }
let mut controls = row([]) let mut controls = row([])
.spacing(8) .spacing(6)
.align_y(Alignment::Center) .align_y(Alignment::Center)
.push( .push(
container(scopes) container(scopes)
.padding(3) .padding(3)
.class(visuals::surface(palette.sidebar, 10.)), .class(visuals::surface(palette.sidebar, 10.)),
) )
.push( .push(self.settings_button(
ferese_theme::controls::text_button(
if file.is_empty() { "Choose…" } else { "Replace…" }, if file.is_empty() { "Choose…" } else { "Replace…" },
self.font, "M3 7V5h6l2 2h10v13H3z",
palette, Some(Message::PickThemeFile(path)),
false, false,
) ));
.on_press(Message::PickThemeFile(path)),
);
if !file.is_empty() { if !file.is_empty() {
controls = controls.push(widget::tooltip( controls = controls.push(widget::tooltip(
button::custom(visuals::action_icon("M6 6l12 12M6 18L18 6", palette.muted)) button::custom(visuals::action_icon("M6 6l12 12M6 18L18 6", palette.muted))
.padding(5) .padding(4)
.on_press(Message::Change(Edit::Unset(path.into()))), .on_press(Message::Change(Edit::Unset(path.into()))),
self.label("Remove override", 11.), self.label("Remove override", 11.),
widget::tooltip::Position::Bottom, widget::tooltip::Position::Bottom,
@@ -72,12 +69,12 @@ impl App {
} }
container( container(
row([]) row([])
.spacing(16) .spacing(6)
.align_y(Alignment::Center) .align_y(Alignment::Center)
.push(labels) .push(labels)
.push(controls), .push(controls),
) )
.padding([12, 12]) .padding([7, 10])
.width(Length::Fill) .width(Length::Fill)
.class(visuals::surface(palette.card, 0.)) .class(visuals::surface(palette.card, 0.))
.into() .into()
@@ -88,24 +85,29 @@ impl App {
let scheduled = self.draft.string("theme.schedule.source", "schedule") == "schedule"; let scheduled = self.draft.string("theme.schedule.source", "schedule") == "schedule";
let mut sources = row([]).spacing(4); let mut sources = row([]).spacing(4);
for (source, label) in [("system", "System"), ("schedule", "Schedule")] { for (source, label) in [("system", "System"), ("schedule", "Schedule")] {
sources = sources.push( sources = sources.push(self.settings_button(
ferese_theme::controls::text_button(label, self.font, palette, scheduled == (source == "schedule")) label,
.name(format!("Auto source: {label}")) if source == "schedule" {
.on_press(Message::Change(set("theme.schedule.source", source))), "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 { let description = if scheduled {
"Switch between light and dark at set times." "Switch between light and dark at set times."
} else { } else {
"Follow the GTK/GNOME appearance preference." "Follow the GTK/GNOME appearance preference."
}; };
let mut options = column([]).spacing(8).push( let mut options = column([]).spacing(6).push(
row([]) row([])
.spacing(12) .spacing(6)
.align_y(Alignment::Center) .align_y(Alignment::Center)
.push( .push(
column([]) column([])
.spacing(3) .spacing(2)
.width(Length::Fill) .width(Length::Fill)
.push(self.label("Automatic appearance", 13.)) .push(self.label("Automatic appearance", 13.))
.push(self.note(description)), .push(self.note(description)),
@@ -113,7 +115,7 @@ impl App {
.push(sources), .push(sources),
); );
if scheduled { 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) for field in schema::fields(Page::Appearance)
.into_iter() .into_iter()
.filter(|field| field.path.starts_with("theme.schedule.")) .filter(|field| field.path.starts_with("theme.schedule."))
@@ -130,6 +132,7 @@ impl App {
let submit = field.clone(); let submit = field.clone();
let input = text_input(*default, value) let input = text_input(*default, value)
.font(self.font) .font(self.font)
.padding([4, 8])
.size(12) .size(12)
.style(visuals::input_style(palette)) .style(visuals::input_style(palette))
.on_input(move |value| Message::Draft(path.clone(), value)) .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 }); .width(if field.path.ends_with("timezone") { 150 } else { 75 });
times = times.push( times = times.push(
row([]) row([])
.spacing(8) .spacing(6)
.align_y(Alignment::Center) .align_y(Alignment::Center)
.push(self.label(field.label, 12.)) .push(self.label(field.label, 12.))
.push(input), .push(input),
@@ -147,7 +150,7 @@ impl App {
options = options.push(times); options = options.push(times);
} }
container(options) container(options)
.padding(12) .padding([7, 10])
.width(Length::Fill) .width(Length::Fill)
.class(visuals::surface(palette.card, 0.)) .class(visuals::surface(palette.card, 0.))
.into() .into()
@@ -162,32 +165,35 @@ impl App {
let selected_id = visuals::family_selection(&self.draft, appearance); let selected_id = visuals::family_selection(&self.draft, appearance);
let choices = self.resolved.families.clone(); let choices = self.resolved.families.clone();
let ids: Vec<_> = choices.iter().map(|family| family.id.clone()).collect(); 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( self.label(
match appearance { match appearance {
Some(Light) => "Light theme", Some(Light) => "Light theme",
Some(Dark) => "Dark theme", Some(Dark) => "Dark theme",
None => "Theme", None => "Theme",
}, },
15., 14.,
) )
.width(Length::Fill), .width(Length::Fill),
); );
if appearance != Some(Dark) { if appearance != Some(Dark) {
if self.undo.is_some() { if self.undo.is_some() {
heading = heading.push( heading = heading.push(self.settings_icon_button(
ferese_theme::controls::text_button("Undo", self.font, palette, false) "Undo",
.on_press_maybe((!self.saving).then_some(Message::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( heading = heading.push(self.settings_button(
ferese_theme::controls::text_button("Import…", self.font, palette, false) "Import…",
.on_press(Message::ImportTheme), "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() { 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() { for (offset, family) in families.iter().enumerate() {
let selected = family.id == selected_id; let selected = family.id == selected_id;
let index = chunk * 3 + offset; let index = chunk * 3 + offset;
@@ -220,7 +226,7 @@ impl App {
gallery = gallery.push(tiles); gallery = gallery.push(tiles);
} }
gallery::group( 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:?}")), cosmic::iced::advanced::widget::Id::new(format!("theme-gallery-{appearance:?}")),
match appearance { match appearance {
Some(Light) => "Light theme", Some(Light) => "Light theme",