From a6b024ffbc0052813d5cfd05fa2cd207d5b20c9b Mon Sep 17 00:00:00 2001 From: Asko Nõmm Date: Wed, 29 Apr 2026 20:45:04 +0300 Subject: 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 --- src/parser.rs | 421 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 421 insertions(+) create mode 100644 src/parser.rs (limited to 'src/parser.rs') 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, + }, + /// 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, + serial: u64, + reply_serial: Option, + strings: Vec, + uint32s: Vec, + last_dict_key: Option, + desktop_entry_hint: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MsgType { + Notify, + CloseCall, + ClosedSignal, + Reply, +} + +fn classify_header(kind: &str, member: &str, has_reply_serial: bool) -> Option { + 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 { + 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 { + 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 { + self.finalize() + } + + fn finalize(&mut self) -> Option { + 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 { + 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 + } +} -- cgit v1.2.3