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
17const QUEUE_CAPACITY: usize = 64;
21const MAX_ACTIVE_NOTIFICATIONS: usize = 64;
24#[cfg(all(unix, not(target_os = "macos")))]
26const XDG_AMPERSAND: &str = "&";
27#[cfg(all(unix, not(target_os = "macos")))]
29const XDG_LESS_THAN: &str = "<";
30#[cfg(all(unix, not(target_os = "macos")))]
32const XDG_GREATER_THAN: &str = ">";
33
34type NotificationKey = (PaneId, NotificationScope, NotificationId);
37
38#[derive(Clone)]
40enum Delivery {
41 Show(Notification),
43 Close {
45 pane: PaneId,
47 scope: NotificationScope,
49 identifier: NotificationId,
51 },
52}
53
54pub struct DesktopNotifier {
59 sender: Sender<Delivery>,
60}
61
62impl DesktopNotifier {
63 pub fn new() -> Self {
65 let (sender, receiver) = bounded::<Delivery>(QUEUE_CAPACITY);
66 thread::spawn(move || {
67 let mut worker = DeliveryWorker::new(OsBackend);
68 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 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
98trait NotificationBackend {
101 type Handle;
103
104 fn show(&mut self, notification: &Notification) -> Option<Self::Handle>;
106
107 fn update(&mut self, handle: &mut Self::Handle, notification: &Notification) -> bool;
109
110 fn close(&mut self, handle: Self::Handle);
112}
113
114struct DeliveryWorker<B: NotificationBackend> {
116 backend: B,
117 active: HashMap<NotificationKey, B::Handle>,
118}
119
120impl<B: NotificationBackend> DeliveryWorker<B> {
121 fn new(backend: B) -> Self {
123 Self {
124 backend,
125 active: HashMap::new(),
126 }
127 }
128
129 fn deliver(&mut self, delivery: Delivery) {
131 match delivery {
132 Delivery::Show(notification) => self.show(¬ification),
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 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 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
190struct 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#[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
229fn os_notification(notification: &Notification) -> OsNotification {
231 let mut output = OsNotification::new();
232 configure_os_notification(&mut output, notification);
233 output
234}
235
236fn 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#[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#[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 #[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 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 "<b>two & three</b>"
442 );
443 }
444}