summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorAsko Nõmm <asko@nmm.ee>2026-04-29 20:45:04 +0300
committerAsko Nõmm <asko@nmm.ee>2026-04-29 20:45:04 +0300
commita6b024ffbc0052813d5cfd05fa2cd207d5b20c9b (patch)
treef0c9c606888e0d3de77c3a39c2df129eae5c0072 /src
Initial commit: Rust notification badge daemon for KDE Plasma 6
Monitors D-Bus for desktop notifications and emits Unity LauncherEntry badge updates so KDE Plasma task manager shows notification counts. - Streaming dbus-monitor parser with app matching and lifecycle tracking - KWin window discovery for automatic desktop file detection - Systemd user service, Makefile install/uninstall targets - 38 tests (32 unit + 6 integration), 97% line coverage via cargo-llvm-cov - CodeScene code health: 10/10 on all source files
Diffstat (limited to 'src')
-rw-r--r--src/app_map.rs282
-rw-r--r--src/badge.rs79
-rw-r--r--src/dbus_glue.rs77
-rw-r--r--src/discovery.rs157
-rw-r--r--src/lib.rs10
-rw-r--r--src/main.rs169
-rw-r--r--src/parser.rs421
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: &regex::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: &regex::Captures) {
+ if let Ok(v) = caps.get(1).unwrap().as_str().parse() {
+ self.uint32s.push(v);
+ }
+ }
+
+ fn try_capture_desktop_entry(&mut self, caps: &regex::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
+ }
+}