Skip to main content

ao_plugin_notifier_desktop/
lib.rs

1//! Desktop notification plugin — Slice 4 Phase A.
2//!
3//! Delivers notifications via the OS notification daemon using the
4//! [`notify-rust`](https://docs.rs/notify-rust) crate. Works on macOS
5//! (Notification Center), Linux (libnotify / D-Bus), and Windows
6//! (toast notifications).
7//!
8//! ## Urgency (Linux/Windows only)
9//!
10//! On Linux and Windows, `notify-rust` supports urgency levels:
11//!
12//! | ao-rs | notify-rust | Escalated |
13//! |-------|-------------|-----------|
14//! | Urgent | Critical | Critical |
15//! | Action | Critical | Critical |
16//! | Warning | Normal | Critical |
17//! | Info | Low | Critical |
18//!
19//! macOS does not support urgency — notifications are displayed with
20//! the OS default styling. Escalated notifications still get the
21//! `[ESCALATED]` title prefix on all platforms.
22//!
23//! ## Error handling
24//!
25//! All `notify_rust::error::Error` variants map to
26//! `NotifierError::Unavailable` — desktop notification failures are
27//! almost always the notification daemon being unreachable (headless
28//! server, SSH session, daemon crashed).
29
30use ao_core::{
31    notifier::{NotificationPayload, Notifier, NotifierError},
32    reactions::EventPriority,
33};
34use async_trait::async_trait;
35use notify_rust::Notification;
36
37/// Notifier that shows OS-native desktop notifications.
38pub struct DesktopNotifier;
39
40impl DesktopNotifier {
41    pub fn new() -> Self {
42        Self
43    }
44}
45
46impl Default for DesktopNotifier {
47    fn default() -> Self {
48        Self::new()
49    }
50}
51
52/// Map `EventPriority` to a `notify_rust::Urgency` value.
53///
54/// Only used on Linux and Windows — macOS does not support urgency.
55#[cfg(not(target_os = "macos"))]
56fn urgency(priority: EventPriority, escalated: bool) -> notify_rust::Urgency {
57    use notify_rust::Urgency;
58    if escalated {
59        return Urgency::Critical;
60    }
61    match priority {
62        EventPriority::Urgent | EventPriority::Action => Urgency::Critical,
63        EventPriority::Warning => Urgency::Normal,
64        EventPriority::Info => Urgency::Low,
65    }
66}
67
68/// Build the notification with platform-appropriate settings.
69fn build_notification(
70    title: &str,
71    body: &str,
72    priority: EventPriority,
73    escalated: bool,
74) -> Notification {
75    let mut n = Notification::new();
76    n.summary(title).body(body);
77
78    // Urgency is only available on Linux and Windows.
79    #[cfg(not(target_os = "macos"))]
80    {
81        n.urgency(urgency(priority, escalated));
82    }
83
84    // Suppress unused-variable warning on macOS.
85    #[cfg(target_os = "macos")]
86    {
87        let _ = (priority, escalated);
88    }
89
90    n
91}
92
93#[async_trait]
94impl Notifier for DesktopNotifier {
95    fn name(&self) -> &str {
96        "desktop"
97    }
98
99    async fn send(&self, payload: &NotificationPayload) -> Result<(), NotifierError> {
100        let title = if payload.escalated {
101            format!("[ESCALATED] {}", payload.title)
102        } else {
103            payload.title.clone()
104        };
105
106        let notification =
107            build_notification(&title, &payload.body, payload.priority, payload.escalated);
108
109        // On Linux/Windows, use show_async() to avoid blocking the tokio runtime
110        // during D-Bus IPC. On macOS, show() is a fast native API call.
111        #[cfg(not(target_os = "macos"))]
112        notification
113            .show_async()
114            .await
115            .map_err(|e| NotifierError::Unavailable(format!("desktop notification failed: {e}")))?;
116
117        #[cfg(target_os = "macos")]
118        notification
119            .show()
120            .map_err(|e| NotifierError::Unavailable(format!("desktop notification failed: {e}")))?;
121
122        tracing::debug!("desktop notification sent");
123        Ok(())
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    #[test]
132    fn name_is_desktop() {
133        assert_eq!(DesktopNotifier::new().name(), "desktop");
134    }
135
136    #[test]
137    fn default_impl_works() {
138        let n: DesktopNotifier = Default::default();
139        assert_eq!(n.name(), "desktop");
140    }
141
142    #[cfg(not(target_os = "macos"))]
143    #[test]
144    fn urgency_mapping_covers_all_variants() {
145        use notify_rust::Urgency;
146        assert_eq!(urgency(EventPriority::Urgent, false), Urgency::Critical);
147        assert_eq!(urgency(EventPriority::Action, false), Urgency::Critical);
148        assert_eq!(urgency(EventPriority::Warning, false), Urgency::Normal);
149        assert_eq!(urgency(EventPriority::Info, false), Urgency::Low);
150    }
151
152    #[cfg(not(target_os = "macos"))]
153    #[test]
154    fn escalated_always_critical() {
155        use notify_rust::Urgency;
156        assert_eq!(urgency(EventPriority::Urgent, true), Urgency::Critical);
157        assert_eq!(urgency(EventPriority::Action, true), Urgency::Critical);
158        assert_eq!(urgency(EventPriority::Warning, true), Urgency::Critical);
159        assert_eq!(urgency(EventPriority::Info, true), Urgency::Critical);
160    }
161
162    #[test]
163    fn build_notification_sets_title_and_body() {
164        let n = build_notification("Test Title", "Test Body", EventPriority::Info, false);
165        assert_eq!(n.summary, "Test Title");
166        assert_eq!(n.body, "Test Body");
167    }
168}