Add Wi-Fi and Bluetooth settings and compact controls
This commit is contained in:
1 parent
94afc6eb5c
commit
a3431cab5b
21 files changed
+3745
-225
No files matched your search
Generated
+2
@@ -1772,6 +1772,8 @@ dependencies = [
|
|||||||
"shlex 1.3.0",
|
"shlex 1.3.0",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"zbus",
|
||||||
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -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 {
|
||||||
mix(p.card, crate::surface_shade(p.sidebar), 0.06)
|
if navigation {
|
||||||
|
mix(p.card, crate::surface_shade(p.sidebar), 0.06)
|
||||||
|
} else {
|
||||||
|
mix(p.card, p.text, 0.08)
|
||||||
|
}
|
||||||
} else if navigation {
|
} 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,
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
use cosmic::Element;
|
||||||
|
use cosmic::iced::{Alignment, Length};
|
||||||
|
use cosmic::widget::{self, button, column, container, row};
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
use super::{Command, Network, Security, Tab};
|
||||||
|
use crate::{App, Message, visuals};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(crate) enum Input {
|
||||||
|
Refresh,
|
||||||
|
PollPrompt,
|
||||||
|
Tab(Tab),
|
||||||
|
Adapter(String),
|
||||||
|
Run(Command),
|
||||||
|
Select(Network),
|
||||||
|
Password(Zeroizing<String>),
|
||||||
|
SubmitWifi,
|
||||||
|
CancelWifi,
|
||||||
|
Hidden(bool),
|
||||||
|
HiddenName(String),
|
||||||
|
HiddenSecurity(Security),
|
||||||
|
PairingValue(u64, Zeroizing<String>),
|
||||||
|
Answer(u64, bool),
|
||||||
|
Forget(Command),
|
||||||
|
ConfirmForget,
|
||||||
|
CancelForget,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Debug for Input {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.write_str("ConnectionsInput")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn message(input: Input) -> Message {
|
||||||
|
Message::Connections(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
pub(super) fn connection_button(
|
||||||
|
&self,
|
||||||
|
label: &str,
|
||||||
|
input: Input,
|
||||||
|
enabled: bool,
|
||||||
|
selected: bool,
|
||||||
|
) -> Element<'static, Message> {
|
||||||
|
let p = visuals::Palette::from_resolved(&self.resolved.presented);
|
||||||
|
let (icon, icon_only) = match &input {
|
||||||
|
Input::Refresh => (Some("M20 7V3l-3 3a8 8 0 1 0 3 6 M20 3h-5"), true),
|
||||||
|
Input::Forget(_) | Input::ConfirmForget => (
|
||||||
|
Some("M4 6h16 M9 6V3h6v3 M6 6l1 15h10l1-15 M10 10v7 M14 10v7"),
|
||||||
|
matches!(input, Input::Forget(_)),
|
||||||
|
),
|
||||||
|
Input::Select(network) if network.saved.is_some() => {
|
||||||
|
(Some("M8 15a5 5 0 1 1 4-4l9-9 M16 7l3 3 M13 10l3 3"), true)
|
||||||
|
}
|
||||||
|
Input::Run(Command::BluetoothTrust(_, on)) => (
|
||||||
|
Some(if *on {
|
||||||
|
"M12 3l8 3v6c0 5-8 9-8 9s-8-4-8-9V6z"
|
||||||
|
} else {
|
||||||
|
"M12 3l8 3v6c0 5-8 9-8 9s-8-4-8-9V6z M8 12l3 3 5-6"
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
Input::Tab(Tab::Wifi) => (Some(crate::schema::Page::Connections.icon()), false),
|
||||||
|
Input::Tab(Tab::Bluetooth) => (Some("M12 3l6 5-12 8 6 5V3 M6 8l12 8-6 5"), false),
|
||||||
|
Input::Run(Command::WifiScan | Command::BluetoothScan(_)) => {
|
||||||
|
(Some("M10 3a7 7 0 1 0 0 14a7 7 0 0 0 0-14 M15 15l6 6"), false)
|
||||||
|
}
|
||||||
|
Input::Run(Command::BluetoothStop) => (Some("M6 6h12v12H6z"), false),
|
||||||
|
Input::Run(Command::WifiConnect(..) | Command::BluetoothConnect(_) | Command::BluetoothPair(_))
|
||||||
|
| Input::Select(_)
|
||||||
|
| Input::SubmitWifi => (Some("M4 12h15 M14 7l5 5-5 5"), false),
|
||||||
|
Input::Run(Command::WifiDisconnect(_) | Command::BluetoothDisconnect(_)) => {
|
||||||
|
(Some("M7 7l10 10 M17 7L7 17"), false)
|
||||||
|
}
|
||||||
|
Input::Hidden(_) => (Some("M12 5v14 M5 12h14"), false),
|
||||||
|
Input::Answer(..) => (Some("M5 12l4 4L19 6"), false),
|
||||||
|
_ => (None, false),
|
||||||
|
};
|
||||||
|
let mut content = row([]).spacing(5).align_y(Alignment::Center);
|
||||||
|
if let Some(icon) = icon {
|
||||||
|
content = content.push(visuals::action_icon(icon, if selected { p.accent } else { p.text }));
|
||||||
|
}
|
||||||
|
if !icon_only {
|
||||||
|
content = content.push(self.label(label.to_owned(), 12.));
|
||||||
|
}
|
||||||
|
let control = button::custom(content)
|
||||||
|
.name(label.to_owned())
|
||||||
|
.padding(if icon_only { [4, 4] } else { [4, 8] })
|
||||||
|
.class(visuals::button_style(p, selected))
|
||||||
|
.on_press_maybe(enabled.then(|| message(input)));
|
||||||
|
if icon_only {
|
||||||
|
widget::tooltip(
|
||||||
|
control,
|
||||||
|
self.label(label.to_owned(), 11.),
|
||||||
|
widget::tooltip::Position::Top,
|
||||||
|
)
|
||||||
|
.into()
|
||||||
|
} else {
|
||||||
|
control.into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn connection_note(&self, text: &str) -> Element<'static, Message> {
|
||||||
|
let p = visuals::Palette::from_resolved(&self.resolved.presented);
|
||||||
|
self.label(text.to_owned(), 11.)
|
||||||
|
.class(cosmic::theme::Text::Color(p.muted))
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn connection_card<'a>(&self, body: impl Into<Element<'a, Message>>) -> Element<'a, Message> {
|
||||||
|
let p = visuals::Palette::from_resolved(&self.resolved.presented);
|
||||||
|
container(body)
|
||||||
|
.padding([7, 10])
|
||||||
|
.width(Length::Fill)
|
||||||
|
.class(visuals::surface(p.card, p.radius))
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn connections_view(&self) -> Element<'_, Message> {
|
||||||
|
let s = &self.connections;
|
||||||
|
let p = visuals::Palette::from_resolved(&self.resolved.presented);
|
||||||
|
let mut body = column([]).spacing(6);
|
||||||
|
body = body.push(
|
||||||
|
row([])
|
||||||
|
.spacing(6)
|
||||||
|
.push(self.connection_button("Wi-Fi", Input::Tab(Tab::Wifi), true, s.tab == Tab::Wifi))
|
||||||
|
.push(self.connection_button("Bluetooth", Input::Tab(Tab::Bluetooth), true, s.tab == Tab::Bluetooth))
|
||||||
|
.push(widget::Space::new().width(Length::Fill))
|
||||||
|
.push(self.connection_button("Refresh", Input::Refresh, !s.loading, false)),
|
||||||
|
);
|
||||||
|
if let Some(error) = &s.error {
|
||||||
|
body = body.push(
|
||||||
|
container(self.label(error.clone(), 12.))
|
||||||
|
.padding(8)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.class(visuals::surface(p.error, 10.)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(busy) = s.busy {
|
||||||
|
let mut status = row([])
|
||||||
|
.align_y(Alignment::Center)
|
||||||
|
.spacing(6)
|
||||||
|
.push(self.label(busy, 12.));
|
||||||
|
if busy == "Pairing…" {
|
||||||
|
status = status.push(self.connection_button("Cancel", Input::Run(Command::CancelPairing), true, false));
|
||||||
|
}
|
||||||
|
body = body.push(status);
|
||||||
|
}
|
||||||
|
// Pairing stays visible even if the user switches between the two tabs.
|
||||||
|
if let Some(prompt) = &s.prompt {
|
||||||
|
body = body.push(self.pairing_view(prompt));
|
||||||
|
}
|
||||||
|
if s.forget.is_some() {
|
||||||
|
body = body.push(
|
||||||
|
self.connection_card(
|
||||||
|
column([])
|
||||||
|
.spacing(6)
|
||||||
|
.push(self.label("Forget this connection?", 14.))
|
||||||
|
.push(self.connection_note(
|
||||||
|
"Saved credentials or pairing keys will be removed. You will need to connect again.",
|
||||||
|
))
|
||||||
|
.push(
|
||||||
|
row([])
|
||||||
|
.spacing(8)
|
||||||
|
.push(self.connection_button("Cancel", Input::CancelForget, true, false))
|
||||||
|
.push(self.connection_button("Forget", Input::ConfirmForget, s.busy.is_none(), false)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(snapshot) = &s.snapshot {
|
||||||
|
body = body.push(match s.tab {
|
||||||
|
Tab::Wifi => self.wifi_view(&snapshot.wifi),
|
||||||
|
Tab::Bluetooth => self.bluetooth_view(&snapshot.bluetooth),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
body = body.push(self.connection_note(if s.loading {
|
||||||
|
"Loading connection services…"
|
||||||
|
} else {
|
||||||
|
"Connection services are unavailable. Use Refresh to try again."
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
body.into()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,474 @@
|
|||||||
|
use std::collections::{BTreeMap, HashMap};
|
||||||
|
|
||||||
|
use zbus::Connection;
|
||||||
|
use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value};
|
||||||
|
|
||||||
|
use super::proxy;
|
||||||
|
|
||||||
|
const NM: &str = "org.freedesktop.NetworkManager";
|
||||||
|
const ROOT: &str = "/org/freedesktop/NetworkManager";
|
||||||
|
const DEVICE: &str = "org.freedesktop.NetworkManager.Device";
|
||||||
|
const WIRELESS: &str = "org.freedesktop.NetworkManager.Device.Wireless";
|
||||||
|
const AP: &str = "org.freedesktop.NetworkManager.AccessPoint";
|
||||||
|
const SETTINGS: &str = "org.freedesktop.NetworkManager.Settings";
|
||||||
|
const PROFILE: &str = "org.freedesktop.NetworkManager.Settings.Connection";
|
||||||
|
type Settings = HashMap<String, HashMap<String, OwnedValue>>;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
pub(crate) enum Security {
|
||||||
|
Open,
|
||||||
|
Personal,
|
||||||
|
Sae,
|
||||||
|
Owe,
|
||||||
|
Enterprise,
|
||||||
|
Unsupported,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Security {
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Open => "Open",
|
||||||
|
Self::Personal => "WPA / WPA2",
|
||||||
|
Self::Sae => "WPA3",
|
||||||
|
Self::Owe => "Enhanced open",
|
||||||
|
Self::Enterprise => "Enterprise",
|
||||||
|
Self::Unsupported => "Legacy security",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn password(self) -> bool {
|
||||||
|
matches!(self, Self::Personal | Self::Sae)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn can_create(self) -> bool {
|
||||||
|
!matches!(self, Self::Enterprise | Self::Unsupported)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub(crate) struct Network {
|
||||||
|
pub ssid: Vec<u8>,
|
||||||
|
pub name: String,
|
||||||
|
pub security: Security,
|
||||||
|
pub strength: u8,
|
||||||
|
pub device: String,
|
||||||
|
pub ap: String,
|
||||||
|
pub saved: Option<String>,
|
||||||
|
pub connected: bool,
|
||||||
|
pub connecting: bool,
|
||||||
|
pub hidden: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub(crate) struct Radio {
|
||||||
|
pub path: String,
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub(crate) struct Wifi {
|
||||||
|
pub enabled: bool,
|
||||||
|
pub hardware_enabled: bool,
|
||||||
|
pub radios: Vec<Radio>,
|
||||||
|
pub networks: Vec<Network>,
|
||||||
|
pub saved: Vec<Saved>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub(crate) struct Saved {
|
||||||
|
pub path: String,
|
||||||
|
pub name: String,
|
||||||
|
pub ssid: Vec<u8>,
|
||||||
|
pub security: Security,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn security(flags: u32, wpa: u32, rsn: u32) -> Security {
|
||||||
|
let auth = wpa | rsn;
|
||||||
|
if auth & (0x200 | 0x2000) != 0 {
|
||||||
|
Security::Enterprise
|
||||||
|
} else if auth & 0x100 != 0 {
|
||||||
|
Security::Personal
|
||||||
|
} else if auth & 0x400 != 0 {
|
||||||
|
Security::Sae
|
||||||
|
} else if auth & (0x800 | 0x1000) != 0 {
|
||||||
|
Security::Owe
|
||||||
|
} else if flags & 1 != 0 {
|
||||||
|
Security::Unsupported
|
||||||
|
} else {
|
||||||
|
Security::Open
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn read(connection: &Connection) -> Result<Wifi, String> {
|
||||||
|
let root = proxy(connection, NM, ROOT, NM).await?;
|
||||||
|
let enabled = root.get_property("WirelessEnabled").await.map_err(|e| e.to_string())?;
|
||||||
|
let hardware_enabled = root
|
||||||
|
.get_property("WirelessHardwareEnabled")
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let devices: Vec<OwnedObjectPath> = root.call("GetDevices", &()).await.map_err(|e| e.to_string())?;
|
||||||
|
let saved = profiles(connection).await?;
|
||||||
|
let mut result = Wifi {
|
||||||
|
enabled,
|
||||||
|
hardware_enabled,
|
||||||
|
saved,
|
||||||
|
..Wifi::default()
|
||||||
|
};
|
||||||
|
let mut networks = BTreeMap::new();
|
||||||
|
for path in devices {
|
||||||
|
let device = proxy(connection, NM, path.as_str(), DEVICE).await?;
|
||||||
|
if device.get_property::<u32>("DeviceType").await.unwrap_or(0) != 2 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let name: String = device.get_property("Interface").await.map_err(|e| e.to_string())?;
|
||||||
|
result.radios.push(Radio {
|
||||||
|
path: path.to_string(),
|
||||||
|
name,
|
||||||
|
});
|
||||||
|
if !enabled {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let state: u32 = device.get_property("State").await.map_err(|e| e.to_string())?;
|
||||||
|
let wireless = proxy(connection, NM, path.as_str(), WIRELESS).await?;
|
||||||
|
let active: OwnedObjectPath = wireless
|
||||||
|
.get_property("ActiveAccessPoint")
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let aps: Vec<OwnedObjectPath> = wireless
|
||||||
|
.call("GetAllAccessPoints", &())
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
for ap_path in aps {
|
||||||
|
let ap = proxy(connection, NM, ap_path.as_str(), AP).await?;
|
||||||
|
let ssid: Vec<u8> = ap.get_property("Ssid").await.map_err(|e| e.to_string())?;
|
||||||
|
if ssid.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let strength = ap.get_property("Strength").await.map_err(|e| e.to_string())?;
|
||||||
|
let flags: u32 = ap.get_property("Flags").await.map_err(|e| e.to_string())?;
|
||||||
|
let wpa: u32 = ap.get_property("WpaFlags").await.map_err(|e| e.to_string())?;
|
||||||
|
let rsn: u32 = ap.get_property("RsnFlags").await.map_err(|e| e.to_string())?;
|
||||||
|
let auth = security(flags, wpa, rsn);
|
||||||
|
let saved = result
|
||||||
|
.saved
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.ssid == ssid && compatible(s.security, auth, wpa | rsn));
|
||||||
|
let network = Network {
|
||||||
|
name: String::from_utf8_lossy(&ssid).into_owned(),
|
||||||
|
saved: saved.map(|s| s.path.clone()),
|
||||||
|
ssid,
|
||||||
|
security: auth,
|
||||||
|
strength,
|
||||||
|
device: path.to_string(),
|
||||||
|
ap: ap_path.to_string(),
|
||||||
|
connected: active == ap_path && state == 100,
|
||||||
|
connecting: active == ap_path && (40..100).contains(&state),
|
||||||
|
hidden: false,
|
||||||
|
};
|
||||||
|
insert_network(&mut networks, network);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.networks = networks.into_values().collect();
|
||||||
|
result.networks.sort_by(|a, b| {
|
||||||
|
b.connected
|
||||||
|
.cmp(&a.connected)
|
||||||
|
.then(b.connecting.cmp(&a.connecting))
|
||||||
|
.then(b.strength.cmp(&a.strength))
|
||||||
|
.then(a.name.cmp(&b.name))
|
||||||
|
});
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compatible(saved: Security, advertised: Security, auth: u32) -> bool {
|
||||||
|
saved == advertised
|
||||||
|
|| saved == Security::Personal && advertised == Security::Sae
|
||||||
|
|| saved == Security::Sae && advertised == Security::Personal && auth & 0x400 != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert_network(networks: &mut BTreeMap<(Vec<u8>, Security, String), Network>, network: Network) {
|
||||||
|
// Keep adapters separate, and prefer the active AP over a stronger same-SSID AP.
|
||||||
|
let key = (network.ssid.clone(), network.security, network.device.clone());
|
||||||
|
if networks.get(&key).is_none_or(|old| {
|
||||||
|
(network.connected, network.connecting, network.strength) > (old.connected, old.connecting, old.strength)
|
||||||
|
}) {
|
||||||
|
networks.insert(key, network);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn profiles(connection: &Connection) -> Result<Vec<Saved>, String> {
|
||||||
|
let settings = proxy(connection, NM, "/org/freedesktop/NetworkManager/Settings", SETTINGS).await?;
|
||||||
|
let paths: Vec<OwnedObjectPath> = settings.call("ListConnections", &()).await.map_err(|e| e.to_string())?;
|
||||||
|
let mut saved = Vec::new();
|
||||||
|
for path in paths {
|
||||||
|
let profile = proxy(connection, NM, path.as_str(), PROFILE).await?;
|
||||||
|
let Ok(values) = profile.call::<_, _, Settings>("GetSettings", &()).await else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(wireless) = values.get("802-11-wireless") else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(ssid) = wireless
|
||||||
|
.get("ssid")
|
||||||
|
.and_then(|v| Vec::<u8>::try_from(v.try_clone().ok()?).ok())
|
||||||
|
else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let key = values
|
||||||
|
.get("802-11-wireless-security")
|
||||||
|
.and_then(|s| s.get("key-mgmt"))
|
||||||
|
.and_then(|v| <&str>::try_from(v).ok());
|
||||||
|
let security = match key {
|
||||||
|
None => Security::Open,
|
||||||
|
Some("wpa-psk") => Security::Personal,
|
||||||
|
Some("sae") => Security::Sae,
|
||||||
|
Some("owe") => Security::Owe,
|
||||||
|
Some("wpa-eap" | "wpa-eap-suite-b-192" | "ieee8021x") => Security::Enterprise,
|
||||||
|
_ => Security::Unsupported,
|
||||||
|
};
|
||||||
|
let name = values
|
||||||
|
.get("connection")
|
||||||
|
.and_then(|s| s.get("id"))
|
||||||
|
.and_then(|v| <&str>::try_from(v).ok())
|
||||||
|
.map(str::to_owned)
|
||||||
|
.unwrap_or_else(|| String::from_utf8_lossy(&ssid).into_owned());
|
||||||
|
saved.push(Saved {
|
||||||
|
path: path.to_string(),
|
||||||
|
name,
|
||||||
|
ssid,
|
||||||
|
security,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
saved.sort_by(|a, b| a.name.cmp(&b.name));
|
||||||
|
Ok(saved)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn power(connection: &Connection, enabled: bool) -> Result<(), String> {
|
||||||
|
proxy(connection, NM, ROOT, NM)
|
||||||
|
.await?
|
||||||
|
.set_property("WirelessEnabled", enabled)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn scan(connection: &Connection) -> Result<(), String> {
|
||||||
|
let root = proxy(connection, NM, ROOT, NM).await?;
|
||||||
|
let devices: Vec<OwnedObjectPath> = root.call("GetDevices", &()).await.map_err(|e| e.to_string())?;
|
||||||
|
let mut found = false;
|
||||||
|
for path in devices {
|
||||||
|
let device = proxy(connection, NM, path.as_str(), DEVICE).await?;
|
||||||
|
if device.get_property::<u32>("DeviceType").await.unwrap_or(0) != 2 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
found = true;
|
||||||
|
proxy(connection, NM, path.as_str(), WIRELESS)
|
||||||
|
.await?
|
||||||
|
.call::<_, _, ()>("RequestScan", &(HashMap::<String, OwnedValue>::new(),))
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
if found {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err("No Wi-Fi adapter is available.".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn valid_password(security: Security, password: &str) -> bool {
|
||||||
|
match security {
|
||||||
|
Security::Personal => {
|
||||||
|
(8..=63).contains(&password.len())
|
||||||
|
|| (password.len() == 64 && password.bytes().all(|b| b.is_ascii_hexdigit()))
|
||||||
|
}
|
||||||
|
Security::Sae => !password.is_empty(),
|
||||||
|
_ => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn settings<'a>(
|
||||||
|
network: &'a Network,
|
||||||
|
password: &'a str,
|
||||||
|
) -> Result<HashMap<&'static str, HashMap<&'static str, Value<'a>>>, String> {
|
||||||
|
if !network.security.can_create() {
|
||||||
|
return Err(
|
||||||
|
"This network needs an existing NetworkManager profile with its enterprise or legacy security settings."
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if !valid_password(network.security, password) {
|
||||||
|
return Err("WPA passwords need 8–63 characters, or 64 hexadecimal digits.".into());
|
||||||
|
}
|
||||||
|
let mut settings = HashMap::from([
|
||||||
|
(
|
||||||
|
"connection",
|
||||||
|
HashMap::from([
|
||||||
|
("id", Value::from(network.name.as_str())),
|
||||||
|
("type", Value::from("802-11-wireless")),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"802-11-wireless",
|
||||||
|
HashMap::from([
|
||||||
|
("ssid", Value::from(network.ssid.as_slice())),
|
||||||
|
("hidden", Value::from(network.hidden)),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
("ipv4", HashMap::from([("method", Value::from("auto"))])),
|
||||||
|
("ipv6", HashMap::from([("method", Value::from("auto"))])),
|
||||||
|
]);
|
||||||
|
let key = match network.security {
|
||||||
|
Security::Personal => Some("wpa-psk"),
|
||||||
|
Security::Sae => Some("sae"),
|
||||||
|
Security::Owe => Some("owe"),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Some(key) = key {
|
||||||
|
let mut security = HashMap::from([("key-mgmt", Value::from(key))]);
|
||||||
|
if network.security.password() {
|
||||||
|
security.insert("psk", Value::from(password));
|
||||||
|
}
|
||||||
|
settings.insert("802-11-wireless-security", security);
|
||||||
|
}
|
||||||
|
Ok(settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn connect(connection: &Connection, network: &Network, password: &str) -> Result<(), String> {
|
||||||
|
let root = proxy(connection, NM, ROOT, NM).await?;
|
||||||
|
let device = OwnedObjectPath::try_from(network.device.as_str()).map_err(|e| e.to_string())?;
|
||||||
|
let ap = OwnedObjectPath::try_from(network.ap.as_str()).map_err(|e| e.to_string())?;
|
||||||
|
let active: OwnedObjectPath = if let Some(saved) = &network.saved {
|
||||||
|
if !password.is_empty() && network.security.password() {
|
||||||
|
if !valid_password(network.security, password) {
|
||||||
|
return Err("Invalid network password.".into());
|
||||||
|
}
|
||||||
|
let profile = proxy(connection, NM, saved, PROFILE).await?;
|
||||||
|
let previous: Settings = profile.call("GetSettings", &()).await.map_err(|e| e.to_string())?;
|
||||||
|
let mut values: HashMap<String, HashMap<String, Value<'_>>> = previous
|
||||||
|
.into_iter()
|
||||||
|
.map(|(section, values)| {
|
||||||
|
(
|
||||||
|
section,
|
||||||
|
values
|
||||||
|
.into_iter()
|
||||||
|
.map(|(key, value)| (key, Value::from(value)))
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let security = values.entry("802-11-wireless-security".into()).or_default();
|
||||||
|
security.insert("psk".into(), Value::from(password));
|
||||||
|
security.insert("psk-flags".into(), Value::from(0u32));
|
||||||
|
profile
|
||||||
|
.call::<_, _, ()>("Update", &(values,))
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
let saved = OwnedObjectPath::try_from(saved.as_str()).map_err(|e| e.to_string())?;
|
||||||
|
root.call("ActivateConnection", &(saved, &device, &ap))
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
} else {
|
||||||
|
let values = settings(network, password)?;
|
||||||
|
let (_, active): (OwnedObjectPath, OwnedObjectPath) = root
|
||||||
|
.call("AddAndActivateConnection", &(values, &device, &ap))
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
active
|
||||||
|
};
|
||||||
|
// Activation's reply only means accepted. Wait for its actual result.
|
||||||
|
let active = proxy(
|
||||||
|
connection,
|
||||||
|
NM,
|
||||||
|
active.as_str(),
|
||||||
|
"org.freedesktop.NetworkManager.Connection.Active",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
for _ in 0..90 {
|
||||||
|
match active.get_property::<u32>("State").await.map_err(|e| e.to_string())? {
|
||||||
|
2 => return Ok(()),
|
||||||
|
3 | 4 => return Err("The network connection failed. Check the password and try again.".into()),
|
||||||
|
_ => tokio::time::sleep(std::time::Duration::from_secs(1)).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err("The network did not finish connecting. Check its status before trying again.".into())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn disconnect(connection: &Connection, device: &str) -> Result<(), String> {
|
||||||
|
proxy(connection, NM, device, DEVICE)
|
||||||
|
.await?
|
||||||
|
.call::<_, _, ()>("Disconnect", &())
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn forget(connection: &Connection, profile: &str) -> Result<(), String> {
|
||||||
|
proxy(connection, NM, profile, PROFILE)
|
||||||
|
.await?
|
||||||
|
.call::<_, _, ()>("Delete", &())
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn network() -> Network {
|
||||||
|
Network {
|
||||||
|
ssid: b"test".to_vec(),
|
||||||
|
name: "test".into(),
|
||||||
|
security: Security::Personal,
|
||||||
|
strength: 10,
|
||||||
|
device: "/device/1".into(),
|
||||||
|
ap: "/ap/1".into(),
|
||||||
|
saved: None,
|
||||||
|
connected: false,
|
||||||
|
connecting: false,
|
||||||
|
hidden: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn security_and_credentials() {
|
||||||
|
assert_eq!(security(0, 0, 0), Security::Open);
|
||||||
|
assert_eq!(security(1, 0x100, 0x500), Security::Personal);
|
||||||
|
assert_eq!(security(1, 0, 0x400), Security::Sae);
|
||||||
|
assert_eq!(security(1, 0, 0x200), Security::Enterprise);
|
||||||
|
assert_eq!(security(0, 0, 0x800), Security::Owe);
|
||||||
|
assert!(!valid_password(Security::Personal, "short"));
|
||||||
|
assert!(valid_password(Security::Personal, &"a".repeat(64)));
|
||||||
|
assert!(!valid_password(Security::Personal, &"z".repeat(64)));
|
||||||
|
assert!(valid_password(Security::Sae, "short"));
|
||||||
|
assert!(compatible(Security::Sae, Security::Personal, 0x500));
|
||||||
|
assert!(!compatible(Security::Sae, Security::Personal, 0x100));
|
||||||
|
assert!(compatible(Security::Personal, Security::Sae, 0x400));
|
||||||
|
assert_eq!(security(1, 0, 0x2000), Security::Enterprise);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn active_ap_wins_deduplication() {
|
||||||
|
let mut networks = BTreeMap::new();
|
||||||
|
let mut active = network();
|
||||||
|
active.connected = true;
|
||||||
|
insert_network(&mut networks, active);
|
||||||
|
let mut strong = network();
|
||||||
|
strong.strength = 99;
|
||||||
|
insert_network(&mut networks, strong.clone());
|
||||||
|
assert!(networks.values().next().unwrap().connected);
|
||||||
|
strong.security = Security::Open;
|
||||||
|
insert_network(&mut networks, strong);
|
||||||
|
assert_eq!(networks.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn secrets_only_go_to_secure_profiles() {
|
||||||
|
let mut network = network();
|
||||||
|
network.hidden = true;
|
||||||
|
let values = settings(&network, "password").unwrap();
|
||||||
|
assert_eq!(values["802-11-wireless-security"]["psk"], Value::from("password"));
|
||||||
|
assert_eq!(values["802-11-wireless"]["hidden"], Value::from(true));
|
||||||
|
network.security = Security::Open;
|
||||||
|
assert!(!settings(&network, "").unwrap().contains_key("802-11-wireless-security"));
|
||||||
|
network.security = Security::Enterprise;
|
||||||
|
assert!(settings(&network, "password").is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
use cosmic::Element;
|
||||||
|
use cosmic::iced::{Alignment, Length};
|
||||||
|
use cosmic::widget::{column, row, text_input};
|
||||||
|
use zeroize::{Zeroize, Zeroizing};
|
||||||
|
|
||||||
|
use super::ui::{Input, message};
|
||||||
|
use super::{Command, Security};
|
||||||
|
use crate::{App, Message, visuals};
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
pub(super) fn wifi_view(&self, result: &Result<super::wifi::Wifi, String>) -> Element<'_, Message> {
|
||||||
|
let s = &self.connections;
|
||||||
|
let p = visuals::Palette::from_resolved(&self.resolved.presented);
|
||||||
|
let ready = s.busy.is_none();
|
||||||
|
let Ok(wifi) = result else {
|
||||||
|
return self.connection_card(
|
||||||
|
column([])
|
||||||
|
.spacing(8)
|
||||||
|
.push(self.label("Wi-Fi unavailable", 14.))
|
||||||
|
.push(self.connection_note(result.as_ref().unwrap_err())),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
let mut body = column([]).spacing(6);
|
||||||
|
let status = if wifi.radios.is_empty() {
|
||||||
|
"No Wi-Fi adapter"
|
||||||
|
} else if !wifi.hardware_enabled {
|
||||||
|
"Blocked by a hardware switch or airplane mode"
|
||||||
|
} else if wifi.enabled {
|
||||||
|
"On"
|
||||||
|
} else {
|
||||||
|
"Off"
|
||||||
|
};
|
||||||
|
body = body.push(
|
||||||
|
self.connection_card(
|
||||||
|
row([])
|
||||||
|
.spacing(8)
|
||||||
|
.align_y(Alignment::Center)
|
||||||
|
.push(
|
||||||
|
column([])
|
||||||
|
.spacing(2)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.push(self.label("Wi-Fi", 15.))
|
||||||
|
.push(self.connection_note(status)),
|
||||||
|
)
|
||||||
|
.push(
|
||||||
|
ferese_theme::controls::switch(wifi.enabled, p)
|
||||||
|
.name("Wi-Fi power")
|
||||||
|
.on_press_maybe(
|
||||||
|
(ready && (wifi.hardware_enabled || wifi.enabled) && !wifi.radios.is_empty())
|
||||||
|
.then(|| message(Input::Run(Command::WifiPower(!wifi.enabled)))),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if !wifi.enabled || !wifi.hardware_enabled || wifi.radios.is_empty() {
|
||||||
|
return body.into();
|
||||||
|
}
|
||||||
|
body = body.push(
|
||||||
|
row([])
|
||||||
|
.align_y(Alignment::Center)
|
||||||
|
.push(self.label("Networks", 14.).width(Length::Fill))
|
||||||
|
.push(self.connection_button("Scan", Input::Run(Command::WifiScan), ready, false)),
|
||||||
|
);
|
||||||
|
if wifi.networks.is_empty() {
|
||||||
|
body = body.push(self.connection_note("No networks found. Scan to look for nearby networks."));
|
||||||
|
}
|
||||||
|
for network in &wifi.networks {
|
||||||
|
let radio = wifi
|
||||||
|
.radios
|
||||||
|
.iter()
|
||||||
|
.find(|r| r.path == network.device)
|
||||||
|
.map(|r| r.name.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
let state = if network.connected {
|
||||||
|
"Connected · "
|
||||||
|
} else if network.connecting {
|
||||||
|
"Connecting · "
|
||||||
|
} else if network.saved.is_some() {
|
||||||
|
"Saved · "
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
let detail = format!(
|
||||||
|
"{state}{} · {}% signal{}",
|
||||||
|
network.security.label(),
|
||||||
|
network.strength,
|
||||||
|
if wifi.radios.len() > 1 {
|
||||||
|
format!(" · {radio}")
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
let mut controls = row([]).spacing(6);
|
||||||
|
if network.connected || network.connecting {
|
||||||
|
controls = controls.push(self.connection_button(
|
||||||
|
"Disconnect",
|
||||||
|
Input::Run(Command::WifiDisconnect(network.device.clone())),
|
||||||
|
ready,
|
||||||
|
false,
|
||||||
|
));
|
||||||
|
} else if network.saved.is_some() || !network.security.password() && network.security.can_create() {
|
||||||
|
controls = controls.push(self.connection_button(
|
||||||
|
"Connect",
|
||||||
|
Input::Run(Command::WifiConnect(network.clone(), Zeroizing::new(String::new()))),
|
||||||
|
ready,
|
||||||
|
false,
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
controls =
|
||||||
|
controls.push(self.connection_button("Connect…", Input::Select(network.clone()), ready, false));
|
||||||
|
}
|
||||||
|
if network.saved.is_some() && network.security.password() {
|
||||||
|
controls =
|
||||||
|
controls.push(self.connection_button("Password…", Input::Select(network.clone()), ready, false));
|
||||||
|
}
|
||||||
|
body = body.push(
|
||||||
|
self.connection_card(
|
||||||
|
row([])
|
||||||
|
.spacing(8)
|
||||||
|
.align_y(Alignment::Center)
|
||||||
|
.push(visuals::action_icon(
|
||||||
|
super::super::schema::Page::Connections.icon(),
|
||||||
|
p.accent,
|
||||||
|
))
|
||||||
|
.push(
|
||||||
|
column([])
|
||||||
|
.spacing(2)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.push(self.label(network.name.clone(), 13.))
|
||||||
|
.push(self.connection_note(&detail)),
|
||||||
|
)
|
||||||
|
.push(controls),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if s.selected.as_ref().is_some_and(|selected| {
|
||||||
|
selected.ssid == network.ssid
|
||||||
|
&& selected.device == network.device
|
||||||
|
&& selected.security == network.security
|
||||||
|
}) {
|
||||||
|
body = body.push(self.wifi_credentials());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body = body.push(self.connection_button("Join a hidden network…", Input::Hidden(true), ready, false));
|
||||||
|
if s.hidden {
|
||||||
|
body = body.push(self.wifi_credentials());
|
||||||
|
}
|
||||||
|
if !wifi.saved.is_empty() {
|
||||||
|
body = body.push(self.label("Saved networks", 14.));
|
||||||
|
for saved in &wifi.saved {
|
||||||
|
body = body.push(
|
||||||
|
self.connection_card(
|
||||||
|
row([])
|
||||||
|
.spacing(8)
|
||||||
|
.align_y(Alignment::Center)
|
||||||
|
.push(visuals::action_icon(crate::schema::Page::Connections.icon(), p.muted))
|
||||||
|
.push(
|
||||||
|
column([])
|
||||||
|
.spacing(2)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.push(self.label(saved.name.clone(), 13.))
|
||||||
|
.push(self.connection_note(saved.security.label())),
|
||||||
|
)
|
||||||
|
.push(self.connection_button(
|
||||||
|
"Forget",
|
||||||
|
Input::Forget(Command::WifiForget(saved.path.clone())),
|
||||||
|
ready,
|
||||||
|
false,
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn wifi_credentials(&self) -> Element<'_, Message> {
|
||||||
|
let s = &self.connections;
|
||||||
|
let p = visuals::Palette::from_resolved(&self.resolved.presented);
|
||||||
|
let target = self.wifi_target();
|
||||||
|
let mut body = column([]).spacing(6).push(self.label(
|
||||||
|
if s.hidden {
|
||||||
|
"Hidden network".into()
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"Join {}",
|
||||||
|
s.selected.as_ref().map(|n| n.name.as_str()).unwrap_or("network")
|
||||||
|
)
|
||||||
|
},
|
||||||
|
16.,
|
||||||
|
));
|
||||||
|
if s.hidden {
|
||||||
|
body = body.push(
|
||||||
|
text_input("Network name (SSID)", s.hidden_name.clone())
|
||||||
|
.font(self.font)
|
||||||
|
.size(13)
|
||||||
|
.padding([5, 8])
|
||||||
|
.style(visuals::input_style(p))
|
||||||
|
.on_input(|name| message(Input::HiddenName(name))),
|
||||||
|
);
|
||||||
|
let mut choices = row([]).spacing(6);
|
||||||
|
for security in [Security::Personal, Security::Sae, Security::Open, Security::Owe] {
|
||||||
|
choices = choices.push(self.connection_button(
|
||||||
|
security.label(),
|
||||||
|
Input::HiddenSecurity(security),
|
||||||
|
true,
|
||||||
|
s.hidden_security == security,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
body = body.push(choices);
|
||||||
|
}
|
||||||
|
let security = target.as_ref().map(|n| n.security).unwrap_or(s.hidden_security);
|
||||||
|
if security.password() {
|
||||||
|
body = body.push(
|
||||||
|
text_input("Network password", s.password.as_str())
|
||||||
|
.id(cosmic::widget::Id::new("wifi-password"))
|
||||||
|
.password()
|
||||||
|
.font(self.font)
|
||||||
|
.size(13)
|
||||||
|
.padding([5, 8])
|
||||||
|
.style(visuals::input_style(p))
|
||||||
|
.on_input(|value| message(Input::Password(Zeroizing::new(value))))
|
||||||
|
.on_submit(|mut value| {
|
||||||
|
value.zeroize();
|
||||||
|
message(Input::SubmitWifi)
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if !security.can_create() {
|
||||||
|
body = body.push(self.connection_note(
|
||||||
|
"Enterprise and legacy networks need a saved NetworkManager profile with their security settings.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
body = body.push(
|
||||||
|
row([])
|
||||||
|
.spacing(8)
|
||||||
|
.push(self.connection_button("Cancel", Input::CancelWifi, true, false))
|
||||||
|
.push(self.connection_button(
|
||||||
|
"Connect",
|
||||||
|
Input::SubmitWifi,
|
||||||
|
target.is_some()
|
||||||
|
&& security.can_create()
|
||||||
|
&& super::wifi::valid_password(security, &s.password)
|
||||||
|
&& s.busy.is_none(),
|
||||||
|
true,
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
self.connection_card(body)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -88,6 +88,171 @@ pub fn edits(prefix: &str, mode: Mode, automatic: bool) -> Vec<Edit> {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub(super) struct Target {
|
||||||
|
pub key: String,
|
||||||
|
pub matcher: String,
|
||||||
|
pub title: String,
|
||||||
|
pub profile: Option<String>,
|
||||||
|
pub prefix: Option<String>,
|
||||||
|
pub active: bool,
|
||||||
|
pub connected: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for Target {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"{} · {}{}",
|
||||||
|
self.title,
|
||||||
|
self.profile.as_deref().unwrap_or("Automatic"),
|
||||||
|
if self.connected { "" } else { " (offline)" }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn targets(snapshot: &crate::store::Snapshot, displays: &[Display]) -> Vec<Target> {
|
||||||
|
let mut targets = Vec::new();
|
||||||
|
for profile in 0..snapshot.records("output_profiles") {
|
||||||
|
let prefix = format!("output_profiles.{profile}");
|
||||||
|
let name = snapshot.string(&format!("{prefix}.name"), "Display profile");
|
||||||
|
for output in 0..snapshot.records(&format!("{prefix}.outputs")) {
|
||||||
|
let prefix = format!("{prefix}.outputs.{output}");
|
||||||
|
let matcher = snapshot.string(&format!("{prefix}.match"), "");
|
||||||
|
let live = displays
|
||||||
|
.iter()
|
||||||
|
.find(|d| d.connector == matcher || d.identity == matcher);
|
||||||
|
targets.push(Target {
|
||||||
|
key: format!("{name}\0{matcher}"),
|
||||||
|
title: live.map(|d| d.connector.clone()).unwrap_or_else(|| matcher.clone()),
|
||||||
|
matcher,
|
||||||
|
profile: Some(name.clone()),
|
||||||
|
prefix: Some(prefix),
|
||||||
|
active: live.is_some_and(|d| d.profile.as_deref() == Some(&name)),
|
||||||
|
connected: live.is_some(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for display in displays {
|
||||||
|
if !targets
|
||||||
|
.iter()
|
||||||
|
.any(|t| t.matcher == display.connector || t.matcher == display.identity)
|
||||||
|
{
|
||||||
|
targets.push(Target {
|
||||||
|
key: format!("automatic\0{}", display.connector),
|
||||||
|
matcher: display.connector.clone(),
|
||||||
|
title: display.connector.clone(),
|
||||||
|
profile: None,
|
||||||
|
prefix: None,
|
||||||
|
active: true,
|
||||||
|
connected: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
targets
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn selected<'a>(targets: &'a [Target], key: Option<&str>) -> Option<&'a Target> {
|
||||||
|
targets
|
||||||
|
.iter()
|
||||||
|
.find(|t| Some(t.key.as_str()) == key)
|
||||||
|
.or_else(|| targets.iter().find(|t| t.active))
|
||||||
|
.or_else(|| targets.iter().find(|t| t.connected))
|
||||||
|
.or_else(|| targets.first())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub(super) struct Resolution {
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for Resolution {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "{} × {}", self.width, self.height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn resolutions(display: &Display) -> Vec<Resolution> {
|
||||||
|
let mut values: Vec<_> = display
|
||||||
|
.modes
|
||||||
|
.iter()
|
||||||
|
.filter(|m| m.width > 0 && m.height > 0)
|
||||||
|
.map(|m| Resolution {
|
||||||
|
width: m.width,
|
||||||
|
height: m.height,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
values.sort_by_key(|r| {
|
||||||
|
(
|
||||||
|
std::cmp::Reverse(u64::from(r.width) * u64::from(r.height)),
|
||||||
|
r.width,
|
||||||
|
r.height,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
values.dedup();
|
||||||
|
values
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn resolution(configured: &str, display: &Display) -> Option<Resolution> {
|
||||||
|
configured
|
||||||
|
.split('@')
|
||||||
|
.next()
|
||||||
|
.and_then(|v| v.split_once('x'))
|
||||||
|
.and_then(|(w, h)| {
|
||||||
|
Some(Resolution {
|
||||||
|
width: w.parse().ok()?,
|
||||||
|
height: h.parse().ok()?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.or_else(|| {
|
||||||
|
display.current.map(|m| Resolution {
|
||||||
|
width: m.width,
|
||||||
|
height: m.height,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn change_resolution(
|
||||||
|
display: &Display,
|
||||||
|
configured: &str,
|
||||||
|
resolution: Resolution,
|
||||||
|
automatic: bool,
|
||||||
|
) -> Option<(Mode, bool)> {
|
||||||
|
let modes = choices(display, &format!("{}x{}", resolution.width, resolution.height));
|
||||||
|
let auto = automatic && modes.iter().any(|m| (59_000..=61_000).contains(&m.refresh));
|
||||||
|
let previous = configured
|
||||||
|
.split('@')
|
||||||
|
.nth(1)
|
||||||
|
.and_then(|r| r.parse::<f64>().ok())
|
||||||
|
.map(|r| r * 1000.)
|
||||||
|
.or_else(|| display.current.map(|m| f64::from(m.refresh)));
|
||||||
|
let mode = if auto {
|
||||||
|
modes.last()
|
||||||
|
} else {
|
||||||
|
modes
|
||||||
|
.iter()
|
||||||
|
.find(|m| previous.is_some_and(|r| (r - f64::from(m.refresh)).abs() < 1.))
|
||||||
|
.or_else(|| modes.last())
|
||||||
|
}?;
|
||||||
|
Some((*mode, auto))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub(super) enum Refresh {
|
||||||
|
Manual(Mode),
|
||||||
|
Automatic(Mode),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for Refresh {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::Manual(mode) => f.write_str(&mode.label()),
|
||||||
|
Self::Automatic(_) => f.write_str("Auto (battery)"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
use cosmic::Element;
|
||||||
|
use cosmic::iced::{Alignment, Length};
|
||||||
|
use cosmic::widget::{column, container, row};
|
||||||
|
|
||||||
|
use crate::displays::{self, Refresh};
|
||||||
|
use crate::{App, Message, schema, visuals};
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
pub(super) fn displays_view(&self) -> Element<'_, Message> {
|
||||||
|
let palette = visuals::Palette::from_resolved(&self.resolved.presented);
|
||||||
|
let targets = displays::targets(&self.draft, &self.displays);
|
||||||
|
let selected = displays::selected(&targets, self.display_selection.as_deref()).cloned();
|
||||||
|
let mut panel = column([]).spacing(6);
|
||||||
|
panel = panel.push(
|
||||||
|
row([])
|
||||||
|
.spacing(8)
|
||||||
|
.align_y(Alignment::Center)
|
||||||
|
.push(visuals::action_icon("M3 4h18v13H3z M12 17v4 M8 21h8", palette.accent))
|
||||||
|
.push(
|
||||||
|
ferese_theme::controls::select(
|
||||||
|
cosmic::iced::widget::pick_list(targets.clone(), selected.clone(), |target| {
|
||||||
|
Message::SelectDisplay(target.key)
|
||||||
|
})
|
||||||
|
.placeholder("Select display")
|
||||||
|
.font(self.font)
|
||||||
|
.text_size(12)
|
||||||
|
.padding([4, 8])
|
||||||
|
.width(Length::Fill),
|
||||||
|
palette,
|
||||||
|
)
|
||||||
|
.width(Length::Fill),
|
||||||
|
)
|
||||||
|
.push(self.settings_icon_button(
|
||||||
|
"Refresh displays",
|
||||||
|
"M20 7V3l-3 3a8 8 0 1 0 3 6 M20 3h-5",
|
||||||
|
Some(Message::RefreshDisplays),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
if let Some(error) = &self.display_error {
|
||||||
|
panel = panel.push(cosmic::widget::tooltip(
|
||||||
|
self.note("Live display information unavailable"),
|
||||||
|
self.label(error.clone(), 11.),
|
||||||
|
cosmic::widget::tooltip::Position::Bottom,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(target) = selected {
|
||||||
|
let live = self
|
||||||
|
.displays
|
||||||
|
.iter()
|
||||||
|
.find(|d| d.connector == target.matcher || d.identity == target.matcher);
|
||||||
|
if let Some(display) = live {
|
||||||
|
let status = display
|
||||||
|
.current
|
||||||
|
.map(|mode| format!("{} × {} · {}", mode.width, mode.height, mode.label()))
|
||||||
|
.unwrap_or_else(|| "Off".into());
|
||||||
|
panel = panel.push(self.note(&status));
|
||||||
|
}
|
||||||
|
if let Some(prefix) = target.prefix {
|
||||||
|
let configured = self.draft.string(&format!("{prefix}.mode"), "");
|
||||||
|
let automatic = self.draft.boolean(&format!("{prefix}.auto_refresh"), false);
|
||||||
|
if let Some(display) = live {
|
||||||
|
let resolutions = displays::resolutions(display);
|
||||||
|
if !resolutions.is_empty() {
|
||||||
|
let path = prefix.clone();
|
||||||
|
panel = panel.push(
|
||||||
|
self.display_control(
|
||||||
|
"Resolution",
|
||||||
|
ferese_theme::controls::select(
|
||||||
|
cosmic::iced::widget::pick_list(
|
||||||
|
resolutions,
|
||||||
|
displays::resolution(&configured, display),
|
||||||
|
move |size| Message::DisplayResolution(path.clone(), size),
|
||||||
|
)
|
||||||
|
.font(self.font)
|
||||||
|
.text_size(12)
|
||||||
|
.padding([4, 8])
|
||||||
|
.width(225),
|
||||||
|
palette,
|
||||||
|
)
|
||||||
|
.width(225)
|
||||||
|
.into(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
panel = panel.push(self.display_mode_field(&prefix));
|
||||||
|
}
|
||||||
|
let modes = displays::choices(display, &configured);
|
||||||
|
let mut refresh: Vec<_> = modes.iter().copied().map(Refresh::Manual).collect();
|
||||||
|
if modes.iter().any(|mode| (59_000..=61_000).contains(&mode.refresh))
|
||||||
|
&& let Some(high) = modes.last()
|
||||||
|
{
|
||||||
|
refresh.push(Refresh::Automatic(*high));
|
||||||
|
}
|
||||||
|
if !refresh.is_empty() {
|
||||||
|
let configured_rate = configured
|
||||||
|
.split('@')
|
||||||
|
.nth(1)
|
||||||
|
.and_then(|r| r.parse::<f64>().ok())
|
||||||
|
.map(|r| r * 1000.)
|
||||||
|
.or_else(|| display.current.map(|m| f64::from(m.refresh)));
|
||||||
|
let selected = refresh.iter().copied().find(|value| match value {
|
||||||
|
Refresh::Automatic(_) => automatic,
|
||||||
|
Refresh::Manual(mode) => {
|
||||||
|
!automatic && configured_rate.is_some_and(|r| (r - f64::from(mode.refresh)).abs() < 1.)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let path = prefix.clone();
|
||||||
|
panel = panel.push(
|
||||||
|
self.display_control(
|
||||||
|
"Refresh rate",
|
||||||
|
ferese_theme::controls::select(
|
||||||
|
cosmic::iced::widget::pick_list(refresh, selected, move |value| match value {
|
||||||
|
Refresh::Manual(mode) => Message::RefreshRate(path.clone(), mode, false),
|
||||||
|
Refresh::Automatic(mode) => Message::RefreshRate(path.clone(), mode, true),
|
||||||
|
})
|
||||||
|
.font(self.font)
|
||||||
|
.text_size(12)
|
||||||
|
.padding([4, 8])
|
||||||
|
.width(225),
|
||||||
|
palette,
|
||||||
|
)
|
||||||
|
.width(225)
|
||||||
|
.into(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
panel = panel.push(self.display_mode_field(&prefix));
|
||||||
|
}
|
||||||
|
panel = panel.push(self.field(schema::range(
|
||||||
|
format!("{prefix}.scale"),
|
||||||
|
"Scale",
|
||||||
|
"",
|
||||||
|
schema::RangeSpec {
|
||||||
|
default: 1.,
|
||||||
|
min: 0.75,
|
||||||
|
max: 3.,
|
||||||
|
step: 0.25,
|
||||||
|
suffix: "×",
|
||||||
|
integer: false,
|
||||||
|
},
|
||||||
|
)));
|
||||||
|
panel = panel.push(row([]).spacing(5).align_y(Alignment::Center)
|
||||||
|
.push(cosmic::widget::tooltip(visuals::action_icon("M12 3a9 9 0 1 0 0 18a9 9 0 0 0 0-18 M12 11v6 M12 7h.01", palette.muted),
|
||||||
|
self.label("Auto uses 60 Hz below 30% battery and returns to normal on AC or at 35%. Applying a display mode can briefly blank the screen.", 11.), cosmic::widget::tooltip::Position::Top))
|
||||||
|
.push(self.note(if target.active { "Changes apply live" } else { "Saved profile · applies when active" })));
|
||||||
|
} else {
|
||||||
|
panel = panel.push(self.note("Managed automatically · no saved display profile"));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
panel = panel.push(self.note("No displays or saved profiles found"));
|
||||||
|
}
|
||||||
|
container(panel)
|
||||||
|
.padding([7, 10])
|
||||||
|
.width(Length::Fill)
|
||||||
|
.class(visuals::surface(palette.card, 14.))
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn display_control<'a>(&self, label: &str, control: Element<'a, Message>) -> Element<'a, Message> {
|
||||||
|
row([])
|
||||||
|
.spacing(10)
|
||||||
|
.align_y(Alignment::Center)
|
||||||
|
.padding([7, 10])
|
||||||
|
.push(self.label(label.to_owned(), 13.).width(Length::Fill))
|
||||||
|
.push(control)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn display_mode_field(&self, prefix: &str) -> Element<'static, Message> {
|
||||||
|
self.field(schema::text(format!("{prefix}.mode"), "Resolution / refresh", "", ""))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
use cosmic::widget::column;
|
use 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,27 +101,32 @@ 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(
|
||||||
cosmic::iced::widget::pick_list(
|
ferese_theme::controls::select(
|
||||||
families,
|
cosmic::iced::widget::pick_list(
|
||||||
selected.map(|index| families[index].clone()),
|
families,
|
||||||
move |family: String| {
|
selected.map(|index| families[index].clone()),
|
||||||
let value = if family == "Default font" {
|
move |family: String| {
|
||||||
String::new()
|
let value = if family == "Default font" {
|
||||||
} else {
|
String::new()
|
||||||
family
|
} else {
|
||||||
};
|
family
|
||||||
Message::SelectFont(selection_path.clone(), value)
|
};
|
||||||
},
|
Message::SelectFont(selection_path.clone(), value)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.font(self.font)
|
||||||
|
.text_size(12)
|
||||||
|
.width(225),
|
||||||
|
palette,
|
||||||
)
|
)
|
||||||
.font(self.font)
|
|
||||||
.text_size(12)
|
|
||||||
.width(225),
|
.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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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() {
|
||||||
return Ok(());
|
Some("--config") => {
|
||||||
|
let Some(value) = args.next() else {
|
||||||
|
eprintln!("--config requires a path");
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
path = PathBuf::from(value);
|
||||||
|
}
|
||||||
|
Some("--page") => {
|
||||||
|
connection_tab = match args.next().as_deref().and_then(|s| s.to_str()) {
|
||||||
|
Some("connections" | "wifi") => Some(connections::Tab::Wifi),
|
||||||
|
Some("bluetooth") => Some(connections::Tab::Bluetooth),
|
||||||
|
_ => {
|
||||||
|
eprintln!("--page accepts connections, wifi, or bluetooth");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
eprintln!("Usage: ferese-settings [--config PATH] [--page connections|wifi|bluetooth]");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
None => store::config_path(),
|
}
|
||||||
};
|
|
||||||
let path = path.canonicalize().unwrap_or(path);
|
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]
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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![],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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…" },
|
"M3 7V5h6l2 2h10v13H3z",
|
||||||
self.font,
|
Some(Message::PickThemeFile(path)),
|
||||||
palette,
|
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",
|
||||||
|
|||||||
Reference in new issue
Block a user