Skip to main content

notifications/
service.rs

1use crate::data::ServiceData;
2use crate::dbus::{DBusService, DBusServiceSignals};
3use crate::private_prelude::*;
4use ignis_events::Event;
5use std::sync::OnceLock;
6use zbus::Connection;
7use zbus::connection::Builder;
8use zbus::object_server::InterfaceRef;
9
10pub(crate) struct NotificationServiceInner {
11    pub(crate) data: ServiceData,
12    pub(crate) connection: OnceLock<Option<Connection>>,
13    pub(crate) cache_dir: Option<PathBuf>,
14    pub(crate) settings: Settings,
15    pub(crate) on_notified: Event<(u32, NotificationHandle, bool)>,
16    pub(crate) on_notification_closed: Event<(u32, CloseReason)>,
17    pub(crate) on_notify_notifications: Event<()>,
18    pub(crate) on_notifications_cleared: Event<()>,
19}
20
21/// A notification daemon that follows XDG Desktop Notifications Specification.
22///
23/// [`NotificationService`] implements [`Clone`] and can be cloned cheapely since underlying data is
24/// shared.
25#[derive(Clone)]
26pub struct NotificationService {
27    pub(crate) inner: Arc<NotificationServiceInner>,
28}
29
30impl NotificationService {
31    fn new_with_data(data: ServiceData, cache_dir: Option<PathBuf>) -> Self {
32        let on_notified = Event::<(u32, NotificationHandle, bool)>::new();
33        let on_notification_closed = Event::<(u32, CloseReason)>::new();
34        let on_notify_notifications = Event::<()>::new();
35        let on_notifications_cleared = Event::<()>::new();
36
37        let on_notify_notifications_clone = on_notify_notifications.clone();
38        on_notified.connect(move |_| on_notify_notifications_clone.emit(&()));
39
40        let on_notify_notifications_clone = on_notify_notifications.clone();
41        on_notification_closed.connect(move |_| on_notify_notifications_clone.emit(&()));
42
43        let on_notify_notifications_clone = on_notify_notifications.clone();
44        on_notifications_cleared.connect(move |_| on_notify_notifications_clone.emit(&()));
45
46        Self {
47            inner: Arc::new(NotificationServiceInner {
48                data,
49                connection: OnceLock::new(),
50                cache_dir,
51                settings: Settings::default(),
52                on_notified,
53                on_notification_closed,
54                on_notify_notifications,
55                on_notifications_cleared,
56            }),
57        }
58    }
59
60    /// Creates a new instance of the service loading the notification history from file.
61    /// * `cache_dir` - Overrides the default cache directory located at `~/.cache/ignis_notifications`.
62    ///
63    /// # Errors
64    /// Returns [`Error::IOError`] if loading notification history from file
65    /// fails.
66    pub fn new(cache_dir: Option<PathBuf>) -> Result<Self> {
67        Ok(Self::new_with_data(
68            ServiceData::new(cache_dir.clone())?,
69            cache_dir,
70        ))
71    }
72
73    /// Creates a new instance of the service without any I/O operations.
74    ///
75    /// It doesn't load the notification history from file and doesn't save it consequently.
76    /// This method can not fail and is guaranteed to return the instance.
77    pub fn new_in_memory() -> Self {
78        Self::new_with_data(ServiceData::new_in_memory(), None)
79    }
80
81    /// Returns an instance of settings that affect behavior of the service.
82    pub fn settings(&self) -> Settings {
83        self.inner.settings.clone()
84    }
85
86    /// Runs the service.
87    ///
88    /// You have to call this this method in order to receive notifications and perform operations
89    /// on them, such as dismissing or invoking actions.
90    /// It creates D-Bus connection and registers D-Bus interface on the session bus.
91    /// Must be called only once.
92    ///
93    /// # Errors
94    /// Returns [`Error::DBusError`], for example, if the name is already taken on the bus.
95    ///
96    /// Returns [`Error::ConnectionInitializedTwice`] if this function is called
97    /// more than once.
98    pub async fn run(&self) -> Result<()> {
99        let service = DBusService::new(self.clone())?;
100
101        let connection = Builder::session()?
102            .name("org.freedesktop.Notifications")?
103            .serve_at("/org/freedesktop/Notifications", service)?
104            .build()
105            .await?;
106
107        self.inner
108            .connection
109            .set(Some(connection))
110            .map_err(|_| Error::ConnectionInitializedTwice)?;
111
112        Ok(())
113    }
114
115    pub(crate) fn get_connection(&self) -> Result<Connection> {
116        self.inner
117            .connection
118            .get()
119            .ok_or(Error::NoConnection)?
120            .to_owned()
121            .ok_or(Error::NoConnection)
122    }
123
124    pub(crate) async fn get_dbus_interface(&self) -> Result<InterfaceRef<DBusService>> {
125        Ok(self
126            .get_connection()?
127            .object_server()
128            .interface("/org/freedesktop/Notifications")
129            .await?)
130    }
131
132    /// Dismiss a notification by its ID.
133    ///
134    /// The notification is removed from the history and application that sent the notification is notified through D-Bus.
135    /// Emits [`Event::NotificationClosed`] event.
136    ///
137    /// # Errors
138    /// Returns [`Error::DBusError`].
139    ///
140    /// Returns [`Error::NotificationNotFound`] if the notification with such ID is
141    /// not found.
142    pub async fn dismiss_notification(&self, id: u32) -> Result<()> {
143        self.get_dbus_interface()
144            .await?
145            .notification_closed(id, CloseReason::Dismissed.into())
146            .await?;
147
148        self.inner.data.remove_notification(id)?;
149
150        self.inner
151            .on_notification_closed
152            .emit(&(id, CloseReason::Dismissed));
153
154        Ok(())
155    }
156
157    /// Invokes an action by its action key and notification ID it belongs to.
158    ///
159    /// # Errors
160    /// Returns [`Error::DBusError`].
161    pub async fn invoke_action(&self, notification_id: u32, action_key: &str) -> Result<()> {
162        self.get_dbus_interface()
163            .await?
164            .action_invoked(notification_id, action_key)
165            .await?;
166
167        Ok(())
168    }
169
170    /// Returns a vector of notification handles.
171    pub fn get_notifications(&self) -> Vec<NotificationHandle> {
172        self.inner
173            .data
174            .get_notifications()
175            .values()
176            .map(|n| NotificationHandle {
177                inner: Arc::clone(n),
178                service: self.clone(),
179            })
180            .collect()
181    }
182
183    /// Returns a notification handle by notification ID.
184    pub fn get_notification_by_id(&self, id: u32) -> Option<NotificationHandle> {
185        self.inner
186            .data
187            .get_notifications()
188            .get(&id)
189            .map(|n| NotificationHandle {
190                inner: n.clone(),
191                service: self.clone(),
192            })
193    }
194
195    /// Clears the notification history.
196    ///
197    /// It dismisses each notification and notifies applications.
198    ///
199    /// # Warning
200    /// It does **NOT** emit [`Event::NotificationClosed`] event for each notification.
201    pub async fn clear_notifications(&self) -> Result<()> {
202        for id in self.inner.data.get_notifications().keys() {
203            self.get_dbus_interface()
204                .await?
205                .notification_closed(id.to_owned(), CloseReason::Dismissed.into())
206                .await?;
207        }
208
209        let res = self.inner.data.clear();
210        self.inner.on_notifications_cleared.emit(&());
211        res
212    }
213
214    /// Invokes a callback when a new notification is received.
215    ///
216    /// The following arguments are passed to the callback:
217    /// 1. id - The ID of the notification
218    /// 2. handle - Notification handle
219    /// 3. replace - Whether this notification replaces the old one with the same ID
220    pub fn on_notified<F>(&self, callback: F) -> usize
221    where
222        F: Fn(&(u32, NotificationHandle, bool)) + Send + Sync + 'static,
223    {
224        self.inner.on_notified.connect(callback)
225    }
226
227    /// Invokes a callback when a notification is closed.
228    ///
229    /// The following arguments are passed to the callback:
230    /// 1. id - The ID of the notification
231    /// 3. reason - The reason why the notification was closed
232    pub fn on_notification_closed<F>(&self, callback: F) -> usize
233    where
234        F: Fn(&(u32, CloseReason)) + Send + Sync + 'static,
235    {
236        self.inner.on_notification_closed.connect(callback)
237    }
238
239    /// Invokes a callback when value of [`get_notifications`] changes.
240    ///
241    /// It includes arriving of new notifications, closing and clearing notifications.
242    pub fn on_notify_notifications<F>(&self, callback: F) -> usize
243    where
244        F: Fn() + Send + Sync + 'static,
245    {
246        self.inner
247            .on_notify_notifications
248            .connect(move |_| callback())
249    }
250
251    /// Invokes a callback when notifications are cleared by a call to [`clear_notifications`].
252    pub fn on_notifications_cleared<F>(&self, callback: F) -> usize
253    where
254        F: Fn() + Send + Sync + 'static,
255    {
256        self.inner
257            .on_notifications_cleared
258            .connect(move |_| callback())
259    }
260}
261
262// Run with `dbus-run-session cargo test -- --test-threads=1`
263// WARNING: must be run serially to avoid D-Bus name conflicts
264#[cfg(test)]
265mod tests {
266
267    use super::*;
268    use std::{collections::HashMap, time::Duration};
269
270    use crate::CloseReason;
271    use crate::Urgency;
272
273    use fake::Fake;
274    use fake::faker::lorem::en::Sentence;
275    use notify_rust::Urgency as ClientUrgency;
276    use notify_rust::{
277        CloseReason as ClientCloseReason, Notification, NotificationHandle, NotificationResponse,
278    };
279    use rand::seq::IndexedRandom;
280    use tempfile::TempDir;
281    use tokio::sync::oneshot;
282
283    impl From<Urgency> for ClientUrgency {
284        fn from(value: Urgency) -> Self {
285            match value {
286                Urgency::Low => ClientUrgency::Low,
287                Urgency::Normal => ClientUrgency::Normal,
288                Urgency::Critical => ClientUrgency::Critical,
289            }
290        }
291    }
292
293    struct TestContext {
294        _temp_dir: TempDir,
295        service: NotificationService,
296    }
297
298    fn no_tmp_cleanup() -> bool {
299        std::env::var_os("NO_TMP_CLEANUP")
300            .map(|v| v == "1")
301            .unwrap_or(false)
302    }
303
304    fn create_random_notification() -> Notification {
305        let summary: String = Sentence(3..6).fake();
306        let body: String = Sentence(6..12).fake();
307        let app_name: String = Sentence(1..3).fake();
308        let icon: String = String::from("cat-sleeping-symbolic");
309
310        let mut notification = Notification::new();
311
312        notification
313            .appname(&app_name)
314            .summary(&summary)
315            .body(&body)
316            .icon(&icon);
317
318        notification
319    }
320
321    async fn send_multiple_random_notifications(quantity: u32) -> HashMap<u32, NotificationHandle> {
322        let mut map: HashMap<u32, NotificationHandle> = HashMap::new();
323
324        for _ in 0..quantity {
325            let handle = create_random_notification().show_async().await.unwrap();
326            let id = handle.id();
327            map.insert(id, handle);
328        }
329
330        map
331    }
332
333    async fn setup_with_details(temp_dir: Option<TempDir>) -> TestContext {
334        let mut temp_dir = temp_dir.unwrap_or_else(|| TempDir::new().unwrap());
335
336        if no_tmp_cleanup() {
337            temp_dir.disable_cleanup(true);
338        }
339
340        let service = NotificationService::new(Some(temp_dir.path().to_path_buf())).unwrap();
341        service.run().await.unwrap();
342
343        TestContext {
344            _temp_dir: temp_dir,
345            service,
346        }
347    }
348
349    async fn setup() -> TestContext {
350        setup_with_details(None).await
351    }
352
353    #[tokio::test]
354    async fn test_single_notification() {
355        let ctx = setup().await;
356
357        let client_urgency_levels = [
358            ClientUrgency::Low,
359            ClientUrgency::Normal,
360            ClientUrgency::Critical,
361        ];
362
363        let client_urgency: ClientUrgency = client_urgency_levels
364            .choose(&mut rand::rng())
365            .unwrap()
366            .to_owned();
367
368        let handle = create_random_notification()
369            .urgency(client_urgency)
370            .show_async()
371            .await
372            .unwrap();
373
374        let notification = ctx.service.get_notification_by_id(handle.id()).unwrap();
375
376        assert_eq!(handle.appname, notification.app_name());
377        assert_eq!(handle.icon, notification.icon().unwrap());
378        assert_eq!(handle.summary, notification.summary());
379        assert_eq!(handle.body, notification.body());
380        assert_eq!(client_urgency, notification.urgency().into());
381        assert_eq!(i32::from(handle.timeout), notification.timeout());
382    }
383
384    #[tokio::test]
385    async fn test_multiple_notifications() {
386        let ctx = setup().await;
387        send_multiple_random_notifications(50).await;
388
389        assert!(ctx.service.get_notifications().is_sorted_by_key(|x| x.id()));
390        assert_eq!(ctx.service.get_notifications().len(), 50);
391    }
392
393    #[tokio::test]
394    async fn test_dismiss_notification() {
395        let ctx = setup().await;
396        let handle = create_random_notification().show_async().await.unwrap();
397        let id = handle.id();
398
399        let (tx, rx) = oneshot::channel();
400
401        tokio::spawn(async move {
402            handle
403                .wait_for_action_async(|response| {
404                    match response {
405                        NotificationResponse::Closed(reason) => tx.send(reason.to_owned()).unwrap(),
406                        _ => unimplemented!(),
407                    };
408                })
409                .await;
410        });
411        // FIXME: Hacky workaround to prevent the test from hanging
412        // For some reason calling NotificationService.close_notification() immediately
413        // makes the handle "miss" the signal and therefore never call the closure
414        tokio::time::sleep(Duration::from_millis(500)).await;
415
416        ctx.service.dismiss_notification(id).await.unwrap();
417
418        let close_reason = rx.await.unwrap();
419        assert_eq!(close_reason, ClientCloseReason::Dismissed);
420    }
421
422    #[tokio::test]
423    async fn test_invoke_action() {
424        let ctx = setup().await;
425        let handle = Notification::new()
426            .summary("i am waiting")
427            .action("default", "default")
428            .action("asked", "no one asked")
429            .show_async()
430            .await
431            .unwrap();
432        let id = handle.id();
433
434        let (tx, rx) = oneshot::channel();
435
436        tokio::spawn(async move {
437            handle
438                .wait_for_action_async(|response| {
439                    match response {
440                        NotificationResponse::Action(action) => {
441                            tx.send(action.clone()).unwrap();
442                        }
443                        _ => unimplemented!(),
444                    };
445                })
446                .await;
447        });
448
449        // FIXME: the same here
450        tokio::time::sleep(Duration::from_millis(500)).await;
451
452        let n = ctx.service.get_notification_by_id(id).unwrap();
453        assert_eq!(n.actions().len(), 2);
454
455        for action in n.actions() {
456            if action.action_key() == "asked" {
457                action.invoke().await.unwrap();
458            }
459        }
460
461        let action_key = rx.await.unwrap();
462        assert_eq!(action_key, "asked")
463    }
464
465    #[tokio::test]
466    async fn test_clear_notifications() {
467        let ctx = setup().await;
468
469        send_multiple_random_notifications(10).await;
470
471        assert_eq!(ctx.service.get_notifications().len(), 10);
472        ctx.service.clear_notifications().await.unwrap();
473
474        assert_eq!(ctx.service.get_notifications().len(), 0);
475    }
476
477    // FIXME: Bug in notify-rust causes panic when using image_data()
478    // because it uses get_server_information() that thereby uses zbus::block_on()
479    // Starting a runtime from within another runtime is prohibited.
480    // Happens only when "tokio" feature of zbus is enabled.
481    // TODO: report the issue in notify-rust repo
482    //
483    // #[tokio::test]
484    // async fn test_image_data() {
485    //     let ctx = setup().await;
486    //
487    //     let width = 64;
488    //     let height = 64;
489    //
490    //     let img_buffer =
491    //         ImageBuffer::<Rgba<u8>, _>::from_pixel(width, height, Rgba([255, 255, 255, 255]));
492    //
493    //     let img = Image::from_rgba(width as i32, height as i32, img_buffer.into_raw()).unwrap();
494    //
495    //     let client_handle = create_random_notification()
496    //         .image_data(img)
497    //         .show_async()
498    //         .await
499    //         .unwrap();
500    //
501    //     let handle = ctx
502    //         .service
503    //         .get_notification_by_id(client_handle.id())
504    //         .unwrap();
505    //
506    //     let path = PathBuf::from(handle.icon().unwrap());
507    //
508    //     assert!(path.exists());
509    // }
510
511    async fn check_timeout(ms: i32) {
512        let ctx = setup().await;
513        ctx.service.settings().set_expire_by_default(true);
514
515        create_random_notification()
516            .timeout(ms)
517            .show_async()
518            .await
519            .unwrap();
520
521        ctx.service.on_notification_closed(|(_, reason)| {
522            assert_eq!(reason, &CloseReason::Expired);
523        });
524
525        tokio::time::sleep(Duration::from_secs(2)).await;
526    }
527
528    #[tokio::test]
529    async fn test_default_timeout() {
530        check_timeout(-1).await;
531    }
532
533    #[tokio::test]
534    async fn test_requested_timeout() {
535        check_timeout(1000).await;
536    }
537
538    #[tokio::test]
539    async fn test_on_closed() {
540        let ctx = setup().await;
541
542        let id = create_random_notification()
543            .show_async()
544            .await
545            .unwrap()
546            .id();
547
548        let n = ctx.service.get_notification_by_id(id).unwrap();
549        n.on_closed(|reason| assert_eq!(reason, &CloseReason::Dismissed));
550
551        n.dismiss().await.unwrap();
552
553        tokio::time::sleep(Duration::from_secs(2)).await;
554    }
555
556    #[tokio::test]
557    async fn test_on_notify_notifications() {
558        let ctx = setup().await;
559
560        let dismissed_flag = Arc::new(std::sync::Mutex::new(false));
561        let notified_flag = Arc::new(std::sync::Mutex::new(false));
562        let clear_flag = Arc::new(std::sync::Mutex::new(false));
563
564        let dismissed_flag_clone = dismissed_flag.clone();
565        let notified_flag_clone = notified_flag.clone();
566        let clear_flag_clone = clear_flag.clone();
567
568        ctx.service
569            .on_notify_notifications(move || *notified_flag_clone.lock().unwrap() = true);
570
571        let id = create_random_notification()
572            .show_async()
573            .await
574            .unwrap()
575            .id();
576
577        tokio::time::sleep(Duration::from_secs(1)).await;
578        assert_eq!(*notified_flag.lock().unwrap(), true);
579
580        ctx.service
581            .on_notify_notifications(move || *dismissed_flag_clone.lock().unwrap() = true);
582
583        let n = ctx.service.get_notification_by_id(id).unwrap();
584        n.dismiss().await.unwrap();
585
586        tokio::time::sleep(Duration::from_secs(1)).await;
587        assert_eq!(*dismissed_flag.lock().unwrap(), true);
588
589        ctx.service
590            .on_notify_notifications(move || *clear_flag_clone.lock().unwrap() = true);
591
592        ctx.service.clear_notifications().await.unwrap();
593
594        tokio::time::sleep(Duration::from_secs(1)).await;
595        assert_eq!(*clear_flag.lock().unwrap(), true);
596    }
597}