diff options
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | Cargo.lock | 275 | ||||
| -rw-r--r-- | Cargo.toml | 17 | ||||
| -rw-r--r-- | LICENSE | 21 | ||||
| -rw-r--r-- | Makefile | 22 | ||||
| -rw-r--r-- | README.md | 98 | ||||
| -rw-r--r-- | notification-badge.service | 12 | ||||
| -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 | ||||
| -rw-r--r-- | tests/integration.rs | 275 |
15 files changed, 1916 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..afec31e --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,275 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "env_filter" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "jiff" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f00b5dbd620d61dfdcb6007c9c1f6054ebd75319f163d886a9055cec1155073d" +dependencies = [ + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e000de030ff8022ea1da3f466fbb0f3a809f5e51ed31f6dd931c35181ad8e6d7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "notification-badge" +version = "0.1.0" +dependencies = [ + "env_logger", + "libc", + "log", + "regex", +] + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..05115ae --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "notification-badge" +version = "0.1.0" +edition = "2021" +description = "Taskbar badge notifications for KDE Plasma 6" +license = "MIT" +readme = "README.md" +keywords = ["kde", "plasma", "notifications", "taskbar", "badge"] +categories = ["os::linux-apis"] + +[dependencies] +regex = "1" +log = "0.4" +env_logger = "0.11" +libc = "0.2" + +[dev-dependencies] @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Asko Nõmm + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..eca1cac --- /dev/null +++ b/Makefile @@ -0,0 +1,22 @@ +PREFIX ?= $(HOME)/.local + +.PHONY: build install uninstall test clean + +build: + cargo build --release + +test: + cargo test + +install: build + install -Dm755 target/release/notification-badge $(PREFIX)/bin/notification-badge + install -Dm644 notification-badge.service $(HOME)/.config/systemd/user/notification-badge.service + +uninstall: + rm -f $(PREFIX)/bin/notification-badge + rm -f $(HOME)/.config/systemd/user/notification-badge.service + -systemctl --user stop notification-badge.service 2>/dev/null + -systemctl --user disable notification-badge.service 2>/dev/null + +clean: + cargo clean diff --git a/README.md b/README.md new file mode 100644 index 0000000..fc05259 --- /dev/null +++ b/README.md @@ -0,0 +1,98 @@ +# notification-badge + +Taskbar badge notifications for KDE Plasma 6. Dynamically discovers running applications via KWin, monitors desktop notifications via D-Bus, and sets badge counts on taskbar icons using the Unity Launcher API. No hardcoded app list needed. + +## How it works + +1. **Discovers apps dynamically** by querying KWin via D-Bus — enumerates open windows using `WindowsRunner.Match`, then calls `KWin.getWindowInfo` for each to get the `desktopFile` property. Rediscovers every 30 seconds in a background thread. +2. Runs an **unfiltered `dbus-monitor --session`** subprocess (unfiltered is required to capture method return messages which contain the assigned notification ID). +3. Parses the dbus-monitor output as a stream, tracking three message types: + - `method_call` with `member=Notify` — extract the `app_name` and message `serial` + - `method_return` with matching `reply_serial` — extract the notification ID + - `signal` with `member=NotificationClosed` — notification was dismissed +4. Matches `app_name` (case-insensitive) against the dynamically discovered app map. Also supports Flatpak apps via `desktop-entry` hints. +5. Emits `com.canonical.Unity.LauncherEntry.Update` signals via `gdbus emit` to set/clear badge counts and the `urgent` flag on the corresponding taskbar icon. +6. On shutdown (SIGINT/SIGTERM), clears all badges. + +## Requirements + +- KDE Plasma 6 with KWin and Task Manager +- `dbus-monitor` (from `dbus-tools`, typically pre-installed) +- `gdbus` (from `glib2`, typically pre-installed) +- Rust toolchain (to build) + +## Install + +```bash +git clone https://github.com/example/notification-badge.git +cd notification-badge +make install +``` + +This installs the binary to `~/.local/bin/` and the systemd service to `~/.config/systemd/user/`. + +### Enable at login + +```bash +systemctl --user enable --now notification-badge.service +``` + +### Check status + +```bash +systemctl --user status notification-badge.service +journalctl --user -u notification-badge.service -f +``` + +## Uninstall + +```bash +make uninstall +``` + +## Usage + +Run manually (logs to stderr): + +```bash +notification-badge +``` + +Test with a sample notification: + +```bash +notify-send --app-name=firefox "Test" "Badge should appear" +``` + +## Development + +```bash +cargo test # run all tests +cargo build # debug build +make build # release build +``` + +## Architecture + +The crate is split into four modules: + +| Module | Purpose | +|---|---| +| `parser` | Streaming parser for `dbus-monitor` text output | +| `app_map` | Tracks discovered apps, pending notifications, and active badge counts | +| `discovery` | Queries KWin D-Bus interfaces to discover running apps and their desktop file IDs | +| `badge` | Emits Unity LauncherEntry Update signals via `gdbus` | + +All modules are designed for testability — external D-Bus calls are injected as function parameters in tests. + +## Known limitations + +- Processes all session bus traffic (unfiltered monitor). Lightweight in practice but could theoretically be optimized with `BecomeMonitor` D-Bus API. +- Only badges apps that currently have an open window. Pinned taskbar icons without a running instance won't be badged. +- If two apps share the same last dot-segment in their desktop file ID, they would collide in the match map. Unlikely in practice. +- Firefox web app notifications all badge under the single Firefox icon. +- Notifications that expire via timeout also emit `NotificationClosed`, so badges clear when notifications auto-dismiss. + +## License + +MIT diff --git a/notification-badge.service b/notification-badge.service new file mode 100644 index 0000000..93f94f3 --- /dev/null +++ b/notification-badge.service @@ -0,0 +1,12 @@ +[Unit] +Description=Notification Badge Monitor for KDE Taskbar +After=graphical-session.target + +[Service] +Type=simple +ExecStart=%h/.local/bin/notification-badge +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=graphical-session.target 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 + } +} diff --git a/tests/integration.rs b/tests/integration.rs new file mode 100644 index 0000000..a63daa6 --- /dev/null +++ b/tests/integration.rs @@ -0,0 +1,275 @@ +//! Integration test: simulate a full notification lifecycle using AppMap + DbusParser together, +//! feeding realistic dbus-monitor output through the parser and processing results with AppMap. + +use notification_badge::{AppMap, DbusMessage, DbusParser}; +use std::collections::HashMap; + +fn setup_app_map() -> AppMap { + let mut map = AppMap::new(); + let mut patterns = HashMap::new(); + patterns.insert( + "firefox".into(), + "application://org.mozilla.firefox.desktop".into(), + ); + patterns.insert( + "org.mozilla.firefox".into(), + "application://org.mozilla.firefox.desktop".into(), + ); + patterns.insert( + "slack".into(), + "application://com.slack.Slack.desktop".into(), + ); + patterns.insert( + "com.slack.slack".into(), + "application://com.slack.Slack.desktop".into(), + ); + map.update_patterns(patterns); + map +} + +fn process_stream(input: &str, map: &mut AppMap) -> Vec<(String, usize)> { + let mut parser = DbusParser::new(); + let mut badge_updates = Vec::new(); + + let mut handle = |msg: DbusMessage, map: &mut AppMap| 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) { + badge_updates.push((desktop_id, count)); + } + } + DbusMessage::NotificationClosed { notification_id } => { + if let Some((desktop_id, count)) = map.notification_closed(notification_id) { + badge_updates.push((desktop_id, count)); + } + } + }; + + for line in input.lines() { + if let Some(msg) = parser.feed_line(line) { + handle(msg, map); + } + } + if let Some(msg) = parser.flush() { + handle(msg, map); + } + + badge_updates +} + +/// Full lifecycle: Firefox notification arrives, badge set to 1, then dismissed, badge set to 0. +#[test] +fn test_full_lifecycle_notify_and_dismiss() { + let stream = r#"method call time=1700000000.000 sender=:1.100 -> destination=:1.5 serial=500 path=/org/freedesktop/Notifications; interface=org.freedesktop.Notifications; member=Notify + string "firefox" + uint32 0 + string "firefox" + string "New tab notification" + string "You have updates" + array [ + ] + array [ + ] + int32 -1 +method return time=1700000000.100 sender=:1.5 -> destination=:1.100 serial=600 reply_serial=500 + uint32 42 +signal time=1700000001.000 sender=:1.5 -> destination=(null destination) serial=700 path=/org/freedesktop/Notifications; interface=org.freedesktop.Notifications; member=NotificationClosed + uint32 42 + uint32 2 +"#; + + let mut map = setup_app_map(); + let updates = process_stream(stream, &mut map); + + assert_eq!(updates.len(), 2); + assert_eq!( + updates[0], + ("application://org.mozilla.firefox.desktop".into(), 1) + ); + assert_eq!( + updates[1], + ("application://org.mozilla.firefox.desktop".into(), 0) + ); +} + +/// Multiple notifications from different apps accumulate independently. +#[test] +fn test_multiple_apps_independent_counts() { + let stream = r#"method call time=1700000000.000 sender=:1.100 -> destination=:1.5 serial=500 path=/org/freedesktop/Notifications; interface=org.freedesktop.Notifications; member=Notify + string "firefox" + uint32 0 + string "" + string "Firefox notification" + string "" +method return time=1700000000.100 sender=:1.5 -> destination=:1.100 serial=600 reply_serial=500 + uint32 42 +method call time=1700000000.200 sender=:1.200 -> destination=:1.5 serial=501 path=/org/freedesktop/Notifications; interface=org.freedesktop.Notifications; member=Notify + string "Slack" + uint32 0 + string "" + string "Slack message" + string "" +method return time=1700000000.300 sender=:1.5 -> destination=:1.200 serial=601 reply_serial=501 + uint32 43 +method call time=1700000000.400 sender=:1.100 -> destination=:1.5 serial=502 path=/org/freedesktop/Notifications; interface=org.freedesktop.Notifications; member=Notify + string "firefox" + uint32 0 + string "" + string "Another Firefox notification" + string "" +method return time=1700000000.500 sender=:1.5 -> destination=:1.100 serial=602 reply_serial=502 + uint32 44 +"#; + + let mut map = setup_app_map(); + let updates = process_stream(stream, &mut map); + + assert_eq!(updates.len(), 3); + // Firefox: 1 + assert_eq!( + updates[0], + ("application://org.mozilla.firefox.desktop".into(), 1) + ); + // Slack: 1 + assert_eq!( + updates[1], + ("application://com.slack.Slack.desktop".into(), 1) + ); + // Firefox: 2 + assert_eq!( + updates[2], + ("application://org.mozilla.firefox.desktop".into(), 2) + ); + + assert_eq!(map.count("application://org.mozilla.firefox.desktop"), 2); + assert_eq!(map.count("application://com.slack.Slack.desktop"), 1); +} + +/// Flatpak app sends notification via portal with desktop-entry hint. +#[test] +fn test_flatpak_desktop_entry_hint() { + let stream = r#"method call time=1700000000.000 sender=:1.100 -> destination=:1.5 serial=500 path=/org/freedesktop/Notifications; interface=org.freedesktop.Notifications; member=Notify + string "System Notifications" + uint32 0 + string "" + string "New message from channel" + string "Message body" + array [ + ] + dict entry( + string "desktop-entry" + variant string "com.slack.Slack" + ) + int32 -1 +method return time=1700000000.100 sender=:1.5 -> destination=:1.100 serial=600 reply_serial=500 + uint32 50 +"#; + + let mut map = setup_app_map(); + let updates = process_stream(stream, &mut map); + + assert_eq!(updates.len(), 1); + assert_eq!( + updates[0], + ("application://com.slack.Slack.desktop".into(), 1) + ); +} + +/// Replacement notification: new notification replaces an existing one, count stays at 1. +#[test] +fn test_replacement_notification() { + let stream = r#"method call time=1700000000.000 sender=:1.100 -> destination=:1.5 serial=500 path=/org/freedesktop/Notifications; interface=org.freedesktop.Notifications; member=Notify + string "firefox" + uint32 0 + string "" + string "First" + string "" +method return time=1700000000.100 sender=:1.5 -> destination=:1.100 serial=600 reply_serial=500 + uint32 42 +method call time=1700000000.200 sender=:1.100 -> destination=:1.5 serial=501 path=/org/freedesktop/Notifications; interface=org.freedesktop.Notifications; member=Notify + string "firefox" + uint32 42 + string "" + string "Replaced" + string "" +method return time=1700000000.300 sender=:1.5 -> destination=:1.100 serial=601 reply_serial=501 + uint32 43 +"#; + + let mut map = setup_app_map(); + let updates = process_stream(stream, &mut map); + + assert_eq!(updates.len(), 2); + // First notification: count=1 + assert_eq!(updates[0].1, 1); + // Replacement: old removed, new added, still count=1 + assert_eq!(updates[1].1, 1); + assert_eq!(map.count("application://org.mozilla.firefox.desktop"), 1); +} + +/// Unknown app notifications are ignored entirely. +#[test] +fn test_unknown_app_ignored() { + let stream = r#"method call time=1700000000.000 sender=:1.100 -> destination=:1.5 serial=500 path=/org/freedesktop/Notifications; interface=org.freedesktop.Notifications; member=Notify + string "unknown-app" + uint32 0 + string "" + string "Some notification" + string "" +method return time=1700000000.100 sender=:1.5 -> destination=:1.100 serial=600 reply_serial=500 + uint32 99 +"#; + + let mut map = setup_app_map(); + let updates = process_stream(stream, &mut map); + + assert_eq!(updates.len(), 0); +} + +/// Interleaved traffic: unrelated D-Bus messages mixed with notifications. +#[test] +fn test_interleaved_unrelated_traffic() { + let stream = r#"signal time=1700000000.000 sender=:1.1 -> destination=(null destination) serial=100 path=/org/freedesktop/DBus; interface=org.freedesktop.DBus; member=NameOwnerChanged + string ":1.200" + string "" + string ":1.200" +method call time=1700000000.100 sender=:1.50 -> destination=:1.5 serial=200 path=/org/kde/StatusNotifierWatcher; interface=org.kde.StatusNotifierWatcher; member=RegisterStatusNotifierItem + string "/StatusNotifierItem" +method call time=1700000000.200 sender=:1.100 -> destination=:1.5 serial=500 path=/org/freedesktop/Notifications; interface=org.freedesktop.Notifications; member=Notify + string "firefox" + uint32 0 + string "" + string "Real notification" + string "" +method return time=1700000000.250 sender=:1.5 -> destination=:1.50 serial=201 reply_serial=200 + string "ok" +method return time=1700000000.300 sender=:1.5 -> destination=:1.100 serial=600 reply_serial=500 + uint32 42 +signal time=1700000000.400 sender=:1.1 -> destination=(null destination) serial=101 path=/org/freedesktop/DBus; interface=org.freedesktop.DBus; member=NameOwnerChanged + string ":1.300" + string ":1.300" + string "" +"#; + + let mut map = setup_app_map(); + let updates = process_stream(stream, &mut map); + + assert_eq!(updates.len(), 1); + assert_eq!( + updates[0], + ("application://org.mozilla.firefox.desktop".into(), 1) + ); +} |
