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