Skip to main content

tauri_plugin_notification/
desktop.rs

1// Copyright 2019-2023 Tauri Programme within The Commons Conservancy
2// SPDX-License-Identifier: Apache-2.0
3// SPDX-License-Identifier: MIT
4
5use std::{
6    collections::HashMap,
7    sync::{Arc, Mutex, MutexGuard, PoisonError},
8};
9
10use serde::de::DeserializeOwned;
11use serde_json::Value;
12use tauri::{
13    AppHandle, Manager, Runtime,
14    ipc::Channel,
15    plugin::{PermissionState, PluginApi},
16};
17
18use crate::{ActionPerformed, ActionType, NotificationBuilder};
19
20/// The event of the JavaScript `onAction` listeners.
21const ACTION_PERFORMED: &str = "actionPerformed";
22
23/// Initializes the desktop implementation of the notification APIs.
24pub fn init<R: Runtime, C: DeserializeOwned>(
25    app: &AppHandle<R>,
26    _api: PluginApi<R, C>,
27) -> crate::Result<Notification<R>> {
28    Ok(Notification {
29        app: app.clone(),
30        shared: Arc::default(),
31    })
32}
33
34type ActionHandler = Arc<dyn Fn(&ActionPerformed) + Send + Sync>;
35
36/// A JavaScript listener, with the webview and the window it was registered from.
37struct Listener {
38    event: String,
39    channel: Channel<Value>,
40    webview: String,
41    window: String,
42}
43
44/// What the notifications of this app share: the registered action types, who listens to
45/// actions, and the shown notifications whose actions are reported.
46#[derive(Default)]
47pub(crate) struct Shared {
48    action_types: Mutex<HashMap<String, ActionType>>,
49    handlers: Mutex<Vec<ActionHandler>>,
50    listeners: Mutex<Vec<Listener>>,
51    #[cfg(all(unix, not(target_os = "macos")))]
52    tracker: imp::Tracker,
53}
54
55fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
56    mutex.lock().unwrap_or_else(PoisonError::into_inner)
57}
58
59impl Shared {
60    fn has_listeners(&self) -> bool {
61        !lock(&self.handlers).is_empty()
62            || lock(&self.listeners)
63                .iter()
64                .any(|listener| listener.event == ACTION_PERFORMED)
65    }
66
67    fn retain_listeners(&self, keep: impl Fn(&Listener) -> bool) {
68        lock(&self.listeners).retain(keep);
69    }
70
71    /// Hands an action to the Rust handlers and the JavaScript listeners. The handlers run
72    /// without a lock held, so they may register handlers or remove notifications themselves.
73    fn dispatch(&self, payload: Value) {
74        if let Ok(performed) = serde_json::from_value::<ActionPerformed>(payload.clone()) {
75            let handlers = lock(&self.handlers).clone();
76            for handler in handlers {
77                handler(&performed);
78            }
79        }
80        for listener in lock(&self.listeners).iter() {
81            if listener.event == ACTION_PERFORMED {
82                let _ = listener.channel.send(payload.clone());
83            }
84        }
85    }
86}
87
88/// Access to the notification APIs.
89///
90/// You can get an instance of this type via [`NotificationExt`](crate::NotificationExt)
91pub struct Notification<R: Runtime> {
92    app: AppHandle<R>,
93    shared: Arc<Shared>,
94}
95
96impl<R: Runtime> crate::NotificationBuilder<R> {
97    /// Shows the notification.
98    ///
99    /// When no title was set with [`Self::title`], the `productName` from the Tauri configuration is used instead.
100    /// Only the title, body, icon, sound and the actions of the [action type](Self::action_type_id)
101    /// of the notification are used on desktop; the scheduling and grouping options are ignored.
102    ///
103    /// When a handler is registered with [`Notification::on_action`] or a JavaScript `onAction`
104    /// listener exists, a click on the notification (`tap`) or on one of its actions is reported
105    /// to them, as far as the notification server reports it. Otherwise the actions of the
106    /// notification are not shown, since nothing would receive them.
107    ///
108    /// ## Platform-specific
109    ///
110    /// - **Windows**: a click is only reported while the toast is on screen, not once it moved to
111    ///   the Action Center.
112    /// - **macOS**: two or more actions are shown in an "Options" menu, and a click on the menu
113    ///   button itself reports the first action. Actions are told apart by their title.
114    ///
115    /// The notification is dispatched on a background task, so this returns as soon as the payload is prepared.
116    ///
117    /// # Errors
118    ///
119    /// Returns an error when the notification could not be prepared,
120    /// e.g. when the path of the running executable cannot be resolved on Windows.
121    pub fn show(self) -> crate::Result<()> {
122        let shared = Arc::clone(&self.app.state::<Notification<R>>().shared);
123        let mut notification = imp::Notification::new(self.app.config().identifier.clone());
124
125        if let Some(title) = self
126            .data
127            .title
128            .clone()
129            .or_else(|| self.app.config().product_name.clone())
130        {
131            notification = notification.title(title);
132        }
133        if let Some(body) = self.data.body.clone() {
134            notification = notification.body(body);
135        }
136        if let Some(icon) = self.data.icon.clone() {
137            notification = notification.icon(icon);
138        }
139        if let Some(sound) = self.data.sound.clone() {
140            notification = notification.sound(sound);
141        }
142        if shared.has_listeners() {
143            if let Some(action_type) = self
144                .data
145                .action_type_id
146                .as_ref()
147                .and_then(|id| lock(&shared.action_types).get(id).cloned())
148            {
149                for action in action_type.actions() {
150                    notification = notification.action(action.id(), action.title());
151                }
152            }
153            let id = self.data.id;
154            let data = serde_json::to_value(&self.data).unwrap_or(Value::Null);
155            notification.show_tracked(shared, id, data)?;
156        } else {
157            notification.show()?;
158        }
159
160        Ok(())
161    }
162}
163
164impl<R: Runtime> Notification<R> {
165    /// Creates a new builder for a notification.
166    ///
167    /// # Examples
168    ///
169    /// ```no_run
170    /// use tauri_plugin_notification::NotificationExt;
171    ///
172    /// fn notify<R: tauri::Runtime>(app: &tauri::AppHandle<R>) {
173    ///   app.notification()
174    ///     .builder()
175    ///     .title("Tauri")
176    ///     .body("Tauri is awesome!")
177    ///     .show()
178    ///     .unwrap();
179    /// }
180    /// ```
181    pub fn builder(&self) -> NotificationBuilder<R> {
182        NotificationBuilder::new(self.app.clone())
183    }
184
185    /// Requests the permission to send notifications.
186    ///
187    /// Desktop applications do not need to ask for this permission,
188    /// so this always resolves to [`PermissionState::Granted`] without prompting the user.
189    pub fn request_permission(&self) -> crate::Result<PermissionState> {
190        Ok(PermissionState::Granted)
191    }
192
193    /// Checks whether the permission to send notifications was granted.
194    ///
195    /// Desktop applications do not need to ask for this permission,
196    /// so this always resolves to [`PermissionState::Granted`].
197    pub fn permission_state(&self) -> crate::Result<PermissionState> {
198        Ok(PermissionState::Granted)
199    }
200
201    /// Registers the action types a notification can reference
202    /// through [`NotificationBuilder::action_type_id`](crate::NotificationBuilder::action_type_id).
203    ///
204    /// On desktop the actions of a type become the buttons of the notification; only their
205    /// identifier and title are used. A type registered again replaces the previous one.
206    pub fn register_action_types(&self, types: Vec<ActionType>) -> crate::Result<()> {
207        let mut registered = lock(&self.shared.action_types);
208        for action_type in types {
209            registered.insert(action_type.id().to_owned(), action_type);
210        }
211        Ok(())
212    }
213
214    /// Removes the delivered notifications with the given identifiers.
215    ///
216    /// ## Platform-specific
217    ///
218    /// - **Linux and the BSDs**: closes notifications shown while an action listener existed.
219    /// - **macOS and Windows**: does nothing, `notify-rust` cannot remove a shown notification there.
220    pub fn remove_active(&self, notifications: Vec<i32>) -> crate::Result<()> {
221        imp::close(&self.shared, Some(&notifications));
222        Ok(())
223    }
224
225    /// Removes all delivered notifications that can still be closed.
226    pub fn remove_all_active(&self) -> crate::Result<()> {
227        imp::close(&self.shared, None);
228        Ok(())
229    }
230
231    /// Calls `handler` for every action the user performs on a notification of this app:
232    /// a click on the notification itself (`tap`) or on one of its actions.
233    ///
234    /// Notifications shown before the first handler or listener existed report nothing.
235    /// The handler runs on a background thread and should return quickly.
236    ///
237    /// # Errors
238    ///
239    /// Never on desktop; the signature matches the mobile one.
240    pub fn on_action<F: Fn(&ActionPerformed) + Send + Sync + 'static>(
241        &self,
242        handler: F,
243    ) -> crate::Result<()> {
244        lock(&self.shared.handlers).push(Arc::new(handler));
245        Ok(())
246    }
247
248    pub(crate) fn register_listener(
249        &self,
250        event: String,
251        channel: Channel<Value>,
252        webview: &tauri::Webview<R>,
253    ) {
254        lock(&self.shared.listeners).push(Listener {
255            event,
256            channel,
257            webview: webview.label().to_owned(),
258            window: webview.window().label().to_owned(),
259        });
260    }
261
262    pub(crate) fn remove_listener(&self, event: &str, channel_id: u32) {
263        lock(&self.shared.listeners)
264            .retain(|listener| !(listener.event == event && listener.channel.id() == channel_id));
265    }
266
267    /// Drops the listeners of a webview that navigates away, since its page no longer exists.
268    pub(crate) fn remove_webview_listeners(&self, webview: &str) {
269        self.shared
270            .retain_listeners(|listener| listener.webview != webview);
271    }
272
273    /// Drops the listeners of the webviews of a destroyed window.
274    pub(crate) fn remove_window_listeners(&self, window: &str) {
275        self.shared
276            .retain_listeners(|listener| listener.window != window);
277    }
278}
279
280mod imp {
281    //! Types and functions related to desktop notifications.
282
283    #[cfg(windows)]
284    use std::path::MAIN_SEPARATOR as SEP;
285    use std::sync::Arc;
286    #[cfg(all(unix, not(target_os = "macos")))]
287    use std::{
288        collections::HashMap,
289        sync::{Mutex, OnceLock, Weak},
290    };
291
292    use serde_json::{Value, json};
293
294    use super::Shared;
295    #[cfg(all(unix, not(target_os = "macos")))]
296    use super::lock;
297
298    /// The desktop notification definition.
299    ///
300    /// Allows you to construct a Notification data and send it.
301    ///
302    /// # Examples
303    /// ```rust,no_run
304    /// use tauri_plugin_notification::NotificationExt;
305    /// // first we build the application to access the Tauri configuration
306    /// let app = tauri::Builder::default()
307    ///   // on an actual app, remove the string argument
308    ///   .build(tauri::generate_context!("test/tauri.conf.json"))
309    ///   .expect("error while building tauri application");
310    ///
311    /// // shows a notification with the given title and body
312    /// app.notification()
313    ///   .builder()
314    ///   .title("New message")
315    ///   .body("You've got a new message.")
316    ///   .show();
317    ///
318    /// // run the app
319    /// app.run(|_app_handle, _event| {});
320    /// ```
321    #[allow(dead_code)]
322    #[derive(Debug, Default)]
323    pub struct Notification {
324        /// The notification body.
325        body: Option<String>,
326        /// The notification title.
327        title: Option<String>,
328        /// The notification icon.
329        icon: Option<String>,
330        /// The notification sound.
331        sound: Option<String>,
332        /// The notification identifier
333        identifier: String,
334        /// The actions, as identifier and title.
335        actions: Vec<(String, String)>,
336    }
337
338    /// Linux and the BSDs: the notifications whose actions are reported. A single session bus
339    /// connection, opened with the first of them, receives the signals of all of them on a thread
340    /// of its own, so a tracked notification costs no thread or connection of its own.
341    #[cfg(all(unix, not(target_os = "macos")))]
342    #[derive(Default)]
343    pub(crate) struct Tracker {
344        /// The connection the signals arrive on; `None` when it could not be set up.
345        connection: OnceLock<Option<zbus::blocking::Connection>>,
346        /// The tracked notifications the server still shows, by server id: their identifier and
347        /// the payload their actions carry.
348        pub(super) shown: Mutex<HashMap<u32, (i32, Value)>>,
349    }
350
351    /// The payload of an `actionPerformed` event, as the mobile plugins send it.
352    pub(super) fn performed(action_id: &str, notification: &Value) -> Value {
353        json!({
354            "actionId": action_id,
355            "inputValue": null,
356            "notification": notification,
357        })
358    }
359
360    impl Notification {
361        /// Initializes a instance of a Notification.
362        pub fn new(identifier: impl Into<String>) -> Self {
363            Self {
364                identifier: identifier.into(),
365                ..Default::default()
366            }
367        }
368
369        /// Sets the notification body.
370        #[must_use]
371        pub fn body(mut self, body: impl Into<String>) -> Self {
372            self.body = Some(body.into());
373            self
374        }
375
376        /// Sets the notification title.
377        #[must_use]
378        pub fn title(mut self, title: impl Into<String>) -> Self {
379            self.title = Some(title.into());
380            self
381        }
382
383        /// Sets the notification icon.
384        #[must_use]
385        pub fn icon(mut self, icon: impl Into<String>) -> Self {
386            self.icon = Some(icon.into());
387            self
388        }
389
390        /// Sets the notification sound file.
391        #[must_use]
392        pub fn sound(mut self, sound: impl Into<String>) -> Self {
393            self.sound = Some(sound.into());
394            self
395        }
396
397        /// Adds an action button.
398        #[must_use]
399        pub fn action(mut self, id: impl Into<String>, title: impl Into<String>) -> Self {
400            self.actions.push((id.into(), title.into()));
401            self
402        }
403
404        /// Shows the notification.
405        ///
406        /// # Examples
407        ///
408        /// ```no_run
409        /// use tauri_plugin_notification::NotificationExt;
410        ///
411        /// tauri::Builder::default()
412        ///   .setup(|app| {
413        ///     app.notification()
414        ///       .builder()
415        ///       .title("Tauri")
416        ///       .body("Tauri is awesome!")
417        ///       .show()
418        ///       .unwrap();
419        ///     Ok(())
420        ///   })
421        ///   .run(tauri::generate_context!("test/tauri.conf.json"))
422        ///   .expect("error while running tauri application");
423        /// ```
424        pub fn show(self) -> crate::Result<()> {
425            let notification = self.build()?;
426            tauri::async_runtime::spawn(async move {
427                let _ = notification.show();
428            });
429
430            Ok(())
431        }
432
433        /// Shows the notification and reports what the user does with it: a click on the
434        /// notification (`tap`) or on an action goes to `shared`'s listeners as an
435        /// `actionPerformed` event carrying `data`. Closing it reports nothing.
436        #[cfg(all(unix, not(target_os = "macos")))]
437        pub(crate) fn show_tracked(
438            self,
439            shared: Arc<Shared>,
440            id: i32,
441            data: Value,
442        ) -> crate::Result<()> {
443            let mut notification = self.build()?;
444            // The Desktop Notifications Specification reports a click on the notification itself
445            // as the action `default`, and only for a notification that offers that action.
446            notification.action("default", "");
447            tauri::async_runtime::spawn_blocking(move || track(notification, &shared, id, data));
448            Ok(())
449        }
450
451        /// Shows the notification and waits on a thread of its own for what the user does with it;
452        /// a click on the notification (`tap`) or on an action goes to `shared`'s listeners as an
453        /// `actionPerformed` event carrying `data`. Closing it reports nothing.
454        #[cfg(any(windows, target_os = "macos"))]
455        pub(crate) fn show_tracked(
456            self,
457            shared: Arc<Shared>,
458            _id: i32,
459            data: Value,
460        ) -> crate::Result<()> {
461            let notification = self.build()?;
462            std::thread::Builder::new()
463                .name("notification".into())
464                .spawn(move || wait(notification, shared, data))?;
465            Ok(())
466        }
467
468        fn build(self) -> crate::Result<notify_rust::Notification> {
469            let mut notification = notify_rust::Notification::new();
470            for (id, title) in &self.actions {
471                notification.action(id, title);
472            }
473            if let Some(body) = self.body {
474                notification.body(&body);
475            }
476            if let Some(title) = self.title {
477                notification.summary(&title);
478            }
479            if let Some(icon) = self.icon {
480                notification.icon(&icon);
481            } else {
482                notification.auto_icon();
483            }
484            if let Some(sound) = self.sound {
485                notification.sound_name(&sound);
486            }
487            #[cfg(windows)]
488            {
489                let exe = tauri::utils::platform::current_exe()?;
490                let exe_dir = exe.parent().expect("failed to get exe directory");
491                let curr_dir = exe_dir.display().to_string();
492                // set the notification's System.AppUserModel.ID only when running the installed app
493                if !(curr_dir.ends_with(format!("{SEP}target{SEP}debug").as_str())
494                    || curr_dir.ends_with(format!("{SEP}target{SEP}release").as_str()))
495                {
496                    notification.app_id(&self.identifier);
497                }
498            }
499            #[cfg(target_os = "macos")]
500            {
501                let _ = notify_rust::set_application(if tauri::is_dev() {
502                    "com.apple.Terminal"
503                } else {
504                    &self.identifier
505                });
506            }
507
508            Ok(notification)
509        }
510
511        /// Shows the notification. Same as [`Self::show`].
512        #[cfg(feature = "windows7-compat")]
513        #[allow(dead_code)]
514        #[cfg_attr(docsrs, doc(cfg(feature = "windows7-compat")))]
515        #[deprecated = "Tauri no longer supports Windows 7, use `Self::show` instead."]
516        pub fn notify<R: tauri::Runtime>(self, _app: &tauri::AppHandle<R>) -> crate::Result<()> {
517            self.show()
518        }
519    }
520
521    /// The bus name, object path and interface of the Desktop Notifications Specification.
522    #[cfg(all(unix, not(target_os = "macos")))]
523    pub(super) const BUS: (&str, &str, &str) = (
524        "org.freedesktop.Notifications",
525        "/org/freedesktop/Notifications",
526        "org.freedesktop.Notifications",
527    );
528
529    /// Linux and the BSDs: shows the notification and records it, so the listener thread knows
530    /// which payload its signals carry.
531    #[cfg(all(unix, not(target_os = "macos")))]
532    fn track(notification: notify_rust::Notification, shared: &Arc<Shared>, id: i32, data: Value) {
533        let tracker = &shared.tracker;
534        let Some(connection) = tracker
535            .connection
536            .get_or_init(|| listen(Arc::downgrade(shared)))
537        else {
538            let _ = notification.show();
539            return;
540        };
541        // Held while the notification is sent, so a signal that arrives before the server id is
542        // recorded waits for it instead of being missed.
543        let mut shown = lock(&tracker.shown);
544        if let Ok(server_id) = notify(connection, &notification) {
545            shown.insert(server_id, (id, data));
546        }
547    }
548
549    /// Linux and the BSDs: sends the notification on the connection the signals arrive on, since
550    /// some servers, like dunst, send the signals of a notification only to the connection that
551    /// sent it. Returns the server id of the notification.
552    #[cfg(all(unix, not(target_os = "macos")))]
553    fn notify(
554        connection: &zbus::blocking::Connection,
555        notification: &notify_rust::Notification,
556    ) -> zbus::Result<u32> {
557        // the only hint the plugin sets
558        let hints = notification
559            .hints
560            .iter()
561            .filter_map(|hint| match hint {
562                notify_rust::Hint::SoundName(name) => {
563                    Some(("sound-name", zbus::zvariant::Value::from(name.as_str())))
564                }
565                _ => None,
566            })
567            .collect::<HashMap<_, _>>();
568        let (destination, path, interface) = BUS;
569        connection
570            .call_method(
571                Some(destination),
572                path,
573                Some(interface),
574                "Notify",
575                &(
576                    &notification.appname,
577                    0u32,
578                    &notification.icon,
579                    &notification.summary,
580                    &notification.body,
581                    &notification.actions,
582                    hints,
583                    -1i32,
584                ),
585            )?
586            .body()
587            .deserialize()
588    }
589
590    /// Linux and the BSDs: connects to the session bus and subscribes to the signals of the
591    /// notification server, then handles them on a thread of its own until the plugin goes away.
592    /// The subscription is in place when this returns, so no signal of a later notification is
593    /// missed.
594    #[cfg(all(unix, not(target_os = "macos")))]
595    fn listen(shared: Weak<Shared>) -> Option<zbus::blocking::Connection> {
596        let (_, path, interface) = BUS;
597        let connection = zbus::blocking::Connection::session().ok()?;
598        let rule = zbus::MatchRule::builder()
599            .msg_type(zbus::message::Type::Signal)
600            .path(path)
601            .ok()?
602            .interface(interface)
603            .ok()?
604            .build();
605        let signals =
606            zbus::blocking::MessageIterator::for_match_rule(rule, &connection, None).ok()?;
607        std::thread::Builder::new()
608            .name("notification-actions".into())
609            .spawn(move || {
610                for signal in signals.flatten() {
611                    let Some(shared) = shared.upgrade() else {
612                        return;
613                    };
614                    on_signal(&shared, &signal);
615                }
616            })
617            .ok()?;
618        Some(connection)
619    }
620
621    /// Linux and the BSDs: reports an `ActionInvoked` signal of a tracked notification and forgets
622    /// the notification on `NotificationClosed`. Signals of other notifications are ignored.
623    #[cfg(all(unix, not(target_os = "macos")))]
624    pub(super) fn on_signal(shared: &Shared, signal: &zbus::message::Message) {
625        let header = signal.header();
626        let body = signal.body();
627        let (server_id, action) = match header.member().map(|member| member.as_str()) {
628            Some("ActionInvoked") => match body.deserialize::<(u32, String)>() {
629                Ok((server_id, action)) => (server_id, action),
630                Err(_) => return,
631            },
632            Some("NotificationClosed") => {
633                if let Ok((server_id, _reason)) = body.deserialize::<(u32, u32)>() {
634                    lock(&shared.tracker.shown).remove(&server_id);
635                }
636                return;
637            }
638            _ => return,
639        };
640        // The entry stays until the server closes the notification, which it may keep showing.
641        let data = lock(&shared.tracker.shown)
642            .get(&server_id)
643            .map(|(_, data)| data.clone());
644        if let Some(data) = data {
645            let action = if action == "default" { "tap" } else { &action };
646            shared.dispatch(performed(action, &data));
647        }
648    }
649
650    /// Linux and the BSDs: closes the tracked notifications with the given identifiers, or all of
651    /// them. They are forgotten when the server reports them closed.
652    #[cfg(all(unix, not(target_os = "macos")))]
653    pub(super) fn close(shared: &Shared, ids: Option<&[i32]>) {
654        let Some(Some(connection)) = shared.tracker.connection.get() else {
655            return;
656        };
657        let server_ids = lock(&shared.tracker.shown)
658            .iter()
659            .filter(|(_, (id, _))| ids.is_none_or(|ids| ids.contains(id)))
660            .map(|(server_id, _)| *server_id)
661            .collect::<Vec<_>>();
662        let (destination, path, interface) = BUS;
663        for server_id in server_ids {
664            let _ = connection.call_method(
665                Some(destination),
666                path,
667                Some(interface),
668                "CloseNotification",
669                &(server_id,),
670            );
671        }
672    }
673
674    /// macOS and Windows: `notify-rust` cannot remove a shown notification.
675    #[cfg(any(windows, target_os = "macos"))]
676    pub(super) fn close(_shared: &Shared, _ids: Option<&[i32]>) {}
677
678    /// macOS and Windows: `notify-rust` sends the notification and blocks until the user acts on
679    /// it or it goes away.
680    #[cfg(any(windows, target_os = "macos"))]
681    fn wait(notification: notify_rust::Notification, shared: Arc<Shared>, data: Value) {
682        let Ok(handle) = notification.show() else {
683            return;
684        };
685        let _ = handle.wait_for_response(|response: &notify_rust::NotificationResponse| {
686            let action = match response {
687                notify_rust::NotificationResponse::Default => "tap",
688                notify_rust::NotificationResponse::Action(action) => action.as_str(),
689                _ => return,
690            };
691            shared.dispatch(performed(action, &data));
692        });
693    }
694}
695
696#[cfg(test)]
697mod tests {
698    use std::sync::{Arc, Mutex};
699
700    use super::*;
701    use crate::NotificationData;
702
703    #[test]
704    fn a_performed_action_reaches_the_rust_handlers_with_its_notification() {
705        let shared = Shared::default();
706        assert!(!shared.has_listeners());
707        let seen = Arc::new(Mutex::new(Vec::new()));
708        let record = Arc::clone(&seen);
709        lock(&shared.handlers).push(Arc::new(move |performed: &ActionPerformed| {
710            record.lock().unwrap().push((
711                performed.action_id().to_owned(),
712                performed.notification().map(|n| n.id()),
713                performed
714                    .notification()
715                    .and_then(|n| n.title().map(str::to_owned)),
716            ));
717        }));
718        assert!(shared.has_listeners());
719
720        let data = NotificationData {
721            id: 7,
722            title: Some("Standup".into()),
723            ..Default::default()
724        };
725        let data = serde_json::to_value(&data).unwrap();
726        shared.dispatch(imp::performed("tap", &data));
727        shared.dispatch(imp::performed("snooze", &data));
728        assert_eq!(
729            *seen.lock().unwrap(),
730            [
731                ("tap".to_owned(), Some(7), Some("Standup".to_owned())),
732                ("snooze".to_owned(), Some(7), Some("Standup".to_owned())),
733            ]
734        );
735    }
736
737    #[test]
738    fn an_unreadable_notification_still_reports_the_action() {
739        let performed: ActionPerformed = serde_json::from_value(serde_json::json!({
740            "actionId": "tap",
741            "inputValue": null,
742            "notification": { "id": "not a number" },
743        }))
744        .unwrap();
745        assert_eq!(performed.action_id(), "tap");
746        assert!(performed.notification().is_none());
747    }
748
749    fn listener(webview: &str, window: &str) -> Listener {
750        Listener {
751            event: ACTION_PERFORMED.into(),
752            channel: Channel::new(|_| Ok(())),
753            webview: webview.into(),
754            window: window.into(),
755        }
756    }
757
758    #[test]
759    fn the_listeners_of_a_gone_page_stop_tracking() {
760        let shared = Shared::default();
761        lock(&shared.listeners).extend([listener("main", "main"), listener("side", "other")]);
762        shared.retain_listeners(|listener| listener.webview != "main");
763        assert!(shared.has_listeners());
764        shared.retain_listeners(|listener| listener.window != "other");
765        assert!(!shared.has_listeners());
766    }
767
768    #[cfg(all(unix, not(target_os = "macos")))]
769    fn signal(
770        member: &str,
771        body: &(impl serde::Serialize + zbus::zvariant::DynamicType),
772    ) -> zbus::message::Message {
773        let (_, path, interface) = imp::BUS;
774        zbus::message::Message::signal(path, interface, member)
775            .unwrap()
776            .build(body)
777            .unwrap()
778    }
779
780    #[cfg(all(unix, not(target_os = "macos")))]
781    #[test]
782    fn the_signals_of_a_tracked_notification_are_reported_until_it_closes() {
783        let shared = Shared::default();
784        let seen = Arc::new(Mutex::new(Vec::new()));
785        let record = Arc::clone(&seen);
786        lock(&shared.handlers).push(Arc::new(move |performed: &ActionPerformed| {
787            record
788                .lock()
789                .unwrap()
790                .push(performed.action_id().to_owned());
791        }));
792        lock(&shared.tracker.shown).insert(41, (7, serde_json::json!({ "id": 7 })));
793
794        imp::on_signal(&shared, &signal("ActionInvoked", &(41u32, "default")));
795        imp::on_signal(&shared, &signal("ActionInvoked", &(41u32, "snooze")));
796        // another notification, maybe of another app
797        imp::on_signal(&shared, &signal("ActionInvoked", &(42u32, "default")));
798        imp::on_signal(&shared, &signal("NotificationClosed", &(41u32, 2u32)));
799        imp::on_signal(&shared, &signal("ActionInvoked", &(41u32, "default")));
800
801        assert_eq!(*seen.lock().unwrap(), ["tap", "snooze"]);
802        assert!(lock(&shared.tracker.shown).is_empty());
803    }
804
805    #[test]
806    fn a_handler_may_register_another_handler() {
807        let shared = Arc::new(Shared::default());
808        let inner = Arc::clone(&shared);
809        lock(&shared.handlers).push(Arc::new(move |_: &ActionPerformed| {
810            lock(&inner.handlers).push(Arc::new(|_: &ActionPerformed| {}));
811        }));
812        shared.dispatch(imp::performed("tap", &Value::Null));
813        assert_eq!(lock(&shared.handlers).len(), 2);
814    }
815}