Skip to main content

muster/adapter/
notifier.rs

1use std::{collections::HashMap, thread};
2
3use crossbeam_channel::{Sender, bounded};
4use notify_rust::Notification as OsNotification;
5#[cfg(all(unix, not(target_os = "macos")))]
6use notify_rust::NotificationHandle as OsNotificationHandle;
7
8use crate::{
9    constants::APP_NAME,
10    domain::{
11        notification::{Notification, NotificationId, NotificationScope},
12        port::Notifier,
13        value::PaneId,
14    },
15};
16
17/// Bounded backlog of pending desktop notification operations. If the daemon
18/// stalls this fills and further operations are dropped, rather than blocking
19/// the caller or growing memory without bound.
20const QUEUE_CAPACITY: usize = 64;
21/// Maximum identified desktop notifications whose delivery handles are kept for
22/// later Kitty update or close operations.
23const MAX_ACTIVE_NOTIFICATIONS: usize = 64;
24/// XDG markup entity for a literal ampersand in a plain-text body.
25#[cfg(all(unix, not(target_os = "macos")))]
26const XDG_AMPERSAND: &str = "&";
27/// XDG markup entity for a literal less-than sign in a plain-text body.
28#[cfg(all(unix, not(target_os = "macos")))]
29const XDG_LESS_THAN: &str = "<";
30/// XDG markup entity for a literal greater-than sign in a plain-text body.
31#[cfg(all(unix, not(target_os = "macos")))]
32const XDG_GREATER_THAN: &str = ">";
33
34/// A Kitty identifier scoped to the managed pane and terminal lifetime that
35/// emitted it.
36type NotificationKey = (PaneId, NotificationScope, NotificationId);
37
38/// One operation queued for the desktop-notification worker.
39#[derive(Clone)]
40enum Delivery {
41    /// Shows a new notification or updates the matching identified one.
42    Show(Notification),
43    /// Closes the matching identified notification if it is still retained.
44    Close {
45        /// Pane whose terminal owns the identifier.
46        pane: PaneId,
47        /// Specific terminal lifetime within the reusable pane.
48        scope: NotificationScope,
49        /// Kitty identifier to close within that pane.
50        identifier: NotificationId,
51    },
52}
53
54/// A [`Notifier`] that raises OS desktop notifications through `notify-rust`
55/// (libnotify/D-Bus on Linux). Delivery runs on a dedicated worker thread, so a
56/// slow or unresponsive daemon never blocks the runtime loop, and every error is
57/// ignored: notifications are best effort.
58pub struct DesktopNotifier {
59    sender: Sender<Delivery>,
60}
61
62impl DesktopNotifier {
63    /// Spawns the delivery worker and returns a notifier that feeds it.
64    pub fn new() -> Self {
65        let (sender, receiver) = bounded::<Delivery>(QUEUE_CAPACITY);
66        thread::spawn(move || {
67            let mut worker = DeliveryWorker::new(OsBackend);
68            // Ends when the sender (and so this notifier) is dropped on shutdown.
69            for delivery in receiver {
70                worker.deliver(delivery);
71            }
72        });
73        Self { sender }
74    }
75}
76
77impl Default for DesktopNotifier {
78    fn default() -> Self {
79        Self::new()
80    }
81}
82
83impl Notifier for DesktopNotifier {
84    fn notify(&self, notification: &Notification) {
85        // Hand off to the worker without blocking; drop it if the queue is full.
86        let _ = self.sender.try_send(Delivery::Show(notification.clone()));
87    }
88
89    fn close(&self, pane: PaneId, scope: NotificationScope, identifier: &NotificationId) {
90        let _ = self.sender.try_send(Delivery::Close {
91            pane,
92            scope,
93            identifier: identifier.clone(),
94        });
95    }
96}
97
98/// OS-delivery behavior used by the stateful worker and replaced by a fake in
99/// tests so update and close semantics do not require a notification daemon.
100trait NotificationBackend {
101    /// Retained handle returned when a notification is shown.
102    type Handle;
103
104    /// Shows `notification`, returning its handle on success.
105    fn show(&mut self, notification: &Notification) -> Option<Self::Handle>;
106
107    /// Replaces the notification represented by `handle` with new content.
108    fn update(&mut self, handle: &mut Self::Handle, notification: &Notification) -> bool;
109
110    /// Closes the notification represented by `handle`.
111    fn close(&mut self, handle: Self::Handle);
112}
113
114/// Owns retained handles and applies queued notification operations in order.
115struct DeliveryWorker<B: NotificationBackend> {
116    backend: B,
117    active: HashMap<NotificationKey, B::Handle>,
118}
119
120impl<B: NotificationBackend> DeliveryWorker<B> {
121    /// Creates a worker with no retained notification handles.
122    fn new(backend: B) -> Self {
123        Self {
124            backend,
125            active: HashMap::new(),
126        }
127    }
128
129    /// Applies one show/update/close operation.
130    fn deliver(&mut self, delivery: Delivery) {
131        match delivery {
132            Delivery::Show(notification) => self.show(&notification),
133            Delivery::Close {
134                pane,
135                scope,
136                identifier,
137            } => {
138                if let Some(handle) = self.active.remove(&(pane, scope, identifier)) {
139                    self.backend.close(handle);
140                }
141            },
142        }
143    }
144
145    /// Shows an unidentified notification independently, or updates/replaces the
146    /// retained handle for an identified Kitty notification.
147    fn show(&mut self, notification: &Notification) {
148        let key = notification
149            .identifier()
150            .clone()
151            .map(|identifier| (*notification.pane(), *notification.scope(), identifier));
152        if let Some(key) = &key {
153            let updated = self
154                .active
155                .get_mut(key)
156                .is_some_and(|handle| self.backend.update(handle, notification));
157            if updated {
158                return;
159            }
160            if let Some(handle) = self.active.remove(key) {
161                self.backend.close(handle);
162            }
163        }
164
165        let Some(handle) = self.backend.show(notification) else {
166            return;
167        };
168        let Some(key) = key else {
169            return;
170        };
171        self.evict_if_full();
172        self.active.insert(key, handle);
173    }
174
175    /// Closes and removes one retained notification before inserting beyond the
176    /// active-handle cap.
177    fn evict_if_full(&mut self) {
178        if self.active.len() < MAX_ACTIVE_NOTIFICATIONS {
179            return;
180        }
181        let key = self.active.keys().next().cloned();
182        if let Some(key) = key
183            && let Some(handle) = self.active.remove(&key)
184        {
185            self.backend.close(handle);
186        }
187    }
188}
189
190/// Concrete `notify-rust` backend used by the desktop worker.
191struct OsBackend;
192
193#[cfg(all(unix, not(target_os = "macos")))]
194impl NotificationBackend for OsBackend {
195    type Handle = OsNotificationHandle;
196
197    fn show(&mut self, notification: &Notification) -> Option<Self::Handle> {
198        os_notification(notification).show().ok()
199    }
200
201    fn update(&mut self, handle: &mut Self::Handle, notification: &Notification) -> bool {
202        configure_os_notification(handle, notification);
203        handle.update().is_ok()
204    }
205
206    fn close(&mut self, handle: Self::Handle) {
207        handle.close();
208    }
209}
210
211// notify-rust's default macOS backend and Windows backend do not expose the
212// update/close handle API. They still receive ordered shows; retaining unit
213// handles prevents duplicate shows from being treated as unidentified.
214#[cfg(not(all(unix, not(target_os = "macos"))))]
215impl NotificationBackend for OsBackend {
216    type Handle = ();
217
218    fn show(&mut self, notification: &Notification) -> Option<Self::Handle> {
219        os_notification(notification).show().ok().map(|_| ())
220    }
221
222    fn update(&mut self, _handle: &mut Self::Handle, notification: &Notification) -> bool {
223        self.show(notification).is_some()
224    }
225
226    fn close(&mut self, _handle: Self::Handle) {}
227}
228
229/// Builds one OS notification from the domain value.
230fn os_notification(notification: &Notification) -> OsNotification {
231    let mut output = OsNotification::new();
232    configure_os_notification(&mut output, notification);
233    output
234}
235
236/// Applies the user-visible notification fields to a notify-rust value or
237/// retained handle before its initial show or update.
238fn configure_os_notification(output: &mut OsNotification, notification: &Notification) {
239    let summary = notification
240        .title()
241        .clone()
242        .unwrap_or_else(|| notification.source().as_ref().to_string());
243    let body = notification
244        .body()
245        .as_deref()
246        .map(notification_body)
247        .unwrap_or_default();
248    output.appname(APP_NAME).summary(&summary).body(&body);
249}
250
251/// Converts a protocol-defined plain-text body into safe XDG notification
252/// markup so servers display markup characters literally.
253#[cfg(all(unix, not(target_os = "macos")))]
254fn notification_body(body: &str) -> String {
255    let mut escaped = String::with_capacity(body.len());
256    for character in body.chars() {
257        match character {
258            '&' => escaped.push_str(XDG_AMPERSAND),
259            '<' => escaped.push_str(XDG_LESS_THAN),
260            '>' => escaped.push_str(XDG_GREATER_THAN),
261            _ => escaped.push(character),
262        }
263    }
264    escaped
265}
266
267/// Keeps protocol-defined plain text unchanged on backends whose body is not
268/// interpreted as XDG markup.
269#[cfg(not(all(unix, not(target_os = "macos"))))]
270fn notification_body(body: &str) -> String {
271    body.to_string()
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277    use crate::domain::value::ProcessName;
278
279    /// Backend that records operations without contacting a desktop daemon.
280    #[derive(Default)]
281    struct RecordingBackend {
282        shown: Vec<Option<String>>,
283        updated: Vec<(usize, Option<String>)>,
284        closed: Vec<usize>,
285    }
286
287    impl NotificationBackend for RecordingBackend {
288        type Handle = usize;
289
290        fn show(&mut self, notification: &Notification) -> Option<Self::Handle> {
291            let handle = self.shown.len();
292            self.shown.push(notification.body().clone());
293            Some(handle)
294        }
295
296        fn update(&mut self, handle: &mut Self::Handle, notification: &Notification) -> bool {
297            self.updated.push((*handle, notification.body().clone()));
298            true
299        }
300
301        fn close(&mut self, handle: Self::Handle) {
302            self.closed.push(handle);
303        }
304    }
305
306    /// Builds an identified fixture notification.
307    fn notification(
308        pane: PaneId,
309        scope: NotificationScope,
310        identifier: &NotificationId,
311        body: &str,
312    ) -> Notification {
313        Notification::builder()
314            .pane(pane)
315            .scope(scope)
316            .source(ProcessName::try_new("worker").unwrap())
317            .body(Some(body.to_string()))
318            .identifier(Some(identifier.clone()))
319            .build()
320    }
321
322    #[test]
323    fn a_reused_kitty_identifier_updates_then_closes_one_handle() {
324        let pane = PaneId::new(1);
325        let scope = NotificationScope::new(1);
326        let identifier = NotificationId::try_new("build").unwrap();
327        let mut worker = DeliveryWorker::new(RecordingBackend::default());
328
329        worker.deliver(Delivery::Show(notification(
330            pane,
331            scope,
332            &identifier,
333            "starting",
334        )));
335        worker.deliver(Delivery::Show(notification(
336            pane,
337            scope,
338            &identifier,
339            "finished",
340        )));
341        worker.deliver(Delivery::Close {
342            pane,
343            scope,
344            identifier,
345        });
346
347        assert_eq!(worker.backend.shown, [Some("starting".to_string())]);
348        assert_eq!(worker.backend.updated, [(0, Some("finished".to_string()))]);
349        assert_eq!(worker.backend.closed, [0]);
350        assert!(worker.active.is_empty());
351    }
352
353    #[test]
354    fn identical_identifiers_from_different_panes_keep_separate_handles() {
355        let first_pane = PaneId::new(1);
356        let second_pane = PaneId::new(2);
357        let scope = NotificationScope::new(1);
358        let identifier = NotificationId::try_new("build").unwrap();
359        let mut worker = DeliveryWorker::new(RecordingBackend::default());
360
361        worker.deliver(Delivery::Show(notification(
362            first_pane,
363            scope,
364            &identifier,
365            "first",
366        )));
367        worker.deliver(Delivery::Show(notification(
368            second_pane,
369            scope,
370            &identifier,
371            "second",
372        )));
373        worker.deliver(Delivery::Show(notification(
374            first_pane,
375            scope,
376            &identifier,
377            "first update",
378        )));
379        worker.deliver(Delivery::Close {
380            pane: first_pane,
381            scope,
382            identifier: identifier.clone(),
383        });
384
385        assert_eq!(worker.backend.shown, [
386            Some("first".to_string()),
387            Some("second".to_string())
388        ]);
389        assert_eq!(worker.backend.updated, [(
390            0,
391            Some("first update".to_string())
392        )]);
393        assert_eq!(worker.backend.closed, [0]);
394        assert!(
395            worker
396                .active
397                .contains_key(&(second_pane, scope, identifier))
398        );
399    }
400
401    #[test]
402    fn a_reused_pane_identifier_is_independent_in_a_new_terminal_scope() {
403        let pane = PaneId::new(1);
404        let old_scope = NotificationScope::new(1);
405        let new_scope = NotificationScope::new(2);
406        let identifier = NotificationId::try_new("build").unwrap();
407        let mut worker = DeliveryWorker::new(RecordingBackend::default());
408
409        worker.deliver(Delivery::Show(notification(
410            pane,
411            old_scope,
412            &identifier,
413            "old project",
414        )));
415        worker.deliver(Delivery::Show(notification(
416            pane,
417            new_scope,
418            &identifier,
419            "new project",
420        )));
421        worker.deliver(Delivery::Close {
422            pane,
423            scope: new_scope,
424            identifier: identifier.clone(),
425        });
426
427        assert_eq!(worker.backend.shown, [
428            Some("old project".to_string()),
429            Some("new project".to_string())
430        ]);
431        assert!(worker.backend.updated.is_empty());
432        assert_eq!(worker.backend.closed, [1]);
433        assert!(worker.active.contains_key(&(pane, old_scope, identifier)));
434    }
435
436    #[cfg(all(unix, not(target_os = "macos")))]
437    #[test]
438    fn xdg_delivery_escapes_plain_text_markup_characters() {
439        assert_eq!(
440            notification_body("<b>two & three</b>"),
441            "&lt;b&gt;two &amp; three&lt;/b&gt;"
442        );
443    }
444}