From 57906de9205c69a59c7cbbc97405256804e00e7b Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Sat, 4 Apr 2026 14:43:35 +0200 Subject: [PATCH] redesign bt module/menu --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/daemon.rs | 4 + src/main.rs | 84 ++++++++---- src/modules/audio.rs | 32 ++--- src/modules/bt/mod.rs | 288 ++++++++++++++++++++++++++---------------- src/modules/dnd.rs | 83 ++++++++---- src/state.rs | 24 +++- 8 files changed, 342 insertions(+), 177 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ea262f7..f00c74d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -572,7 +572,7 @@ checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" [[package]] name = "fluxo-rs" -version = "0.4.2" +version = "0.5.0" dependencies = [ "anyhow", "bluer", diff --git a/Cargo.toml b/Cargo.toml index 5fa1538..cee90ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fluxo-rs" -version = "0.4.3" +version = "0.5.0" edition = "2024" [features] diff --git a/src/daemon.rs b/src/daemon.rs index 0406747..2d492ff 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -85,6 +85,8 @@ pub async fn run_daemon(config_path: Option) -> Result<()> { let health = Arc::new(RwLock::new(HashMap::new())); #[cfg(feature = "mod-bt")] let (bt_force_tx, mut bt_force_rx) = mpsc::channel(1); + #[cfg(feature = "mod-bt")] + let bt_cycle = Arc::new(RwLock::new(0usize)); #[cfg(feature = "mod-audio")] let (audio_cmd_tx, audio_cmd_rx) = mpsc::channel(8); @@ -103,6 +105,8 @@ pub async fn run_daemon(config_path: Option) -> Result<()> { disks: disks_rx, #[cfg(feature = "mod-bt")] bluetooth: bt_rx, + #[cfg(feature = "mod-bt")] + bt_cycle, #[cfg(feature = "mod-audio")] audio: audio_rx, #[cfg(feature = "mod-dbus")] diff --git a/src/main.rs b/src/main.rs index 17f0e48..6d4bef4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -83,43 +83,79 @@ fn main() { let config = config::load_config(None); let mut items = Vec::new(); - if let Ok(json_str) = ipc::request_data("bt", &["get_modes"]) - && let Ok(val) = serde_json::from_str::(&json_str) - && let Some(modes_str) = val.get("text").and_then(|t| t.as_str()) - && !modes_str.is_empty() - { - for mode in modes_str.lines() { - items.push(format!("Mode: {}", mode)); - } - } - - if !items.is_empty() { - items.push("Disconnect".to_string()); - } - - items.push("--- Connect Device ---".to_string()); + // Parse menu_data to get connected and paired devices + let mut connected: Vec<(String, String)> = Vec::new(); // (alias, mac) + let mut paired: Vec<(String, String)> = Vec::new(); if let Ok(json_str) = ipc::request_data("bt", &["menu_data"]) && let Ok(val) = serde_json::from_str::(&json_str) - && let Some(devices_str) = val.get("text").and_then(|t| t.as_str()) + && let Some(text) = val.get("text").and_then(|t| t.as_str()) { - for line in devices_str.lines() { - if !line.is_empty() { - items.push(line.to_string()); + for line in text.lines() { + if let Some(rest) = line.strip_prefix("CONNECTED:") + && let Some((alias, mac)) = rest.split_once('|') + { + connected.push((alias.to_string(), mac.to_string())); + } else if let Some(rest) = line.strip_prefix("PAIRED:") + && let Some((alias, mac)) = rest.split_once('|') + { + paired.push((alias.to_string(), mac.to_string())); } } } + // Per-device sections for connected devices + for (alias, mac) in &connected { + // Get modes for this specific device + if let Ok(json_str) = ipc::request_data("bt", &["get_modes", mac]) + && let Ok(val) = serde_json::from_str::(&json_str) + && let Some(modes_str) = val.get("text").and_then(|t| t.as_str()) + && !modes_str.is_empty() + { + for mode in modes_str.lines() { + items.push(format!("{}: Mode: {} [{}]", alias, mode, mac)); + } + } + items.push(format!("Disconnect {} [{}]", alias, mac)); + } + + // Separator and paired devices for connecting + if !paired.is_empty() { + items.push("--- Connect Device ---".to_string()); + for (alias, mac) in &paired { + items.push(format!("{} ({})", alias, mac)); + } + } + if !items.is_empty() { if let Ok(selected) = utils::show_menu("BT Menu: ", &items, &config.general.menu_command) { - if let Some(mode) = selected.strip_prefix("Mode: ") { - handle_ipc_response(ipc::request_data("bt", &["set_mode", mode])); - } else if selected == "Disconnect" { - handle_ipc_response(ipc::request_data("bt", &["disconnect"])); + if selected.contains(": Mode: ") { + // ": Mode: []" + if let Some(bracket_start) = selected.rfind('[') + && let Some(bracket_end) = selected.rfind(']') + { + let mac = &selected[bracket_start + 1..bracket_end]; + if let Some(mode_start) = selected.find(": Mode: ") { + let mode = + &selected[mode_start + ": Mode: ".len()..bracket_start - 1]; + handle_ipc_response(ipc::request_data( + "bt", + &["set_mode", mode, mac], + )); + } + } + } else if selected.starts_with("Disconnect ") { + // "Disconnect []" + if let Some(bracket_start) = selected.rfind('[') + && let Some(bracket_end) = selected.rfind(']') + { + let mac = &selected[bracket_start + 1..bracket_end]; + handle_ipc_response(ipc::request_data("bt", &["disconnect", mac])); + } } else if selected == "--- Connect Device ---" { - // Do nothing + // separator, do nothing } else if let Some(mac_start) = selected.rfind('(') && let Some(mac_end) = selected.rfind(')') { diff --git a/src/modules/audio.rs b/src/modules/audio.rs index b4a5d5b..2f58eed 100644 --- a/src/modules/audio.rs +++ b/src/modules/audio.rs @@ -9,6 +9,7 @@ use libpulse_binding::context::subscribe::{Facility, InterestMaskSet}; use libpulse_binding::context::{Context, FlagSet as ContextFlag}; use libpulse_binding::mainloop::threaded::Mainloop as ThreadedMainloop; use libpulse_binding::volume::Volume; +use std::sync::Arc; use tokio::sync::{mpsc, watch}; use tracing::error; @@ -230,15 +231,16 @@ fn fetch_audio_data_sync( }); let tx_sink = state_tx.clone(); + let pending_sinks: Arc>> = + Arc::new(std::sync::Mutex::new(Vec::new())); + let pending_sinks_cb = Arc::clone(&pending_sinks); context.introspect().get_sink_info_list(move |res| { let mut current = tx_sink.borrow().clone(); match res { ListResult::Item(item) => { if let Some(name) = item.name.as_ref() { let name_str = name.to_string(); - if !current.available_sinks.contains(&name_str) { - current.available_sinks.push(name_str); - } + pending_sinks_cb.lock().unwrap().push(name_str); } let is_default = item @@ -261,8 +263,7 @@ fn fetch_audio_data_sync( let _ = tx_sink.send(current); } ListResult::End => { - // Clear the list on End so it rebuilds fresh next time - current.available_sinks.clear(); + current.available_sinks = pending_sinks_cb.lock().unwrap().drain(..).collect(); let _ = tx_sink.send(current); } ListResult::Error => {} @@ -270,17 +271,17 @@ fn fetch_audio_data_sync( }); let tx_source = state_tx.clone(); + let pending_sources: Arc>> = + Arc::new(std::sync::Mutex::new(Vec::new())); + let pending_sources_cb = Arc::clone(&pending_sources); context.introspect().get_source_info_list(move |res| { let mut current = tx_source.borrow().clone(); match res { ListResult::Item(item) => { if let Some(name) = item.name.as_ref() { let name_str = name.to_string(); - // PulseAudio includes monitor sources, ignore them if we want to - if !name_str.contains(".monitor") - && !current.available_sources.contains(&name_str) - { - current.available_sources.push(name_str); + if !name_str.contains(".monitor") { + pending_sources_cb.lock().unwrap().push(name_str); } } @@ -304,8 +305,7 @@ fn fetch_audio_data_sync( let _ = tx_source.send(current); } ListResult::End => { - // Clear the list on End so it rebuilds fresh next time - current.available_sources.clear(); + current.available_sources = pending_sources_cb.lock().unwrap().drain(..).collect(); let _ = tx_source.send(current); } ListResult::Error => {} @@ -331,19 +331,19 @@ impl WaybarModule for AudioModule { match *action { "up" => { self.change_volume(state, target_type, step, true).await?; - Ok(WaybarOutput::default()) + self.get_status(config, state, target_type).await } "down" => { self.change_volume(state, target_type, step, false).await?; - Ok(WaybarOutput::default()) + self.get_status(config, state, target_type).await } "mute" => { self.toggle_mute(state, target_type).await?; - Ok(WaybarOutput::default()) + self.get_status(config, state, target_type).await } "cycle" => { self.cycle_device(state, target_type).await?; - Ok(WaybarOutput::default()) + self.get_status(config, state, target_type).await } "show" => self.get_status(config, state, target_type).await, other => Err(FluxoError::Module { diff --git a/src/modules/bt/mod.rs b/src/modules/bt/mod.rs index 382c5bd..dff02a2 100644 --- a/src/modules/bt/mod.rs +++ b/src/modules/bt/mod.rs @@ -5,7 +5,7 @@ use crate::config::Config; use crate::error::Result as FluxoResult; use crate::modules::WaybarModule; use crate::output::WaybarOutput; -use crate::state::{AppReceivers, BtState}; +use crate::state::{AppReceivers, BtDeviceInfo, BtState}; use crate::utils::{TokenValue, format_template}; use anyhow::Result; use std::sync::LazyLock; @@ -47,60 +47,64 @@ impl BtDaemon { let adapter = session.default_adapter().await?; let adapter_powered = adapter.is_powered().await.unwrap_or(false); - let mut bt_state = BtState { - adapter_powered, - ..Default::default() - }; + let mut connected_devices = Vec::new(); if adapter_powered { - let devices = adapter.device_addresses().await?; - for addr in devices { - let device = adapter.device(addr)?; - if device.is_connected().await.unwrap_or(false) { - let uuids = device.uuids().await?.unwrap_or_default(); - let audio_sink_uuid = - bluer::Uuid::from_u128(0x0000110b_0000_1000_8000_00805f9b34fb); - if uuids.contains(&audio_sink_uuid) { - bt_state.connected = true; - bt_state.device_address = addr.to_string(); - bt_state.device_alias = - device.alias().await.unwrap_or_else(|_| addr.to_string()); - bt_state.battery_percentage = - device.battery_percentage().await.unwrap_or(None); + let mut addresses = adapter.device_addresses().await?; + addresses.sort(); + let audio_sink_uuid = bluer::Uuid::from_u128(0x0000110b_0000_1000_8000_00805f9b34fb); - for p in PLUGINS.iter() { - if p.can_handle(&bt_state.device_alias, &bt_state.device_address) { - match p.get_data(config, state, &bt_state.device_address).await { - Ok(data) => { - bt_state.plugin_data = data - .into_iter() - .map(|(k, v)| { - let val_str = match v { - TokenValue::String(s) => s, - TokenValue::Int(i) => i.to_string(), - TokenValue::Float(f) => format!("{:.1}", f), - }; - (k, val_str) - }) - .collect(); - } - Err(e) => { - warn!("Plugin {} failed for {}: {}", p.name(), addr, e); - bt_state - .plugin_data - .push(("plugin_error".to_string(), e.to_string())); - } - } - break; + for addr in addresses { + let device = adapter.device(addr)?; + if !device.is_connected().await.unwrap_or(false) { + continue; + } + let uuids = device.uuids().await?.unwrap_or_default(); + if !uuids.contains(&audio_sink_uuid) { + continue; + } + + let mut dev_info = BtDeviceInfo { + device_address: addr.to_string(), + device_alias: device.alias().await.unwrap_or_else(|_| addr.to_string()), + battery_percentage: device.battery_percentage().await.unwrap_or(None), + plugin_data: vec![], + }; + + for p in PLUGINS.iter() { + if p.can_handle(&dev_info.device_alias, &dev_info.device_address) { + match p.get_data(config, state, &dev_info.device_address).await { + Ok(data) => { + dev_info.plugin_data = data + .into_iter() + .map(|(k, v)| { + let val_str = match v { + TokenValue::String(s) => s, + TokenValue::Int(i) => i.to_string(), + TokenValue::Float(f) => format!("{:.1}", f), + }; + (k, val_str) + }) + .collect(); + } + Err(e) => { + warn!("Plugin {} failed for {}: {}", p.name(), addr, e); + dev_info + .plugin_data + .push(("plugin_error".to_string(), e.to_string())); } } break; } } + connected_devices.push(dev_info); } } - let _ = tx.send(bt_state); + let _ = tx.send(BtState { + adapter_powered, + devices: connected_devices, + }); Ok(()) } @@ -111,8 +115,6 @@ static PLUGINS: LazyLock>> = fn trigger_robust_poll(state: AppReceivers) { tokio::spawn(async move { - // Poll immediately and then a few times over the next few seconds - // to catch slow state changes in bluez or plugins. for delay in [200, 500, 1000, 2000, 3000] { tokio::time::sleep(std::time::Duration::from_millis(delay)).await; let _ = state.bt_force_poll.try_send(()); @@ -120,6 +122,26 @@ fn trigger_robust_poll(state: AppReceivers) { }); } +/// Resolve a target MAC: use explicit arg if given, otherwise fall back to the active device. +async fn resolve_target_mac( + bt_state: &BtState, + state: &AppReceivers, + explicit_mac: Option<&str>, +) -> Option { + if let Some(mac) = explicit_mac { + return Some(mac.to_string()); + } + let idx = *state.bt_cycle.read().await; + bt_state + .active_device(idx) + .map(|d| d.device_address.clone()) +} + +/// Find a device in the current state by MAC. +fn find_device<'a>(bt_state: &'a BtState, mac: &str) -> Option<&'a BtDeviceInfo> { + bt_state.devices.iter().find(|d| d.device_address == mac) +} + pub struct BtModule; impl WaybarModule for BtModule { @@ -129,12 +151,10 @@ impl WaybarModule for BtModule { state: &AppReceivers, args: &[&str], ) -> FluxoResult { - let action = args.first().cloned().unwrap_or("show").to_string(); - let args = args.iter().map(|s| s.to_string()).collect::>(); - + let action = args.first().cloned().unwrap_or("show"); let bt_state = state.bluetooth.borrow().clone(); - match action.as_str() { + match action { "connect" => { if let Some(mac) = args.get(1) { if let Ok(session) = bluer::Session::new().await @@ -148,77 +168,125 @@ impl WaybarModule for BtModule { } return Ok(WaybarOutput::default()); } - "disconnect" if bt_state.connected => { - if let Ok(session) = bluer::Session::new().await - && let Ok(adapter) = session.default_adapter().await - && let Ok(addr) = bt_state.device_address.parse::() - && let Ok(device) = adapter.device(addr) - { - let _ = device.disconnect().await; - } - trigger_robust_poll(state.clone()); - return Ok(WaybarOutput::default()); - } - "menu_data" => { - let mut devs = Vec::new(); - if let Ok(session) = bluer::Session::new().await - && let Ok(adapter) = session.default_adapter().await - && let Ok(addresses) = adapter.device_addresses().await - { - for addr in addresses { - if let Ok(device) = adapter.device(addr) - && device.is_paired().await.unwrap_or(false) - { - let alias = device.alias().await.unwrap_or_else(|_| addr.to_string()); - devs.push(format!("{} ({})", alias, addr)); - } + "disconnect" => { + let target_mac = resolve_target_mac(&bt_state, state, args.get(1).copied()).await; + if let Some(mac) = target_mac { + if let Ok(session) = bluer::Session::new().await + && let Ok(adapter) = session.default_adapter().await + && let Ok(addr) = mac.parse::() + && let Ok(device) = adapter.device(addr) + { + let _ = device.disconnect().await; } - } - return Ok(WaybarOutput { - text: devs.join("\n"), - ..Default::default() - }); - } - "cycle_mode" if bt_state.connected => { - let plugin = PLUGINS - .iter() - .find(|p| p.can_handle(&bt_state.device_alias, &bt_state.device_address)); - if let Some(p) = plugin { - p.cycle_mode(&bt_state.device_address, state).await?; trigger_robust_poll(state.clone()); } return Ok(WaybarOutput::default()); } - "get_modes" if bt_state.connected => { - let plugin = PLUGINS - .iter() - .find(|p| p.can_handle(&bt_state.device_alias, &bt_state.device_address)); - let modes = if let Some(p) = plugin { - p.get_modes(&bt_state.device_address, state).await? - } else { - vec![] - }; + "cycle" => { + let count = bt_state.devices.len(); + if count > 1 { + let mut idx = state.bt_cycle.write().await; + *idx = (*idx + 1) % count; + } + let _ = state.bt_force_poll.try_send(()); + return Ok(WaybarOutput::default()); + } + "menu_data" => { + let mut lines = Vec::new(); + + // Connected devices + for dev in &bt_state.devices { + lines.push(format!( + "CONNECTED:{}|{}", + dev.device_alias, dev.device_address + )); + } + + // Paired-but-not-connected devices + if let Ok(session) = bluer::Session::new().await + && let Ok(adapter) = session.default_adapter().await + && let Ok(addresses) = adapter.device_addresses().await + { + let connected_macs: std::collections::HashSet<&str> = bt_state + .devices + .iter() + .map(|d| d.device_address.as_str()) + .collect(); + + for addr in addresses { + let addr_str = addr.to_string(); + if connected_macs.contains(addr_str.as_str()) { + continue; + } + if let Ok(device) = adapter.device(addr) + && device.is_paired().await.unwrap_or(false) + { + let alias = device.alias().await.unwrap_or_else(|_| addr.to_string()); + lines.push(format!("PAIRED:{}|{}", alias, addr_str)); + } + } + } + return Ok(WaybarOutput { - text: modes.join("\n"), + text: lines.join("\n"), ..Default::default() }); } - "set_mode" if bt_state.connected => { - if let Some(mode) = args.get(1) { + "get_modes" => { + let target_mac = resolve_target_mac(&bt_state, state, args.get(1).copied()).await; + if let Some(mac) = target_mac + && let Some(dev) = find_device(&bt_state, &mac) + { let plugin = PLUGINS .iter() - .find(|p| p.can_handle(&bt_state.device_alias, &bt_state.device_address)); + .find(|p| p.can_handle(&dev.device_alias, &dev.device_address)); if let Some(p) = plugin { - p.set_mode(mode, &bt_state.device_address, state).await?; + let modes = p.get_modes(&mac, state).await?; + return Ok(WaybarOutput { + text: modes.join("\n"), + ..Default::default() + }); + } + } + return Ok(WaybarOutput::default()); + } + "set_mode" => { + if let Some(mode) = args.get(1) { + let target_mac = + resolve_target_mac(&bt_state, state, args.get(2).copied()).await; + if let Some(mac) = target_mac + && let Some(dev) = find_device(&bt_state, &mac) + { + let plugin = PLUGINS + .iter() + .find(|p| p.can_handle(&dev.device_alias, &dev.device_address)); + if let Some(p) = plugin { + p.set_mode(mode, &mac, state).await?; + trigger_robust_poll(state.clone()); + } + } + } + return Ok(WaybarOutput::default()); + } + "cycle_mode" => { + let target_mac = resolve_target_mac(&bt_state, state, args.get(1).copied()).await; + if let Some(mac) = target_mac + && let Some(dev) = find_device(&bt_state, &mac) + { + let plugin = PLUGINS + .iter() + .find(|p| p.can_handle(&dev.device_alias, &dev.device_address)); + if let Some(p) = plugin { + p.cycle_mode(&mac, state).await?; trigger_robust_poll(state.clone()); } } return Ok(WaybarOutput::default()); } - "show" => {} _ => {} } + // "show" and fallthrough if !bt_state.adapter_powered { return Ok(WaybarOutput { text: config.bt.format_disabled.clone(), @@ -228,22 +296,23 @@ impl WaybarModule for BtModule { }); } - if bt_state.connected { + let cycle_idx = *state.bt_cycle.read().await; + if let Some(dev) = bt_state.active_device(cycle_idx) { let mut tokens: Vec<(String, TokenValue)> = vec![ ( "alias".to_string(), - TokenValue::String(bt_state.device_alias.clone()), + TokenValue::String(dev.device_alias.clone()), ), ( "mac".to_string(), - TokenValue::String(bt_state.device_address.clone()), + TokenValue::String(dev.device_address.clone()), ), ]; let mut class = vec!["connected".to_string()]; let mut has_plugin = false; - for (k, v) in &bt_state.plugin_data { + for (k, v) in &dev.plugin_data { if k == "plugin_class" { class.push(v.clone()); has_plugin = true; @@ -263,10 +332,9 @@ impl WaybarModule for BtModule { let text = format_template(format, &tokens); let tooltip = format!( "{} | MAC: {}\nBattery: {}", - bt_state.device_alias, - bt_state.device_address, - bt_state - .battery_percentage + dev.device_alias, + dev.device_address, + dev.battery_percentage .map(|b| format!("{}%", b)) .unwrap_or_else(|| "N/A".to_string()) ); @@ -275,7 +343,7 @@ impl WaybarModule for BtModule { text, tooltip: Some(tooltip), class: Some(class.join(" ")), - percentage: bt_state.battery_percentage, + percentage: dev.battery_percentage, }) } else { Ok(WaybarOutput { diff --git a/src/modules/dnd.rs b/src/modules/dnd.rs index d9451ba..245b6ac 100644 --- a/src/modules/dnd.rs +++ b/src/modules/dnd.rs @@ -5,8 +5,9 @@ use crate::output::WaybarOutput; use crate::state::{AppReceivers, DndState}; use futures::StreamExt; use tokio::sync::watch; +use tokio::time::Duration; use tracing::{debug, error, info}; -use zbus::{Connection, fdo::PropertiesProxy, proxy}; +use zbus::{Connection, fdo::PropertiesProxy, names::InterfaceName, proxy}; pub struct DndModule; @@ -117,11 +118,11 @@ impl DndDaemon { info!("Connected to D-Bus for DND monitoring"); - // Try SwayNC + // Try SwayNC first (signal-based) if let Ok(proxy) = SwayncControlProxy::new(&connection).await && let Ok(is_dnd) = proxy.dnd().await { - debug!("Found SwayNC, using it for DND state."); + debug!("Found SwayNC, using signal-based DND monitoring"); let _ = tx.send(DndState { is_dnd }); if let Ok(props_proxy) = PropertiesProxy::builder(&connection) @@ -141,34 +142,72 @@ impl DndDaemon { } } } + + return Err(anyhow::anyhow!("SwayNC DND stream ended")); } - // Try Dunst - if let Ok(proxy) = DunstControlProxy::new(&connection).await - && let Ok(is_dnd) = proxy.paused().await - { - debug!("Found Dunst, using it for DND state."); - let _ = tx.send(DndState { is_dnd }); + // Try Dunst (polling via raw D-Bus Properties.Get for maximum compatibility) + let props_proxy = PropertiesProxy::builder(&connection) + .destination("org.freedesktop.Notifications")? + .path("/org/freedesktop/Notifications")? + .build() + .await; - if let Ok(props_proxy) = PropertiesProxy::builder(&connection) - .destination("org.freedesktop.Notifications")? - .path("/org/freedesktop/Notifications")? - .build() - .await + match &props_proxy { + Ok(_) => debug!("Created Properties proxy for Dunst"), + Err(e) => debug!("Failed to create Properties proxy for Dunst: {}", e), + } + + if let Ok(props) = props_proxy { + // Read paused property via raw Properties.Get + let initial = props + .get( + InterfaceName::from_static_str_unchecked("org.dunstproject.cmd0"), + "paused", + ) + .await; + + match &initial { + Ok(_) => debug!("Successfully read Dunst paused property"), + Err(e) => debug!("Failed to read Dunst paused property: {}", e), + } + + if let Ok(value) = initial + && let Ok(is_paused) = bool::try_from(&*value) { - let mut stream = props_proxy.receive_properties_changed().await?; - while let Some(signal) = stream.next().await { - let args = signal.args()?; - if args.interface_name == "org.dunstproject.cmd0" - && let Some(val) = args.changed_properties.get("paused") - && let Ok(is_dnd) = bool::try_from(val) + info!("Found Dunst, using polling-based DND monitoring"); + let _ = tx.send(DndState { is_dnd: is_paused }); + + loop { + tokio::time::sleep(Duration::from_secs(2)).await; + match props + .get( + InterfaceName::from_static_str_unchecked("org.dunstproject.cmd0"), + "paused", + ) + .await { - let _ = tx.send(DndState { is_dnd }); + Ok(value) => { + if let Ok(is_paused) = bool::try_from(&*value) { + let current = tx.borrow().is_dnd; + if current != is_paused { + let _ = tx.send(DndState { is_dnd: is_paused }); + } + } + } + Err(e) => { + debug!("Dunst paused() poll failed: {}", e); + break; + } } } + + return Err(anyhow::anyhow!("Dunst connection lost")); } } - Err(anyhow::anyhow!("DND stream ended or daemon not found")) + Err(anyhow::anyhow!( + "No supported notification daemon found (tried SwayNC, Dunst)" + )) } } diff --git a/src/state.rs b/src/state.rs index 0c67e82..557c2f3 100644 --- a/src/state.rs +++ b/src/state.rs @@ -20,6 +20,8 @@ pub struct AppReceivers { pub disks: watch::Receiver>, #[cfg(feature = "mod-bt")] pub bluetooth: watch::Receiver, + #[cfg(feature = "mod-bt")] + pub bt_cycle: Arc>, #[cfg(feature = "mod-audio")] pub audio: watch::Receiver, #[cfg(feature = "mod-dbus")] @@ -76,15 +78,29 @@ pub struct AudioSourceInfo { } #[derive(Default, Clone)] -pub struct BtState { - pub connected: bool, - pub adapter_powered: bool, +pub struct BtDeviceInfo { pub device_alias: String, pub device_address: String, pub battery_percentage: Option, pub plugin_data: Vec<(String, String)>, } +#[derive(Default, Clone)] +pub struct BtState { + pub adapter_powered: bool, + pub devices: Vec, +} + +impl BtState { + pub fn active_device(&self, index: usize) -> Option<&BtDeviceInfo> { + if self.devices.is_empty() { + None + } else { + Some(&self.devices[index % self.devices.len()]) + } + } +} + #[derive(Default, Clone)] pub struct DiskInfo { pub mount_point: String, @@ -295,6 +311,8 @@ pub fn mock_state(state: AppState) -> MockState { disks: disks_rx, #[cfg(feature = "mod-bt")] bluetooth: bt_rx, + #[cfg(feature = "mod-bt")] + bt_cycle: Arc::new(RwLock::new(0usize)), #[cfg(feature = "mod-audio")] audio: audio_rx, #[cfg(feature = "mod-dbus")]