From a449ed904dbf0d7746503084b1f176497ecfe339 Mon Sep 17 00:00:00 2001 From: Simone Camito Date: Wed, 30 Sep 2026 17:41:41 +0200 Subject: [PATCH 1/9] refactor(services): move bluetooth into the ashell-services workspace crate Turn the repo into a Cargo workspace and add crates/ashell-services, a library for services that do not depend on iced or any other UI toolkit, so the same code can back other UIs. The crate imposes no service trait. Bluetooth is exposed as a cloneable handle with async command methods, a data() snapshot and an updates() stream that yields a fresh snapshot on every change. Each service sits behind a cargo feature of the same name, with no default features. src/services/bluetooth.rs wraps that API into the iced ReadOnlyService and Service traits and keeps the UI policy (optimistic toggle, 15s discovery, refresh after each command), so the settings module and the network service are unchanged. CI and make check now lint the whole workspace. --- .github/workflows/ci.yml | 3 +- Cargo.lock | 13 + Cargo.toml | 42 +- Makefile | 4 +- crates/ashell-services/Cargo.toml | 29 ++ .../ashell-services/src}/bluetooth/dbus.rs | 0 crates/ashell-services/src/bluetooth/mod.rs | 281 +++++++++++ crates/ashell-services/src/lib.rs | 4 + crates/ashell-services/src/stream.rs | 18 + src/services/bluetooth.rs | 161 +++++++ src/services/bluetooth/mod.rs | 444 ------------------ src/services/network/dbus.rs | 8 +- src/services/network/iwd_dbus/mod.rs | 4 +- 13 files changed, 546 insertions(+), 465 deletions(-) create mode 100644 crates/ashell-services/Cargo.toml rename {src/services => crates/ashell-services/src}/bluetooth/dbus.rs (100%) create mode 100644 crates/ashell-services/src/bluetooth/mod.rs create mode 100644 crates/ashell-services/src/lib.rs create mode 100644 crates/ashell-services/src/stream.rs create mode 100644 src/services/bluetooth.rs delete mode 100644 src/services/bluetooth/mod.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fdb71e455..5459aa741 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,7 @@ jobs: filters: | code: - 'src/**' + - 'crates/**' - 'assets/**' - 'build.rs' - 'Cargo.toml' @@ -52,7 +53,7 @@ jobs: - name: Clippy if: steps.filter.outputs.code == 'true' - run: cargo clippy --all-features -- -D warnings + run: cargo clippy --workspace --all-features -- -D warnings - name: Build if: steps.filter.outputs.code == 'true' diff --git a/Cargo.lock b/Cargo.lock index e9bf55950..4a2156366 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -140,6 +140,7 @@ version = "0.11.0" dependencies = [ "allsorts", "anyhow", + "ashell-services", "chrono", "chrono-tz", "clap", @@ -186,6 +187,18 @@ dependencies = [ "zbus", ] +[[package]] +name = "ashell-services" +version = "0.11.0" +dependencies = [ + "anyhow", + "futures", + "inotify", + "log", + "tokio", + "zbus", +] + [[package]] name = "async-broadcast" version = "0.7.2" diff --git a/Cargo.toml b/Cargo.toml index 3cbe77390..7ec16e7dc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,14 +1,33 @@ -[package] -name = "ashell" -description = "A ready to go status bar for Wayland compositors" -homepage = "https://malpenzibo.github.io/ashell/" +[workspace] +members = ["crates/ashell-services"] + +[workspace.package] repository = "https://github.com/MalpenZibo/ashell" authors = ["Simone Camito (Zibo)"] license = "GPL-3.0-or-later" -version = "0.11.0" edition = "2024" rust-version = "1.89" +[workspace.dependencies] +anyhow = "1" +inotify = { version = "0.11.1", default-features = false, features = [ + "stream", +] } +log = { version = "0.4", features = [] } +tokio = { version = "1", default-features = false } +zbus = { version = "5", default-features = false, features = ["tokio"] } + +[package] +name = "ashell" +description = "A ready to go status bar for Wayland compositors" +homepage = "https://malpenzibo.github.io/ashell/" +repository.workspace = true +authors.workspace = true +license.workspace = true +version = "0.11.0" +edition.workspace = true +rust-version.workspace = true + [profile.release] lto = "thin" strip = true @@ -29,6 +48,7 @@ depends = ["libwayland-client0", "libpipewire-0.3-0t64", "libpulse0"] depends = ["libwayland-client", "pipewire-libs", "pulseaudio-libs"] [dependencies] +ashell-services = { path = "crates/ashell-services", features = ["bluetooth"] } iced = { package = "iced_layershell", git = "https://github.com/MalpenZibo/iced_layershell", tag = "v0.6.0", features = [ "tokio", "advanced", @@ -47,16 +67,16 @@ chrono = { version = "0.4", default-features = false, features = [ hyprland = { git = "https://github.com/hyprland-community/hyprland-rs", branch = "master" } serde = { version = "1.0", default-features = false, features = [] } sysinfo = { version = "0.39", features = ["linux-netdevs"] } -tokio = { version = "1", default-features = false, features = ["net"] } -zbus = { version = "5", default-features = false, features = ["tokio"] } +tokio = { workspace = true, features = ["net"] } +zbus.workspace = true libpulse-binding = { version = "2.28", features = ["pa_v15"] } -log = { version = "0.4", features = [] } +log.workspace = true flexi_logger = "0.31" pipewire = "0.10" itertools = "0.15" unicode-segmentation = "1.13.2" hex_color = { version = "3", features = ["serde"] } -anyhow = "1" +anyhow.workspace = true udev = { version = "0.9", features = ["send", "sync"] } toml = "1" serde_ignored = "0.1" @@ -76,9 +96,7 @@ clap = { version = "4.5", default-features = false, features = [ "help", ] } shellexpand = { version = "3", features = ["path"] } -inotify = { version = "0.11.1", default-features = false, features = [ - "stream", -] } +inotify.workspace = true pin-project-lite = "0.2.16" niri-ipc = "26.4" reqwest = { version = "0.13", default-features = false, features = [ diff --git a/Makefile b/Makefile index f5436af3a..4f2518402 100644 --- a/Makefile +++ b/Makefile @@ -27,5 +27,5 @@ fmt: cargo fmt check: fmt - cargo check - cargo clippy -- -D warnings + cargo check --workspace + cargo clippy --workspace -- -D warnings diff --git a/crates/ashell-services/Cargo.toml b/crates/ashell-services/Cargo.toml new file mode 100644 index 000000000..3806d35ca --- /dev/null +++ b/crates/ashell-services/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "ashell-services" +description = "UI-agnostic backend services for ashell" +version = "0.11.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +publish = false + +[features] +full = ["bluetooth"] +bluetooth = [ + "dep:anyhow", + "dep:inotify", + "dep:log", + "dep:tokio", + "dep:zbus", + "tokio/process", +] + +[dependencies] +futures = "0.3" +anyhow = { workspace = true, optional = true } +inotify = { workspace = true, optional = true } +log = { workspace = true, optional = true } +tokio = { workspace = true, optional = true } +zbus = { workspace = true, optional = true } diff --git a/src/services/bluetooth/dbus.rs b/crates/ashell-services/src/bluetooth/dbus.rs similarity index 100% rename from src/services/bluetooth/dbus.rs rename to crates/ashell-services/src/bluetooth/dbus.rs diff --git a/crates/ashell-services/src/bluetooth/mod.rs b/crates/ashell-services/src/bluetooth/mod.rs new file mode 100644 index 000000000..c2effab25 --- /dev/null +++ b/crates/ashell-services/src/bluetooth/mod.rs @@ -0,0 +1,281 @@ +use crate::stream::channel; +use dbus::{BatteryProxy, BluetoothDbus, DeviceProxy}; +use futures::{ + SinkExt, Stream, StreamExt, + stream::{pending, select_all}, + stream_select, +}; +use inotify::{Inotify, WatchMask}; +use log::{error, info, warn}; +use std::{io::ErrorKind, pin::Pin}; +use tokio::process::Command; +use zbus::zvariant::OwnedObjectPath; + +mod dbus; + +type EventStream = Pin + Send>>; + +#[derive(PartialEq, Eq, Debug, Clone)] +pub enum BluetoothState { + Unavailable, + Active, + Inactive, +} + +#[derive(Debug, Clone)] +pub struct BluetoothDevice { + pub name: String, + pub battery: Option, + pub path: OwnedObjectPath, + pub connected: bool, + pub paired: bool, +} + +#[derive(Debug, Clone)] +pub struct BluetoothData { + pub state: BluetoothState, + pub devices: Vec, + pub discovering: bool, +} + +#[derive(Debug, Clone)] +pub struct Bluetooth { + conn: zbus::Connection, +} + +impl Bluetooth { + pub async fn connect() -> anyhow::Result { + let conn = zbus::Connection::system().await?; + + Ok(Self { conn }) + } + + pub async fn data(&self) -> anyhow::Result { + Self::read_data(&self.conn).await + } + + pub fn updates(&self) -> impl Stream + Send + 'static { + let conn = self.conn.clone(); + + channel(100, |mut output| async move { + loop { + info!("Listening for bluetooth events"); + + let mut events = match Self::events(&conn).await { + Ok(events) => events, + Err(err) => { + error!("Failed to listen for bluetooth events: {err}"); + return; + } + }; + + while events.next().await.is_some() { + if let Ok(data) = Self::read_data(&conn).await + && output.send(data).await.is_err() + { + return; + } + } + } + }) + } + + pub async fn set_powered(&self, powered: bool) -> anyhow::Result<()> { + BluetoothDbus::new(&self.conn) + .await? + .set_powered(powered) + .await?; + + Ok(()) + } + + pub async fn start_discovery(&self) -> anyhow::Result<()> { + BluetoothDbus::new(&self.conn) + .await? + .start_discovery() + .await?; + + Ok(()) + } + + pub async fn stop_discovery(&self) -> anyhow::Result<()> { + BluetoothDbus::new(&self.conn) + .await? + .stop_discovery() + .await?; + + Ok(()) + } + + pub async fn pair_device(&self, device: &OwnedObjectPath) -> anyhow::Result<()> { + BluetoothDbus::new(&self.conn) + .await? + .pair_device(device) + .await?; + + Ok(()) + } + + pub async fn connect_device(&self, device: &OwnedObjectPath) -> anyhow::Result<()> { + BluetoothDbus::new(&self.conn) + .await? + .connect_device(device) + .await?; + + Ok(()) + } + + pub async fn disconnect_device(&self, device: &OwnedObjectPath) -> anyhow::Result<()> { + BluetoothDbus::new(&self.conn) + .await? + .disconnect_device(device) + .await?; + + Ok(()) + } + + pub async fn remove_device(&self, device: &OwnedObjectPath) -> anyhow::Result<()> { + BluetoothDbus::new(&self.conn) + .await? + .remove_device(device) + .await?; + + Ok(()) + } + + pub async fn check_rfkill_soft_block() -> anyhow::Result { + let output = match Command::new("rfkill") + .args(["list", "bluetooth"]) + .output() + .await + { + Ok(output) => output, + Err(err) if err.kind() == ErrorKind::NotFound => { + warn!("rfkill binary not found, assuming bluetooth is not soft blocked"); + return Ok(false); + } + Err(err) => return Err(err.into()), + }; + + let output = String::from_utf8(output.stdout)?; + + Ok(output.contains("Soft blocked: yes")) + } + + async fn listen_rfkill_soft_block_changes() -> anyhow::Result { + let inotify = Inotify::init()?; + + match inotify.watches().add("/dev/rfkill", WatchMask::MODIFY) { + Ok(_) => { + let buffer = [0; 512]; + Ok(inotify.into_event_stream(buffer)?.map(|_| {}).boxed()) + } + Err(err) if err.kind() == ErrorKind::NotFound => { + warn!("/dev/rfkill not found, disabling rfkill change notifications for bluetooth"); + Ok(pending().boxed()) + } + Err(err) => Err(err.into()), + } + } + + async fn read_data(conn: &zbus::Connection) -> anyhow::Result { + let bluetooth = BluetoothDbus::new(conn).await?; + + let state = bluetooth.state().await?; + let rfkill_soft_block = Self::check_rfkill_soft_block().await?; + + let state = match state { + BluetoothState::Unavailable => BluetoothState::Unavailable, + BluetoothState::Active if rfkill_soft_block => BluetoothState::Inactive, + state => state, + }; + let devices = bluetooth.devices().await?; + let discovering = bluetooth.discovering().await.unwrap_or(false); + + Ok(BluetoothData { + state, + devices, + discovering, + }) + } + + async fn events(conn: &zbus::Connection) -> anyhow::Result + use<>> { + let bluetooth = BluetoothDbus::new(conn).await?; + + let interface_changed = stream_select!( + bluetooth + .bluez + .receive_interfaces_added() + .await? + .map(|_| {}), + bluetooth + .bluez + .receive_interfaces_removed() + .await? + .map(|_| {}), + ) + .boxed(); + + let combined = match bluetooth.adapter.as_ref() { + Some(adapter) => { + let powered = adapter.receive_powered_changed().await.map(|_| {}); + let discovering = adapter.receive_discovering_changed().await.map(|_| {}); + let rfkill = Self::listen_rfkill_soft_block_changes().await?; + let devices = bluetooth.devices().await?; + + let mut batteries: Vec = Vec::with_capacity(devices.len()); + let mut device_properties: Vec = Vec::with_capacity(devices.len()); + for device in devices { + let conn = bluetooth.bluez.inner().connection(); + + let battery = BatteryProxy::builder(conn) + .path(device.path.clone())? + .build() + .await?; + batteries.push( + battery + .receive_percentage_changed() + .await + .map(|_| {}) + .boxed(), + ); + + let device_proxy = DeviceProxy::builder(conn) + .path(device.path)? + .build() + .await?; + let connected_changed: EventStream = device_proxy + .receive_connected_changed() + .await + .map(|_| {}) + .boxed(); + device_properties.push(connected_changed); + } + + let battery_events = if batteries.is_empty() { + pending().boxed() + } else { + select_all(batteries).boxed() + }; + + let device_property_events = if device_properties.is_empty() { + pending().boxed() + } else { + select_all(device_properties).boxed() + }; + + Box::pin(stream_select!( + interface_changed, + powered, + discovering, + rfkill, + battery_events, + device_property_events, + )) + } + _ => interface_changed, + }; + + Ok(combined) + } +} diff --git a/crates/ashell-services/src/lib.rs b/crates/ashell-services/src/lib.rs new file mode 100644 index 000000000..b6b44515c --- /dev/null +++ b/crates/ashell-services/src/lib.rs @@ -0,0 +1,4 @@ +#[cfg(feature = "bluetooth")] +pub mod bluetooth; +#[cfg(feature = "bluetooth")] +mod stream; diff --git a/crates/ashell-services/src/stream.rs b/crates/ashell-services/src/stream.rs new file mode 100644 index 000000000..503a153a7 --- /dev/null +++ b/crates/ashell-services/src/stream.rs @@ -0,0 +1,18 @@ +use futures::{ + Stream, StreamExt, + channel::mpsc, + stream::{self}, +}; + +pub(crate) fn channel( + size: usize, + f: impl FnOnce(mpsc::Sender) -> F, +) -> impl Stream +where + F: Future, +{ + let (sender, receiver) = mpsc::channel(size); + let runner = stream::once(f(sender)).filter_map(|_| async { None }); + + stream::select(receiver, runner) +} diff --git a/src/services/bluetooth.rs b/src/services/bluetooth.rs new file mode 100644 index 000000000..f9caa3a90 --- /dev/null +++ b/src/services/bluetooth.rs @@ -0,0 +1,161 @@ +use super::{ReadOnlyService, Service, ServiceEvent}; +use ashell_services::bluetooth::Bluetooth; +use iced::{ + Subscription, Task, + futures::{SinkExt, StreamExt}, + stream::channel, +}; +use log::{debug, error, info}; +use std::{any::TypeId, ops::Deref, pin::pin, time::Duration}; +use zbus::zvariant::OwnedObjectPath; + +pub use ashell_services::bluetooth::{BluetoothData, BluetoothDevice, BluetoothState}; + +#[derive(Debug, Clone)] +pub struct BluetoothService { + handle: Bluetooth, + data: BluetoothData, +} + +impl Deref for BluetoothService { + type Target = BluetoothData; + + fn deref(&self) -> &Self::Target { + &self.data + } +} + +#[derive(Debug, Clone)] +pub enum BluetoothCommand { + Toggle, + StartDiscovery, + PairDevice(OwnedObjectPath), + ConnectDevice(OwnedObjectPath), + DisconnectDevice(OwnedObjectPath), + RemoveDevice(OwnedObjectPath), +} + +async fn refresh(handle: &Bluetooth) -> BluetoothData { + handle.data().await.unwrap_or_else(|_| BluetoothData { + state: BluetoothState::Unavailable, + devices: vec![], + discovering: false, + }) +} + +impl ReadOnlyService for BluetoothService { + type UpdateEvent = BluetoothData; + type Error = (); + + fn update(&mut self, event: Self::UpdateEvent) { + self.data = event; + } + + fn subscribe() -> Subscription> { + Subscription::run_with(TypeId::of::(), |_| { + channel(100, async |mut output| { + let handle = match Bluetooth::connect().await { + Ok(handle) => handle, + Err(err) => { + error!("Failed to connect to system bus: {err}"); + return; + } + }; + let data = match handle.data().await { + Ok(data) => data, + Err(err) => { + error!("Failed to initialize bluetooth service: {err}"); + return; + } + }; + info!("Bluetooth service initialized"); + + let mut updates = pin!(handle.updates()); + let _ = output + .send(ServiceEvent::Init(BluetoothService { handle, data })) + .await; + + while let Some(data) = updates.next().await { + let _ = output.send(ServiceEvent::Update(data)).await; + } + }) + }) + } +} + +impl Service for BluetoothService { + type Command = BluetoothCommand; + + fn command(&mut self, command: Self::Command) -> Task> { + let handle = self.handle.clone(); + + match command { + BluetoothCommand::Toggle => { + if self.data.state == BluetoothState::Unavailable { + return Task::none(); + } + + let mut data = self.data.clone(); + Task::perform( + async move { + let powered = data.state == BluetoothState::Active; + debug!("Toggling bluetooth power to: {}", !powered); + + if handle.set_powered(!powered).await.is_ok() { + data.state = if powered { + BluetoothState::Inactive + } else { + BluetoothState::Active + }; + } + + data + }, + ServiceEvent::Update, + ) + } + BluetoothCommand::StartDiscovery => Task::perform( + async move { + if handle.start_discovery().await.is_ok() { + tokio::time::sleep(Duration::from_secs(15)).await; + let _ = handle.stop_discovery().await; + } + refresh(&handle).await + }, + ServiceEvent::Update, + ), + BluetoothCommand::PairDevice(device) => Task::perform( + async move { + debug!("Pairing device: {device:?}"); + let _ = handle.pair_device(&device).await; + refresh(&handle).await + }, + ServiceEvent::Update, + ), + BluetoothCommand::ConnectDevice(device) => Task::perform( + async move { + debug!("Connecting device: {device:?}"); + let _ = handle.connect_device(&device).await; + refresh(&handle).await + }, + ServiceEvent::Update, + ), + BluetoothCommand::DisconnectDevice(device) => Task::perform( + async move { + debug!("Disconnecting device: {device:?}"); + let _ = handle.disconnect_device(&device).await; + refresh(&handle).await + }, + ServiceEvent::Update, + ), + BluetoothCommand::RemoveDevice(device) => Task::perform( + async move { + debug!("Removing device: {device:?}"); + let _ = handle.remove_device(&device).await; + refresh(&handle).await + }, + ServiceEvent::Update, + ), + } + } +} diff --git a/src/services/bluetooth/mod.rs b/src/services/bluetooth/mod.rs deleted file mode 100644 index 9772c420e..000000000 --- a/src/services/bluetooth/mod.rs +++ /dev/null @@ -1,444 +0,0 @@ -use super::{ReadOnlyService, Service, ServiceEvent}; -use dbus::{BatteryProxy, BluetoothDbus, DeviceProxy}; -use iced::{ - Subscription, Task, - futures::{SinkExt, Stream, StreamExt, channel::mpsc::Sender, stream::pending, stream_select}, - stream::channel, -}; -use inotify::{Inotify, WatchMask}; -use log::{debug, error, info, warn}; -use std::{any::TypeId, io::ErrorKind, ops::Deref, pin::Pin}; -use tokio::process::Command; -use zbus::zvariant::OwnedObjectPath; - -mod dbus; - -type EventStream = Pin + Send>>; - -#[derive(PartialEq, Eq, Debug, Clone)] -pub enum BluetoothState { - Unavailable, - Active, - Inactive, -} - -#[derive(Debug, Clone)] -pub struct BluetoothDevice { - pub name: String, - pub battery: Option, - pub path: OwnedObjectPath, - pub connected: bool, - pub paired: bool, -} - -#[derive(Debug, Clone)] -pub struct BluetoothData { - pub state: BluetoothState, - pub devices: Vec, - pub discovering: bool, -} - -#[derive(Debug, Clone)] -pub struct BluetoothService { - conn: zbus::Connection, - data: BluetoothData, -} - -impl Deref for BluetoothService { - type Target = BluetoothData; - - fn deref(&self) -> &Self::Target { - &self.data - } -} - -#[derive(Debug, Clone)] -pub enum BluetoothCommand { - Toggle, - StartDiscovery, - PairDevice(OwnedObjectPath), - ConnectDevice(OwnedObjectPath), - DisconnectDevice(OwnedObjectPath), - RemoveDevice(OwnedObjectPath), -} - -enum State { - Init, - Active(zbus::Connection), - Error, -} - -impl BluetoothService { - async fn initialize_data(conn: &zbus::Connection) -> anyhow::Result { - let bluetooth = BluetoothDbus::new(conn).await?; - - let state = bluetooth.state().await?; - let rfkill_soft_block = BluetoothService::check_rfkill_soft_block().await?; - - let state = match state { - BluetoothState::Unavailable => BluetoothState::Unavailable, - BluetoothState::Active if rfkill_soft_block => BluetoothState::Inactive, - state => state, - }; - let devices = bluetooth.devices().await?; - let discovering = bluetooth.discovering().await.unwrap_or(false); - - Ok(BluetoothData { - state, - devices, - discovering, - }) - } - - async fn events(conn: &zbus::Connection) -> anyhow::Result + use<>> { - let bluetooth = BluetoothDbus::new(conn).await?; - - let interface_changed = stream_select!( - bluetooth - .bluez - .receive_interfaces_added() - .await? - .map(|_| {}), - bluetooth - .bluez - .receive_interfaces_removed() - .await? - .map(|_| {}), - ) - .boxed(); - - let combined = match bluetooth.adapter.as_ref() { - Some(adapter) => { - let powered = adapter.receive_powered_changed().await.map(|_| {}); - let discovering = adapter.receive_discovering_changed().await.map(|_| {}); - let rfkill = BluetoothService::listen_rfkill_soft_block_changes().await?; - let devices = bluetooth.devices().await?; - - let mut batteries: Vec = Vec::with_capacity(devices.len()); - let mut device_properties: Vec = Vec::with_capacity(devices.len()); - for device in devices { - let conn = bluetooth.bluez.inner().connection(); - - let battery = BatteryProxy::builder(conn) - .path(device.path.clone())? - .build() - .await?; - batteries.push( - battery - .receive_percentage_changed() - .await - .map(|_| {}) - .boxed(), - ); - - let device_proxy = DeviceProxy::builder(conn) - .path(device.path)? - .build() - .await?; - let connected_changed: EventStream = device_proxy - .receive_connected_changed() - .await - .map(|_| {}) - .boxed(); - device_properties.push(connected_changed); - } - - let battery_events = if batteries.is_empty() { - iced::futures::stream::pending().boxed() - } else { - iced::futures::stream::select_all(batteries).boxed() - }; - - let device_property_events = if device_properties.is_empty() { - iced::futures::stream::pending().boxed() - } else { - iced::futures::stream::select_all(device_properties).boxed() - }; - - Box::pin(stream_select!( - interface_changed, - powered, - discovering, - rfkill, - battery_events, - device_property_events, - )) - } - _ => interface_changed, - }; - - Ok(combined) - } - - async fn start_listening(state: State, output: &mut Sender>) -> State { - match state { - State::Init => match zbus::Connection::system().await { - Ok(conn) => { - let data = BluetoothService::initialize_data(&conn).await; - - match data { - Ok(data) => { - info!("Bluetooth service initialized"); - - let _ = output - .send(ServiceEvent::Init(BluetoothService { - data, - conn: conn.clone(), - })) - .await; - - State::Active(conn) - } - Err(err) => { - error!("Failed to initialize bluetooth service: {err}"); - - State::Error - } - } - } - Err(err) => { - error!("Failed to connect to system bus: {err}"); - - State::Error - } - }, - State::Active(conn) => { - info!("Listening for bluetooth events"); - - match BluetoothService::events(&conn).await { - Ok(mut events) => { - while events.next().await.is_some() { - if let Ok(data) = BluetoothService::initialize_data(&conn).await { - let _ = output.send(ServiceEvent::Update(data)).await; - } - } - - State::Active(conn) - } - Err(err) => { - error!("Failed to listen for bluetooth events: {err}"); - State::Error - } - } - } - State::Error => { - error!("Bluetooth service error"); - - let _ = pending::().next().await; - State::Error - } - } - } - - async fn spawn_rfkill(binary: &str, args: &[&str]) -> std::io::Result { - let mut command = Command::new(binary); - for arg in args { - command.arg(arg); - } - command.output().await - } - - async fn run_rfkill_command(args: &[&str]) -> std::io::Result { - BluetoothService::spawn_rfkill("rfkill", args).await - } - - pub async fn check_rfkill_soft_block() -> anyhow::Result { - let output = match BluetoothService::run_rfkill_command(&["list", "bluetooth"]).await { - Ok(output) => output, - Err(err) if err.kind() == ErrorKind::NotFound => { - warn!("rfkill binary not found, assuming bluetooth is not soft blocked"); - return Ok(false); - } - Err(err) => return Err(err.into()), - }; - - let output = String::from_utf8(output.stdout)?; - - Ok(output.contains("Soft blocked: yes")) - } - - pub async fn listen_rfkill_soft_block_changes() -> anyhow::Result { - let inotify = Inotify::init()?; - - match inotify.watches().add("/dev/rfkill", WatchMask::MODIFY) { - Ok(_) => { - let buffer = [0; 512]; - Ok(inotify.into_event_stream(buffer)?.map(|_| {}).boxed()) - } - Err(err) if err.kind() == ErrorKind::NotFound => { - warn!("/dev/rfkill not found, disabling rfkill change notifications for bluetooth"); - Ok(pending().boxed()) - } - Err(err) => Err(err.into()), - } - } - - async fn toggle_power(conn: &zbus::Connection, power: bool) -> anyhow::Result<()> { - let bluetooth = BluetoothDbus::new(conn).await?; - - bluetooth.set_powered(power).await?; - - Ok(()) - } -} - -impl ReadOnlyService for BluetoothService { - type UpdateEvent = BluetoothData; - type Error = (); - - fn update(&mut self, event: Self::UpdateEvent) { - self.data = event; - } - - fn subscribe() -> Subscription> { - Subscription::run_with(TypeId::of::(), |_| { - channel(100, async |mut output| { - let mut state = State::Init; - - loop { - state = BluetoothService::start_listening(state, &mut output).await; - } - }) - }) - } -} - -impl Service for BluetoothService { - type Command = BluetoothCommand; - - fn command(&mut self, command: Self::Command) -> Task> { - match command { - BluetoothCommand::Toggle => { - let conn = self.conn.clone(); - - if self.data.state == BluetoothState::Unavailable { - Task::none() - } else { - let mut data = self.data.clone(); - - Task::perform( - async move { - let powered = data.state == BluetoothState::Active; - debug!("Toggling bluetooth power to: {}", !powered); - let res = BluetoothService::toggle_power(&conn, !powered).await; - - if res.is_ok() { - data.state = if powered { - BluetoothState::Inactive - } else { - BluetoothState::Active - } - } - - data - }, - ServiceEvent::Update, - ) - } - } - BluetoothCommand::StartDiscovery => { - let conn = self.conn.clone(); - Task::perform( - async move { - let bluetooth = BluetoothDbus::new(&conn).await; - if let Ok(bluetooth) = bluetooth { - let _ = bluetooth.start_discovery().await; - - // Auto-stop after 15 seconds - tokio::time::sleep(tokio::time::Duration::from_secs(15)).await; - let _ = bluetooth.stop_discovery().await; - } - BluetoothService::initialize_data(&conn) - .await - .unwrap_or_else(|_| BluetoothData { - state: BluetoothState::Unavailable, - devices: vec![], - discovering: false, - }) - }, - ServiceEvent::Update, - ) - } - BluetoothCommand::PairDevice(device_path) => { - let conn = self.conn.clone(); - Task::perform( - async move { - let bluetooth = BluetoothDbus::new(&conn).await; - if let Ok(bluetooth) = bluetooth { - debug!("Pairing device: {:?}", device_path); - let _ = bluetooth.pair_device(&device_path).await; - } - BluetoothService::initialize_data(&conn) - .await - .unwrap_or_else(|_| BluetoothData { - state: BluetoothState::Unavailable, - devices: vec![], - discovering: false, - }) - }, - ServiceEvent::Update, - ) - } - BluetoothCommand::ConnectDevice(device_path) => { - let conn = self.conn.clone(); - Task::perform( - async move { - let bluetooth = BluetoothDbus::new(&conn).await; - if let Ok(bluetooth) = bluetooth { - debug!("Connecting device: {:?}", device_path); - let _ = bluetooth.connect_device(&device_path).await; - } - BluetoothService::initialize_data(&conn) - .await - .unwrap_or_else(|_| BluetoothData { - state: BluetoothState::Unavailable, - devices: vec![], - discovering: false, - }) - }, - ServiceEvent::Update, - ) - } - BluetoothCommand::DisconnectDevice(device_path) => { - let conn = self.conn.clone(); - Task::perform( - async move { - let bluetooth = BluetoothDbus::new(&conn).await; - if let Ok(bluetooth) = bluetooth { - debug!("Disconnecting device: {:?}", device_path); - let _ = bluetooth.disconnect_device(&device_path).await; - } - BluetoothService::initialize_data(&conn) - .await - .unwrap_or_else(|_| BluetoothData { - state: BluetoothState::Unavailable, - devices: vec![], - discovering: false, - }) - }, - ServiceEvent::Update, - ) - } - BluetoothCommand::RemoveDevice(device_path) => { - let conn = self.conn.clone(); - Task::perform( - async move { - let bluetooth = BluetoothDbus::new(&conn).await; - if let Ok(bluetooth) = bluetooth { - debug!("Removing device: {:?}", device_path); - let _ = bluetooth.remove_device(&device_path).await; - } - BluetoothService::initialize_data(&conn) - .await - .unwrap_or_else(|_| BluetoothData { - state: BluetoothState::Unavailable, - devices: vec![], - discovering: false, - }) - }, - ServiceEvent::Update, - ) - } - } - } -} diff --git a/src/services/network/dbus.rs b/src/services/network/dbus.rs index 9f53357d8..5816b8fd2 100644 --- a/src/services/network/dbus.rs +++ b/src/services/network/dbus.rs @@ -1,7 +1,7 @@ -use crate::services::{ - bluetooth::BluetoothService, - network::{NetworkBackend, NetworkData, NetworkEvent, PskOutcome, WifiSecurity}, +use crate::services::network::{ + NetworkBackend, NetworkData, NetworkEvent, PskOutcome, WifiSecurity, }; +use ashell_services::bluetooth::Bluetooth; use super::{AccessPointData, ActiveConnectionInfo, KnownConnection, Vpn}; use iced::futures::{Stream, StreamExt, stream::select_all}; @@ -21,7 +21,7 @@ impl super::NetworkBackend for NetworkDbus<'_> { let nm = self; // airplane mode - let bluetooth_soft_blocked = BluetoothService::check_rfkill_soft_block() + let bluetooth_soft_blocked = Bluetooth::check_rfkill_soft_block() .await .unwrap_or_default(); diff --git a/src/services/network/iwd_dbus/mod.rs b/src/services/network/iwd_dbus/mod.rs index aba79f9f2..5363f372b 100644 --- a/src/services/network/iwd_dbus/mod.rs +++ b/src/services/network/iwd_dbus/mod.rs @@ -19,7 +19,7 @@ use tokio_stream::wrappers::UnboundedReceiverStream; // source for dbus: https://git.kernel.org/pub/scm/network/wireless/iwd.git/tree/doc //info!("{:?}",n.inner().introspect().await?); => can use this to generate proxy implementations -use crate::services::bluetooth::BluetoothService; +use ashell_services::bluetooth::Bluetooth; use zbus::interface; @@ -98,7 +98,7 @@ impl super::NetworkBackend for IwdDbus<'_> { let nm = self; // airplane mode - let bluetooth_soft_blocked = BluetoothService::check_rfkill_soft_block() + let bluetooth_soft_blocked = Bluetooth::check_rfkill_soft_block() .await .unwrap_or_default(); From 74a242c42ab32e0103dba9e5b8caba6b1a29a564 Mon Sep 17 00:00:00 2001 From: Simone Camito Date: Wed, 30 Sep 2026 17:42:07 +0200 Subject: [PATCH 2/9] refactor(services): extract rfkill helpers into ashell-services The soft-block check and the /dev/rfkill watch lived on the bluetooth service even though the NetworkManager and IWD backends also use them to derive airplane mode. Move them to an rfkill utility module behind their own feature, enabled by bluetooth, so network no longer depends on the bluetooth service. --- Cargo.toml | 5 ++- crates/ashell-services/Cargo.toml | 12 ++---- crates/ashell-services/src/bluetooth/mod.rs | 47 +++------------------ crates/ashell-services/src/lib.rs | 2 + crates/ashell-services/src/rfkill.rs | 46 ++++++++++++++++++++ src/services/network/dbus.rs | 6 +-- src/services/network/iwd_dbus/mod.rs | 6 +-- 7 files changed, 64 insertions(+), 60 deletions(-) create mode 100644 crates/ashell-services/src/rfkill.rs diff --git a/Cargo.toml b/Cargo.toml index 7ec16e7dc..1c8559223 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,7 +48,10 @@ depends = ["libwayland-client0", "libpipewire-0.3-0t64", "libpulse0"] depends = ["libwayland-client", "pipewire-libs", "pulseaudio-libs"] [dependencies] -ashell-services = { path = "crates/ashell-services", features = ["bluetooth"] } +ashell-services = { path = "crates/ashell-services", features = [ + "bluetooth", + "rfkill", +] } iced = { package = "iced_layershell", git = "https://github.com/MalpenZibo/iced_layershell", tag = "v0.6.0", features = [ "tokio", "advanced", diff --git a/crates/ashell-services/Cargo.toml b/crates/ashell-services/Cargo.toml index 3806d35ca..9e29779f5 100644 --- a/crates/ashell-services/Cargo.toml +++ b/crates/ashell-services/Cargo.toml @@ -10,15 +10,9 @@ repository.workspace = true publish = false [features] -full = ["bluetooth"] -bluetooth = [ - "dep:anyhow", - "dep:inotify", - "dep:log", - "dep:tokio", - "dep:zbus", - "tokio/process", -] +full = ["bluetooth", "rfkill"] +bluetooth = ["rfkill", "dep:anyhow", "dep:log", "dep:tokio", "dep:zbus"] +rfkill = ["dep:anyhow", "dep:inotify", "dep:log", "dep:tokio", "tokio/process"] [dependencies] futures = "0.3" diff --git a/crates/ashell-services/src/bluetooth/mod.rs b/crates/ashell-services/src/bluetooth/mod.rs index c2effab25..98529e4ca 100644 --- a/crates/ashell-services/src/bluetooth/mod.rs +++ b/crates/ashell-services/src/bluetooth/mod.rs @@ -1,14 +1,12 @@ -use crate::stream::channel; +use crate::{rfkill, stream::channel}; use dbus::{BatteryProxy, BluetoothDbus, DeviceProxy}; use futures::{ SinkExt, Stream, StreamExt, stream::{pending, select_all}, stream_select, }; -use inotify::{Inotify, WatchMask}; -use log::{error, info, warn}; -use std::{io::ErrorKind, pin::Pin}; -use tokio::process::Command; +use log::{error, info}; +use std::pin::Pin; use zbus::zvariant::OwnedObjectPath; mod dbus; @@ -143,46 +141,11 @@ impl Bluetooth { Ok(()) } - pub async fn check_rfkill_soft_block() -> anyhow::Result { - let output = match Command::new("rfkill") - .args(["list", "bluetooth"]) - .output() - .await - { - Ok(output) => output, - Err(err) if err.kind() == ErrorKind::NotFound => { - warn!("rfkill binary not found, assuming bluetooth is not soft blocked"); - return Ok(false); - } - Err(err) => return Err(err.into()), - }; - - let output = String::from_utf8(output.stdout)?; - - Ok(output.contains("Soft blocked: yes")) - } - - async fn listen_rfkill_soft_block_changes() -> anyhow::Result { - let inotify = Inotify::init()?; - - match inotify.watches().add("/dev/rfkill", WatchMask::MODIFY) { - Ok(_) => { - let buffer = [0; 512]; - Ok(inotify.into_event_stream(buffer)?.map(|_| {}).boxed()) - } - Err(err) if err.kind() == ErrorKind::NotFound => { - warn!("/dev/rfkill not found, disabling rfkill change notifications for bluetooth"); - Ok(pending().boxed()) - } - Err(err) => Err(err.into()), - } - } - async fn read_data(conn: &zbus::Connection) -> anyhow::Result { let bluetooth = BluetoothDbus::new(conn).await?; let state = bluetooth.state().await?; - let rfkill_soft_block = Self::check_rfkill_soft_block().await?; + let rfkill_soft_block = rfkill::bluetooth_soft_blocked().await?; let state = match state { BluetoothState::Unavailable => BluetoothState::Unavailable, @@ -220,7 +183,7 @@ impl Bluetooth { Some(adapter) => { let powered = adapter.receive_powered_changed().await.map(|_| {}); let discovering = adapter.receive_discovering_changed().await.map(|_| {}); - let rfkill = Self::listen_rfkill_soft_block_changes().await?; + let rfkill = rfkill::soft_block_changes().await?; let devices = bluetooth.devices().await?; let mut batteries: Vec = Vec::with_capacity(devices.len()); diff --git a/crates/ashell-services/src/lib.rs b/crates/ashell-services/src/lib.rs index b6b44515c..91739c9f5 100644 --- a/crates/ashell-services/src/lib.rs +++ b/crates/ashell-services/src/lib.rs @@ -1,4 +1,6 @@ #[cfg(feature = "bluetooth")] pub mod bluetooth; +#[cfg(feature = "rfkill")] +pub mod rfkill; #[cfg(feature = "bluetooth")] mod stream; diff --git a/crates/ashell-services/src/rfkill.rs b/crates/ashell-services/src/rfkill.rs new file mode 100644 index 000000000..64fd9aa3d --- /dev/null +++ b/crates/ashell-services/src/rfkill.rs @@ -0,0 +1,46 @@ +use futures::{ + Stream, StreamExt, + stream::{BoxStream, pending}, +}; +use inotify::{Inotify, WatchMask}; +use log::warn; +use std::io::ErrorKind; +use tokio::process::Command; + +pub async fn bluetooth_soft_blocked() -> anyhow::Result { + let output = match Command::new("rfkill") + .args(["list", "bluetooth"]) + .output() + .await + { + Ok(output) => output, + Err(err) if err.kind() == ErrorKind::NotFound => { + warn!("rfkill binary not found, assuming bluetooth is not soft blocked"); + return Ok(false); + } + Err(err) => return Err(err.into()), + }; + + let output = String::from_utf8(output.stdout)?; + + Ok(output.contains("Soft blocked: yes")) +} + +pub async fn soft_block_changes() -> anyhow::Result + Send + use<>> { + let inotify = Inotify::init()?; + + let changes: BoxStream<'static, ()> = + match inotify.watches().add("/dev/rfkill", WatchMask::MODIFY) { + Ok(_) => { + let buffer = [0; 512]; + inotify.into_event_stream(buffer)?.map(|_| {}).boxed() + } + Err(err) if err.kind() == ErrorKind::NotFound => { + warn!("/dev/rfkill not found, disabling rfkill change notifications"); + pending().boxed() + } + Err(err) => return Err(err.into()), + }; + + Ok(changes) +} diff --git a/src/services/network/dbus.rs b/src/services/network/dbus.rs index 5816b8fd2..5406bc997 100644 --- a/src/services/network/dbus.rs +++ b/src/services/network/dbus.rs @@ -1,7 +1,7 @@ use crate::services::network::{ NetworkBackend, NetworkData, NetworkEvent, PskOutcome, WifiSecurity, }; -use ashell_services::bluetooth::Bluetooth; +use ashell_services::rfkill; use super::{AccessPointData, ActiveConnectionInfo, KnownConnection, Vpn}; use iced::futures::{Stream, StreamExt, stream::select_all}; @@ -21,9 +21,7 @@ impl super::NetworkBackend for NetworkDbus<'_> { let nm = self; // airplane mode - let bluetooth_soft_blocked = Bluetooth::check_rfkill_soft_block() - .await - .unwrap_or_default(); + let bluetooth_soft_blocked = rfkill::bluetooth_soft_blocked().await.unwrap_or_default(); let wifi_present = nm.wifi_device_present().await?; diff --git a/src/services/network/iwd_dbus/mod.rs b/src/services/network/iwd_dbus/mod.rs index 5363f372b..9fe7f196a 100644 --- a/src/services/network/iwd_dbus/mod.rs +++ b/src/services/network/iwd_dbus/mod.rs @@ -19,7 +19,7 @@ use tokio_stream::wrappers::UnboundedReceiverStream; // source for dbus: https://git.kernel.org/pub/scm/network/wireless/iwd.git/tree/doc //info!("{:?}",n.inner().introspect().await?); => can use this to generate proxy implementations -use ashell_services::bluetooth::Bluetooth; +use ashell_services::rfkill; use zbus::interface; @@ -98,9 +98,7 @@ impl super::NetworkBackend for IwdDbus<'_> { let nm = self; // airplane mode - let bluetooth_soft_blocked = Bluetooth::check_rfkill_soft_block() - .await - .unwrap_or_default(); + let bluetooth_soft_blocked = rfkill::bluetooth_soft_blocked().await.unwrap_or_default(); let wifi_present = nm.wifi_device_present().await?; From 47b9f2b94af852bb1a5a428484fec146c761a1d7 Mon Sep 17 00:00:00 2001 From: Simone Camito Date: Wed, 30 Sep 2026 17:42:43 +0200 Subject: [PATCH 3/9] chore(deps): build against iced_layershell v0.6.1 --- Cargo.lock | 16 ++++++++-------- Cargo.toml | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4a2156366..5fe499b1a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1017,7 +1017,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -1955,8 +1955,8 @@ dependencies = [ [[package]] name = "iced_layershell" -version = "0.6.0" -source = "git+https://github.com/MalpenZibo/iced_layershell?tag=v0.6.0#5a310655f1e3d364da03cd4909bfe76bcbbeaeff" +version = "0.6.1" +source = "git+https://github.com/MalpenZibo/iced_layershell?tag=v0.6.1#9a455b465a6fe2b9beb9e4e82b164b553af40e9f" dependencies = [ "calloop", "calloop-wayland-source", @@ -3459,7 +3459,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3798,7 +3798,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3855,7 +3855,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4455,7 +4455,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5431,7 +5431,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 1c8559223..eab335abb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,7 +52,7 @@ ashell-services = { path = "crates/ashell-services", features = [ "bluetooth", "rfkill", ] } -iced = { package = "iced_layershell", git = "https://github.com/MalpenZibo/iced_layershell", tag = "v0.6.0", features = [ +iced = { package = "iced_layershell", git = "https://github.com/MalpenZibo/iced_layershell", tag = "v0.6.1", features = [ "tokio", "advanced", "wgpu", From b8c791978164818f60e535e0e55efab82c665f04 Mon Sep 17 00:00:00 2001 From: Simone Camito Date: Wed, 30 Sep 2026 17:42:43 +0200 Subject: [PATCH 4/9] docs: document the ashell-services crate Describe the workspace layout, the trait-free service API and the iced glue in src/services, the per-service cargo features and the rfkill helpers, and point the bluetooth D-Bus references at the new crate. --- AGENTS.md | 11 +++++++---- docs/src/reference/dbus-interfaces.md | 6 +++--- docs/src/services/dbus-services.md | 4 ++-- docs/src/services/overview.md | 2 +- docs/src/services/service-traits.md | 23 ++++++++++++++++++++++- 5 files changed, 35 insertions(+), 11 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 969c66a5e..98d099b60 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,7 +3,7 @@ ## Quick reference ```bash -make check # format check + cargo check + clippy -D warnings (run before pushing) +make check # format check + cargo check + clippy -D warnings on the whole workspace (run before pushing) make build # cargo build --release make fmt # cargo fmt make start # run ./target/release/ashell @@ -40,17 +40,20 @@ src/ ├── components/ # shared UI components, icons ├── modules/ # UI modules (clock, workspaces, settings, tray, etc.) │ └── settings/ # settings sub-panels (audio, network, bluetooth, power, brightness) -├── services/ # backend services (D-Bus, IPC, system integration) +├── services/ # iced glue (ReadOnlyService/Service) + services not yet migrated to ashell-services │ ├── compositor/ # Hyprland/Niri/generic Wayland abstraction │ ├── network/ # NetworkManager + IWD backends -│ ├── bluetooth/ │ ├── mpris/ # media player control │ ├── tray/ │ └── upower/ # battery/power ├── widgets/ # custom iced widgets (centerbox, position_button, menu_wrapper) └── utils/ +crates/ +└── ashell-services/ # UI-agnostic services (no iced dependency), e.g. bluetooth ``` +The repo is a Cargo workspace: the root package is the `ashell` binary, `crates/ashell-services` is a library member. + ## Key system dependencies libxkbcommon, libwayland, libpipewire-0.3, libpulse, dbus, udev, pkg-config, clang/llvm @@ -94,7 +97,7 @@ Scope examples: `fix(brightness)`, `feat(system_info)`, `fix(network)` ## Architecture patterns - **Elm architecture:** `App` struct holds all state. `Message` enum drives updates. `update()` returns `Task` for async work. -- **Services** are backend abstractions (D-Bus, IPC). Two traits: `ReadOnlyService` and `Service` (mutable). +- **Services** are backend abstractions (D-Bus, IPC). Two traits: `ReadOnlyService` and `Service` (mutable). Services moved to `ashell-services` expose a plain API (a cloneable handle with async command methods, a snapshot method and an updates `Stream`) with no trait; a module in `src/services/` wraps each one into the iced traits. - **Modules** are UI components (workspaces, clock, settings). Each implements `view()` returning iced `Element`. - **Compositor abstraction:** the `CompositorChoice` enum routes to per-backend modules (Hyprland, Niri, generic Wayland), each exposing `is_available`/`run_listener`/`execute_command`, auto-detected at runtime. - **Config hot-reload:** inotify file watcher triggers `ConfigChanged` message on config file changes. diff --git a/docs/src/reference/dbus-interfaces.md b/docs/src/reference/dbus-interfaces.md index 40a455bcf..7d487435c 100644 --- a/docs/src/reference/dbus-interfaces.md +++ b/docs/src/reference/dbus-interfaces.md @@ -6,9 +6,9 @@ ashell connects to several D-Bus services. This reference lists all interfaces u | Service | Interface | Proxy File | Purpose | Required Package | |---------|-----------|------------|---------|-----------------| -| BlueZ | `org.bluez.Adapter1` | `services/bluetooth/dbus.rs` | Bluetooth adapter control | `bluez` | -| BlueZ | `org.bluez.Device1` | `services/bluetooth/dbus.rs` | Bluetooth device management | `bluez` | -| BlueZ | `org.bluez.Battery1` | `services/bluetooth/dbus.rs` | Bluetooth device battery level | `bluez` | +| BlueZ | `org.bluez.Adapter1` | `crates/ashell-services/src/bluetooth/dbus.rs` | Bluetooth adapter control | `bluez` | +| BlueZ | `org.bluez.Device1` | `crates/ashell-services/src/bluetooth/dbus.rs` | Bluetooth device management | `bluez` | +| BlueZ | `org.bluez.Battery1` | `crates/ashell-services/src/bluetooth/dbus.rs` | Bluetooth device battery level | `bluez` | | NetworkManager | `org.freedesktop.NetworkManager` | `services/network/dbus.rs` | Network state and connections | `networkmanager` | | NetworkManager | `org.freedesktop.NetworkManager.Device` | `services/network/dbus.rs` | Network device info | `networkmanager` | | NetworkManager | `org.freedesktop.NetworkManager.Connection.Active` | `services/network/dbus.rs` | Active connections | `networkmanager` | diff --git a/docs/src/services/dbus-services.md b/docs/src/services/dbus-services.md index 5fef0fc7f..55a48022d 100644 --- a/docs/src/services/dbus-services.md +++ b/docs/src/services/dbus-services.md @@ -11,7 +11,7 @@ D-Bus is the standard IPC mechanism on Linux desktops. ashell connects to the ** ashell uses zbus's `#[proxy]` attribute macro to generate type-safe D-Bus client code. These are defined in `dbus.rs` files alongside each service: ```rust -// Example from services/bluetooth/dbus.rs +// Example from crates/ashell-services/src/bluetooth/dbus.rs #[proxy( default_service = "org.bluez", default_path = "/org/bluez/hci0", @@ -41,7 +41,7 @@ The `#[proxy]` macro generates an `AdapterProxy` struct with async methods for e | Service | Bus | D-Bus Service Name | Proxy File | |---------|-----|-------------------|------------| -| Bluetooth | System | `org.bluez` | `services/bluetooth/dbus.rs` | +| Bluetooth | System | `org.bluez` | `crates/ashell-services/src/bluetooth/dbus.rs` | | Network (NM) | System | `org.freedesktop.NetworkManager` | `services/network/dbus.rs` | | Network (IWD) | System | `net.connman.iwd` | `services/network/iwd_dbus/` | | UPower | System | `org.freedesktop.UPower` | `services/upower/dbus.rs` | diff --git a/docs/src/services/overview.md b/docs/src/services/overview.md index 355cfc67b..f54b75598 100644 --- a/docs/src/services/overview.md +++ b/docs/src/services/overview.md @@ -9,7 +9,7 @@ Services are the backend layer of ashell. They manage communication with system | Compositor | `services/compositor/` | Hyprland / Niri / MangoWC / generic Wayland | IPC socket / `mmsg` / Wayland protocols | Hyprland, Niri, MangoWC, or any Wayland compositor (generic fallback) | | Audio | `services/audio.rs` | PulseAudio | libpulse C library | PulseAudio or PipeWire-Pulse | | Brightness | `services/brightness.rs` | sysfs + logind | File I/O + D-Bus | systemd-logind | -| Bluetooth | `services/bluetooth/` | BlueZ | D-Bus | `bluez` | +| Bluetooth | `crates/ashell-services/src/bluetooth/` (iced glue in `services/bluetooth.rs`) | BlueZ | D-Bus | `bluez` | | Network | `services/network/` | NetworkManager / IWD | D-Bus | `networkmanager` or `iwd` | | MPRIS | `services/mpris/` | Media players | D-Bus | MPRIS-compatible player | | Tray | `services/tray/` | StatusNotifierItem | D-Bus | — | diff --git a/docs/src/services/service-traits.md b/docs/src/services/service-traits.md index 81e866e21..9260b83b3 100644 --- a/docs/src/services/service-traits.md +++ b/docs/src/services/service-traits.md @@ -2,6 +2,27 @@ The service abstraction is defined in `src/services/mod.rs`. It provides a standard interface for all backend services. +## UI-agnostic services (`ashell-services`) + +Services are being moved to the `ashell-services` workspace crate (`crates/ashell-services/`), which does not depend on iced or any other UI toolkit. The crate imposes no service trait: the traits on this page are iced glue and stay in ashell. Each crate service is a plain API built on `futures` types, typically: + +- a cloneable **handle** holding the connection, with async methods for each command; +- a **snapshot** method returning the current state; +- an **updates** stream yielding a fresh snapshot on every change. + +```rust +let bluetooth = Bluetooth::connect().await?; +let data = bluetooth.data().await?; +let mut updates = pin!(bluetooth.updates()); +bluetooth.set_powered(true).await?; +``` + +ashell wraps each of them in a small module under `src/services/` (e.g. `src/services/bluetooth.rs`) that implements `ReadOnlyService`/`Service`: `subscribe` connects, sends `Init` with the handle and the first snapshot, then forwards the updates stream; `command` maps each command to the handle's methods. Other UIs write their own glue on the same API. + +Each service sits behind a cargo feature of the same name (`full` enables all of them), so consumers only compile the services, and pull in the system dependencies, they need. The crate has no default features. + +Currently migrated: Bluetooth (`bluetooth` feature). Shared helpers that are not services live in plain modules, e.g. `rfkill` (soft-block state and change notifications, used by both bluetooth and network). + ## ServiceEvent All services communicate through a common event enum: @@ -42,7 +63,7 @@ For services that accept commands (bidirectional): ```rust pub trait Service: ReadOnlyService { - type Command; + type Command: Send + 'static; fn command(&mut self, command: Self::Command) -> Task>; } From 0d80b239dc58e06f375470ff074b59c2b8d6debf Mon Sep 17 00:00:00 2001 From: Simone Camito Date: Wed, 30 Sep 2026 19:02:23 +0200 Subject: [PATCH 5/9] refactor(bluetooth): move command handling into ashell-services Both the iced glue and a guido consumer had to duplicate the command enum, the dispatch to the handle methods, the toggle and discovery policy and a refresh after every command. Move all of that into the crate so glue code only forwards commands and publishes snapshots. - BluetoothCommand and Bluetooth::execute live in the crate. Toggle reads the real adapter and rfkill state instead of trusting a UI copy, and StartDiscovery stops on its own after 15 seconds. - updates() yields the current state as soon as it is subscribed, which also closes the gap between the initial read and the subscription. - updates() is complete: it also watches Paired and Alias, and rebuilds the per-device watchers when devices are added or removed, so devices that appear later are tracked too. Commands no longer refresh by hand. - Property streams yield their current value first; skip it so each (re)subscription reads a single snapshot instead of one per property. - Devices are sorted by name, and the data types derive PartialEq and Default, so reactive UIs can diff snapshots without spurious updates. --- AGENTS.md | 2 +- crates/ashell-services/Cargo.toml | 2 +- crates/ashell-services/src/bluetooth/dbus.rs | 2 + crates/ashell-services/src/bluetooth/mod.rs | 236 ++++++++++++------- docs/src/services/service-traits.md | 13 +- src/modules/settings/bluetooth.rs | 14 +- src/services/bluetooth.rs | 111 ++------- 7 files changed, 180 insertions(+), 200 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 98d099b60..7c8173d19 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -97,7 +97,7 @@ Scope examples: `fix(brightness)`, `feat(system_info)`, `fix(network)` ## Architecture patterns - **Elm architecture:** `App` struct holds all state. `Message` enum drives updates. `update()` returns `Task` for async work. -- **Services** are backend abstractions (D-Bus, IPC). Two traits: `ReadOnlyService` and `Service` (mutable). Services moved to `ashell-services` expose a plain API (a cloneable handle with async command methods, a snapshot method and an updates `Stream`) with no trait; a module in `src/services/` wraps each one into the iced traits. +- **Services** are backend abstractions (D-Bus, IPC). Two traits: `ReadOnlyService` and `Service` (mutable). Services moved to `ashell-services` expose a plain API with no trait (a cloneable handle with `execute(command)` and an updates `Stream` that starts with the current state); a module in `src/services/` wraps each one into the iced traits. - **Modules** are UI components (workspaces, clock, settings). Each implements `view()` returning iced `Element`. - **Compositor abstraction:** the `CompositorChoice` enum routes to per-backend modules (Hyprland, Niri, generic Wayland), each exposing `is_available`/`run_listener`/`execute_command`, auto-detected at runtime. - **Config hot-reload:** inotify file watcher triggers `ConfigChanged` message on config file changes. diff --git a/crates/ashell-services/Cargo.toml b/crates/ashell-services/Cargo.toml index 9e29779f5..ff484d4d7 100644 --- a/crates/ashell-services/Cargo.toml +++ b/crates/ashell-services/Cargo.toml @@ -11,7 +11,7 @@ publish = false [features] full = ["bluetooth", "rfkill"] -bluetooth = ["rfkill", "dep:anyhow", "dep:log", "dep:tokio", "dep:zbus"] +bluetooth = ["rfkill", "dep:anyhow", "dep:log", "dep:tokio", "dep:zbus", "tokio/time"] rfkill = ["dep:anyhow", "dep:inotify", "dep:log", "dep:tokio", "tokio/process"] [dependencies] diff --git a/crates/ashell-services/src/bluetooth/dbus.rs b/crates/ashell-services/src/bluetooth/dbus.rs index c9ea86fad..8b2604a8b 100644 --- a/crates/ashell-services/src/bluetooth/dbus.rs +++ b/crates/ashell-services/src/bluetooth/dbus.rs @@ -127,6 +127,8 @@ impl BluetoothDbus<'_> { }); } + devices.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(devices) } diff --git a/crates/ashell-services/src/bluetooth/mod.rs b/crates/ashell-services/src/bluetooth/mod.rs index 98529e4ca..e2e089d97 100644 --- a/crates/ashell-services/src/bluetooth/mod.rs +++ b/crates/ashell-services/src/bluetooth/mod.rs @@ -2,16 +2,16 @@ use crate::{rfkill, stream::channel}; use dbus::{BatteryProxy, BluetoothDbus, DeviceProxy}; use futures::{ SinkExt, Stream, StreamExt, - stream::{pending, select_all}, + stream::{BoxStream, select_all}, stream_select, }; -use log::{error, info}; -use std::pin::Pin; +use log::{debug, error, info, warn}; +use std::time::Duration; use zbus::zvariant::OwnedObjectPath; mod dbus; -type EventStream = Pin + Send>>; +const DISCOVERY_DURATION: Duration = Duration::from_secs(15); #[derive(PartialEq, Eq, Debug, Clone)] pub enum BluetoothState { @@ -20,7 +20,7 @@ pub enum BluetoothState { Inactive, } -#[derive(Debug, Clone)] +#[derive(PartialEq, Eq, Debug, Clone)] pub struct BluetoothDevice { pub name: String, pub battery: Option, @@ -29,13 +29,39 @@ pub struct BluetoothDevice { pub paired: bool, } -#[derive(Debug, Clone)] +#[derive(PartialEq, Eq, Debug, Clone)] pub struct BluetoothData { pub state: BluetoothState, pub devices: Vec, pub discovering: bool, } +impl Default for BluetoothData { + fn default() -> Self { + Self { + state: BluetoothState::Unavailable, + devices: Vec::new(), + discovering: false, + } + } +} + +#[derive(Debug, Clone)] +pub enum BluetoothCommand { + Toggle, + StartDiscovery, + PairDevice(OwnedObjectPath), + ConnectDevice(OwnedObjectPath), + DisconnectDevice(OwnedObjectPath), + RemoveDevice(OwnedObjectPath), +} + +#[derive(PartialEq, Eq)] +enum Change { + Topology, + Property, +} + #[derive(Debug, Clone)] pub struct Bluetooth { conn: zbus::Connection, @@ -52,6 +78,7 @@ impl Bluetooth { Self::read_data(&self.conn).await } + /// Yields the current state once subscribed, then a fresh snapshot on every change. pub fn updates(&self) -> impl Stream + Send + 'static { let conn = self.conn.clone(); @@ -59,25 +86,56 @@ impl Bluetooth { loop { info!("Listening for bluetooth events"); - let mut events = match Self::events(&conn).await { - Ok(events) => events, + let mut changes = match Self::changes(&conn).await { + Ok(changes) => changes, Err(err) => { error!("Failed to listen for bluetooth events: {err}"); return; } }; - while events.next().await.is_some() { - if let Ok(data) = Self::read_data(&conn).await - && output.send(data).await.is_err() - { - return; + loop { + match Self::read_data(&conn).await { + Ok(data) => { + if output.send(data).await.is_err() { + return; + } + } + Err(err) => warn!("Failed to read bluetooth data: {err}"), + } + + match changes.next().await { + Some(Change::Property) => {} + Some(Change::Topology) | None => break, } } } }) } + pub async fn execute(&self, command: BluetoothCommand) -> anyhow::Result<()> { + debug!("Bluetooth command: {command:?}"); + + match command { + BluetoothCommand::Toggle => { + match Self::read_state(&BluetoothDbus::new(&self.conn).await?).await? { + BluetoothState::Unavailable => Ok(()), + BluetoothState::Active => self.set_powered(false).await, + BluetoothState::Inactive => self.set_powered(true).await, + } + } + BluetoothCommand::StartDiscovery => { + self.start_discovery().await?; + tokio::time::sleep(DISCOVERY_DURATION).await; + self.stop_discovery().await + } + BluetoothCommand::PairDevice(device) => self.pair_device(&device).await, + BluetoothCommand::ConnectDevice(device) => self.connect_device(&device).await, + BluetoothCommand::DisconnectDevice(device) => self.disconnect_device(&device).await, + BluetoothCommand::RemoveDevice(device) => self.remove_device(&device).await, + } + } + pub async fn set_powered(&self, powered: bool) -> anyhow::Result<()> { BluetoothDbus::new(&self.conn) .await? @@ -141,17 +199,21 @@ impl Bluetooth { Ok(()) } - async fn read_data(conn: &zbus::Connection) -> anyhow::Result { - let bluetooth = BluetoothDbus::new(conn).await?; - + async fn read_state(bluetooth: &BluetoothDbus<'_>) -> anyhow::Result { let state = bluetooth.state().await?; - let rfkill_soft_block = rfkill::bluetooth_soft_blocked().await?; - let state = match state { - BluetoothState::Unavailable => BluetoothState::Unavailable, - BluetoothState::Active if rfkill_soft_block => BluetoothState::Inactive, + Ok(match state { + BluetoothState::Active if rfkill::bluetooth_soft_blocked().await? => { + BluetoothState::Inactive + } state => state, - }; + }) + } + + async fn read_data(conn: &zbus::Connection) -> anyhow::Result { + let bluetooth = BluetoothDbus::new(conn).await?; + + let state = Self::read_state(&bluetooth).await?; let devices = bluetooth.devices().await?; let discovering = bluetooth.discovering().await.unwrap_or(false); @@ -162,10 +224,10 @@ impl Bluetooth { }) } - async fn events(conn: &zbus::Connection) -> anyhow::Result + use<>> { + async fn changes(conn: &zbus::Connection) -> anyhow::Result> { let bluetooth = BluetoothDbus::new(conn).await?; - let interface_changed = stream_select!( + let topology = stream_select!( bluetooth .bluez .receive_interfaces_added() @@ -177,68 +239,76 @@ impl Bluetooth { .await? .map(|_| {}), ) - .boxed(); - - let combined = match bluetooth.adapter.as_ref() { - Some(adapter) => { - let powered = adapter.receive_powered_changed().await.map(|_| {}); - let discovering = adapter.receive_discovering_changed().await.map(|_| {}); - let rfkill = rfkill::soft_block_changes().await?; - let devices = bluetooth.devices().await?; - - let mut batteries: Vec = Vec::with_capacity(devices.len()); - let mut device_properties: Vec = Vec::with_capacity(devices.len()); - for device in devices { - let conn = bluetooth.bluez.inner().connection(); - - let battery = BatteryProxy::builder(conn) - .path(device.path.clone())? - .build() - .await?; - batteries.push( - battery - .receive_percentage_changed() - .await - .map(|_| {}) - .boxed(), - ); - - let device_proxy = DeviceProxy::builder(conn) - .path(device.path)? - .build() - .await?; - let connected_changed: EventStream = device_proxy - .receive_connected_changed() - .await - .map(|_| {}) - .boxed(); - device_properties.push(connected_changed); - } + .map(|_| Change::Topology); - let battery_events = if batteries.is_empty() { - pending().boxed() - } else { - select_all(batteries).boxed() - }; - - let device_property_events = if device_properties.is_empty() { - pending().boxed() - } else { - select_all(device_properties).boxed() - }; - - Box::pin(stream_select!( - interface_changed, - powered, - discovering, - rfkill, - battery_events, - device_property_events, - )) - } - _ => interface_changed, + let Some(adapter) = bluetooth.adapter.as_ref() else { + return Ok(topology.boxed()); }; - Ok(combined) + // Property streams yield their current value first: skip it, updates() reads a snapshot + let mut properties: Vec> = vec![ + adapter + .receive_powered_changed() + .await + .skip(1) + .map(|_| {}) + .boxed(), + adapter + .receive_discovering_changed() + .await + .skip(1) + .map(|_| {}) + .boxed(), + rfkill::soft_block_changes().await?.boxed(), + ]; + + let conn = bluetooth.bluez.inner().connection(); + for device in bluetooth.devices().await? { + let device_proxy = DeviceProxy::builder(conn) + .path(device.path.clone())? + .build() + .await?; + properties.push( + device_proxy + .receive_connected_changed() + .await + .skip(1) + .map(|_| {}) + .boxed(), + ); + properties.push( + device_proxy + .receive_paired_changed() + .await + .skip(1) + .map(|_| {}) + .boxed(), + ); + properties.push( + device_proxy + .receive_alias_changed() + .await + .skip(1) + .map(|_| {}) + .boxed(), + ); + + let battery = BatteryProxy::builder(conn) + .path(device.path)? + .build() + .await?; + properties.push( + battery + .receive_percentage_changed() + .await + .skip(1) + .map(|_| {}) + .boxed(), + ); + } + + let properties = select_all(properties).map(|_| Change::Property); + + Ok(stream_select!(topology, properties).boxed()) } } diff --git a/docs/src/services/service-traits.md b/docs/src/services/service-traits.md index 9260b83b3..32cd72272 100644 --- a/docs/src/services/service-traits.md +++ b/docs/src/services/service-traits.md @@ -6,18 +6,19 @@ The service abstraction is defined in `src/services/mod.rs`. It provides a stand Services are being moved to the `ashell-services` workspace crate (`crates/ashell-services/`), which does not depend on iced or any other UI toolkit. The crate imposes no service trait: the traits on this page are iced glue and stay in ashell. Each crate service is a plain API built on `futures` types, typically: -- a cloneable **handle** holding the connection, with async methods for each command; -- a **snapshot** method returning the current state; -- an **updates** stream yielding a fresh snapshot on every change. +- plain data types (`PartialEq`, with a `Default` for the unavailable state) so reactive UIs can diff them; +- a cloneable **handle** holding the connection; +- a **command** enum and an `execute(command)` method on the handle, which owns the command semantics (e.g. what toggling means, how long discovery lasts); +- an **updates** stream that yields the current state as soon as it is subscribed, then a fresh snapshot on every change. It is the single source of truth: commands return only success or failure, and their effect arrives through the stream. ```rust let bluetooth = Bluetooth::connect().await?; -let data = bluetooth.data().await?; let mut updates = pin!(bluetooth.updates()); -bluetooth.set_powered(true).await?; +let initial = updates.next().await; +bluetooth.execute(BluetoothCommand::Toggle).await?; ``` -ashell wraps each of them in a small module under `src/services/` (e.g. `src/services/bluetooth.rs`) that implements `ReadOnlyService`/`Service`: `subscribe` connects, sends `Init` with the handle and the first snapshot, then forwards the updates stream; `command` maps each command to the handle's methods. Other UIs write their own glue on the same API. +ashell wraps each of them in a small module under `src/services/` (e.g. `src/services/bluetooth.rs`) that implements `ReadOnlyService`/`Service`: `subscribe` connects, sends `Init` with the handle and the first snapshot, then forwards the rest of the stream as `Update`s; `command` runs `execute` and produces no message. Other UIs write their own glue on the same API. Each service sits behind a cargo feature of the same name (`full` enables all of them), so consumers only compile the services, and pull in the system dependencies, they need. The crate has no default features. diff --git a/src/modules/settings/bluetooth.rs b/src/modules/settings/bluetooth.rs index 167537889..8282a2102 100644 --- a/src/modules/settings/bluetooth.rs +++ b/src/modules/settings/bluetooth.rs @@ -21,7 +21,6 @@ use iced::{ alignment::{Horizontal, Vertical}, widget::{Column, MouseArea, Row, column, container, row, text}, }; -use itertools::Itertools; use zbus::zvariant::OwnedObjectPath; #[derive(Debug, Clone)] @@ -221,23 +220,14 @@ impl BluetoothSettings { theme: &AshellTheme, ) -> Option> { self.service.as_ref().map(|service| { - let connected_devices = service - .devices - .iter() - .filter(|d| d.connected) - .sorted_by_key(|d| &d.name); - let paired_devices = service - .devices - .iter() - .filter(|d| d.paired && !d.connected) - .sorted_by_key(|d| &d.name); + let connected_devices = service.devices.iter().filter(|d| d.connected); + let paired_devices = service.devices.iter().filter(|d| d.paired && !d.connected); let mut known_devices = connected_devices.chain(paired_devices).peekable(); let mut available_devices = service .devices .iter() .filter(|d| !d.paired && !d.connected) - .sorted_by_key(|d| &d.name) .peekable(); let some_known = known_devices.peek().is_some(); diff --git a/src/services/bluetooth.rs b/src/services/bluetooth.rs index f9caa3a90..4d5ccc895 100644 --- a/src/services/bluetooth.rs +++ b/src/services/bluetooth.rs @@ -5,11 +5,12 @@ use iced::{ futures::{SinkExt, StreamExt}, stream::channel, }; -use log::{debug, error, info}; -use std::{any::TypeId, ops::Deref, pin::pin, time::Duration}; -use zbus::zvariant::OwnedObjectPath; +use log::{error, info, warn}; +use std::{any::TypeId, ops::Deref, pin::pin}; -pub use ashell_services::bluetooth::{BluetoothData, BluetoothDevice, BluetoothState}; +pub use ashell_services::bluetooth::{ + BluetoothCommand, BluetoothData, BluetoothDevice, BluetoothState, +}; #[derive(Debug, Clone)] pub struct BluetoothService { @@ -25,24 +26,6 @@ impl Deref for BluetoothService { } } -#[derive(Debug, Clone)] -pub enum BluetoothCommand { - Toggle, - StartDiscovery, - PairDevice(OwnedObjectPath), - ConnectDevice(OwnedObjectPath), - DisconnectDevice(OwnedObjectPath), - RemoveDevice(OwnedObjectPath), -} - -async fn refresh(handle: &Bluetooth) -> BluetoothData { - handle.data().await.unwrap_or_else(|_| BluetoothData { - state: BluetoothState::Unavailable, - devices: vec![], - discovering: false, - }) -} - impl ReadOnlyService for BluetoothService { type UpdateEvent = BluetoothData; type Error = (); @@ -61,16 +44,12 @@ impl ReadOnlyService for BluetoothService { return; } }; - let data = match handle.data().await { - Ok(data) => data, - Err(err) => { - error!("Failed to initialize bluetooth service: {err}"); - return; - } - }; - info!("Bluetooth service initialized"); let mut updates = pin!(handle.updates()); + let Some(data) = updates.next().await else { + return; + }; + info!("Bluetooth service initialized"); let _ = output .send(ServiceEvent::Init(BluetoothService { handle, data })) .await; @@ -89,73 +68,11 @@ impl Service for BluetoothService { fn command(&mut self, command: Self::Command) -> Task> { let handle = self.handle.clone(); - match command { - BluetoothCommand::Toggle => { - if self.data.state == BluetoothState::Unavailable { - return Task::none(); - } - - let mut data = self.data.clone(); - Task::perform( - async move { - let powered = data.state == BluetoothState::Active; - debug!("Toggling bluetooth power to: {}", !powered); - - if handle.set_powered(!powered).await.is_ok() { - data.state = if powered { - BluetoothState::Inactive - } else { - BluetoothState::Active - }; - } - - data - }, - ServiceEvent::Update, - ) + Task::future(async move { + if let Err(err) = handle.execute(command).await { + warn!("Bluetooth command failed: {err}"); } - BluetoothCommand::StartDiscovery => Task::perform( - async move { - if handle.start_discovery().await.is_ok() { - tokio::time::sleep(Duration::from_secs(15)).await; - let _ = handle.stop_discovery().await; - } - refresh(&handle).await - }, - ServiceEvent::Update, - ), - BluetoothCommand::PairDevice(device) => Task::perform( - async move { - debug!("Pairing device: {device:?}"); - let _ = handle.pair_device(&device).await; - refresh(&handle).await - }, - ServiceEvent::Update, - ), - BluetoothCommand::ConnectDevice(device) => Task::perform( - async move { - debug!("Connecting device: {device:?}"); - let _ = handle.connect_device(&device).await; - refresh(&handle).await - }, - ServiceEvent::Update, - ), - BluetoothCommand::DisconnectDevice(device) => Task::perform( - async move { - debug!("Disconnecting device: {device:?}"); - let _ = handle.disconnect_device(&device).await; - refresh(&handle).await - }, - ServiceEvent::Update, - ), - BluetoothCommand::RemoveDevice(device) => Task::perform( - async move { - debug!("Removing device: {device:?}"); - let _ = handle.remove_device(&device).await; - refresh(&handle).await - }, - ServiceEvent::Update, - ), - } + }) + .discard() } } From 1aeba05fd8cabd75bb12c6724fe82fb062c946f3 Mon Sep 17 00:00:00 2001 From: Simone Camito Date: Fri, 2 Oct 2026 19:15:09 +0200 Subject: [PATCH 6/9] docs: sync lint commands and service docs with the workspace Run the Nix build when only crates/ changes, update the documented lint commands to the workspace-wide ones, and fix the bluetooth entry in the project layout and the Service trait signature. --- .github/workflows/nix-ci.yml | 1 + docs/src/ci-and-release/ci-pipeline.md | 2 +- docs/src/contributing/code-style.md | 4 ++-- docs/src/contributing/common-tasks.md | 2 +- docs/src/getting-started/building.md | 2 +- docs/src/getting-started/project-layout.md | 4 +--- docs/src/services/service-traits.md | 4 ++-- 7 files changed, 9 insertions(+), 10 deletions(-) diff --git a/.github/workflows/nix-ci.yml b/.github/workflows/nix-ci.yml index 664f408b5..a975ee0a0 100644 --- a/.github/workflows/nix-ci.yml +++ b/.github/workflows/nix-ci.yml @@ -27,6 +27,7 @@ jobs: filters: | code: - 'src/**' + - 'crates/**' - 'assets/**' - 'build.rs' - 'Cargo.toml' diff --git a/docs/src/ci-and-release/ci-pipeline.md b/docs/src/ci-and-release/ci-pipeline.md index 3c79b14b3..6d8a28ea9 100644 --- a/docs/src/ci-and-release/ci-pipeline.md +++ b/docs/src/ci-and-release/ci-pipeline.md @@ -20,7 +20,7 @@ ashell uses GitHub Actions for continuous integration. All workflow files are in 2. **Format check**: `cargo fmt --all -- --check` - Fails if any code is not properly formatted. -3. **Clippy lint**: `cargo clippy --all-features -- -D warnings` +3. **Clippy lint**: `cargo clippy --workspace --all-features -- -D warnings` - Zero warnings policy. All clippy warnings are treated as errors. 4. **Build**: `cargo build` diff --git a/docs/src/contributing/code-style.md b/docs/src/contributing/code-style.md index e94b63c49..93a92af3f 100644 --- a/docs/src/contributing/code-style.md +++ b/docs/src/contributing/code-style.md @@ -15,7 +15,7 @@ CI enforces formatting with `cargo fmt --all -- --check`. All clippy warnings are treated as errors: ```bash -cargo clippy -- -D warnings +cargo clippy --workspace -- -D warnings ``` This is enforced in CI. Fix all warnings before submitting a PR. @@ -26,7 +26,7 @@ The Makefile runs both: ```bash make check -# Equivalent to: cargo fmt && cargo check && cargo clippy -- -D warnings +# Equivalent to: cargo fmt && cargo check --workspace && cargo clippy --workspace -- -D warnings ``` ## Module Structure Conventions diff --git a/docs/src/contributing/common-tasks.md b/docs/src/contributing/common-tasks.md index c6e5ee8c6..15e7f6a39 100644 --- a/docs/src/contributing/common-tasks.md +++ b/docs/src/contributing/common-tasks.md @@ -107,7 +107,7 @@ Always run the full check before pushing: make check ``` -This runs `cargo fmt`, `cargo check`, and `cargo clippy -- -D warnings`. +This runs `cargo fmt`, `cargo check --workspace`, and `cargo clippy --workspace -- -D warnings`. ## Debugging a Specific Module diff --git a/docs/src/getting-started/building.md b/docs/src/getting-started/building.md index 96042ea10..e770e84e9 100644 --- a/docs/src/getting-started/building.md +++ b/docs/src/getting-started/building.md @@ -20,7 +20,7 @@ The project includes a `Makefile` with convenience targets: | `make start` | Build + `./target/release/ashell` | Build and run | | `make install` | Build + `install -Dm755 target/release/ashell $(DESTDIR)$(BINDIR)/ashell` | Install to system (`PREFIX` defaults to `/usr`, `BINDIR` to `$(PREFIX)/bin`) | | `make fmt` | `cargo fmt` | Format code | -| `make check` | `cargo fmt` + `cargo check` + `cargo clippy -- -D warnings` | Full lint check | +| `make check` | `cargo fmt` + `cargo check --workspace` + `cargo clippy --workspace -- -D warnings` | Full lint check | ## What build.rs Does diff --git a/docs/src/getting-started/project-layout.md b/docs/src/getting-started/project-layout.md index bd8d561c4..4cb82d62e 100644 --- a/docs/src/getting-started/project-layout.md +++ b/docs/src/getting-started/project-layout.md @@ -85,9 +85,7 @@ src/ │ │ └── generic.rs # Generic Wayland fallback (ext-workspace, foreign-toplevel) │ ├── audio.rs # PulseAudio/PipeWire audio service │ ├── brightness.rs # Display brightness via sysfs -│ ├── bluetooth/ -│ │ ├── mod.rs # Bluetooth service logic -│ │ └── dbus.rs # BlueZ D-Bus proxy definitions +│ ├── bluetooth.rs # iced glue for the ashell-services Bluetooth service │ ├── network/ │ │ ├── mod.rs # Network service logic │ │ ├── dbus.rs # NetworkManager D-Bus proxies diff --git a/docs/src/services/service-traits.md b/docs/src/services/service-traits.md index 32cd72272..f2f71c7e2 100644 --- a/docs/src/services/service-traits.md +++ b/docs/src/services/service-traits.md @@ -20,7 +20,7 @@ bluetooth.execute(BluetoothCommand::Toggle).await?; ashell wraps each of them in a small module under `src/services/` (e.g. `src/services/bluetooth.rs`) that implements `ReadOnlyService`/`Service`: `subscribe` connects, sends `Init` with the handle and the first snapshot, then forwards the rest of the stream as `Update`s; `command` runs `execute` and produces no message. Other UIs write their own glue on the same API. -Each service sits behind a cargo feature of the same name (`full` enables all of them), so consumers only compile the services, and pull in the system dependencies, they need. The crate has no default features. +Each service sits behind a cargo feature of the same name, so consumers only compile the services, and pull in the system dependencies, they need. The crate has no default features. Currently migrated: Bluetooth (`bluetooth` feature). Shared helpers that are not services live in plain modules, e.g. `rfkill` (soft-block state and change notifications, used by both bluetooth and network). @@ -64,7 +64,7 @@ For services that accept commands (bidirectional): ```rust pub trait Service: ReadOnlyService { - type Command: Send + 'static; + type Command; fn command(&mut self, command: Self::Command) -> Task>; } From f8a07a1de6530835588d182a05da0da2c741bf7e Mon Sep 17 00:00:00 2001 From: Simone Camito Date: Fri, 2 Oct 2026 19:15:09 +0200 Subject: [PATCH 7/9] refactor(bluetooth): read one snapshot and watch bluez with one subscription Build BluetoothData from a single GetManagedObjects reply instead of reading each device's properties one call at a time: the snapshot is consistent, and a device can't vanish halfway through. Watch changes with one event stream, subscribed once: bluez's owner (owner_watch, which never activates bluez), InterfacesAdded/Removed, a single PropertiesChanged rule on /org/bluez filtered to the properties shown, and rfkill. Adding or removing a device doesn't rebuild a stream per device. Signals already queued are read as one burst, and unchanged snapshots aren't sent again. Also: - rfkill::set_bluetooth_soft_block() for airplane mode, so both network backends stop shelling out to /usr/sbin/rfkill on their own. - Drop redundant feature edges and the unused BluetoothData re-export. --- Cargo.toml | 5 +- crates/ashell-services/Cargo.toml | 3 +- crates/ashell-services/src/bluetooth/dbus.rs | 309 +++++++++++------- crates/ashell-services/src/bluetooth/mod.rs | 322 +++++++------------ crates/ashell-services/src/bus.rs | 26 ++ crates/ashell-services/src/lib.rs | 2 + crates/ashell-services/src/rfkill.rs | 10 + src/services/bluetooth.rs | 6 +- src/services/network/dbus.rs | 9 +- src/services/network/iwd_dbus/mod.rs | 7 +- 10 files changed, 356 insertions(+), 343 deletions(-) create mode 100644 crates/ashell-services/src/bus.rs diff --git a/Cargo.toml b/Cargo.toml index eab335abb..40e528398 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,10 +48,7 @@ depends = ["libwayland-client0", "libpipewire-0.3-0t64", "libpulse0"] depends = ["libwayland-client", "pipewire-libs", "pulseaudio-libs"] [dependencies] -ashell-services = { path = "crates/ashell-services", features = [ - "bluetooth", - "rfkill", -] } +ashell-services = { path = "crates/ashell-services", features = ["bluetooth"] } iced = { package = "iced_layershell", git = "https://github.com/MalpenZibo/iced_layershell", tag = "v0.6.1", features = [ "tokio", "advanced", diff --git a/crates/ashell-services/Cargo.toml b/crates/ashell-services/Cargo.toml index ff484d4d7..bc4a07b38 100644 --- a/crates/ashell-services/Cargo.toml +++ b/crates/ashell-services/Cargo.toml @@ -10,8 +10,7 @@ repository.workspace = true publish = false [features] -full = ["bluetooth", "rfkill"] -bluetooth = ["rfkill", "dep:anyhow", "dep:log", "dep:tokio", "dep:zbus", "tokio/time"] +bluetooth = ["rfkill", "dep:zbus", "tokio/time"] rfkill = ["dep:anyhow", "dep:inotify", "dep:log", "dep:tokio", "tokio/process"] [dependencies] diff --git a/crates/ashell-services/src/bluetooth/dbus.rs b/crates/ashell-services/src/bluetooth/dbus.rs index 8b2604a8b..6d9b66b8c 100644 --- a/crates/ashell-services/src/bluetooth/dbus.rs +++ b/crates/ashell-services/src/bluetooth/dbus.rs @@ -1,42 +1,118 @@ use std::collections::HashMap; - use zbus::{ proxy, - zvariant::{OwnedObjectPath, OwnedValue}, + proxy::CacheProperties, + zvariant::{OwnedObjectPath, OwnedValue, Value}, }; -use super::{BluetoothDevice, BluetoothState}; +use super::BluetoothDevice; + +type Properties = HashMap; +type ManagedObjects = HashMap>; + +pub(super) const ADAPTER: &str = "org.bluez.Adapter1"; +pub(super) const DEVICE: &str = "org.bluez.Device1"; +pub(super) const BATTERY: &str = "org.bluez.Battery1"; + +#[derive(Clone, Copy)] +pub(super) struct AdapterSnapshot { + pub powered: bool, + pub discovering: bool, +} + +/// The adapter and devices bluez exposes, all from one `GetManagedObjects` +/// reply, so the snapshot is consistent and a device can't vanish halfway. +pub(super) struct Snapshot { + pub adapter: Option, + pub devices: Vec, +} + +pub(super) async fn snapshot(conn: &zbus::Connection) -> zbus::Result { + let objects = object_manager(conn).await?.get_managed_objects().await?; + + Ok(Snapshot::from_objects(&objects)) +} + +impl Snapshot { + fn from_objects(objects: &ManagedObjects) -> Self { + let adapter = objects + .values() + .find_map(|interfaces| interfaces.get(ADAPTER)) + .map(|adapter| AdapterSnapshot { + powered: property(adapter, "Powered").unwrap_or(false), + discovering: property(adapter, "Discovering").unwrap_or(false), + }); + + let mut devices: Vec<_> = objects + .iter() + .filter_map(|(path, interfaces)| { + let device = interfaces.get(DEVICE)?; + let connected = property(device, "Connected").unwrap_or(false); + + Some(BluetoothDevice { + name: property::<&str>(device, "Alias") + .unwrap_or_default() + .to_owned(), + battery: interfaces + .get(BATTERY) + .filter(|_| connected) + .and_then(|battery| property(battery, "Percentage")), + path: path.clone(), + connected, + paired: property(device, "Paired").unwrap_or(false), + }) + }) + .collect(); + + devices.sort_by(|a, b| a.name.cmp(&b.name)); + + Self { adapter, devices } + } +} -type ManagedObjects = HashMap>>; +fn property<'a, T>(properties: &'a Properties, name: &str) -> Option +where + T: TryFrom<&'a Value<'a>>, + >>::Error: Into, +{ + properties.get(name)?.downcast_ref().ok() +} + +/// bluez's object manager. Properties aren't cached: building it must not +/// call bluez, which would activate the service. +pub(super) async fn object_manager( + conn: &zbus::Connection, +) -> zbus::Result> { + BluezObjectManagerProxy::builder(conn) + .cache_properties(CacheProperties::No) + .build() + .await +} +/// The adapter and devices, for running commands on them. pub struct BluetoothDbus<'a> { - pub bluez: BluezObjectManagerProxy<'a>, pub adapter: Option>, + conn: zbus::Connection, } impl BluetoothDbus<'_> { pub async fn new(conn: &zbus::Connection) -> anyhow::Result { - let bluez = BluezObjectManagerProxy::new(conn).await?; - let adapter = bluez + let adapter = object_manager(conn) + .await? .get_managed_objects() .await? .into_iter() - .filter_map(|(key, item)| { - if item.contains_key("org.bluez.Adapter1") { - Some(key) - } else { - None - } - }) - .next(); + .find_map(|(path, interfaces)| interfaces.contains_key(ADAPTER).then_some(path)); - let adapter = if let Some(adapter) = adapter { - Some(AdapterProxy::builder(conn).path(adapter)?.build().await?) - } else { - None + let adapter = match adapter { + Some(path) => Some(AdapterProxy::builder(conn).path(path)?.build().await?), + None => None, }; - Ok(Self { bluez, adapter }) + Ok(Self { + adapter, + conn: conn.clone(), + }) } pub async fn set_powered(&self, value: bool) -> zbus::Result<()> { @@ -47,19 +123,6 @@ impl BluetoothDbus<'_> { Ok(()) } - pub async fn state(&self) -> zbus::Result { - match &self.adapter { - Some(adapter) => { - if adapter.powered().await? { - Ok(BluetoothState::Active) - } else { - Ok(BluetoothState::Inactive) - } - } - _ => Ok(BluetoothState::Unavailable), - } - } - pub async fn start_discovery(&self) -> zbus::Result<()> { if let Some(adapter) = &self.adapter { adapter.start_discovery().await?; @@ -74,89 +137,16 @@ impl BluetoothDbus<'_> { Ok(()) } - pub async fn discovering(&self) -> zbus::Result { - match &self.adapter { - Some(adapter) => adapter.discovering().await, - _ => Ok(false), - } - } - - pub async fn devices(&self) -> anyhow::Result> { - let devices_proxy = self - .bluez - .get_managed_objects() - .await? - .into_iter() - .filter_map(|(key, item)| { - if item.contains_key("org.bluez.Device1") { - Some((key.clone(), item.contains_key("org.bluez.Battery1"))) - } else { - None - } - }) - .collect::>(); - - let mut devices = Vec::new(); - for (device_path, has_battery) in devices_proxy { - let device = DeviceProxy::builder(self.bluez.inner().connection()) - .path(device_path.clone())? - .build() - .await?; - - let name = device.alias().await?; - let connected = device.connected().await?; - let paired = device.paired().await?; - - let battery = if connected && has_battery { - let battery_proxy = BatteryProxy::builder(self.bluez.inner().connection()) - .path(&device_path)? - .build() - .await?; - - Some(battery_proxy.percentage().await?) - } else { - None - }; - - devices.push(BluetoothDevice { - name, - battery, - path: device_path, - connected, - paired, - }); - } - - devices.sort_by(|a, b| a.name.cmp(&b.name)); - - Ok(devices) - } - pub async fn pair_device(&self, device_path: &OwnedObjectPath) -> zbus::Result<()> { - let device = DeviceProxy::builder(self.bluez.inner().connection()) - .path(device_path)? - .build() - .await?; - - device.pair().await + self.device(device_path).await?.pair().await } pub async fn connect_device(&self, device_path: &OwnedObjectPath) -> zbus::Result<()> { - let device = DeviceProxy::builder(self.bluez.inner().connection()) - .path(device_path)? - .build() - .await?; - - device.connect().await + self.device(device_path).await?.connect().await } pub async fn disconnect_device(&self, device_path: &OwnedObjectPath) -> zbus::Result<()> { - let device = DeviceProxy::builder(self.bluez.inner().connection()) - .path(device_path)? - .build() - .await?; - - device.disconnect().await + self.device(device_path).await?.disconnect().await } pub async fn remove_device(&self, device_path: &OwnedObjectPath) -> zbus::Result<()> { @@ -165,6 +155,13 @@ impl BluetoothDbus<'_> { } Ok(()) } + + async fn device(&self, path: &OwnedObjectPath) -> zbus::Result> { + DeviceProxy::builder(&self.conn) + .path(path.clone())? + .build() + .await + } } #[proxy( @@ -222,8 +219,94 @@ pub trait Device { fn disconnect(&self) -> zbus::Result<()>; } -#[proxy(default_service = "org.bluez", interface = "org.bluez.Battery1")] -pub trait Battery { - #[zbus(property)] - fn percentage(&self) -> zbus::Result; +#[cfg(test)] +mod tests { + use super::*; + + fn properties(values: Vec<(&str, Value<'static>)>) -> Properties { + values + .into_iter() + .map(|(name, value)| (name.to_owned(), value.try_into().unwrap())) + .collect() + } + + fn path(path: &str) -> OwnedObjectPath { + OwnedObjectPath::try_from(path).unwrap() + } + + fn device(alias: &str, connected: bool, battery: Option) -> HashMap { + let mut interfaces = HashMap::from([( + DEVICE.to_owned(), + properties(vec![ + ("Alias", Value::from(alias.to_owned())), + ("Connected", Value::from(connected)), + ("Paired", Value::from(true)), + ]), + )]); + if let Some(percentage) = battery { + interfaces.insert( + BATTERY.to_owned(), + properties(vec![("Percentage", Value::from(percentage))]), + ); + } + interfaces + } + + #[test] + fn reads_adapter_and_sorts_devices() { + let objects = ManagedObjects::from([ + ( + path("/org/bluez/hci0"), + HashMap::from([( + ADAPTER.to_owned(), + properties(vec![ + ("Powered", Value::from(true)), + ("Discovering", Value::from(false)), + ]), + )]), + ), + ( + path("/org/bluez/hci0/dev_B"), + device("Mouse", true, Some(80)), + ), + ( + path("/org/bluez/hci0/dev_A"), + device("Headphones", false, Some(50)), + ), + ]); + + let snapshot = Snapshot::from_objects(&objects); + + let adapter = snapshot.adapter.unwrap(); + assert!(adapter.powered); + assert!(!adapter.discovering); + let names: Vec<_> = snapshot.devices.iter().map(|d| d.name.as_str()).collect(); + assert_eq!(names, ["Headphones", "Mouse"]); + assert_eq!(snapshot.devices[1].battery, Some(80)); + } + + #[test] + fn battery_only_for_connected_devices() { + let objects = ManagedObjects::from([( + path("/org/bluez/hci0/dev_A"), + device("Headphones", false, Some(50)), + )]); + + assert_eq!(Snapshot::from_objects(&objects).devices[0].battery, None); + } + + #[test] + fn missing_adapter_and_properties() { + let objects = ManagedObjects::from([( + path("/org/bluez/hci0/dev_A"), + HashMap::from([(DEVICE.to_owned(), Properties::new())]), + )]); + + let snapshot = Snapshot::from_objects(&objects); + + assert!(snapshot.adapter.is_none()); + let device = &snapshot.devices[0]; + assert_eq!(device.name, ""); + assert!(!device.connected && !device.paired); + } } diff --git a/crates/ashell-services/src/bluetooth/mod.rs b/crates/ashell-services/src/bluetooth/mod.rs index e2e089d97..9f7dcde7f 100644 --- a/crates/ashell-services/src/bluetooth/mod.rs +++ b/crates/ashell-services/src/bluetooth/mod.rs @@ -1,16 +1,17 @@ -use crate::{rfkill, stream::channel}; -use dbus::{BatteryProxy, BluetoothDbus, DeviceProxy}; +use crate::{bus, rfkill, stream::channel}; +use dbus::{ADAPTER, BATTERY, BluetoothDbus, DEVICE}; use futures::{ - SinkExt, Stream, StreamExt, - stream::{BoxStream, select_all}, - stream_select, + FutureExt, SinkExt, Stream, StreamExt, future::ready, stream::BoxStream, stream_select, }; -use log::{debug, error, info, warn}; +use log::{debug, error, warn}; use std::time::Duration; -use zbus::zvariant::OwnedObjectPath; +use zbus::{ + MatchRule, MessageStream, fdo::PropertiesChanged, message::Type, zvariant::OwnedObjectPath, +}; mod dbus; +const BLUEZ: &str = "org.bluez"; const DISCOVERY_DURATION: Duration = Duration::from_secs(15); #[derive(PartialEq, Eq, Debug, Clone)] @@ -56,10 +57,11 @@ pub enum BluetoothCommand { RemoveDevice(OwnedObjectPath), } -#[derive(PartialEq, Eq)] -enum Change { - Topology, - Property, +enum Event { + /// Whether bluez is running. + Bluez(bool), + /// Something shown in `BluetoothData` may have changed. + Changed, } #[derive(Debug, Clone)] @@ -74,39 +76,50 @@ impl Bluetooth { Ok(Self { conn }) } - pub async fn data(&self) -> anyhow::Result { - Self::read_data(&self.conn).await - } - /// Yields the current state once subscribed, then a fresh snapshot on every change. pub fn updates(&self) -> impl Stream + Send + 'static { let conn = self.conn.clone(); channel(100, |mut output| async move { - loop { - info!("Listening for bluetooth events"); - - let mut changes = match Self::changes(&conn).await { - Ok(changes) => changes, - Err(err) => { - error!("Failed to listen for bluetooth events: {err}"); - return; + let mut events = match Self::events(&conn).await { + Ok(events) => events, + Err(err) => { + error!("Failed to listen for bluetooth events: {err}"); + return; + } + }; + + let mut bluez_running = false; + let mut last = None; + + while let Some(event) = events.next().await { + // One action fires a burst of signals (pairing: Paired, Connected, + // Battery1, Percentage): read once for all those already queued. + let mut event = Some(event); + while let Some(current) = event { + if let Event::Bluez(running) = current { + bluez_running = running; } - }; + event = events.next().now_or_never().flatten(); + } - loop { + // Reading bluez while it isn't running would activate it + let data = if bluez_running { match Self::read_data(&conn).await { - Ok(data) => { - if output.send(data).await.is_err() { - return; - } + Ok(data) => data, + Err(err) => { + warn!("Failed to read bluetooth data: {err}"); + continue; } - Err(err) => warn!("Failed to read bluetooth data: {err}"), } + } else { + BluetoothData::default() + }; - match changes.next().await { - Some(Change::Property) => {} - Some(Change::Topology) | None => break, + if last.as_ref() != Some(&data) { + last = Some(data.clone()); + if output.send(data).await.is_err() { + return; } } } @@ -116,199 +129,96 @@ impl Bluetooth { pub async fn execute(&self, command: BluetoothCommand) -> anyhow::Result<()> { debug!("Bluetooth command: {command:?}"); + let bluetooth = BluetoothDbus::new(&self.conn).await?; + match command { - BluetoothCommand::Toggle => { - match Self::read_state(&BluetoothDbus::new(&self.conn).await?).await? { - BluetoothState::Unavailable => Ok(()), - BluetoothState::Active => self.set_powered(false).await, - BluetoothState::Inactive => self.set_powered(true).await, - } - } + BluetoothCommand::Toggle => match Self::read_data(&self.conn).await?.state { + BluetoothState::Unavailable => {} + BluetoothState::Active => bluetooth.set_powered(false).await?, + BluetoothState::Inactive => bluetooth.set_powered(true).await?, + }, BluetoothCommand::StartDiscovery => { - self.start_discovery().await?; + bluetooth.start_discovery().await?; tokio::time::sleep(DISCOVERY_DURATION).await; - self.stop_discovery().await + bluetooth.stop_discovery().await?; + } + BluetoothCommand::PairDevice(device) => bluetooth.pair_device(&device).await?, + BluetoothCommand::ConnectDevice(device) => bluetooth.connect_device(&device).await?, + BluetoothCommand::DisconnectDevice(device) => { + bluetooth.disconnect_device(&device).await? } - BluetoothCommand::PairDevice(device) => self.pair_device(&device).await, - BluetoothCommand::ConnectDevice(device) => self.connect_device(&device).await, - BluetoothCommand::DisconnectDevice(device) => self.disconnect_device(&device).await, - BluetoothCommand::RemoveDevice(device) => self.remove_device(&device).await, + BluetoothCommand::RemoveDevice(device) => bluetooth.remove_device(&device).await?, } - } - - pub async fn set_powered(&self, powered: bool) -> anyhow::Result<()> { - BluetoothDbus::new(&self.conn) - .await? - .set_powered(powered) - .await?; - - Ok(()) - } - - pub async fn start_discovery(&self) -> anyhow::Result<()> { - BluetoothDbus::new(&self.conn) - .await? - .start_discovery() - .await?; Ok(()) } - pub async fn stop_discovery(&self) -> anyhow::Result<()> { - BluetoothDbus::new(&self.conn) - .await? - .stop_discovery() - .await?; - - Ok(()) - } - - pub async fn pair_device(&self, device: &OwnedObjectPath) -> anyhow::Result<()> { - BluetoothDbus::new(&self.conn) - .await? - .pair_device(device) - .await?; - - Ok(()) - } - - pub async fn connect_device(&self, device: &OwnedObjectPath) -> anyhow::Result<()> { - BluetoothDbus::new(&self.conn) - .await? - .connect_device(device) - .await?; - - Ok(()) - } - - pub async fn disconnect_device(&self, device: &OwnedObjectPath) -> anyhow::Result<()> { - BluetoothDbus::new(&self.conn) - .await? - .disconnect_device(device) - .await?; - - Ok(()) - } - - pub async fn remove_device(&self, device: &OwnedObjectPath) -> anyhow::Result<()> { - BluetoothDbus::new(&self.conn) - .await? - .remove_device(device) - .await?; - - Ok(()) - } - - async fn read_state(bluetooth: &BluetoothDbus<'_>) -> anyhow::Result { - let state = bluetooth.state().await?; - - Ok(match state { - BluetoothState::Active if rfkill::bluetooth_soft_blocked().await? => { - BluetoothState::Inactive - } - state => state, - }) - } - async fn read_data(conn: &zbus::Connection) -> anyhow::Result { - let bluetooth = BluetoothDbus::new(conn).await?; + let snapshot = dbus::snapshot(conn).await?; - let state = Self::read_state(&bluetooth).await?; - let devices = bluetooth.devices().await?; - let discovering = bluetooth.discovering().await.unwrap_or(false); + let state = match snapshot.adapter { + None => BluetoothState::Unavailable, + Some(adapter) if !adapter.powered => BluetoothState::Inactive, + Some(_) if rfkill::bluetooth_soft_blocked().await? => BluetoothState::Inactive, + Some(_) => BluetoothState::Active, + }; Ok(BluetoothData { state, - devices, - discovering, + discovering: snapshot.adapter.is_some_and(|adapter| adapter.discovering), + devices: snapshot.devices, }) } - async fn changes(conn: &zbus::Connection) -> anyhow::Result> { - let bluetooth = BluetoothDbus::new(conn).await?; + /// Everything that can change `BluetoothData`, subscribed once: bluez + /// starting or stopping, objects appearing or going, the properties shown, + /// and rfkill. + async fn events(conn: &zbus::Connection) -> anyhow::Result> { + let bluez = bus::owner_watch(conn, BLUEZ).await?.map(Event::Bluez); + let objects = dbus::object_manager(conn).await?; let topology = stream_select!( - bluetooth - .bluez - .receive_interfaces_added() - .await? - .map(|_| {}), - bluetooth - .bluez - .receive_interfaces_removed() - .await? - .map(|_| {}), - ) - .map(|_| Change::Topology); - - let Some(adapter) = bluetooth.adapter.as_ref() else { - return Ok(topology.boxed()); - }; - - // Property streams yield their current value first: skip it, updates() reads a snapshot - let mut properties: Vec> = vec![ - adapter - .receive_powered_changed() - .await - .skip(1) - .map(|_| {}) - .boxed(), - adapter - .receive_discovering_changed() - .await - .skip(1) - .map(|_| {}) - .boxed(), - rfkill::soft_block_changes().await?.boxed(), - ]; - - let conn = bluetooth.bluez.inner().connection(); - for device in bluetooth.devices().await? { - let device_proxy = DeviceProxy::builder(conn) - .path(device.path.clone())? - .build() - .await?; - properties.push( - device_proxy - .receive_connected_changed() - .await - .skip(1) - .map(|_| {}) - .boxed(), - ); - properties.push( - device_proxy - .receive_paired_changed() - .await - .skip(1) - .map(|_| {}) - .boxed(), - ); - properties.push( - device_proxy - .receive_alias_changed() - .await - .skip(1) - .map(|_| {}) - .boxed(), - ); - - let battery = BatteryProxy::builder(conn) - .path(device.path)? - .build() - .await?; - properties.push( - battery - .receive_percentage_changed() - .await - .skip(1) - .map(|_| {}) - .boxed(), - ); - } + objects.receive_interfaces_added().await?.map(|_| {}), + objects.receive_interfaces_removed().await?.map(|_| {}), + ); + + // One rule for every bluez object instead of a stream per device + let rule = MatchRule::builder() + .msg_type(Type::Signal) + .interface("org.freedesktop.DBus.Properties")? + .member("PropertiesChanged")? + .path_namespace("/org/bluez")? + .build(); + let properties = MessageStream::for_match_rule(rule, conn, None) + .await? + .filter_map(|message| ready(message.ok().filter(shows_change).map(|_| {}))); - let properties = select_all(properties).map(|_| Change::Property); + let changed = stream_select!(topology, properties, rfkill::soft_block_changes().await?) + .map(|_| Event::Changed); - Ok(stream_select!(topology, properties).boxed()) + Ok(stream_select!(bluez, changed).boxed()) } } + +/// Whether a `PropertiesChanged` touches something `BluetoothData` shows, so +/// e.g. the RSSI updates during discovery don't trigger a read each. +fn shows_change(message: &zbus::Message) -> bool { + let Some(signal) = PropertiesChanged::from_message(message.clone()) else { + return false; + }; + let Ok(args) = signal.args() else { + return false; + }; + + let shown: &[&str] = match args.interface_name().as_str() { + ADAPTER => &["Powered", "Discovering"], + DEVICE => &["Alias", "Connected", "Paired"], + BATTERY => &["Percentage"], + _ => return false, + }; + + args.changed_properties() + .keys() + .chain(args.invalidated_properties().iter()) + .any(|name| shown.contains(name)) +} diff --git a/crates/ashell-services/src/bus.rs b/crates/ashell-services/src/bus.rs new file mode 100644 index 000000000..ccf860644 --- /dev/null +++ b/crates/ashell-services/src/bus.rs @@ -0,0 +1,26 @@ +use futures::{ + Stream, StreamExt, + future::ready, + stream::{self}, +}; +use zbus::{fdo::DBusProxy, names::BusName}; + +/// Whether `name` is owned on the bus: the current value, then one per change. +/// Asked to the bus itself, so it never triggers activation of the service. +pub(crate) async fn owner_watch( + conn: &zbus::Connection, + name: &'static str, +) -> anyhow::Result + Send + 'static> { + let dbus = DBusProxy::new(conn).await?; + + // Subscribe before asking, so a change in between isn't lost + let changes = dbus + .receive_name_owner_changed_with_args(&[(0, name)]) + .await?; + let owned = dbus.name_has_owner(BusName::try_from(name)?).await?; + + Ok(stream::once(ready(owned)).chain( + changes + .filter_map(|signal| ready(signal.args().ok().map(|args| args.new_owner().is_some()))), + )) +} diff --git a/crates/ashell-services/src/lib.rs b/crates/ashell-services/src/lib.rs index 91739c9f5..fce15b19b 100644 --- a/crates/ashell-services/src/lib.rs +++ b/crates/ashell-services/src/lib.rs @@ -1,5 +1,7 @@ #[cfg(feature = "bluetooth")] pub mod bluetooth; +#[cfg(feature = "bluetooth")] +mod bus; #[cfg(feature = "rfkill")] pub mod rfkill; #[cfg(feature = "bluetooth")] diff --git a/crates/ashell-services/src/rfkill.rs b/crates/ashell-services/src/rfkill.rs index 64fd9aa3d..ae4c26336 100644 --- a/crates/ashell-services/src/rfkill.rs +++ b/crates/ashell-services/src/rfkill.rs @@ -26,6 +26,16 @@ pub async fn bluetooth_soft_blocked() -> anyhow::Result { Ok(output.contains("Soft blocked: yes")) } +/// Soft blocks or unblocks bluetooth, e.g. for airplane mode. +pub async fn set_bluetooth_soft_block(blocked: bool) -> anyhow::Result<()> { + Command::new("rfkill") + .args([if blocked { "block" } else { "unblock" }, "bluetooth"]) + .output() + .await?; + + Ok(()) +} + pub async fn soft_block_changes() -> anyhow::Result + Send + use<>> { let inotify = Inotify::init()?; diff --git a/src/services/bluetooth.rs b/src/services/bluetooth.rs index 4d5ccc895..4e281bc16 100644 --- a/src/services/bluetooth.rs +++ b/src/services/bluetooth.rs @@ -1,5 +1,5 @@ use super::{ReadOnlyService, Service, ServiceEvent}; -use ashell_services::bluetooth::Bluetooth; +use ashell_services::bluetooth::{Bluetooth, BluetoothData}; use iced::{ Subscription, Task, futures::{SinkExt, StreamExt}, @@ -8,9 +8,7 @@ use iced::{ use log::{error, info, warn}; use std::{any::TypeId, ops::Deref, pin::pin}; -pub use ashell_services::bluetooth::{ - BluetoothCommand, BluetoothData, BluetoothDevice, BluetoothState, -}; +pub use ashell_services::bluetooth::{BluetoothCommand, BluetoothDevice, BluetoothState}; #[derive(Debug, Clone)] pub struct BluetoothService { diff --git a/src/services/network/dbus.rs b/src/services/network/dbus.rs index 5406bc997..732c78c2f 100644 --- a/src/services/network/dbus.rs +++ b/src/services/network/dbus.rs @@ -8,7 +8,6 @@ use iced::futures::{Stream, StreamExt, stream::select_all}; use itertools::Itertools; use log::{debug, warn}; use std::{collections::HashMap, ops::Deref}; -use tokio::process::Command; use zbus::{ Result, proxy, zvariant::{self, ObjectPath, OwnedObjectPath, OwnedValue, Value}, @@ -55,13 +54,7 @@ impl super::NetworkBackend for NetworkDbus<'_> { } async fn set_airplane_mode(&self, enable: bool) -> anyhow::Result<()> { - let rfkill_res = Command::new("/usr/sbin/rfkill") - .arg(if enable { "block" } else { "unblock" }) - .arg("bluetooth") - .output() - .await; - - if let Err(e) = rfkill_res { + if let Err(e) = rfkill::set_bluetooth_soft_block(enable).await { debug!("Failed to set bluetooth rfkill: {e}"); } else { debug!("Bluetooth rfkill set successfully"); diff --git a/src/services/network/iwd_dbus/mod.rs b/src/services/network/iwd_dbus/mod.rs index 9fe7f196a..875d12c5a 100644 --- a/src/services/network/iwd_dbus/mod.rs +++ b/src/services/network/iwd_dbus/mod.rs @@ -33,7 +33,6 @@ use log::{debug, info, warn}; use std::collections::{HashMap, HashSet}; use std::ops::Deref; use std::sync::atomic::{AtomicU64, Ordering}; -use tokio::process::Command; use zbus::fdo::ObjectManagerProxy; use zbus::zvariant::OwnedObjectPath; @@ -300,11 +299,7 @@ impl super::NetworkBackend for IwdDbus<'_> { } async fn set_airplane_mode(&self, airplane: bool) -> anyhow::Result<()> { - Command::new("/usr/sbin/rfkill") - .arg(if airplane { "block" } else { "unblock" }) - .arg("bluetooth") - .output() - .await?; + rfkill::set_bluetooth_soft_block(airplane).await?; self.set_wifi_enabled(!airplane).await?; Ok(()) } From eda8af26a77f4b677af31f50b145264fb606eec6 Mon Sep 17 00:00:00 2001 From: Simone Camito Date: Thu, 8 Oct 2026 10:50:51 +0200 Subject: [PATCH 8/9] fix(bluetooth): harden the bluez listener and commands Keep consuming events while a snapshot read is in flight and read again if anything arrived: once a zbus signal queue fills up, the connection stops reading replies, so a burst of more than 64 signals during GetManagedObjects deadlocked the service. The PropertiesChanged rule now only accepts signals sent by bluez. Bluez methods are called with no_autostart, so commands never activate bluez. Failing to watch /dev/rfkill only disables soft block notifications instead of the whole service, and devices with the same alias are ordered by path so snapshots don't differ by order alone. --- crates/ashell-services/src/bluetooth/dbus.rs | 31 ++++++++++- crates/ashell-services/src/bluetooth/mod.rs | 54 ++++++++++++++++---- crates/ashell-services/src/rfkill.rs | 34 ++++++------ 3 files changed, 89 insertions(+), 30 deletions(-) diff --git a/crates/ashell-services/src/bluetooth/dbus.rs b/crates/ashell-services/src/bluetooth/dbus.rs index 6d9b66b8c..ef889a619 100644 --- a/crates/ashell-services/src/bluetooth/dbus.rs +++ b/crates/ashell-services/src/bluetooth/dbus.rs @@ -64,7 +64,8 @@ impl Snapshot { }) .collect(); - devices.sort_by(|a, b| a.name.cmp(&b.name)); + // Same aliases are common (two "JBL Flip"): the path keeps their order stable + devices.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.path.cmp(&b.path))); Self { adapter, devices } } @@ -170,6 +171,7 @@ impl BluetoothDbus<'_> { interface = "org.freedesktop.DBus.ObjectManager" )] pub trait BluezObjectManager { + #[zbus(no_autostart)] fn get_managed_objects(&self) -> zbus::Result; #[zbus(signal)] @@ -191,13 +193,16 @@ pub trait Adapter { #[zbus(property)] fn set_powered(&self, value: bool) -> zbus::Result<()>; + #[zbus(no_autostart)] fn start_discovery(&self) -> zbus::Result<()>; + #[zbus(no_autostart)] fn stop_discovery(&self) -> zbus::Result<()>; #[zbus(property)] fn discovering(&self) -> zbus::Result; + #[zbus(no_autostart)] fn remove_device(&self, device: zbus::zvariant::ObjectPath<'_>) -> zbus::Result<()>; } @@ -212,10 +217,13 @@ pub trait Device { #[zbus(property)] fn paired(&self) -> zbus::Result; + #[zbus(no_autostart)] fn pair(&self) -> zbus::Result<()>; + #[zbus(no_autostart)] fn connect(&self) -> zbus::Result<()>; + #[zbus(no_autostart)] fn disconnect(&self) -> zbus::Result<()>; } @@ -285,6 +293,27 @@ mod tests { assert_eq!(snapshot.devices[1].battery, Some(80)); } + #[test] + fn same_alias_sorted_by_path() { + let objects = ManagedObjects::from([ + ( + path("/org/bluez/hci0/dev_B"), + device("JBL Flip", false, None), + ), + ( + path("/org/bluez/hci0/dev_A"), + device("JBL Flip", false, None), + ), + ]); + + let paths: Vec<_> = Snapshot::from_objects(&objects) + .devices + .into_iter() + .map(|d| d.path.to_string()) + .collect(); + assert_eq!(paths, ["/org/bluez/hci0/dev_A", "/org/bluez/hci0/dev_B"]); + } + #[test] fn battery_only_for_connected_devices() { let objects = ManagedObjects::from([( diff --git a/crates/ashell-services/src/bluetooth/mod.rs b/crates/ashell-services/src/bluetooth/mod.rs index 9f7dcde7f..35edea244 100644 --- a/crates/ashell-services/src/bluetooth/mod.rs +++ b/crates/ashell-services/src/bluetooth/mod.rs @@ -1,10 +1,13 @@ use crate::{bus, rfkill, stream::channel}; use dbus::{ADAPTER, BATTERY, BluetoothDbus, DEVICE}; use futures::{ - FutureExt, SinkExt, Stream, StreamExt, future::ready, stream::BoxStream, stream_select, + FutureExt, SinkExt, Stream, StreamExt, + future::{Either, ready, select}, + stream::BoxStream, + stream_select, }; use log::{debug, error, warn}; -use std::time::Duration; +use std::{pin::pin, time::Duration}; use zbus::{ MatchRule, MessageStream, fdo::PropertiesChanged, message::Type, zvariant::OwnedObjectPath, }; @@ -64,6 +67,14 @@ enum Event { Changed, } +impl Event { + fn apply(self, bluez_running: &mut bool) { + if let Self::Bluez(running) = self { + *bluez_running = running; + } + } +} + #[derive(Debug, Clone)] pub struct Bluetooth { conn: zbus::Connection, @@ -91,21 +102,41 @@ impl Bluetooth { let mut bluez_running = false; let mut last = None; + // Something changed while the last read was in flight: read again + let mut stale = false; + + loop { + if !stale { + let Some(event) = events.next().await else { + return; + }; + event.apply(&mut bluez_running); + } + stale = false; - while let Some(event) = events.next().await { // One action fires a burst of signals (pairing: Paired, Connected, // Battery1, Percentage): read once for all those already queued. - let mut event = Some(event); - while let Some(current) = event { - if let Event::Bluez(running) = current { - bluez_running = running; - } - event = events.next().now_or_never().flatten(); + while let Some(event) = events.next().now_or_never().flatten() { + event.apply(&mut bluez_running); } // Reading bluez while it isn't running would activate it let data = if bluez_running { - match Self::read_data(&conn).await { + // Keep consuming events while waiting for the reply: once a zbus + // signal queue is full, the connection stops reading replies too. + let mut read = pin!(Self::read_data(&conn)); + let result = loop { + match select(read.as_mut(), events.next()).await { + Either::Left((result, _)) => break result, + Either::Right((Some(event), _)) => { + event.apply(&mut bluez_running); + stale = true; + } + Either::Right((None, _)) => return, + } + }; + + match result { Ok(data) => data, Err(err) => { warn!("Failed to read bluetooth data: {err}"); @@ -185,6 +216,7 @@ impl Bluetooth { // One rule for every bluez object instead of a stream per device let rule = MatchRule::builder() .msg_type(Type::Signal) + .sender(BLUEZ)? .interface("org.freedesktop.DBus.Properties")? .member("PropertiesChanged")? .path_namespace("/org/bluez")? @@ -193,7 +225,7 @@ impl Bluetooth { .await? .filter_map(|message| ready(message.ok().filter(shows_change).map(|_| {}))); - let changed = stream_select!(topology, properties, rfkill::soft_block_changes().await?) + let changed = stream_select!(topology, properties, rfkill::soft_block_changes()) .map(|_| Event::Changed); Ok(stream_select!(bluez, changed).boxed()) diff --git a/crates/ashell-services/src/rfkill.rs b/crates/ashell-services/src/rfkill.rs index ae4c26336..2a11fbf1b 100644 --- a/crates/ashell-services/src/rfkill.rs +++ b/crates/ashell-services/src/rfkill.rs @@ -1,5 +1,5 @@ use futures::{ - Stream, StreamExt, + StreamExt, stream::{BoxStream, pending}, }; use inotify::{Inotify, WatchMask}; @@ -36,21 +36,19 @@ pub async fn set_bluetooth_soft_block(blocked: bool) -> anyhow::Result<()> { Ok(()) } -pub async fn soft_block_changes() -> anyhow::Result + Send + use<>> { - let inotify = Inotify::init()?; - - let changes: BoxStream<'static, ()> = - match inotify.watches().add("/dev/rfkill", WatchMask::MODIFY) { - Ok(_) => { - let buffer = [0; 512]; - inotify.into_event_stream(buffer)?.map(|_| {}).boxed() - } - Err(err) if err.kind() == ErrorKind::NotFound => { - warn!("/dev/rfkill not found, disabling rfkill change notifications"); - pending().boxed() - } - Err(err) => return Err(err.into()), - }; - - Ok(changes) +/// Fires on every rfkill change. Never fails: without a watch, soft block +/// changes just aren't noticed until the next read. +pub fn soft_block_changes() -> BoxStream<'static, ()> { + let watch = Inotify::init().and_then(|inotify| { + inotify.watches().add("/dev/rfkill", WatchMask::MODIFY)?; + inotify.into_event_stream([0; 512]) + }); + + match watch { + Ok(events) => events.map(|_| {}).boxed(), + Err(err) => { + warn!("Can't watch /dev/rfkill, disabling rfkill change notifications: {err}"); + pending().boxed() + } + } } From 39e2c443e39ecb52b3b9b4e1c12e3e8458c12d5e Mon Sep 17 00:00:00 2001 From: Simone Camito Date: Thu, 8 Oct 2026 10:50:51 +0200 Subject: [PATCH 9/9] ci: test ashell-services and version every workspace member Run the ashell-services unit tests in CI and make check, and bump the version of every workspace member on pre-release. --- .github/workflows/ci.yml | 4 ++++ .github/workflows/pre-release.yml | 4 ++-- Makefile | 1 + 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5459aa741..98884e1fb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,6 +55,10 @@ jobs: if: steps.filter.outputs.code == 'true' run: cargo clippy --workspace --all-features -- -D warnings + - name: Test ashell-services + if: steps.filter.outputs.code == 'true' + run: cargo test -p ashell-services --all-features + - name: Build if: steps.filter.outputs.code == 'true' run: cargo build diff --git a/.github/workflows/pre-release.yml b/.github/workflows/pre-release.yml index 294d9537e..e226430a2 100644 --- a/.github/workflows/pre-release.yml +++ b/.github/workflows/pre-release.yml @@ -78,8 +78,8 @@ jobs: # Install cargo-edit for `cargo set-version` cargo install cargo-edit -f --no-default-features --features "set-version" - # Update the root package version - cargo set-version "${VERSION}" + # Update every workspace member, so the crates stay in step with ashell + cargo set-version --workspace "${VERSION}" - name: Git add updated cargo toml run: | diff --git a/Makefile b/Makefile index 4f2518402..61d371e7f 100644 --- a/Makefile +++ b/Makefile @@ -29,3 +29,4 @@ fmt: check: fmt cargo check --workspace cargo clippy --workspace -- -D warnings + cargo test -p ashell-services --all-features