From cbbbe9239397fe6e5c86ab26e9c935b5a41da596 Mon Sep 17 00:00:00 2001 From: Antoine C Date: Fri, 6 Sep 2024 19:39:59 +0100 Subject: [PATCH] feat: add Bluetooth settings page Co-authored-by: Michael Murphy --- Cargo.lock | 20 +- cosmic-settings/Cargo.toml | 1 + cosmic-settings/src/app.rs | 11 +- .../src/pages/bluetooth/backend.rs | 635 +++++++++++++++ cosmic-settings/src/pages/bluetooth/mod.rs | 726 ++++++++++++++++++ .../src/pages/bluetooth/subscription.rs | 214 ++++++ .../src/pages/desktop/panel/inner.rs | 16 +- cosmic-settings/src/pages/mod.rs | 2 + .../src/pages/networking/vpn/mod.rs | 4 +- cosmic-settings/src/pages/networking/wifi.rs | 4 +- cosmic-settings/src/pages/networking/wired.rs | 5 +- cosmic-settings/src/subscription/bluetooth.rs | 209 +++++ cosmic-settings/src/subscription/mod.rs | 1 + i18n/en/cosmic_settings.ftl | 22 + i18n/fr/cosmic_settings.ftl | 22 + justfile | 3 + ....system76.CosmicSettings.Bluetooth.desktop | 12 + 17 files changed, 1888 insertions(+), 19 deletions(-) create mode 100644 cosmic-settings/src/pages/bluetooth/backend.rs create mode 100644 cosmic-settings/src/pages/bluetooth/mod.rs create mode 100644 cosmic-settings/src/pages/bluetooth/subscription.rs create mode 100644 cosmic-settings/src/subscription/bluetooth.rs create mode 100644 resources/com.system76.CosmicSettings.Bluetooth.desktop diff --git a/Cargo.lock b/Cargo.lock index a8527f5..4198326 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -813,6 +813,15 @@ dependencies = [ "piper", ] +[[package]] +name = "bluez-zbus" +version = "0.1.0" +source = "git+https://github.com/pop-os/dbus-settings-bindings#8059e6bdaa35fecd70d228a999ca342fb00d313b" +dependencies = [ + "futures", + "zbus 4.4.0", +] + [[package]] name = "borsh" version = "1.5.1" @@ -1477,7 +1486,7 @@ dependencies = [ [[package]] name = "cosmic-dbus-networkmanager" version = "0.1.0" -source = "git+https://github.com/pop-os/dbus-settings-bindings#063c2ad0dbe090d7e1221817128098493838b13a" +source = "git+https://github.com/pop-os/dbus-settings-bindings#8059e6bdaa35fecd70d228a999ca342fb00d313b" dependencies = [ "bitflags 2.6.0", "derive_builder", @@ -1552,6 +1561,7 @@ dependencies = [ "as-result", "ashpd 0.9.1", "async-channel", + "bluez-zbus", "chrono", "clap", "color-eyre", @@ -1623,7 +1633,7 @@ dependencies = [ [[package]] name = "cosmic-settings-daemon" version = "0.1.0" -source = "git+https://github.com/pop-os/dbus-settings-bindings#063c2ad0dbe090d7e1221817128098493838b13a" +source = "git+https://github.com/pop-os/dbus-settings-bindings#8059e6bdaa35fecd70d228a999ca342fb00d313b" dependencies = [ "zbus 4.4.0", ] @@ -2964,7 +2974,7 @@ checksum = "f558a64ac9af88b5ba400d99b579451af0d39c6d360980045b91aac966d705e2" [[package]] name = "hostname1-zbus" version = "0.1.0" -source = "git+https://github.com/pop-os/dbus-settings-bindings#063c2ad0dbe090d7e1221817128098493838b13a" +source = "git+https://github.com/pop-os/dbus-settings-bindings#8059e6bdaa35fecd70d228a999ca342fb00d313b" dependencies = [ "zbus 4.4.0", ] @@ -6647,7 +6657,7 @@ dependencies = [ [[package]] name = "timedate-zbus" version = "0.1.0" -source = "git+https://github.com/pop-os/dbus-settings-bindings#063c2ad0dbe090d7e1221817128098493838b13a" +source = "git+https://github.com/pop-os/dbus-settings-bindings#8059e6bdaa35fecd70d228a999ca342fb00d313b" dependencies = [ "zbus 4.4.0", ] @@ -7067,7 +7077,7 @@ checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] name = "upower_dbus" version = "0.3.2" -source = "git+https://github.com/pop-os/dbus-settings-bindings#063c2ad0dbe090d7e1221817128098493838b13a" +source = "git+https://github.com/pop-os/dbus-settings-bindings#8059e6bdaa35fecd70d228a999ca342fb00d313b" dependencies = [ "serde", "serde_repr", diff --git a/cosmic-settings/Cargo.toml b/cosmic-settings/Cargo.toml index d50a434..7056b25 100644 --- a/cosmic-settings/Cargo.toml +++ b/cosmic-settings/Cargo.toml @@ -56,6 +56,7 @@ tracing = "0.1.40" tracing-subscriber = "0.3.18" udev = "0.9.0" upower_dbus = { git = "https://github.com/pop-os/dbus-settings-bindings" } +bluez-zbus = { git = "https://github.com/pop-os/dbus-settings-bindings" } url = "2.5.2" xkb-data = "0.2.1" zbus = { version = "4.4.0", features = ["tokio"] } diff --git a/cosmic-settings/src/app.rs b/cosmic-settings/src/app.rs index da4463e..54895ca 100644 --- a/cosmic-settings/src/app.rs +++ b/cosmic-settings/src/app.rs @@ -11,7 +11,7 @@ use crate::pages::desktop::{ }, }; use crate::pages::input::{self}; -use crate::pages::{self, display, networking, power, sound, system, time}; +use crate::pages::{self, bluetooth, display, networking, power, sound, system, time}; use crate::subscription::desktop_files; use crate::widget::{page_title, search_header}; use crate::PageCommands; @@ -59,7 +59,7 @@ impl SettingsApp { match cmd { PageCommands::About => self.pages.page_id::(), PageCommands::Appearance => self.pages.page_id::(), - PageCommands::Bluetooth => None, + PageCommands::Bluetooth => self.pages.page_id::(), PageCommands::DateTime => self.pages.page_id::(), PageCommands::Desktop => self.pages.page_id::(), PageCommands::Displays => self.pages.page_id::(), @@ -146,6 +146,7 @@ impl cosmic::Application for SettingsApp { }; app.insert_page::(); + app.insert_page::(); let desktop_id = app.insert_page::().id(); app.insert_page::(); app.insert_page::(); @@ -324,6 +325,12 @@ impl cosmic::Application for SettingsApp { } } + crate::pages::Message::Bluetooth(message) => { + if let Some(page) = self.pages.page_mut::() { + return page.update(message).map(Into::into); + } + } + crate::pages::Message::DateAndTime(message) => { if let Some(page) = self.pages.page_mut::() { return page.update(message).map(Into::into); diff --git a/cosmic-settings/src/pages/bluetooth/backend.rs b/cosmic-settings/src/pages/bluetooth/backend.rs new file mode 100644 index 0000000..5961a2c --- /dev/null +++ b/cosmic-settings/src/pages/bluetooth/backend.rs @@ -0,0 +1,635 @@ +// Copyright 2024 System76 +// SPDX-License-Identifier: MPL-2.0 + +use futures::join; +use std::{ + collections::HashMap, + hash::{Hash, Hasher}, + time::Duration, +}; +use zbus::zvariant::OwnedObjectPath; + +use super::Message; + +#[derive(Default, Debug, Clone)] +pub struct Device { + alias: Option, + pub address: String, + pub adapter: OwnedObjectPath, + pub enabled: Active, + pub paired: bool, + pub icon: &'static str, + pub battery: Option, +} +#[derive(Debug, Clone)] +pub enum DeviceUpdate { + Alias(Option), + Enabled(Active), + Paired(bool), + Icon(&'static str), + Battery(Option), +} + +impl DeviceUpdate { + pub fn from_update(update: HashMap<&'_ str, zbus::zvariant::Value<'_>>) -> Vec { + update + .into_iter() + .filter_map(|(key, value)| { + match (key, value) { + ("Alias", zbus::zvariant::Value::Str(value)) => { + Some(DeviceUpdate::Alias(Some(value.into()))) + } + ("Connected", zbus::zvariant::Value::Bool(value)) => { + Some(DeviceUpdate::Enabled(if value { + Active::Enabled + } else { + Active::Disabled + })) + } + ("Paired", zbus::zvariant::Value::Bool(value)) => { + Some(DeviceUpdate::Paired(value)) + } + ("Icon", zbus::zvariant::Value::Str(value)) => { + Some(DeviceUpdate::Icon(device_type_to_icon(&value))) + } + ("Percentage", zbus::zvariant::Value::U8(percentage)) => { + Some(DeviceUpdate::Battery(Some(fl!( + "bluetooth-paired", + "battery", + percentage = percentage.to_string() + )))) + } + // Battery + _ => None, + } + }) + .collect() + } +} + +#[derive(Default, Debug, Clone)] +pub struct Adapter { + pub alias: String, + pub address: String, + pub scanning: Active, + pub enabled: Active, +} +#[derive(Debug, Clone)] +pub enum AdapterUpdate { + Alias(String), + Address(String), + Scanning(Active), + Enabled(Active), +} + +impl AdapterUpdate { + #[must_use] + pub fn from_update(update: HashMap<&'_ str, zbus::zvariant::Value<'_>>) -> Vec { + update + .into_iter() + .filter_map(|(key, value)| { + match (key, value) { + ("Alias", zbus::zvariant::Value::Str(value)) => Some(Self::Alias(value.into())), + ("Discovering" | "Discoverable", zbus::zvariant::Value::Bool(value)) => { + Some(Self::Scanning(if value { + Active::Enabled + } else { + Active::Disabled + })) + } + ("Powered", zbus::zvariant::Value::Bool(value)) => { + Some(Self::Enabled(if value { + Active::Enabled + } else { + Active::Disabled + })) + } + ("Address", zbus::zvariant::Value::Str(value)) => { + Some(Self::Address(value.into())) + } + // Battery + _ => None, + } + }) + .collect() + } +} + +#[derive(Default, Debug, Clone, Copy, Eq, PartialEq)] +pub enum Active { + #[default] + Disabled, + Disabling, + Enabling, + Enabled, +} + +impl Hash for Device { + fn hash(&self, state: &mut H) { + self.address.hash(state); + } +} + +impl PartialEq for Device { + fn eq(&self, other: &Self) -> bool { + self.address == other.address + } +} + +impl Eq for Device {} + +impl Hash for Adapter { + fn hash(&self, state: &mut H) { + self.address.hash(state); + } +} + +impl PartialEq for Adapter { + fn eq(&self, other: &Self) -> bool { + self.address == other.address + } +} + +impl Eq for Adapter {} + +fn device_type_to_icon(device_type: &str) -> &'static str { + match device_type { + "computer" => "laptop-symbolic", + "phone" => "smartphone-symbolic", + "network-wireless" => "network-wireless-symbolic", + "audio-headset" => "audio-headset-symbolic", + "audio-headphones" => "audio-headphones-symbolic", + "camera-video" => "camera-video-symbolic", + "audio-card" => "audio-card-symbolic", + "input-gaming" => "input-gaming-symbolic", + "input-keyboard" => "input-keyboard-symbolic", + "input-tablet" => "input-tablet-symbolic", + "input-mouse" => "input-mouse-symbolic", + "printer" => "printer-network-symbolic", + "camera-photo" => "camera-photo-symbolic", + _ => "bluetooth-symbolic", + } +} + +impl Device { + pub async fn from_device(proxy: &bluez_zbus::BluetoothDevice<'_>) -> zbus::Result { + let (address, adapter, alias) = join!( + proxy.device.address(), + proxy.device.adapter(), + proxy.device.name() + ); + let address = address?; + if address.is_empty() { + return Err(zbus::Error::Failure("Device has no MAC address".to_owned())); + } + let adapter = adapter?; + if adapter.is_empty() { + return Err(zbus::Error::Failure("Device has no adapter".to_owned())); + } + let alias = alias.ok(); + let device_type: String = proxy.icon().await; + let paired = proxy.device.paired().await.unwrap_or(false); + let enabled = if proxy.device.connected().await.unwrap_or(false) && paired { + Active::Enabled + } else { + Active::Disabled + }; + let battery = match &proxy.battery { + Some(battery) => match battery.percentage().await { + Ok(percentage) => Some(fl!( + "bluetooth-paired", + "battery", + percentage = percentage.to_string() + )), + Err(why) => { + eprintln!("couldn't fetch battery percentage: {why}"); + None + } + }, + None => None, + }; + + // Copied from https://github.com/bluez/bluez/blob/39467578207889fd015775cbe81a3db9dd26abea/src/dbus-common.c#L53 + let icon = device_type_to_icon(device_type.as_str()); + + Ok(Self { + alias, + address, + adapter, + enabled, + paired, + icon, + battery, + }) + } + #[must_use] + pub fn is_connected(&self) -> bool { + self.enabled == Active::Enabled + } + /// Update the state of the device without overriding intermediary states. + /// + /// # Panics + /// + /// Panics if the device used for update doesn't have the same MAC address + pub fn update(&mut self, updates: Vec) { + for udpate in updates { + match udpate { + DeviceUpdate::Alias(alias) => self.alias = alias, + DeviceUpdate::Enabled(enabled) => { + self.enabled = match (self.enabled, enabled) { + (Active::Enabling, Active::Enabled) => Active::Enabled, + (Active::Disabling, Active::Disabled) => Active::Disabled, + (Active::Enabled | Active::Disabled, status) => status, + (status, _) => status, + } + } + DeviceUpdate::Paired(paired) => { + self.enabled = Active::Disabling; + self.paired = paired; + } + DeviceUpdate::Icon(icon) => self.icon = icon, + DeviceUpdate::Battery(battery) => self.battery = battery, + } + } + if self.enabled == Active::Disabled { + self.battery = None; + } + } + #[must_use] + pub fn has_alias(&self) -> bool { + self.alias.is_some() + } + #[must_use] + pub fn alias_or_addr(&self) -> &str { + self.alias.as_ref().unwrap_or(&self.address) + } +} + +impl Adapter { + pub async fn from_device( + proxy: &bluez_zbus::adapter1::Adapter1Proxy<'_>, + ) -> zbus::Result { + let address = proxy.address().await?; + let alias = proxy.alias().await?; + let scanning = if proxy.discoverable().await? && proxy.discovering().await? { + Active::Enabled + } else { + Active::Disabled + }; + let enabled = if proxy.powered().await? { + Active::Enabled + } else { + Active::Disabled + }; + + Ok(Self { + alias, + address, + scanning, + enabled, + }) + } + pub fn update(&mut self, updates: Vec) { + for update in updates { + match update { + AdapterUpdate::Alias(alias) => self.alias = alias, + AdapterUpdate::Address(address) => self.address = address, + AdapterUpdate::Enabled(enabled) => { + self.enabled = match (self.enabled, enabled) { + (Active::Enabling, Active::Enabled) => Active::Enabled, + (Active::Disabling, Active::Disabled) => Active::Disabled, + (Active::Enabled | Active::Disabled, status) => status, + (status, _) => status, + } + } + AdapterUpdate::Scanning(scanning) => { + self.scanning = match (self.scanning, scanning) { + (Active::Enabling, Active::Enabled) => Active::Enabled, + (Active::Disabling, Active::Disabled) => Active::Disabled, + (Active::Enabled | Active::Disabled, status) => status, + (status, _) => status, + } + } + } + } + } +} + +pub async fn start_discovery( + connection: zbus::Connection, + adapter_path: OwnedObjectPath, +) -> Message { + let result: zbus::Result<()> = Ok(()); + match bluez_zbus::get_adapter(&connection, adapter_path).await { + Err(why) => { + tracing::error!("Unable to get the adapter: {why}"); + return Message::DBusError(why.to_string()); + } + Ok(adapter) => { + for attempt in 1..5 { + let result = async { + tracing::debug!("Starting discovery"); + // We don't seem to be able to use join here as it seem to lead to some kind of race condition and not start scanning occasionally + adapter.set_pairable(true).await?; + adapter.set_discoverable(true).await?; + if adapter.discovering().await? { + return Ok(()); + } + adapter.start_discovery().await + } + .await; + if let Err(why) = result { + tracing::warn!("Unable to start bluetooth scanning: {why}"); + tokio::time::sleep(Duration::from_millis(1000 * attempt)).await; + } else { + tracing::debug!("Discovery started"); + return Message::Nop; + } + } + } + } + if let Err(why) = result { + return Message::DBusError(why.to_string()); + } + Message::Nop +} + +pub async fn stop_discovery( + connection: zbus::Connection, + adapter_path: OwnedObjectPath, +) -> Message { + let result: zbus::Result<()> = Ok(()); + match bluez_zbus::get_adapter(&connection, adapter_path).await { + Err(why) => { + tracing::error!("Unable to get the adapter: {why}"); + return Message::DBusError(why.to_string()); + } + Ok(adapter) => { + for attempt in 1..5 { + let result = async { + tracing::debug!("Stopping discovery"); + + // We don't seem to be able to use join here as it seem to lead to some kind of race condition and not stop scanning occasionally + adapter.set_pairable(false).await?; + adapter.set_discoverable(false).await?; + if adapter.discovering().await? { + adapter.stop_discovery().await + } else { + Ok(()) + } + } + .await; + if let Err(why) = result { + tracing::warn!("Unable to stop bluetooth scanning: {why}"); + tokio::time::sleep(Duration::from_millis(1000 * attempt)).await; + } else { + tracing::debug!("Discovery stopped"); + return Message::Nop; + } + } + } + } + if let Err(why) = result { + return Message::DBusError(why.to_string()); + } + Message::Nop +} + +pub async fn disconnect_device( + connection: zbus::Connection, + device_path: OwnedObjectPath, +) -> Message { + match bluez_zbus::get_device(&connection, device_path.clone()).await { + Err(why) => { + tracing::error!("Unable to get the device: {why}"); + return Message::DeviceFailed(device_path); + } + Ok(proxy) => { + for attempt in 1..5 { + let result = async { + if !proxy.device.connected().await? { + return Ok(()); + } + + proxy.device.disconnect().await + } + .await; + if let Err(why) = result { + tracing::warn!("Unable to disconnect to device: {why}"); + tokio::time::sleep(Duration::from_millis(1000 * attempt)).await; + } else { + return Message::Nop; + } + } + } + } + Message::DeviceFailed(device_path) +} + +pub async fn connect_device(connection: zbus::Connection, device_path: OwnedObjectPath) -> Message { + match bluez_zbus::get_device(&connection, device_path.clone()).await { + Err(why) => { + tracing::error!("Unable to get the device: {why}"); + return Message::DeviceFailed(device_path); + } + Ok(proxy) => { + for attempt in 1..5 { + let result = async { + if proxy.device.connected().await? { + Ok(()) + } else { + proxy.device.connect().await + } + } + .await; + if let Err(why) = result { + tracing::warn!("Unable to connect to device: {why}"); + tokio::time::sleep(Duration::from_millis(1000 * attempt)).await; + } else { + return Message::Nop; + } + } + } + } + Message::DeviceFailed(device_path) +} + +pub async fn forget_device(connection: zbus::Connection, device_path: OwnedObjectPath) -> Message { + let mut result: zbus::Result<()> = Ok(()); + match bluez_zbus::get_device(&connection, device_path.clone()).await { + Err(why) => { + tracing::error!("Unable to get the device: {why}"); + return Message::DeviceFailed(device_path); + } + Ok(proxy) => match proxy.device.adapter().await { + Err(why) => { + tracing::error!("Unable to get the adapter: {why}"); + return Message::DeviceFailed(device_path); + } + Ok(adapter) => match bluez_zbus::get_adapter(&connection, adapter).await { + Err(why) => { + tracing::error!("Unable to get the adapter: {why}"); + return Message::DeviceFailed(device_path); + } + Ok(adapter) => { + for attempt in 1..5 { + result = async { + if proxy.device.connected().await? { + proxy.device.disconnect().await?; + } + + adapter.remove_device(&proxy.path()).await + } + .await; + if let Err(why) = &result { + tracing::warn!("Unable to connect to device: {why}"); + tokio::time::sleep(Duration::from_millis(1000 * attempt)).await; + } else { + return Message::Nop; + } + } + } + }, + }, + } + if result.is_err() { + return Message::DeviceFailed(device_path); + } + Message::Nop +} + +pub async fn change_adapter_status( + connection: zbus::Connection, + adapter_path: OwnedObjectPath, + active: bool, +) -> Message { + let mut result: zbus::Result<()> = Ok(()); + for attempt in 1..5 { + result = async { + let adapter = bluez_zbus::get_adapter(&connection, adapter_path.clone()).await?; + if active { + adapter.set_powered(true).await?; + adapter.set_discoverable(true).await + } else { + if let Err(why) = adapter.set_discoverable(false).await { + tracing::warn!("Unable to change discoverability: {why}"); + } + adapter.set_powered(false).await + } + } + .await; + if let Err(why) = &result { + tracing::warn!("Unable to change the adapter state: {why}"); + tokio::time::sleep(Duration::from_millis(1000 * attempt)).await; + } else { + return Message::Nop; + } + } + + if let Err(why) = result { + tracing::error!("Failed to change the adapter state!"); + return Message::DBusError(why.to_string()).into(); + } + + Message::Nop +} + +pub async fn get_devices(connection: zbus::Connection, adapter_path: OwnedObjectPath) -> Message { + // TODO error handling + let result: zbus::Result> = async { + futures::future::join_all( + bluez_zbus::get_devices(&connection, Some(&adapter_path)) + .await? + .into_iter() + .map( + |(path, device)| async move { Ok((path, Device::from_device(&device).await?)) }, + ), + ) + .await + .into_iter() + .collect::, _>>() + } + .await; + match result { + Ok(devices) => Message::SetDevices(devices), + Err(why) => { + tracing::error!("zbus connection failed. {why}"); + Message::DBusError(fl!("bluetooth", "dbus-error", why = why.to_string())) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_update_device_with_intermediary_state() { + let mut device = Device { + alias: None, + adapter: OwnedObjectPath::try_from("/dev/bluez/hci0").unwrap(), + address: "AA:BB:CC:DD:EE:FF".to_owned(), + enabled: Active::Disabled, + paired: false, + icon: "bluetooth-symbolic", + battery: None, + }; + device.update(vec![ + DeviceUpdate::Enabled(Active::Enabled), + DeviceUpdate::Alias(Some("Foo".to_owned())), + ]); + assert_eq!(device.enabled, Active::Enabled); + assert_eq!(device.alias, Some("Foo".to_owned())); + + device.enabled = Active::Disabling; + device.update(vec![ + DeviceUpdate::Enabled(Active::Enabled), + DeviceUpdate::Alias(Some("Foo".to_owned())), + ]); + assert_eq!(device.enabled, Active::Disabling); + + device.enabled = Active::Enabling; + device.update(vec![ + DeviceUpdate::Enabled(Active::Enabled), + DeviceUpdate::Alias(Some("Foo".to_owned())), + ]); + assert_eq!(device.enabled, Active::Enabled); + } + + #[test] + fn test_adapter_device_with_intermediary_state() { + let mut adapter = Adapter { + alias: "foo".to_owned(), + address: "AA:BB:CC:DD:EE:FF".to_owned(), + scanning: Active::Disabled, + enabled: Active::Disabled, + }; + adapter.update(vec![ + AdapterUpdate::Enabled(Active::Enabled), + AdapterUpdate::Alias("xxx".to_owned()), + ]); + assert_eq!(adapter.enabled, Active::Enabled); + assert_eq!(&adapter.alias, "xxx"); + + adapter.enabled = Active::Disabling; + adapter.update(vec![ + AdapterUpdate::Enabled(Active::Enabled), + AdapterUpdate::Alias("xxx".to_owned()), + ]); + assert_eq!(adapter.enabled, Active::Disabling); + + adapter.scanning = Active::Enabling; + adapter.update(vec![ + AdapterUpdate::Scanning(Active::Disabled), + AdapterUpdate::Alias("xxx".to_owned()), + ]); + assert_eq!(adapter.scanning, Active::Enabling); + + adapter.update(vec![ + AdapterUpdate::Scanning(Active::Enabled), + AdapterUpdate::Alias("xxx".to_owned()), + ]); + assert_eq!(adapter.scanning, Active::Enabled); + assert_eq!(&adapter.alias, "xxx"); + } +} diff --git a/cosmic-settings/src/pages/bluetooth/mod.rs b/cosmic-settings/src/pages/bluetooth/mod.rs new file mode 100644 index 0000000..f872978 --- /dev/null +++ b/cosmic-settings/src/pages/bluetooth/mod.rs @@ -0,0 +1,726 @@ +// Copyright 2024 System76 +// SPDX-License-Identifier: GPL-3.0-only + +use cosmic::iced::{alignment, color, Length}; +use cosmic::iced_core::text::Wrap; +use cosmic::prelude::CollectionWidget; +use cosmic::widget::{self, settings, text}; +use cosmic::Command; +use cosmic::{Apply, Element}; +use cosmic_settings_page::{self as page, section, Section}; +use slab::Slab; +use slotmap::SlotMap; +use std::collections::{HashMap, HashSet}; +use zbus::zvariant::OwnedObjectPath; + +mod backend; +pub use backend::*; +mod subscription; + +#[derive(Default)] +pub struct Page { + active: Active, + connection: Option, + adapters: HashMap, + selected_adapter: Option, + heading: String, + devices: HashMap, + popup_setting: bool, + popup_device: Option, + show_device_without_alias: bool, + subscription: Option>, +} + +impl page::Page for Page { + fn info(&self) -> page::Info { + page::Info::new("bluetooth", "bluetooth-symbolic") + .title(fl!("bluetooth")) + .description(fl!("bluetooth", "desc")) + } + + fn content( + &self, + sections: &mut SlotMap>, + ) -> Option { + Some(vec![ + sections.insert(status()), + sections.insert(multiple_adapter()), + sections.insert(connected_devices()), + sections.insert(available_devices()), + ]) + } + + fn on_enter( + &mut self, + _page: cosmic_settings_page::Entity, + sender: tokio::sync::mpsc::Sender, + ) -> cosmic::Command { + // TODO start stream for new device + cosmic::command::future(async move { + match zbus::Connection::system().await { + Ok(connection) => Message::DBusConnect(connection, sender), + Err(why) => Message::DBusError(why.to_string()), + } + }) + } + + fn on_leave(&mut self) -> Command { + if let Some(cancel) = self.subscription.take() { + _ = cancel.send(()); + } + + self.connection = None; + self.adapters.clear(); + self.selected_adapter = None; + self.devices.clear(); + self.popup_device = None; + self.popup_setting = false; + self.show_device_without_alias = false; + + Command::none() + } +} + +#[derive(Clone, Debug)] +pub enum Message { + AddedAdapter(OwnedObjectPath, Adapter), + AddedDevice(OwnedObjectPath, Device), + ConnectDevice(OwnedObjectPath), + DBusConnect( + zbus::Connection, + tokio::sync::mpsc::Sender, + ), + DBusError(String), + DeviceFailed(OwnedObjectPath), + DisconnectDevice(OwnedObjectPath), + ForgetDevice(OwnedObjectPath), + PopupDevice(Option), + PopupSetting(bool), + Nop, + RemovedAdapter(OwnedObjectPath), + RemovedDevice(OwnedObjectPath), + SelectAdapter(Option), + SetActive(bool), + SetAdapters(HashMap), + SetDevices(HashMap), + ShowDeviceWithoutAlias(bool), + UpdatedAdapter(OwnedObjectPath, Vec), + UpdatedDevice(OwnedObjectPath, Vec), +} + +impl From for crate::app::Message { + fn from(message: Message) -> Self { + crate::pages::Message::Bluetooth(message).into() + } +} + +impl From for crate::pages::Message { + fn from(message: Message) -> Self { + crate::pages::Message::Bluetooth(message) + } +} + +impl Page { + pub fn update(&mut self, message: Message) -> cosmic::Command { + let span = tracing::span!(tracing::Level::INFO, "bluetooth::update"); + let _span = span.enter(); + + match message { + Message::SetActive(active) => { + if let Some(connection) = self.connection.clone() { + if let Some((path, adapter)) = self.get_selected_adapter_mut() { + adapter.enabled = if active { + Active::Enabling + } else { + Active::Disabling + }; + self.update_status(); + return cosmic::command::future(change_adapter_status( + connection.clone(), + path, + active, + )); + } + let commands: Vec> = self + .adapters + .iter_mut() + .map(|(path, adapter)| { + adapter.enabled = if active { + Active::Enabling + } else { + Active::Disabling + }; + cosmic::command::future(change_adapter_status( + connection.clone(), + path.clone(), + active, + )) + }) + .collect(); + self.update_status(); + return cosmic::command::batch(commands); + } + tracing::warn!("No DBus connection ready"); + } + Message::DBusConnect(connection, sender) => { + self.connection = Some(connection.clone()); + + if self.subscription.is_none() { + let connection = connection.clone(); + self.subscription = Some(crate::utils::forward_event_loop( + sender, + crate::pages::Message::Bluetooth, + move |tx| async move { subscription::watch(connection, tx).await }, + )); + } + + return cosmic::command::future(async move { + let result: zbus::Result> = async { + futures::future::join_all( + bluez_zbus::get_adapters(&connection) + .await? + .into_iter() + .map(|(path, proxy)| async move { + Ok((path.to_owned(), Adapter::from_device(&proxy).await?)) + }), + ) + .await + .into_iter() + .collect::>>() + } + .await; + match result { + Ok(adapters) => Message::SetAdapters(adapters), + Err(why) => { + tracing::error!("dbus connection failed. {why}"); + Message::DBusError(fl!( + "bluetooth", + "dbus-error", + why = why.to_string() + )) + } + } + }); + } + Message::SetDevices(devices) => { + self.devices = devices; + } + Message::SetAdapters(adapters) => { + self.adapters = adapters; + self.update_status(); + + if self.selected_adapter.is_none() && self.adapters.len() == 1 { + return cosmic::command::message(Message::SelectAdapter( + self.adapters.keys().next().cloned(), + )); + } + } + Message::AddedDevice(path, device) => { + tracing::debug!("Device {} added", device.address); + self.devices.insert(path, device); + } + Message::UpdatedDevice(path, update) => { + if let Some(existing) = self.devices.get_mut(&path) { + tracing::debug!("Device {} updated", existing.address); + existing.update(update); + } + } + Message::RemovedDevice(path) => { + tracing::debug!("Device {path} removed"); + self.devices.remove(&path); + } + Message::AddedAdapter(path, adapter) => { + tracing::debug!("Adapter {} added", adapter.address); + self.adapters.insert(path.clone(), adapter); + if self.selected_adapter.is_none() { + return cosmic::command::message(Message::SelectAdapter(Some(path))); + } + } + Message::UpdatedAdapter(path, update) => { + if let Some(existing) = self.adapters.get_mut(&path) { + tracing::debug!("Adapter {} updated: {update:#?}", existing.address); + existing.update(update); + } + self.update_status(); + if let Some(connection) = self.connection.clone() { + match self.get_selected_adapter_mut() { + Some((path, existing)) + if existing.enabled == Active::Enabled + && existing.scanning == Active::Disabled => + { + existing.scanning = Active::Enabling; + return cosmic::command::future(start_discovery(connection, path)); + } + _ => {} + } + } else { + tracing::warn!("No DBus connection ready"); + } + } + Message::RemovedAdapter(path) => { + tracing::debug!("Device {path} removed"); + self.adapters.remove(&path); + if self.selected_adapter == Some(path) { + self.selected_adapter = None; + } + } + Message::PopupDevice(popup) => { + self.popup_device = popup; + } + Message::PopupSetting(popup) => { + self.popup_setting = popup; + } + Message::ShowDeviceWithoutAlias(show_device_without_alias) => { + self.show_device_without_alias = show_device_without_alias; + } + Message::SelectAdapter(adapter_maybe) => { + tracing::debug!("Adapter selected: {adapter_maybe:?}"); + self.selected_adapter = adapter_maybe; + self.update_status(); + if let Some(connection) = self.connection.as_ref() { + let connection = connection.clone(); + if let Some((path, adapter)) = self.get_selected_adapter_mut() { + let mut fut: Vec> = vec![cosmic::command::future( + get_devices(connection.clone(), path.clone()), + )]; + if adapter.enabled == Active::Enabled + && adapter.scanning == Active::Disabled + { + fut.push(cosmic::command::future(start_discovery( + connection, + path.clone(), + ))); + } + + return cosmic::command::batch(fut); + } + } else { + tracing::warn!("No DBus connection ready"); + } + } + Message::ForgetDevice(path) => { + tracing::debug!("Forgetting to device {path}"); + self.popup_device = None; + if self.connection.is_none() { + return cosmic::Command::none(); + } + if let Some(connection) = self.connection.as_ref() { + let connection = connection.clone(); + if let Some(device) = self.devices.get_mut(&path) { + device.enabled = Active::Disabling; + return cosmic::command::future(forget_device(connection, path.clone())); + } + } else { + tracing::warn!("No DBus connection ready"); + } + } + Message::ConnectDevice(path) => { + tracing::debug!("Connecting device {path}"); + if self.connection.is_none() { + return cosmic::Command::none(); + } + if let Some(connection) = self.connection.as_ref() { + let connection = connection.clone(); + if let Some(device) = self.devices.get_mut(&path) { + if matches!(device.enabled, Active::Enabled | Active::Enabling) { + return cosmic::Command::none(); + } + device.enabled = Active::Enabling; + return cosmic::command::future(connect_device(connection, path)); + } + } else { + tracing::warn!("No DBus connection ready"); + } + } + Message::DisconnectDevice(path) => { + tracing::debug!("Disconnecting device {path}"); + self.popup_device = None; + if let Some(connection) = self.connection.as_ref() { + let connection = connection.clone(); + if let Some(device) = self.devices.get_mut(&path) { + if matches!(device.enabled, Active::Disabled | Active::Disabling) { + return cosmic::Command::none(); + } + device.enabled = Active::Disabling; + return cosmic::command::future(disconnect_device(connection, path)); + } + } else { + tracing::warn!("No DBus connection ready"); + } + } + Message::DeviceFailed(path) => { + tracing::warn!("Failed operation on device {path}"); + if let Some(device) = self.devices.get_mut(&path) { + if matches!(device.enabled, Active::Disabled | Active::Disabling) { + return cosmic::Command::none(); + } + device.enabled = match device.enabled { + Active::Disabling => Active::Enabled, + Active::Enabling => Active::Disabled, + e => e, + }; + } + } + Message::Nop => {} + Message::DBusError(why) => { + tracing::error!("dbus connection failed. {why}"); + } + }; + cosmic::Command::none() + } + + fn update_status(&mut self) { + if let Some((_, adapter)) = self.get_selected_adapter() { + self.heading = fl!( + "bluetooth", + "status", + aliases = format!("“{}”", adapter.alias) + ); + } else { + self.heading = fl!( + "bluetooth", + "status", + aliases = self + .adapters + .values() + .map(|adapter| format!("“{}”", adapter.alias)) + .collect::>() + .into_iter() + .collect::>() + .join(", ") + ); + } + self.active = if let Some((_, adapter)) = self.get_selected_adapter() { + adapter.enabled + } else { + self.adapters + .values() + .fold(Active::Disabled, |current, adapter| { + if current == Active::Enabled || adapter.enabled == Active::Enabled { + Active::Enabled + } else { + Active::Disabled + } + }) + } + } + fn adapter_connected(&self, adapter_path: &OwnedObjectPath) -> bool { + self.devices + .iter() + .any(|(path, device)| path.starts_with(adapter_path.as_str()) && device.is_connected()) + } + fn get_selected_adapter(&self) -> Option<(&'_ OwnedObjectPath, &'_ Adapter)> { + if let Some(iface) = &self.selected_adapter { + self.adapters.get_key_value(iface) + } else { + None + } + } + fn devices_for_adapter<'a>( + &'a self, + adapter_path: &'a OwnedObjectPath, + ) -> impl Iterator { + self.devices.iter().filter_map(|(path, device)| { + if device.adapter.eq(adapter_path) { + Some((path, device)) + } else { + None + } + }) + } + fn get_selected_adapter_mut(&mut self) -> Option<(OwnedObjectPath, &'_ mut Adapter)> { + if let Some(path) = &self.selected_adapter { + self.adapters + .get_mut(path) + .map(|adapter| (path.to_owned(), adapter)) + } else { + None + } + } +} + +fn status() -> Section { + let mut descriptions = Slab::new(); + + let bluetooth_heading = descriptions.insert(fl!("bluetooth")); + let bluetooth_opt_device_without_name = + descriptions.insert(fl!("bluetooth", "show-device-without-name")); + + Section::default() + .descriptions(descriptions) + .show_while::(|page| !page.adapters.is_empty()) + .view::(move |_binder, page, section| { + let descriptions = §ion.descriptions; + let status = page + .get_selected_adapter() + .map_or(page.active, |(_, adapter)| adapter.enabled); + widget::list_column() + .add(settings::item::item_row(vec![ + if matches!(status, Active::Enabling | Active::Enabled) { + widget::column::with_capacity(2) + .push(text::body(&descriptions[bluetooth_heading])) + .push(text::caption(&page.heading)) + .into() + } else { + text::body(&descriptions[bluetooth_heading]).into() + }, + widget::horizontal_space(Length::Fill).into(), + if page.popup_setting { + widget::popover( + widget::button::icon(widget::icon::from_name( + "preferences-system-symbolic", + )) + .on_press(Message::PopupSetting(false)), + ) + .position(widget::popover::Position::Bottom) + .on_close(Message::PopupSetting(false)) + .popup({ + let theme = cosmic::theme::active(); + let theme = theme.cosmic(); + widget::container( + settings::item::builder( + &descriptions[bluetooth_opt_device_without_name], + ) + .toggler( + page.show_device_without_alias, + Message::ShowDeviceWithoutAlias, + ), + ) + .width(Length::Fixed(300.0)) + .height(Length::Shrink) + .padding([theme.space_xs(), theme.space_xxxs()]) + .style(cosmic::theme::Container::Dialog) + }) + .into() + } else { + widget::button::icon(widget::icon::from_name("preferences-system-symbolic")) + .on_press(Message::PopupSetting(true)) + .into() + }, + widget::toggler(None, status == Active::Enabled, Message::SetActive).into(), + ])) + .apply(cosmic::Element::from) + .map(crate::pages::Message::Bluetooth) + }) +} + +fn popup_button(message: Option, text: &str) -> Element<'_, Message> { + let theme = cosmic::theme::active(); + let theme = theme.cosmic(); + widget::text::body(text) + .vertical_alignment(alignment::Vertical::Center) + .apply(widget::button::custom) + .padding([theme.space_xxxs(), theme.space_xs()]) + .width(Length::Fill) + .style(cosmic::theme::Button::MenuItem) + .on_press_maybe(message) + .into() +} + +fn connected_devices() -> Section { + crate::slab!(descriptions { + device_connected = fl!("bluetooth", "connected"); + device_connecting = fl!("bluetooth", "connecting"); + device_disconnecting = fl!("bluetooth", "disconnecting"); + device_connect = fl!("bluetooth", "connect"); + device_disconnect = fl!("bluetooth", "disconnect"); + device_forget = fl!("bluetooth", "forget"); + }); + + Section::default() + .title(fl!("bluetooth-paired")) + .descriptions(descriptions) + .show_while::(|page| { + page.selected_adapter.as_ref().map(|adapter| { + page.devices_for_adapter(adapter) + .any(|(_, device)| device.paired) + }) == Some(true) + && page.active != Active::Disabled + }) + .view::(move |_binder, page, section| { + let descriptions = §ion.descriptions; + let section = settings::section().title(§ion.title); + + page.devices_for_adapter(page.selected_adapter.as_ref().unwrap()) + .filter_map(|(path, device)| { + if !device.paired { + return None; + } + if !page.show_device_without_alias && !device.has_alias() { + return None; + } + + let device_menu: Element<_> = if page + .popup_device + .as_deref() + .map_or(false, |p| path.as_str() == p.as_str()) + { + widget::popover( + widget::button::icon(widget::icon::from_name("view-more-symbolic")) + .on_press(Message::PopupDevice(None)), + ) + .position(widget::popover::Position::Bottom) + .on_close(Message::PopupDevice(None)) + .popup({ + let theme = cosmic::theme::active(); + let theme = theme.cosmic(); + widget::container( + widget::column() + .push_maybe(device.is_connected().then(|| { + popup_button( + Some(Message::DisconnectDevice(path.clone())), + &descriptions[device_disconnect], + ) + })) + .push(popup_button( + Some(Message::ForgetDevice(path.clone())), + &descriptions[device_forget], + )), + ) + .width(Length::Fixed(200.0)) + .padding(theme.space_xxxs()) + .style(cosmic::theme::Container::Dialog) + }) + .into() + } else { + widget::button::icon(widget::icon::from_name("view-more-symbolic")) + .on_press(Message::PopupDevice(Some(path.clone()))) + .into() + }; + + Some(settings::item_row(vec![ + widget::icon::from_name(device.icon).size(16).into(), + if let Some(battery) = &device.battery { + widget::column::with_capacity(2) + .push(text::body(device.alias_or_addr())) + .push(text::caption(battery)) + .into() + } else { + widget::text(device.alias_or_addr()).wrap(Wrap::Word).into() + }, + widget::horizontal_space(Length::Fill).into(), + match device.enabled { + Active::Enabled => widget::text(&descriptions[device_connected]).into(), + Active::Enabling => widget::text(&descriptions[device_connecting]) + .style(cosmic::theme::Text::Color(color!(128, 128, 128))) + .into(), + Active::Disabling => widget::text(&descriptions[device_disconnecting]) + .style(cosmic::theme::Text::Color(color!(128, 128, 128))) + .into(), + Active::Disabled => widget::button::text(&descriptions[device_connect]) + .on_press(Message::ConnectDevice(path.clone())) + .style(widget::button::Style::Text) + .into(), + }, + device_menu, + ])) + }) + .fold(section, settings::Section::add) + .apply(cosmic::Element::from) + .map(crate::pages::Message::Bluetooth) + }) +} + +fn available_devices() -> Section { + let mut descriptions = Slab::new(); + + let device_connecting = descriptions.insert(fl!("bluetooth", "connecting")); + + Section::default() + .title(fl!("bluetooth-available")) + .descriptions(descriptions) + .show_while::(|page| { + page.selected_adapter.as_ref().map(|adapter| { + page.devices_for_adapter(adapter).any(|(_, device)| { + !device.paired && (page.show_device_without_alias || device.has_alias()) + }) + }) == Some(true) + && page.active != Active::Disabled + }) + .view::(move |_binder, page, section| { + let descriptions = §ion.descriptions; + let section = settings::section().title(§ion.title); + + page.devices_for_adapter(page.selected_adapter.as_ref().unwrap()) + .filter_map(|(path, device)| { + if device.paired { + return None::>; + } + if !page.show_device_without_alias && !device.has_alias() { + return None::>; + } + + let mut items = vec![ + widget::icon::from_name(device.icon).size(16).into(), + text(device.alias_or_addr()).wrap(Wrap::Word).into(), + widget::horizontal_space(Length::Fill).into(), + ]; + + if device.enabled == Active::Enabling { + items.push( + text(&descriptions[device_connecting]) + .style(cosmic::theme::Text::Color(color!(128, 128, 128))) + .into(), + ); + } + let theme = cosmic::theme::active(); + let theme = theme.cosmic(); + Some( + widget::mouse_area( + settings::item_row(items).padding([theme.space_xxs(), theme.space_m()]), + ) + .on_press(Message::ConnectDevice(path.clone())) + .into(), + ) + }) + .fold(section, settings::Section::add) + .apply(cosmic::Element::from) + .map(crate::pages::Message::Bluetooth) + }) +} + +fn multiple_adapter() -> Section { + let mut descriptions = Slab::new(); + + let device_connected = descriptions.insert(fl!("bluetooth", "connected")); + + Section::default() + .title(fl!("bluetooth-adapters")) + .descriptions(descriptions) + .show_while::(|page| page.adapters.len() > 1 && page.selected_adapter.is_none()) + .view::(move |_binder, page, section| { + let descriptions = §ion.descriptions; + let section = settings::section().title(§ion.title); + let theme = cosmic::theme::active(); + let theme = theme.cosmic(); + + page.adapters + .iter() + .map(|(path, adapter)| { + let mut items = vec![ + widget::icon::from_name("bluetooth-symbolic") + .size(20) + .into(), + widget::horizontal_space(theme.space_xxs()).into(), + text(&adapter.alias).wrap(Wrap::Word).into(), + widget::horizontal_space(Length::Fill).into(), + widget::icon::from_name("go-next-symbolic").into(), + ]; + if page.adapter_connected(path) { + items.insert( + 4, + text(&descriptions[device_connected]) + .wrap(Wrap::Word) + .into(), + ); + } + widget::mouse_area(settings::item_row(items)) + .on_press(Message::SelectAdapter(Some(path.clone()))) + }) + .fold(section, settings::Section::add) + .apply(cosmic::Element::from) + .map(crate::pages::Message::Bluetooth) + }) +} + +impl page::AutoBind for Page {} diff --git a/cosmic-settings/src/pages/bluetooth/subscription.rs b/cosmic-settings/src/pages/bluetooth/subscription.rs new file mode 100644 index 0000000..dcdb2a2 --- /dev/null +++ b/cosmic-settings/src/pages/bluetooth/subscription.rs @@ -0,0 +1,214 @@ +// Copyright 2024 System76 +// SPDX-License-Identifier: MPL-2.0 + +use crate::pages::bluetooth; +use std::pin::Pin; + +use bluez_zbus::BluetoothDevice; +use cosmic::iced::futures::{SinkExt, StreamExt}; +use futures::{channel::mpsc, stream::FusedStream}; +use zbus::zvariant::OwnedObjectPath; + +enum DevicePropertyWatcherCommand { + Add(OwnedObjectPath), + Removed(OwnedObjectPath), +} + +struct DevicePropertyWatcher<'a> { + stream: futures::stream::SelectAll>, + rx: mpsc::Receiver, +} + +struct SignalWatcher<'a> { + stream: zbus::fdo::PropertiesChangedStream<'a>, + path: OwnedObjectPath, +} + +impl<'a> futures::Stream for SignalWatcher<'a> { + type Item = zbus::fdo::PropertiesChanged; + + fn poll_next( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + futures::Stream::poll_next(Pin::new(&mut self.stream), cx) + } + fn size_hint(&self) -> (usize, Option) { + self.stream.size_hint() + } +} + +impl<'a> DevicePropertyWatcher<'a> { + fn new() -> (Self, mpsc::Sender) { + let stream = futures::stream::select_all(vec![]); + let (tx, rx) = mpsc::channel(10); + + (Self { stream, rx }, tx) + } + async fn insert( + &mut self, + connection: &zbus::Connection, + path: OwnedObjectPath, + ) -> zbus::Result<()> { + if let Some(signal) = self.stream.iter_mut().find(|s| s.path.eq(&path)) { + if signal.stream.is_terminated() { + let property_proxy = + zbus::fdo::PropertiesProxy::new(connection, "org.bluez", path.clone()).await?; + signal.stream = property_proxy.receive_properties_changed().await?; + } + return Ok(()); + } + let property_proxy = + zbus::fdo::PropertiesProxy::new(connection, "org.bluez", path.clone()).await?; + let stream = property_proxy.receive_properties_changed().await?; + self.stream.push(SignalWatcher { stream, path }); + Ok(()) + } + fn remove(mut self, path: &OwnedObjectPath) -> Self { + self.stream = + futures::stream::select_all(self.stream.into_iter().filter(|p| !p.path.eq(path))); + self + } +} + +/// Watching new/removed devices, connected state changed +pub async fn watch( + connection: zbus::Connection, + mut tx: futures::channel::mpsc::Sender, +) { + let span = tracing::span!(tracing::Level::INFO, "bluetooth::watch"); + let _span = span.enter(); + + loop { + let result = async { + let managed_object_proxy = + zbus::fdo::ObjectManagerProxy::new(&connection, "org.bluez", "/") + .await?; + + let mut receive_interfaces_added = managed_object_proxy + .receive_interfaces_added() + .await?; + let mut receive_interfaces_removed = managed_object_proxy + .receive_interfaces_removed() + .await?; + + let (mut property_watcher, mut property_watcher_command) = DevicePropertyWatcher::new(); + + for (path, interfaces) in managed_object_proxy.get_managed_objects().await? { + if interfaces.contains_key("org.bluez.Device1") + || interfaces.contains_key("org.bluez.Adapter1") + || interfaces.contains_key("org.bluez.Battery1") + { + property_watcher.insert(&connection, path).await?; + } + } + + while !property_watcher.rx.is_terminated() { + futures::select! { + command = property_watcher.rx.next() => match command { + Some(DevicePropertyWatcherCommand::Add(path)) => { + property_watcher.insert(&connection, path).await?; + } + Some(DevicePropertyWatcherCommand::Removed(path)) => { + property_watcher = property_watcher.remove(&path); + } + None => { + tracing::error!("Bluetooth property watcher has shutdown unexpectedly"); + } + }, + signal = property_watcher.stream.next() => match signal { + Some(signal) => { + let args = signal.args()?; + let header = signal.message().header(); + match header.path() { + Some(path) if path.contains("/dev_") => + tx + .send(bluetooth::Message::UpdatedDevice(path.to_owned().into(), bluetooth::DeviceUpdate::from_update(args.changed_properties))) + .await + .map_err(|e| zbus::Error::Failure(e.to_string()))?, + Some(path) => tx + .send(bluetooth::Message::UpdatedAdapter(path.to_owned().into(), bluetooth::AdapterUpdate::from_update(args.changed_properties))) + .await + .map_err(|e| zbus::Error::Failure(e.to_string()))?, + None => continue + } + } + None => { + tracing::error!("Bluetooth object watcher has shutdown unexpectedly"); + } + }, + signal = receive_interfaces_added.next() => match signal { + Some(signal) => { + let args = signal.args()?; + match BluetoothDevice::new(&connection, args.object_path.clone()).await { + Ok(device) => { + match bluetooth::Device::from_device(&device).await { + Ok(device) => { + property_watcher_command + .send(DevicePropertyWatcherCommand::Add(args.object_path.to_owned().into())).await.map_err(|e| zbus::Error::Failure(e.to_string()))?; + + tx + .send(bluetooth::Message::AddedDevice(args.object_path.to_owned().into(), device)) + .await + .map_err(|e| zbus::Error::Failure(e.to_string()))?; + + } + Err(why) => { + tracing::warn!("Cannot deserialise device: {why}"); + } + } + } + Err(zbus::Error::InterfaceNotFound) => continue, + Err(e) => return Err(e), + } + } + None => { + tracing::error!("Bluetooth object watcher has shutdown unexpectedly"); + } + }, + signal = receive_interfaces_removed.next() => match signal { + Some(signal) => { + let args = signal.args()?; + if args.interfaces.contains(&"org.bluez.Device1") { + property_watcher_command.send(DevicePropertyWatcherCommand::Removed( + args.object_path.to_owned().into(), + )).await.map_err(|e| zbus::Error::Failure(e.to_string()))?; + tx + .send(bluetooth::Message::RemovedDevice(args.object_path.to_owned().into())) + .await + .map_err(|e| zbus::Error::Failure(e.to_string()))?; + + } else if args.interfaces.contains(&"org.bluez.Battery1") { + tx + .send(bluetooth::Message::UpdatedDevice(args.object_path.to_owned().into(), vec![bluetooth::DeviceUpdate::Battery(None)])) + .await + .map_err(|e| zbus::Error::Failure(e.to_string()))?; + } else if args.interfaces.contains(&"org.bluez.Adapter1") { + tx + .send(bluetooth::Message::RemovedAdapter(args.object_path.to_owned().into())) + .await + .map_err(|e| zbus::Error::Failure(e.to_string()))?; + } + }, + None => { + tracing::error!("Bluetooth object watcher has shutdown unexpectedly"); + } + }, + } + } + tracing::warn!("bluetooth event loop gracefully terminated"); + Ok(()) + }.await; + + if let Err(why) = result { + tracing::error!("failed to watch bluetooth event: {why}"); + if let Err(why) = tx + .send(bluetooth::Message::DBusError(why.to_string())) + .await + { + tracing::error!("failed to communicate error to app: {why}"); + } + tracing::error!("failed to watch bluetooth event: {why}. Restarting..."); + } + } +} diff --git a/cosmic-settings/src/pages/desktop/panel/inner.rs b/cosmic-settings/src/pages/desktop/panel/inner.rs index 83d7520..2fd6846 100644 --- a/cosmic-settings/src/pages/desktop/panel/inner.rs +++ b/cosmic-settings/src/pages/desktop/panel/inner.rs @@ -118,9 +118,11 @@ pub(crate) fn behavior_and_position< .title(§ion.title) .add(settings::item( &descriptions[autohide_label], - toggler(None, panel_config.autohide.is_some(), |value| { - Message::AutoHidePanel(value) - }), + toggler( + None, + panel_config.autohide.is_some(), + Message::AutoHidePanel, + ), )) .add(settings::item( &descriptions[position], @@ -175,15 +177,11 @@ pub(crate) fn style< .title(§ion.title) .add(settings::item( &descriptions[gap_label], - toggler(None, panel_config.anchor_gap, |value| { - Message::AnchorGap(value) - }), + toggler(None, panel_config.anchor_gap, Message::AnchorGap), )) .add(settings::item( &descriptions[extend_label], - toggler(None, panel_config.expand_to_edges, |value| { - Message::ExtendToEdge(value) - }), + toggler(None, panel_config.expand_to_edges, Message::ExtendToEdge), )) .add(settings::item( &descriptions[appearance], diff --git a/cosmic-settings/src/pages/mod.rs b/cosmic-settings/src/pages/mod.rs index 0caa4d2..0c07def 100644 --- a/cosmic-settings/src/pages/mod.rs +++ b/cosmic-settings/src/pages/mod.rs @@ -3,6 +3,7 @@ use cosmic_settings_page::Entity; +pub mod bluetooth; pub mod desktop; pub mod display; pub mod input; @@ -16,6 +17,7 @@ pub mod time; pub enum Message { About(system::about::Message), Appearance(desktop::appearance::Message), + Bluetooth(bluetooth::Message), CustomShortcuts(input::keyboard::shortcuts::custom::Message), DateAndTime(time::date::Message), Desktop(desktop::Message), diff --git a/cosmic-settings/src/pages/networking/vpn/mod.rs b/cosmic-settings/src/pages/networking/vpn/mod.rs index d513472..f925b98 100644 --- a/cosmic-settings/src/pages/networking/vpn/mod.rs +++ b/cosmic-settings/src/pages/networking/vpn/mod.rs @@ -723,10 +723,12 @@ fn devices_view() -> Section { } fn popup_button<'a>(message: Message, text: &'a str) -> Element<'a, Message> { + let theme = cosmic::theme::active(); + let theme = theme.cosmic(); widget::text::body(text) .vertical_alignment(alignment::Vertical::Center) .apply(widget::button::custom) - .padding([4, 16]) + .padding([theme.space_xxxs(), theme.space_xs()]) .width(Length::Fill) .style(cosmic::theme::Button::MenuItem) .on_press(message) diff --git a/cosmic-settings/src/pages/networking/wifi.rs b/cosmic-settings/src/pages/networking/wifi.rs index 46ff9be..30e6ffa 100644 --- a/cosmic-settings/src/pages/networking/wifi.rs +++ b/cosmic-settings/src/pages/networking/wifi.rs @@ -715,10 +715,12 @@ fn is_connected(state: &NetworkManagerState, network: &AccessPoint) -> bool { } fn popup_button<'a>(message: Message, text: &'a str) -> Element<'a, Message> { + let theme = cosmic::theme::active(); + let theme = theme.cosmic(); widget::text::body(text) .vertical_alignment(alignment::Vertical::Center) .apply(widget::button::custom) - .padding([4, 16]) + .padding([theme.space_xxxs(), theme.space_xs()]) .width(Length::Fill) .style(cosmic::theme::Button::MenuItem) .on_press(message) diff --git a/cosmic-settings/src/pages/networking/wired.rs b/cosmic-settings/src/pages/networking/wired.rs index bf76e83..441584e 100644 --- a/cosmic-settings/src/pages/networking/wired.rs +++ b/cosmic-settings/src/pages/networking/wired.rs @@ -519,6 +519,7 @@ impl Page { })) .width(Length::Fixed(200.0)) .apply(widget::container) + .padding(spacing.space_xxxs) .style(cosmic::style::Container::Dialog) }) .apply(|e| Some(Element::from(e))) @@ -601,10 +602,12 @@ fn devices_view() -> Section { } fn popup_button<'a>(message: Message, text: &'a str) -> Element<'a, Message> { + let theme = cosmic::theme::active(); + let theme = theme.cosmic(); widget::text::body(text) .vertical_alignment(alignment::Vertical::Center) .apply(widget::button::custom) - .padding([4, 16]) + .padding([theme.space_xxxs(), theme.space_xs()]) .width(Length::Fill) .style(cosmic::theme::Button::MenuItem) .on_press(message) diff --git a/cosmic-settings/src/subscription/bluetooth.rs b/cosmic-settings/src/subscription/bluetooth.rs new file mode 100644 index 0000000..2ec577b --- /dev/null +++ b/cosmic-settings/src/subscription/bluetooth.rs @@ -0,0 +1,209 @@ +use crate::pages::bluetooth; +use std::{any::TypeId, pin::Pin}; + +use bluez_zbus::BluetoothDevice; +use cosmic::iced::{ + self, + futures::{SinkExt, StreamExt}, +}; +use futures::{channel::mpsc, stream::FusedStream}; +use zbus::zvariant::OwnedObjectPath; + +enum DevicePropertyWatcherCommand { + Add(OwnedObjectPath), + Removed(OwnedObjectPath), +} + +struct DevicePropertyWatcher<'a> { + stream: futures::stream::SelectAll>, + rx: mpsc::Receiver, +} + +struct SignalWatcher<'a> { + stream: zbus::fdo::PropertiesChangedStream<'a>, + path: OwnedObjectPath, +} + +impl<'a> futures::Stream for SignalWatcher<'a> { + type Item = zbus::fdo::PropertiesChanged; + + fn poll_next( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + futures::Stream::poll_next(Pin::new(&mut self.stream), cx) + } + fn size_hint(&self) -> (usize, Option) { + self.stream.size_hint() + } +} + +impl<'a> DevicePropertyWatcher<'a> { + fn new() -> (Self, mpsc::Sender) { + let stream = futures::stream::select_all(vec![]); + let (tx, rx) = mpsc::channel(10); + + (Self { stream, rx }, tx) + } + async fn insert( + &mut self, + connection: &zbus::Connection, + path: OwnedObjectPath, + ) -> zbus::Result<()> { + if let Some(signal) = self.stream.iter_mut().find(|s| s.path.eq(&path)) { + if signal.stream.is_terminated() { + let property_proxy = + zbus::fdo::PropertiesProxy::new(connection, "org.bluez", path.clone()).await?; + signal.stream = property_proxy.receive_properties_changed().await?; + } + return Ok(()); + } + let property_proxy = + zbus::fdo::PropertiesProxy::new(connection, "org.bluez", path.clone()).await?; + let stream = property_proxy.receive_properties_changed().await?; + self.stream.push(SignalWatcher { stream, path }); + Ok(()) + } + fn remove(mut self, path: &OwnedObjectPath) -> Self { + self.stream = + futures::stream::select_all(self.stream.into_iter().filter(|p| !p.path.eq(path))); + self + } +} + +/// Watching new/removed devices, connected state changed +pub async fn watch(mut tx: futures::channel::mpsc::Sender) { + loop { + let result = async { + let connection = zbus::Connection::system().await?; + let managed_object_proxy = + zbus::fdo::ObjectManagerProxy::new(&connection, "org.bluez", "/") + .await?; + + let mut receive_interfaces_added = managed_object_proxy + .receive_interfaces_added() + .await?; + let mut receive_interfaces_removed = managed_object_proxy + .receive_interfaces_removed() + .await?; + + let (mut property_watcher, mut property_watcher_command) = DevicePropertyWatcher::new(); + + for (path, interfaces) in managed_object_proxy.get_managed_objects().await? { + if interfaces.contains_key("org.bluez.Device1") + || interfaces.contains_key("org.bluez.Adapter1") + || interfaces.contains_key("org.bluez.Battery1") + { + property_watcher.insert(&connection, path).await?; + } + } + + while !property_watcher.rx.is_terminated() { + futures::select! { + command = property_watcher.rx.next() => match command { + Some(DevicePropertyWatcherCommand::Add(path)) => { + property_watcher.insert(&connection, path).await?; + } + Some(DevicePropertyWatcherCommand::Removed(path)) => { + property_watcher = property_watcher.remove(&path); + } + None => { + tracing::error!("Bluetooth property watcher has shutdown unexpectedly"); + } + }, + signal = property_watcher.stream.next() => match signal { + Some(signal) => { + let args = signal.args()?; + let header = signal.message().header(); + match header.path() { + Some(path) if path.contains("/dev_") => + tx + .send(bluetooth::Message::UpdatedDevice(path.to_owned().into(), bluetooth::DeviceUpdate::from_update(args.changed_properties))) + .await + .map_err(|e| zbus::Error::Failure(e.to_string()))?, + Some(path) => tx + .send(bluetooth::Message::UpdatedAdapter(path.to_owned().into(), bluetooth::AdapterUpdate::from_update(args.changed_properties))) + .await + .map_err(|e| zbus::Error::Failure(e.to_string()))?, + None => continue + } + } + None => { + tracing::error!("Bluetooth object watcher has shutdown unexpectedly"); + } + }, + signal = receive_interfaces_added.next() => match signal { + Some(signal) => { + let args = signal.args()?; + match BluetoothDevice::new(&connection, args.object_path.clone()).await { + Ok(device) => { + match bluetooth::Device::from_device(&device).await { + Ok(device) => { + property_watcher_command + .send(DevicePropertyWatcherCommand::Add(args.object_path.to_owned().into())).await.map_err(|e| zbus::Error::Failure(e.to_string()))?; + + tx + .send(bluetooth::Message::AddedDevice(args.object_path.to_owned().into(), device)) + .await + .map_err(|e| zbus::Error::Failure(e.to_string()))?; + + } + Err(why) => { + tracing::warn!("Cannot deserialise device: {why}"); + } + } + } + Err(zbus::Error::InterfaceNotFound) => continue, + Err(e) => return Err(e), + } + } + None => { + tracing::error!("Bluetooth object watcher has shutdown unexpectedly"); + } + }, + signal = receive_interfaces_removed.next() => match signal { + Some(signal) => { + let args = signal.args()?; + if args.interfaces.contains(&"org.bluez.Device1") { + property_watcher_command.send(DevicePropertyWatcherCommand::Removed( + args.object_path.to_owned().into(), + )).await.map_err(|e| zbus::Error::Failure(e.to_string()))?; + tx + .send(bluetooth::Message::RemovedDevice(args.object_path.to_owned().into())) + .await + .map_err(|e| zbus::Error::Failure(e.to_string()))?; + + } else if args.interfaces.contains(&"org.bluez.Battery1") { + tx + .send(bluetooth::Message::UpdatedDevice(args.object_path.to_owned().into(), vec![bluetooth::DeviceUpdate::Battery(None)])) + .await + .map_err(|e| zbus::Error::Failure(e.to_string()))?; + } else if args.interfaces.contains(&"org.bluez.Adapter1") { + tx + .send(bluetooth::Message::RemovedAdapter(args.object_path.to_owned().into())) + .await + .map_err(|e| zbus::Error::Failure(e.to_string()))?; + } + }, + None => { + tracing::error!("Bluetooth object watcher has shutdown unexpectedly"); + } + }, + } + } + tracing::warn!("bluetooth event loop gracefully terminated"); + Ok(()) + }.await; + + if let Err(why) = result { + tracing::error!("failed to watch bluetooth event: {why}"); + if let Err(why) = tx + .send(bluetooth::Message::DBusError(why.to_string())) + .await + { + tracing::error!("failed to communicate error to app: {why}"); + } + tracing::error!("failed to watch bluetooth event: {why}. Restarting..."); + } + } +} diff --git a/cosmic-settings/src/subscription/mod.rs b/cosmic-settings/src/subscription/mod.rs index cfcbf48..bd9f495 100644 --- a/cosmic-settings/src/subscription/mod.rs +++ b/cosmic-settings/src/subscription/mod.rs @@ -1,3 +1,4 @@ +// TODO: Do not use subscriptions for pages. mod desktop_files; pub use desktop_files::*; mod daytime; diff --git a/i18n/en/cosmic_settings.ftl b/i18n/en/cosmic_settings.ftl index 3fd7954..7630326 100644 --- a/i18n/en/cosmic_settings.ftl +++ b/i18n/en/cosmic_settings.ftl @@ -689,3 +689,25 @@ firmware = Firmware users = Users .desc = Authentication and user accounts. + +# Bluetooth + +bluetooth = Bluetooth + .desc = Manage Bluetooth devices + .status = This system is visible as { $aliases } while the Bluetooth settings is open. + .connected = Connected + .connecting = Connecting + .disconnecting = Disconnecting + .connect = Connect + .disconnect = Disconnect + .forget = Forget + .dbus-error = An error has occurred while interacting with DBus: { $why } + .show-device-without-name = Show device without name + +bluetooth-paired = Previously Connected Devices + .connect = Connect + .battery = { $percentage }% battery + +bluetooth-available = Nearby Devices + +bluetooth-adapters = Bluetooth Adapters diff --git a/i18n/fr/cosmic_settings.ftl b/i18n/fr/cosmic_settings.ftl index 6d62c11..7679569 100644 --- a/i18n/fr/cosmic_settings.ftl +++ b/i18n/fr/cosmic_settings.ftl @@ -681,3 +681,25 @@ firmware = Micrologiciel users = Utilisateurs .desc = Authentification et connexion, écran de verrouillage. + +# Bluetooth + +bluetooth = Bluetooth + .desc = Gestion du Bluetooth. + .status = Ce système est visible en tant que { $aliases } pandant que les paramètres Bluetooth sont ouvert. + .connected = Connecté + .connecting = Connexion + .disconnecting = Deconnexion + .connect = Connecter + .disconnect = Deconnecter + .forget = Oublier + .dbus-error = Une erreur est survenue lors de l'interaction avec DBus: { $why } + .show-device-without-name = Afficher les périphériques sans nom + +bluetooth-paired = Périphériques precedemment connectés + .connect = Connecter + .battery = { $percentage }% de batterie + +bluetooth-available = Périphériques à proximité + +bluetooth-adapters = Adaptateur Bluetooth diff --git a/justfile b/justfile index 6b6ef5a..28a4a9f 100644 --- a/justfile +++ b/justfile @@ -27,6 +27,7 @@ polkit-rules-dst := clean(rootdir / prefix) / 'share' / 'polkit-1' / 'rules.d' / entry-settings := appid + '.desktop' entry-about := appid + '.About.desktop' entry-appear := appid + '.Appearance.desktop' +entry-bluetooth := appid + '.Bluetooth.desktop' entry-date-time := appid + '.DateTime.desktop' entry-desktop := appid + '.Desktop.desktop' entry-displays := appid + '.Displays.desktop' @@ -60,6 +61,7 @@ install-desktop-entries: install -Dm0644 'resources/{{entry-settings}}' '{{appdir}}/{{entry-settings}}' install -Dm0644 'resources/{{entry-about}}' '{{appdir}}/{{entry-about}}' install -Dm0644 'resources/{{entry-appear}}' '{{appdir}}/{{entry-appear}}' + install -Dm0644 'resources/{{entry-bluetooth}}' '{{appdir}}/{{entry-bluetooth}}' install -Dm0644 'resources/{{entry-date-time}}' '{{appdir}}/{{entry-date-time}}' install -Dm0644 'resources/{{entry-desktop}}' '{{appdir}}/{{entry-desktop}}' install -Dm0644 'resources/{{entry-displays}}' '{{appdir}}/{{entry-displays}}' @@ -105,6 +107,7 @@ uninstall: '{{appdir}}/{{entry-settings}}' \ '{{appdir}}/{{entry-about}}' \ '{{appdir}}/{{entry-appear}}' \ + '{{appdir}}/{{entry-bluetooth}}' \ '{{appdir}}/{{entry-date-time}}' \ '{{appdir}}/{{entry-desktop}}' \ '{{appdir}}/{{entry-displays}}' \ diff --git a/resources/com.system76.CosmicSettings.Bluetooth.desktop b/resources/com.system76.CosmicSettings.Bluetooth.desktop new file mode 100644 index 0000000..85c6027 --- /dev/null +++ b/resources/com.system76.CosmicSettings.Bluetooth.desktop @@ -0,0 +1,12 @@ +[Desktop Entry] +Name=Bluetooth +Comment=Manage Bluetooth devices +Type=Settings +Exec=cosmic-settings bluetooth +Terminal=false +Categories=COSMIC +Keywords=COSMIC +NoDisplay=true +OnlyShowIn=COSMIC +Icon=bluetooth +StartupNotify=true