diff --git a/.changes/fix-gnome-notifications.md b/.changes/fix-gnome-notifications.md new file mode 100644 index 000000000..8d2fa0aea --- /dev/null +++ b/.changes/fix-gnome-notifications.md @@ -0,0 +1,6 @@ +--- +notification: patch +notification-js: patch +--- + +Fix notifications closing right after they show on GNOME: on Linux and the BSDs, every notification is now sent on the D-Bus connection the plugin keeps open. diff --git a/.changes/notification-on-close.md b/.changes/notification-on-close.md new file mode 100644 index 000000000..5e2da1dd4 --- /dev/null +++ b/.changes/notification-on-close.md @@ -0,0 +1,6 @@ +--- +notification: minor:feat +notification-js: minor:feat +--- + +Add `NotificationBuilder::on_close` on Linux and the BSDs, a Rust callback that runs once the notification server reports the notification closed. diff --git a/plugins/notification/src/desktop.rs b/plugins/notification/src/desktop.rs index 60062bbec..1a4774502 100644 --- a/plugins/notification/src/desktop.rs +++ b/plugins/notification/src/desktop.rs @@ -139,19 +139,27 @@ impl crate::NotificationBuilder { if let Some(sound) = self.data.sound.clone() { notification = notification.sound(sound); } - if shared.has_listeners() { - if let Some(action_type) = self + let report = shared.has_listeners(); + if report + && let Some(action_type) = self .data .action_type_id .as_ref() .and_then(|id| lock(&shared.action_types).get(id).cloned()) - { - for action in action_type.actions() { - notification = notification.action(action.id(), action.title()); - } + { + for action in action_type.actions() { + notification = notification.action(action.id(), action.title()); } - let id = self.data.id; - let data = serde_json::to_value(&self.data).unwrap_or(Value::Null); + } + let id = self.data.id; + let data = serde_json::to_value(&self.data).unwrap_or(Value::Null); + // Linux and the BSDs send every notification on the connection the plugin keeps open, + // since servers like GNOME's close a notification once the connection that sent it goes + // away. + #[cfg(all(unix, not(target_os = "macos")))] + notification.show_tracked(shared, id, data, report, self.on_close)?; + #[cfg(any(windows, target_os = "macos"))] + if report { notification.show_tracked(shared, id, data)?; } else { notification.show()?; @@ -346,6 +354,8 @@ mod imp { /// The tracked notifications the server still shows, by server id: their identifier and /// the payload their actions carry. pub(super) shown: Mutex>, + /// The `on_close` callbacks of the tracked notifications, by server id. + pub(super) on_close: Mutex>, } /// The payload of an `actionPerformed` event, as the mobile plugins send it. @@ -421,6 +431,7 @@ mod imp { /// .run(tauri::generate_context!("test/tauri.conf.json")) /// .expect("error while running tauri application"); /// ``` + #[cfg(any(windows, target_os = "macos", feature = "windows7-compat"))] pub fn show(self) -> crate::Result<()> { let notification = self.build()?; tauri::async_runtime::spawn(async move { @@ -430,21 +441,29 @@ mod imp { Ok(()) } - /// Shows the notification and reports what the user does with it: a click on the - /// notification (`tap`) or on an action goes to `shared`'s listeners as an - /// `actionPerformed` event carrying `data`. Closing it reports nothing. + /// Shows the notification on the connection `shared` keeps open. When `report` is set, + /// what the user does with it is reported: a click on the notification (`tap`) or on an + /// action goes to `shared`'s listeners as an `actionPerformed` event carrying `data`. + /// `on_close` runs once the server reports the notification closed. #[cfg(all(unix, not(target_os = "macos")))] pub(crate) fn show_tracked( self, shared: Arc, id: i32, data: Value, + report: bool, + on_close: Option, ) -> crate::Result<()> { let mut notification = self.build()?; - // The Desktop Notifications Specification reports a click on the notification itself - // as the action `default`, and only for a notification that offers that action. - notification.action("default", ""); - tauri::async_runtime::spawn_blocking(move || track(notification, &shared, id, data)); + if report { + // The Desktop Notifications Specification reports a click on the notification + // itself as the action `default`, and only for a notification that offers that + // action. + notification.action("default", ""); + } + tauri::async_runtime::spawn_blocking(move || { + track(notification, &shared, id, report.then_some(data), on_close) + }); Ok(()) } @@ -527,9 +546,15 @@ mod imp { ); /// Linux and the BSDs: shows the notification and records it, so the listener thread knows - /// which payload its signals carry. + /// which payload its signals carry, when there is one, and what to run once it closes. #[cfg(all(unix, not(target_os = "macos")))] - fn track(notification: notify_rust::Notification, shared: &Arc, id: i32, data: Value) { + fn track( + notification: notify_rust::Notification, + shared: &Arc, + id: i32, + data: Option, + on_close: Option, + ) { let tracker = &shared.tracker; let Some(connection) = tracker .connection @@ -542,7 +567,12 @@ mod imp { // recorded waits for it instead of being missed. let mut shown = lock(&tracker.shown); if let Ok(server_id) = notify(connection, ¬ification) { - shown.insert(server_id, (id, data)); + if let Some(data) = data { + shown.insert(server_id, (id, data)); + } + if let Some(on_close) = on_close { + lock(&tracker.on_close).insert(server_id, on_close); + } } } @@ -632,6 +662,13 @@ mod imp { Some("NotificationClosed") => { if let Ok((server_id, _reason)) = body.deserialize::<(u32, u32)>() { lock(&shared.tracker.shown).remove(&server_id); + // Taken out first, so the callback runs without the lock held. + let on_close = lock(&shared.tracker.on_close).remove(&server_id); + if let Some(on_close) = on_close { + // A panicking callback must not end the thread that reports the signals + // of the other notifications. + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(on_close.0)); + } } return; } @@ -802,6 +839,29 @@ mod tests { assert!(lock(&shared.tracker.shown).is_empty()); } + #[cfg(all(unix, not(target_os = "macos")))] + #[test] + fn on_close_runs_once_without_the_lock_held() { + let shared = Arc::new(Shared::default()); + let closed = Arc::new(Mutex::new(0)); + let (inner, count) = (Arc::clone(&shared), Arc::clone(&closed)); + lock(&shared.tracker.on_close).insert( + 41, + crate::OnClose(Box::new(move || { + // deadlocks if the callback runs with the lock held + assert!(lock(&inner.tracker.on_close).is_empty()); + *count.lock().unwrap() += 1; + })), + ); + lock(&shared.tracker.on_close).insert(42, crate::OnClose(Box::new(|| panic!("boom")))); + + imp::on_signal(&shared, &signal("NotificationClosed", &(42u32, 2u32))); + imp::on_signal(&shared, &signal("NotificationClosed", &(41u32, 2u32))); + imp::on_signal(&shared, &signal("NotificationClosed", &(41u32, 2u32))); + + assert_eq!(*closed.lock().unwrap(), 1); + } + #[test] fn a_handler_may_register_another_handler() { let shared = Arc::new(Shared::default()); diff --git a/plugins/notification/src/lib.rs b/plugins/notification/src/lib.rs index 269d0276d..70b22f0b3 100644 --- a/plugins/notification/src/lib.rs +++ b/plugins/notification/src/lib.rs @@ -14,6 +14,8 @@ )] use serde::Serialize; +#[cfg(all(desktop, unix, not(target_os = "macos")))] +use std::fmt; #[cfg(desktop)] use tauri::AppHandle; #[cfg(mobile)] @@ -50,6 +52,18 @@ pub struct NotificationBuilder { #[cfg(mobile)] handle: PluginHandle, pub(crate) data: NotificationData, + #[cfg(all(desktop, unix, not(target_os = "macos")))] + on_close: Option, +} + +#[cfg(all(desktop, unix, not(target_os = "macos")))] +pub(crate) struct OnClose(Box); + +#[cfg(all(desktop, unix, not(target_os = "macos")))] +impl fmt::Debug for OnClose { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("OnClose") + } } impl NotificationBuilder { @@ -58,6 +72,8 @@ impl NotificationBuilder { Self { app, data: Default::default(), + #[cfg(all(desktop, unix, not(target_os = "macos")))] + on_close: None, } } @@ -212,6 +228,21 @@ impl NotificationBuilder { self.data.silent = true; self } + + /// Sets a callback to run when the notification closes, whether the user dismissed it, it + /// expired or the app removed it. + /// + /// The callback runs on a background thread and should return quickly. It does not run when + /// the session bus cannot be reached, or when the app exits first. + /// + /// ## Platform-specific + /// + /// - **Linux and the BSDs**: only available there. + #[cfg(all(desktop, unix, not(target_os = "macos")))] + pub fn on_close(mut self, handler: F) -> Self { + self.on_close = Some(OnClose(Box::new(handler))); + self + } } /// Extensions to [`tauri::App`], [`tauri::AppHandle`], [`tauri::WebviewWindow`], [`tauri::Webview`] and [`tauri::Window`] to access the notification APIs.