diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/app_map.rs | 282 | ||||
| -rw-r--r-- | src/badge.rs | 79 | ||||
| -rw-r--r-- | src/dbus_glue.rs | 77 | ||||
| -rw-r--r-- | src/discovery.rs | 157 | ||||
| -rw-r--r-- | src/lib.rs | 10 | ||||
| -rw-r--r-- | src/main.rs | 169 | ||||
| -rw-r--r-- | src/parser.rs | 421 |
7 files changed, 1195 insertions, 0 deletions
diff --git a/src/app_map.rs b/src/app_map.rs new file mode 100644 index 0000000..07a4cf3 --- /dev/null +++ b/src/app_map.rs @@ -0,0 +1,282 @@ +use std::collections::{HashMap, HashSet}; + +/// Maps notification app names / desktop-entry hints to Unity desktop IDs, +/// and tracks active notification IDs per app. +#[derive(Debug, Default)] +pub struct AppMap { + /// pattern (lowercase) -> "application://<id>.desktop" + patterns: HashMap<String, String>, + /// desktop_id -> set of active notification IDs + active: HashMap<String, HashSet<u32>>, + /// notification_id -> desktop_id + notif_to_app: HashMap<u32, String>, + /// pending Notify calls: serial -> desktop_id + pending: HashMap<u64, String>, +} + +impl AppMap { + pub fn new() -> Self { + Self::default() + } + + /// Replace the pattern map with a new set of discovered apps. + /// Returns true if the map changed. + pub fn update_patterns(&mut self, new_map: HashMap<String, String>) -> bool { + if new_map == self.patterns { + return false; + } + self.patterns = new_map; + true + } + + /// Return the desktop_id if any candidate matches a known app pattern. + pub fn match_app(&self, candidates: &[&str]) -> Option<String> { + candidates + .iter() + .filter(|c| !c.is_empty()) + .find_map(|candidate| self.find_pattern(&candidate.to_lowercase())) + } + + fn find_pattern(&self, lower: &str) -> Option<String> { + self.patterns + .iter() + .find(|(pattern, _)| lower.contains(pattern.as_str())) + .map(|(_, desktop_id)| desktop_id.clone()) + } + + /// Record a pending Notify call (serial -> desktop_id) after matching. + /// Handles replaces_id: if the notification replaces an existing one, + /// the old one is removed from tracking first. + pub fn record_notify(&mut self, serial: u64, replaces_id: u32, desktop_id: &str) { + if replaces_id > 0 { + if let Some(old_desktop) = self.notif_to_app.remove(&replaces_id) { + if let Some(set) = self.active.get_mut(&old_desktop) { + set.remove(&replaces_id); + } + } + } + self.pending.insert(serial, desktop_id.to_string()); + } + + /// Resolve a method_return for a Notify call. Returns (desktop_id, new_count) if matched. + pub fn resolve_reply(&mut self, reply_serial: u64, nid: u32) -> Option<(String, usize)> { + let desktop_id = self.pending.remove(&reply_serial)?; + self.notif_to_app.insert(nid, desktop_id.clone()); + let set = self.active.entry(desktop_id.clone()).or_default(); + set.insert(nid); + Some((desktop_id, set.len())) + } + + /// Record that a notification was closed. Returns (desktop_id, new_count) if tracked. + pub fn notification_closed(&mut self, nid: u32) -> Option<(String, usize)> { + let desktop_id = self.notif_to_app.remove(&nid)?; + let set = self.active.entry(desktop_id.clone()).or_default(); + set.remove(&nid); + Some((desktop_id, set.len())) + } + + /// Return the count of active notifications for a desktop_id. + pub fn count(&self, desktop_id: &str) -> usize { + self.active.get(desktop_id).map_or(0, |s| s.len()) + } + + /// Return all unique desktop_ids that have been registered (from patterns). + pub fn all_desktop_ids(&self) -> Vec<String> { + let mut seen = HashSet::new(); + let mut ids = Vec::new(); + for id in self.patterns.values() { + if seen.insert(id.clone()) { + ids.push(id.clone()); + } + } + ids + } + + /// Clear all active notifications for every app, returning the desktop_ids that were cleared. + pub fn clear_all(&mut self) -> Vec<String> { + let ids = self.all_desktop_ids(); + for id in &ids { + if let Some(set) = self.active.get_mut(id) { + for nid in set.drain() { + self.notif_to_app.remove(&nid); + } + } + } + ids + } + + /// Return the current pattern map for logging. + pub fn patterns(&self) -> &HashMap<String, String> { + &self.patterns + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_map() -> HashMap<String, String> { + let mut m = HashMap::new(); + m.insert( + "firefox".into(), + "application://org.mozilla.firefox.desktop".into(), + ); + m.insert( + "org.mozilla.firefox".into(), + "application://org.mozilla.firefox.desktop".into(), + ); + m.insert( + "slack".into(), + "application://com.slack.Slack.desktop".into(), + ); + m.insert( + "com.slack.slack".into(), + "application://com.slack.Slack.desktop".into(), + ); + m + } + + #[test] + fn test_match_app_by_short_name() { + let mut am = AppMap::new(); + am.update_patterns(sample_map()); + + let ff = Some("application://org.mozilla.firefox.desktop".to_string()); + let sl = Some("application://com.slack.Slack.desktop".to_string()); + + assert_eq!(am.match_app(&["firefox"]), ff); + assert_eq!(am.match_app(&["FIREFOX"]), ff); + assert_eq!(am.match_app(&["FireFox"]), ff); + assert_eq!(am.match_app(&["Slack"]), sl); + } + + #[test] + fn test_match_app_by_desktop_entry() { + let mut am = AppMap::new(); + am.update_patterns(sample_map()); + + assert_eq!( + am.match_app(&["", "com.slack.Slack"]), + Some("application://com.slack.Slack.desktop".into()) + ); + } + + #[test] + fn test_match_app_no_match() { + let mut am = AppMap::new(); + am.update_patterns(sample_map()); + + assert_eq!(am.match_app(&["unknown-app"]), None); + assert_eq!(am.match_app(&[""]), None); + assert_eq!(am.match_app(&[]), None); + } + + #[test] + fn test_notify_reply_closed_lifecycle() { + let mut am = AppMap::new(); + am.update_patterns(sample_map()); + + let desktop = "application://org.mozilla.firefox.desktop"; + + // Notify call with serial=100, replaces_id=0 + am.record_notify(100, 0, desktop); + + // Reply with notification ID 42 + let result = am.resolve_reply(100, 42); + assert_eq!(result, Some((desktop.into(), 1))); + assert_eq!(am.count(desktop), 1); + + // Second notification + am.record_notify(101, 0, desktop); + let result = am.resolve_reply(101, 43); + assert_eq!(result, Some((desktop.into(), 2))); + assert_eq!(am.count(desktop), 2); + + // Close first + let result = am.notification_closed(42); + assert_eq!(result, Some((desktop.into(), 1))); + assert_eq!(am.count(desktop), 1); + + // Close second + let result = am.notification_closed(43); + assert_eq!(result, Some((desktop.into(), 0))); + assert_eq!(am.count(desktop), 0); + } + + #[test] + fn test_replacement_notification() { + let mut am = AppMap::new(); + am.update_patterns(sample_map()); + + let desktop = "application://org.mozilla.firefox.desktop"; + + // First notification + am.record_notify(100, 0, desktop); + am.resolve_reply(100, 42); + assert_eq!(am.count(desktop), 1); + + // Replacement notification (replaces_id=42) + am.record_notify(101, 42, desktop); + assert_eq!(am.count(desktop), 0); // old one removed during record + am.resolve_reply(101, 43); + assert_eq!(am.count(desktop), 1); // new one added + } + + #[test] + fn test_close_unknown_notification() { + let mut am = AppMap::new(); + assert_eq!(am.notification_closed(999), None); + } + + #[test] + fn test_resolve_unknown_reply() { + let mut am = AppMap::new(); + assert_eq!(am.resolve_reply(999, 42), None); + } + + #[test] + fn test_update_patterns_returns_changed() { + let mut am = AppMap::new(); + assert!(am.update_patterns(sample_map())); + assert!(!am.update_patterns(sample_map())); + + let mut different = HashMap::new(); + different.insert("new".into(), "application://new.desktop".into()); + assert!(am.update_patterns(different)); + } + + #[test] + fn test_clear_all() { + let mut am = AppMap::new(); + am.update_patterns(sample_map()); + + let desktop = "application://org.mozilla.firefox.desktop"; + am.record_notify(100, 0, desktop); + am.resolve_reply(100, 42); + am.record_notify(101, 0, desktop); + am.resolve_reply(101, 43); + + let cleared = am.clear_all(); + assert!(cleared.contains(&desktop.to_string())); + assert_eq!(am.count(desktop), 0); + } + + #[test] + fn test_all_desktop_ids_deduplicates() { + let mut am = AppMap::new(); + am.update_patterns(sample_map()); + let ids = am.all_desktop_ids(); + // sample_map has 4 entries mapping to 2 unique desktop IDs + assert_eq!(ids.len(), 2); + assert!(ids.contains(&"application://org.mozilla.firefox.desktop".to_string())); + assert!(ids.contains(&"application://com.slack.Slack.desktop".to_string())); + } + + #[test] + fn test_patterns_accessor() { + let mut am = AppMap::new(); + assert!(am.patterns().is_empty()); + am.update_patterns(sample_map()); + assert_eq!(am.patterns().len(), 4); + } +} diff --git a/src/badge.rs b/src/badge.rs new file mode 100644 index 0000000..4868570 --- /dev/null +++ b/src/badge.rs @@ -0,0 +1,79 @@ +use log::warn; + +/// Emits com.canonical.Unity.LauncherEntry.Update signals via gdbus. +pub struct BadgeEmitter; + +impl BadgeEmitter { + /// Emit a badge update for the given desktop_id with the specified count. + pub fn emit(desktop_id: &str, count: usize) { + Self::emit_with(desktop_id, count, crate::dbus_glue::emit_badge); + } + + /// Testable version that accepts a function for the actual D-Bus emission. + pub fn emit_with<F>(desktop_id: &str, count: usize, emit_fn: F) + where + F: FnOnce(&str, &str) -> Result<(), String>, + { + let visible = if count > 0 { "true" } else { "false" }; + let urgent = visible; + let properties = format!( + "{{'count': <int64 {count}>, 'count-visible': <{visible}>, 'urgent': <{urgent}>}}" + ); + + if let Err(e) = emit_fn(desktop_id, &properties) { + warn!("badge error: {e}"); + } + } + + /// Clear badges for a list of desktop_ids (used on shutdown). + pub fn clear_all(desktop_ids: &[String]) { + for id in desktop_ids { + Self::emit(id, 0); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::RefCell; + + #[test] + fn test_emit_with_count() { + let captured = RefCell::new(None); + + BadgeEmitter::emit_with("application://firefox.desktop", 3, |id, props| { + *captured.borrow_mut() = Some((id.to_string(), props.to_string())); + Ok(()) + }); + + let (id, props) = captured.borrow().clone().unwrap(); + assert_eq!(id, "application://firefox.desktop"); + assert!(props.contains("count': <int64 3>")); + assert!(props.contains("count-visible': <true>")); + assert!(props.contains("urgent': <true>")); + } + + #[test] + fn test_emit_with_zero_count() { + let captured = RefCell::new(None); + + BadgeEmitter::emit_with("application://firefox.desktop", 0, |id, props| { + *captured.borrow_mut() = Some((id.to_string(), props.to_string())); + Ok(()) + }); + + let (_, props) = captured.borrow().clone().unwrap(); + assert!(props.contains("count': <int64 0>")); + assert!(props.contains("count-visible': <false>")); + assert!(props.contains("urgent': <false>")); + } + + #[test] + fn test_emit_error_is_logged_not_panicked() { + // Should not panic on error + BadgeEmitter::emit_with("application://firefox.desktop", 1, |_, _| { + Err("dbus error".into()) + }); + } +} diff --git a/src/dbus_glue.rs b/src/dbus_glue.rs new file mode 100644 index 0000000..58c7651 --- /dev/null +++ b/src/dbus_glue.rs @@ -0,0 +1,77 @@ +//! Thin wrappers around `gdbus` / `Command` calls. +//! +//! These functions are excluded from code coverage because they require +//! a live D-Bus session and KDE Plasma environment. + +use std::process::Command; + +/// Emit a Unity LauncherEntry Update signal via gdbus. +pub fn emit_badge(desktop_id: &str, properties: &str) -> Result<(), String> { + let output = Command::new("gdbus") + .args([ + "emit", + "--session", + "--object-path", + "/", + "--signal", + "com.canonical.Unity.LauncherEntry.Update", + desktop_id, + properties, + ]) + .output() + .map_err(|e| e.to_string())?; + + if output.status.success() { + Ok(()) + } else { + Err(String::from_utf8_lossy(&output.stderr).trim().to_string()) + } +} + +/// Query KWin WindowsRunner for open windows. +pub fn kwin_match() -> Option<String> { + let output = Command::new("gdbus") + .args([ + "call", + "--session", + "--dest", + "org.kde.KWin", + "--object-path", + "/WindowsRunner", + "--method", + "org.kde.krunner1.Match", + "", + ]) + .output() + .ok()?; + + if output.status.success() { + Some(String::from_utf8_lossy(&output.stdout).to_string()) + } else { + None + } +} + +/// Get window info for a specific KWin UUID. +pub fn kwin_window_info(uuid: &str) -> Option<String> { + let output = Command::new("gdbus") + .args([ + "call", + "--session", + "--dest", + "org.kde.KWin", + "--object-path", + "/KWin", + "--method", + "org.kde.KWin.getWindowInfo", + &format!("{{{uuid}}}"), + ]) + .output() + .ok()?; + + if output.status.success() { + Some(String::from_utf8_lossy(&output.stdout).to_string()) + } else { + None + } +} diff --git a/src/discovery.rs b/src/discovery.rs new file mode 100644 index 0000000..4418068 --- /dev/null +++ b/src/discovery.rs @@ -0,0 +1,157 @@ +use regex::Regex; +use std::collections::HashMap; + +/// Query KWin for open windows and return a pattern map: +/// pattern (lowercase) -> "application://<desktop_file>.desktop" +/// +/// Two keys per app: +/// - The last dot-segment (e.g. "firefox") -- matches native app_name +/// - The full desktop file ID (e.g. "org.mozilla.firefox") -- matches Flatpak desktop-entry hints +pub fn discover_apps() -> HashMap<String, String> { + discover_apps_with( + crate::dbus_glue::kwin_match, + crate::dbus_glue::kwin_window_info, + ) +} + +/// Testable version that accepts function pointers for the D-Bus calls. +pub fn discover_apps_with<F, G>(match_fn: F, info_fn: G) -> HashMap<String, String> +where + F: Fn() -> Option<String>, + G: Fn(&str) -> Option<String>, +{ + let output = match match_fn() { + Some(s) => s, + None => return HashMap::new(), + }; + + let uuid_re = Regex::new(r"\{([0-9a-f-]{36})\}").unwrap(); + let desktop_re = Regex::new(r"'desktopFile':\s*<'([^']+)'>").unwrap(); + + let mut map = HashMap::new(); + + for caps in uuid_re.captures_iter(&output) { + let uid = &caps[1]; + if let Some(entries) = extract_desktop_entries(uid, &info_fn, &desktop_re) { + map.extend(entries); + } + } + + map +} + +fn extract_desktop_entries<G>( + uid: &str, + info_fn: &G, + desktop_re: &Regex, +) -> Option<Vec<(String, String)>> +where + G: Fn(&str) -> Option<String>, +{ + let info_output = info_fn(uid)?; + let dcaps = desktop_re.captures(&info_output)?; + let desktop_file = &dcaps[1]; + if desktop_file.is_empty() { + return None; + } + let key = desktop_file + .rsplit('.') + .next() + .unwrap_or(desktop_file) + .to_lowercase(); + let desktop_id = format!("application://{desktop_file}.desktop"); + Some(vec![ + (key, desktop_id.clone()), + (desktop_file.to_lowercase(), desktop_id), + ]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_discover_apps_parses_kwin_output() { + let match_output = r#"([('0_{aabbccdd-1234-5678-9abc-def012345678}', 'Some Window — Firefox', 'firefox', 100, 0.8, {'subtext': <'Activate running window on Desktop 1'>}), ('0_{11223344-5566-7788-99aa-bbccddeeff00}', 'Slack', 'com.slack.Slack', 100, 0.8, {'subtext': <'Activate running window on Desktop 1'>})],)"#; + + let info_firefox = + r#"({'desktopFile': <'org.mozilla.firefox'>, 'caption': <'Some Window — Firefox'>},)"#; + let info_slack = r#"({'desktopFile': <'com.slack.Slack'>, 'caption': <'Slack'>},)"#; + + let match_fn = || Some(match_output.to_string()); + let info_fn = |uuid: &str| match uuid { + "aabbccdd-1234-5678-9abc-def012345678" => Some(info_firefox.to_string()), + "11223344-5566-7788-99aa-bbccddeeff00" => Some(info_slack.to_string()), + _ => None, + }; + + let map = discover_apps_with(match_fn, info_fn); + + assert_eq!( + map.get("firefox"), + Some(&"application://org.mozilla.firefox.desktop".to_string()) + ); + assert_eq!( + map.get("org.mozilla.firefox"), + Some(&"application://org.mozilla.firefox.desktop".to_string()) + ); + assert_eq!( + map.get("slack"), + Some(&"application://com.slack.Slack.desktop".to_string()) + ); + assert_eq!( + map.get("com.slack.slack"), + Some(&"application://com.slack.Slack.desktop".to_string()) + ); + } + + #[test] + fn test_discover_apps_empty_match() { + let map = discover_apps_with(|| None, |_| None); + assert!(map.is_empty()); + } + + #[test] + fn test_discover_apps_no_uuids() { + let map = discover_apps_with(|| Some("([],)".to_string()), |_| None); + assert!(map.is_empty()); + } + + #[test] + fn test_discover_apps_empty_desktop_file() { + let match_output = + r#"([('0_{aabbccdd-1234-5678-9abc-def012345678}', 'Window', 'icon', 100, 0.8, {})],)"#; + let info_output = r#"({'desktopFile': <''>, 'caption': <'Window'>},)"#; + + let map = discover_apps_with( + || Some(match_output.to_string()), + |_| Some(info_output.to_string()), + ); + assert!(map.is_empty()); + } + + #[test] + fn test_discover_apps_window_info_fails() { + let match_output = + r#"([('0_{aabbccdd-1234-5678-9abc-def012345678}', 'Window', 'icon', 100, 0.8, {})],)"#; + + let map = discover_apps_with(|| Some(match_output.to_string()), |_| None); + assert!(map.is_empty()); + } + + #[test] + fn test_discover_apps_deduplicates_same_app() { + // Two windows from the same app + let match_output = r#"([('0_{aaaaaaaa-1234-5678-9abc-def012345678}', 'Tab 1 — Firefox', 'firefox', 100, 0.8, {}), ('0_{bbbbbbbb-1234-5678-9abc-def012345678}', 'Tab 2 — Firefox', 'firefox', 100, 0.8, {})],)"#; + let info_output = r#"({'desktopFile': <'org.mozilla.firefox'>, 'caption': <'Firefox'>},)"#; + + let map = discover_apps_with( + || Some(match_output.to_string()), + |_| Some(info_output.to_string()), + ); + // Should have exactly 2 entries (short name + full name), not duplicated + assert_eq!(map.len(), 2); + assert!(map.contains_key("firefox")); + assert!(map.contains_key("org.mozilla.firefox")); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..f3ec003 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,10 @@ +mod app_map; +mod badge; +pub mod dbus_glue; +mod discovery; +mod parser; + +pub use app_map::AppMap; +pub use badge::BadgeEmitter; +pub use discovery::discover_apps; +pub use parser::{DbusMessage, DbusParser}; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..cafcaec --- /dev/null +++ b/src/main.rs @@ -0,0 +1,169 @@ +use log::{error, info, warn}; +use notification_badge::{AppMap, BadgeEmitter, DbusMessage, DbusParser}; +use std::io::BufRead; +use std::process::{Command, Stdio}; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::Duration; + +/// How often (seconds) to re-scan open windows and rebuild the app map. +const DISCOVERY_INTERVAL: u64 = 30; + +fn main() { + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")) + .format_timestamp(None) + .init(); + + let app_map = Arc::new(Mutex::new(AppMap::new())); + + install_signal_handler(&app_map); + run_initial_discovery(&app_map); + spawn_discovery_thread(&app_map); + run_monitor_loop(&app_map); +} + +fn install_signal_handler(app_map: &Arc<Mutex<AppMap>>) { + let app_map_sig = Arc::clone(app_map); + ctrlc::handle(move || { + info!("shutting down, clearing badges..."); + let map = app_map_sig.lock().unwrap(); + BadgeEmitter::clear_all(&map.all_desktop_ids()); + std::process::exit(0); + }); +} + +fn run_initial_discovery(app_map: &Arc<Mutex<AppMap>>) { + let new_map = notification_badge::discover_apps(); + let mut map = app_map.lock().unwrap(); + if map.update_patterns(new_map) { + log_discovered(&map); + } else { + warn!("no apps discovered, will retry..."); + } +} + +fn spawn_discovery_thread(app_map: &Arc<Mutex<AppMap>>) { + let app_map_disc = Arc::clone(app_map); + thread::spawn(move || loop { + thread::sleep(Duration::from_secs(DISCOVERY_INTERVAL)); + let new_map = notification_badge::discover_apps(); + let mut map = app_map_disc.lock().unwrap(); + if map.update_patterns(new_map) { + log_discovered(&map); + } + }); +} + +fn run_monitor_loop(app_map: &Arc<Mutex<AppMap>>) { + let mut monitor = Command::new("dbus-monitor") + .args(["--session"]) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("failed to start dbus-monitor"); + + let stdout = monitor + .stdout + .take() + .expect("failed to capture dbus-monitor stdout"); + + let reader = std::io::BufReader::new(stdout); + let mut parser = DbusParser::new(); + + for line in reader.lines() { + let line = match line { + Ok(l) => l, + Err(e) => { + error!("read error: {e}"); + break; + } + }; + + if let Some(msg) = parser.feed_line(&line) { + handle_message(msg, app_map); + } + } + + if let Some(msg) = parser.flush() { + handle_message(msg, app_map); + } + + error!("dbus-monitor exited unexpectedly"); + let map = app_map.lock().unwrap(); + BadgeEmitter::clear_all(&map.all_desktop_ids()); +} + +fn handle_message(msg: DbusMessage, app_map: &Arc<Mutex<AppMap>>) { + let mut map = app_map.lock().unwrap(); + + match msg { + DbusMessage::Notify { + serial, + app_name, + replaces_id, + desktop_entry_hint, + } => { + let hint_ref = desktop_entry_hint.as_deref().unwrap_or(""); + if let Some(desktop_id) = map.match_app(&[&app_name, hint_ref]) { + map.record_notify(serial, replaces_id, &desktop_id); + } + } + DbusMessage::NotifyReply { + reply_serial, + notification_id, + } => { + if let Some((desktop_id, count)) = map.resolve_reply(reply_serial, notification_id) { + BadgeEmitter::emit(&desktop_id, count); + info!("+ [{notification_id}] {desktop_id} (count: {count})"); + } + } + DbusMessage::NotificationClosed { notification_id } => { + if let Some((desktop_id, count)) = map.notification_closed(notification_id) { + BadgeEmitter::emit(&desktop_id, count); + info!("- [{notification_id}] {desktop_id} (count: {count})"); + } + } + } +} + +fn log_discovered(map: &AppMap) { + let mut apps: Vec<_> = map.patterns().values().collect(); + apps.sort(); + apps.dedup(); + info!( + "discovered apps: [{}]", + apps.iter() + .map(|s| s.as_str()) + .collect::<Vec<_>>() + .join(", ") + ); +} + +/// Minimal signal handler using libc. +mod ctrlc { + pub fn handle<F: Fn() + Send + 'static>(handler: F) { + unsafe { + libc::signal( + libc::SIGINT, + signal_handler as *const () as libc::sighandler_t, + ); + libc::signal( + libc::SIGTERM, + signal_handler as *const () as libc::sighandler_t, + ); + } + + HANDLER.lock().unwrap().replace(Box::new(move || handler())); + } + + use std::sync::Mutex; + static HANDLER: Mutex<Option<Box<dyn Fn() + Send>>> = Mutex::new(None); + + extern "C" fn signal_handler(_sig: libc::c_int) { + if let Ok(guard) = HANDLER.lock() { + if let Some(ref handler) = *guard { + handler(); + } + } + } +} diff --git a/src/parser.rs b/src/parser.rs new file mode 100644 index 0000000..3490366 --- /dev/null +++ b/src/parser.rs @@ -0,0 +1,421 @@ +use regex::Regex; + +/// Parsed message types from the dbus-monitor stream. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DbusMessage { + /// A Notify method call was detected. + /// Fields: serial, app_name, replaces_id, desktop_entry_hint + Notify { + serial: u64, + app_name: String, + replaces_id: u32, + desktop_entry_hint: Option<String>, + }, + /// A method return matching a pending Notify serial. + /// Fields: reply_serial, notification_id + NotifyReply { + reply_serial: u64, + notification_id: u32, + }, + /// A NotificationClosed signal. + NotificationClosed { notification_id: u32 }, +} + +/// Streaming parser for dbus-monitor text output. +/// +/// Feed it lines one at a time via `feed_line`. When a complete message +/// is recognized, it is returned from `feed_line` or `flush`. +pub struct DbusParser { + header_re: Regex, + reply_serial_re: Regex, + member_re: Regex, + string_re: Regex, + uint32_re: Regex, + variant_string_re: Regex, + + // Current message block state + msg_type: Option<MsgType>, + serial: u64, + reply_serial: Option<u64>, + strings: Vec<String>, + uint32s: Vec<u32>, + last_dict_key: Option<String>, + desktop_entry_hint: Option<String>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MsgType { + Notify, + CloseCall, + ClosedSignal, + Reply, +} + +fn classify_header(kind: &str, member: &str, has_reply_serial: bool) -> Option<MsgType> { + match (kind, member) { + ("method call", "Notify") => Some(MsgType::Notify), + ("method call", "CloseNotification") => Some(MsgType::CloseCall), + ("signal", "NotificationClosed") => Some(MsgType::ClosedSignal), + ("method return", _) if has_reply_serial => Some(MsgType::Reply), + _ => None, + } +} + +impl DbusParser { + pub fn new() -> Self { + Self { + header_re: Regex::new(r"^(method call|method return|signal)\s+.*?serial=(\d+)") + .unwrap(), + reply_serial_re: Regex::new(r"reply_serial=(\d+)").unwrap(), + member_re: Regex::new(r"member=(\w+)").unwrap(), + string_re: Regex::new(r#"^\s+string\s+"(.*)""#).unwrap(), + uint32_re: Regex::new(r"^\s+uint32\s+(\d+)").unwrap(), + variant_string_re: Regex::new(r#"^\s+variant\s+string\s+"(.*)""#).unwrap(), + msg_type: None, + serial: 0, + reply_serial: None, + strings: Vec::new(), + uint32s: Vec::new(), + last_dict_key: None, + desktop_entry_hint: None, + } + } + + /// Feed a single line from dbus-monitor output. Returns a parsed message + /// if the previous message block is now complete (triggered by seeing + /// the next header line). + pub fn feed_line(&mut self, line: &str) -> Option<DbusMessage> { + let line = line.trim_end_matches('\n'); + + if let Some(caps) = self.header_re.captures(line) { + return self.handle_header(line, caps); + } + + self.parse_body_line(line); + None + } + + fn handle_header(&mut self, line: &str, caps: regex::Captures) -> Option<DbusMessage> { + let result = self.finalize(); + + let kind = caps.get(1).unwrap().as_str(); + self.serial = caps.get(2).unwrap().as_str().parse().unwrap_or(0); + self.strings.clear(); + self.uint32s.clear(); + self.last_dict_key = None; + self.desktop_entry_hint = None; + + self.reply_serial = self + .reply_serial_re + .captures(line) + .and_then(|c| c.get(1)) + .and_then(|m| m.as_str().parse().ok()); + + let member = self + .member_re + .captures(line) + .and_then(|c| c.get(1)) + .map(|m| m.as_str().to_string()) + .unwrap_or_default(); + + self.msg_type = classify_header(kind, &member, self.reply_serial.is_some()); + + result + } + + fn parse_body_line(&mut self, line: &str) { + if let Some(caps) = self.string_re.captures(line) { + self.collect_string(&caps); + } else if let Some(caps) = self.variant_string_re.captures(line) { + self.try_capture_desktop_entry(&caps); + } else if let Some(caps) = self.uint32_re.captures(line) { + self.collect_uint32(&caps); + } + } + + fn collect_string(&mut self, caps: ®ex::Captures) { + let val = caps.get(1).unwrap().as_str().to_string(); + self.strings.push(val.clone()); + if self.msg_type == Some(MsgType::Notify) { + self.last_dict_key = Some(val); + } + } + + fn collect_uint32(&mut self, caps: ®ex::Captures) { + if let Ok(v) = caps.get(1).unwrap().as_str().parse() { + self.uint32s.push(v); + } + } + + fn try_capture_desktop_entry(&mut self, caps: ®ex::Captures) { + if self.msg_type == Some(MsgType::Notify) + && self.last_dict_key.as_deref() == Some("desktop-entry") + { + self.desktop_entry_hint = Some(caps.get(1).unwrap().as_str().to_string()); + self.last_dict_key = None; + } + } + + /// Flush the current message block (e.g. at end of stream). + pub fn flush(&mut self) -> Option<DbusMessage> { + self.finalize() + } + + fn finalize(&mut self) -> Option<DbusMessage> { + let result = match self.msg_type { + Some(MsgType::Notify) if !self.strings.is_empty() => { + let app_name = self.strings[0].clone(); + let replaces_id = self.uint32s.first().copied().unwrap_or(0); + Some(DbusMessage::Notify { + serial: self.serial, + app_name, + replaces_id, + desktop_entry_hint: self.desktop_entry_hint.clone(), + }) + } + Some(MsgType::Reply) => { + let reply_serial = self.reply_serial?; + let notification_id = *self.uint32s.first()?; + Some(DbusMessage::NotifyReply { + reply_serial, + notification_id, + }) + } + Some(MsgType::ClosedSignal) => { + let notification_id = *self.uint32s.first()?; + Some(DbusMessage::NotificationClosed { notification_id }) + } + Some(MsgType::Notify) => None, // No strings collected + Some(MsgType::CloseCall) => None, // Signal will follow + None => None, + }; + + self.msg_type = None; + result + } +} + +impl Default for DbusParser { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_block(lines: &str) -> Vec<DbusMessage> { + let mut parser = DbusParser::new(); + let mut results = Vec::new(); + for line in lines.lines() { + if let Some(msg) = parser.feed_line(line) { + results.push(msg); + } + } + if let Some(msg) = parser.flush() { + results.push(msg); + } + results + } + + fn expect_single_notify(input: &str) -> DbusMessage { + let msgs = parse_block(input); + assert_eq!(msgs.len(), 1); + assert!(matches!(msgs[0], DbusMessage::Notify { .. })); + msgs.into_iter().next().unwrap() + } + + #[test] + fn test_parse_notify_calls() { + // Case 1: plain notification + let plain = r#"method call time=1234.0 sender=:1.100 -> destination=:1.5 serial=500 path=/org/freedesktop/Notifications; interface=org.freedesktop.Notifications; member=Notify + string "firefox" + uint32 0 + string "firefox-icon" + string "New message" + string "You have a new message" + array [ + ] + array [ + ] + int32 -1 +"#; + let msg = expect_single_notify(plain); + assert_eq!( + msg, + DbusMessage::Notify { + serial: 500, + app_name: "firefox".into(), + replaces_id: 0, + desktop_entry_hint: None, + } + ); + + // Case 2: with desktop-entry hint + let with_hint = r#"method call time=1234.0 sender=:1.100 -> destination=:1.5 serial=501 path=/org/freedesktop/Notifications; interface=org.freedesktop.Notifications; member=Notify + string "System Notifications" + uint32 0 + string "" + string "New message" + string "body" + array [ + ] + dict entry( + string "desktop-entry" + variant string "com.slack.Slack" + ) + int32 -1 +"#; + let msg = expect_single_notify(with_hint); + assert_eq!( + msg, + DbusMessage::Notify { + serial: 501, + app_name: "System Notifications".into(), + replaces_id: 0, + desktop_entry_hint: Some("com.slack.Slack".into()), + } + ); + } + + #[test] + fn test_parse_method_return() { + let input = r#"method return time=1234.0 sender=:1.5 -> destination=:1.100 serial=600 reply_serial=500 + uint32 42 +"#; + let msgs = parse_block(input); + assert_eq!(msgs.len(), 1); + assert_eq!( + msgs[0], + DbusMessage::NotifyReply { + reply_serial: 500, + notification_id: 42, + } + ); + } + + #[test] + fn test_parse_notification_closed() { + let input = r#"signal time=1234.0 sender=:1.5 -> destination=(null destination) serial=700 path=/org/freedesktop/Notifications; interface=org.freedesktop.Notifications; member=NotificationClosed + uint32 42 + uint32 2 +"#; + let msgs = parse_block(input); + assert_eq!(msgs.len(), 1); + assert_eq!( + msgs[0], + DbusMessage::NotificationClosed { + notification_id: 42 + } + ); + } + + #[test] + fn test_parse_close_call_ignored() { + let input = r#"method call time=1234.0 sender=:1.100 -> destination=:1.5 serial=800 path=/org/freedesktop/Notifications; interface=org.freedesktop.Notifications; member=CloseNotification + uint32 42 +"#; + let msgs = parse_block(input); + assert_eq!(msgs.len(), 0); // CloseNotification calls are ignored (signal follows) + } + + #[test] + fn test_parse_unrelated_messages_ignored() { + let input = r#"signal time=1234.0 sender=:1.5 -> destination=(null destination) serial=900 path=/org/freedesktop/DBus; interface=org.freedesktop.DBus; member=NameOwnerChanged + string ":1.200" + string "" + string ":1.200" +method call time=1234.0 sender=:1.100 -> destination=:1.5 serial=901 path=/org/kde/StatusNotifierWatcher; interface=org.kde.StatusNotifierWatcher; member=RegisterStatusNotifierItem + string "/StatusNotifierItem" +"#; + let msgs = parse_block(input); + assert_eq!(msgs.len(), 0); + } + + #[test] + fn test_parse_notify_with_replaces_id() { + let input = r#"method call time=1234.0 sender=:1.100 -> destination=:1.5 serial=502 path=/org/freedesktop/Notifications; interface=org.freedesktop.Notifications; member=Notify + string "firefox" + uint32 42 + string "firefox-icon" + string "Updated message" + string "Updated body" + array [ + ] + array [ + ] + int32 -1 +"#; + let msgs = parse_block(input); + assert_eq!(msgs.len(), 1); + match &msgs[0] { + DbusMessage::Notify { replaces_id, .. } => { + assert_eq!(*replaces_id, 42); + } + other => panic!("Expected Notify, got {:?}", other), + } + } + + #[test] + fn test_multi_message_stream() { + let input = r#"method call time=1234.0 sender=:1.100 -> destination=:1.5 serial=500 path=/org/freedesktop/Notifications; interface=org.freedesktop.Notifications; member=Notify + string "firefox" + uint32 0 + string "icon" + string "summary" + string "body" +method return time=1234.1 sender=:1.5 -> destination=:1.100 serial=600 reply_serial=500 + uint32 42 +signal time=1234.2 sender=:1.5 -> destination=(null destination) serial=700 path=/org/freedesktop/Notifications; interface=org.freedesktop.Notifications; member=NotificationClosed + uint32 42 + uint32 2 +"#; + let msgs = parse_block(input); + assert_eq!(msgs.len(), 3); + assert!(matches!(msgs[0], DbusMessage::Notify { .. })); + assert!(matches!(msgs[1], DbusMessage::NotifyReply { .. })); + assert!(matches!(msgs[2], DbusMessage::NotificationClosed { .. })); + } + + #[test] + fn test_method_return_without_uint32_ignored() { + let input = r#"method return time=1234.0 sender=:1.5 -> destination=:1.100 serial=600 reply_serial=999 + string "some string" +"#; + let msgs = parse_block(input); + assert_eq!(msgs.len(), 0); // No uint32 means not a Notify reply + } + + #[test] + fn test_empty_input() { + let msgs = parse_block(""); + assert_eq!(msgs.len(), 0); + } + + #[test] + fn test_default_parser() { + let mut parser = DbusParser::default(); + assert_eq!(parser.flush(), None); + } + + #[test] + fn test_notify_without_strings_produces_nothing() { + // Notify header followed immediately by another header — no strings collected + let input = "method call time=1234.0 sender=:1.100 -> destination=:1.5 serial=500 path=/org/freedesktop/Notifications; interface=org.freedesktop.Notifications; member=Notify\nmethod call time=1234.0 sender=:1.100 -> destination=:1.5 serial=501 path=/org/freedesktop/Notifications; interface=org.freedesktop.Notifications; member=Notify\n"; + let msgs = parse_block(input); + assert!(msgs.is_empty()); + } + + #[test] + fn test_desktop_entry_hint_only_from_notify() { + // A non-Notify message with "desktop-entry" should not be captured + let input = r#"signal time=1234.0 sender=:1.5 -> destination=(null destination) serial=700 path=/some/path; interface=some.Interface; member=SomeSignal + string "desktop-entry" + variant string "com.slack.Slack" + uint32 99 +"#; + let msgs = parse_block(input); + assert_eq!(msgs.len(), 0); // Not a recognized message type + } +} |
