summaryrefslogtreecommitdiff
path: root/src/app_map.rs
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/app_map.rs
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/app_map.rs')
-rw-r--r--src/app_map.rs282
1 files changed, 282 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);
+ }
+}