//! Do-Not-Disturb toggle + status for SwayNC or Dunst. //! //! SwayNC exposes a `dnd` property on its `org.erikreider.swaync.control` //! interface that fires PropertiesChanged signals, so we subscribe. Dunst has //! no change signal for its `paused` property, so we fall back to a 2 s poll. use crate::config::Config; use crate::error::Result; use crate::modules::WaybarModule; 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::proxy; use zbus::zvariant::OwnedValue; use zbus::{Connection, fdo::PropertiesProxy}; /// Renders + toggles DND state. Args: `["show"]` (default) or `["toggle"]`. pub struct DndModule; /// Read dunst's `paused` property via raw D-Bus call. async fn dunst_get_paused(connection: &Connection) -> anyhow::Result { let reply = connection .call_method( Some("org.freedesktop.Notifications"), "/org/freedesktop/Notifications", Some("org.freedesktop.DBus.Properties"), "Get", &("org.dunstproject.cmd0", "paused"), ) .await?; let value: OwnedValue = reply.body().deserialize()?; Ok(bool::try_from(&*value)?) } /// Set dunst's `paused` property via raw D-Bus call. async fn dunst_set_paused(connection: &Connection, paused: bool) -> anyhow::Result<()> { let value = zbus::zvariant::Value::from(paused); connection .call_method( Some("org.freedesktop.Notifications"), "/org/freedesktop/Notifications", Some("org.freedesktop.DBus.Properties"), "Set", &("org.dunstproject.cmd0", "paused", value), ) .await?; Ok(()) } impl WaybarModule for DndModule { async fn run( &self, config: &Config, state: &AppReceivers, args: &[&str], ) -> Result { let action = args.first().unwrap_or(&"show"); if *action == "toggle" { let connection = Connection::session() .await .map_err(|e| crate::error::FluxoError::Module { module: "dnd", message: format!("DBus connection failed: {}", e), })?; if let Ok(proxy) = SwayncControlProxy::new(&connection).await && let Ok(is_dnd) = proxy.dnd().await { let _ = proxy.set_dnd(!is_dnd).await; return Ok(WaybarOutput::default()); } if let Ok(is_paused) = dunst_get_paused(&connection).await { let _ = dunst_set_paused(&connection, !is_paused).await; return Ok(WaybarOutput::default()); } return Err(crate::error::FluxoError::Module { module: "dnd", message: "No supported notification daemon found to toggle".to_string(), }); } let is_dnd = state.dnd.borrow().is_dnd; if is_dnd { Ok(WaybarOutput { text: config.dnd.format_dnd.clone(), tooltip: Some("Do Not Disturb: On".to_string()), class: Some("dnd".to_string()), percentage: None, }) } else { Ok(WaybarOutput { text: config.dnd.format_normal.clone(), tooltip: Some("Do Not Disturb: Off".to_string()), class: Some("normal".to_string()), percentage: None, }) } } } /// Background watcher that keeps [`DndState`] in sync with the active /// notification daemon (SwayNC via signals, Dunst via polling). pub struct DndDaemon; #[proxy( interface = "org.erikreider.swaync.control", default_service = "org.erikreider.swaync.control", default_path = "/org/erikreider/swaync/control" )] trait SwayncControl { #[zbus(property)] fn dnd(&self) -> zbus::Result; #[zbus(property)] fn set_dnd(&self, value: bool) -> zbus::Result<()>; } impl DndDaemon { /// Construct a new (stateless) daemon. pub fn new() -> Self { Self } /// Spawn a supervised listen loop that reconnects with a 5 s backoff. pub fn start(&self, tx: watch::Sender) { tokio::spawn(async move { loop { if let Err(e) = Self::listen_loop(&tx).await { error!("DND listener error: {}", e); tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; } } }); } async fn listen_loop(tx: &watch::Sender) -> anyhow::Result<()> { let connection = Connection::session().await?; info!("Connected to D-Bus for DND monitoring"); if let Ok(proxy) = SwayncControlProxy::new(&connection).await && let Ok(is_dnd) = proxy.dnd().await { debug!("Found SwayNC, using signal-based DND monitoring"); let _ = tx.send(DndState { is_dnd }); if let Ok(props_proxy) = PropertiesProxy::builder(&connection) .destination("org.erikreider.swaync.control")? .path("/org/erikreider/swaync/control")? .build() .await { 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.erikreider.swaync.control" && let Some(val) = args.changed_properties.get("dnd") && let Ok(is_dnd) = bool::try_from(val) { let _ = tx.send(DndState { is_dnd }); } } } return Err(anyhow::anyhow!("SwayNC DND stream ended")); } // Dunst: raw D-Bus call avoids zbus proxy typing quirks with its // non-standard `org.dunstproject.cmd0` interface. match dunst_get_paused(&connection).await { Ok(is_paused) => { 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 dunst_get_paused(&connection).await { Ok(is_paused) => { 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(e) => { info!("Dunst not available: {}", e); } } Err(anyhow::anyhow!( "No supported notification daemon found (tried SwayNC, Dunst)" )) } }