Skip to main content

tauri_plugin_notifications/
desktop.rs

1use serde::de::DeserializeOwned;
2use tauri::{
3    plugin::{PermissionState, PluginApi},
4    AppHandle, Runtime,
5};
6
7use crate::NotificationsBuilder;
8
9/// Tracks a single live `notify-rust` notification on Linux. Owning the
10/// `NotificationHandle` keeps the underlying D-Bus `Connection` alive
11/// (preventing the "popup disappears when the sending client disconnects"
12/// behavior some Linux daemons exhibit) and lets us implement
13/// `active`/`cancel` for the caller-supplied id.
14///
15/// macOS / Windows: `notify_rust::NotificationHandle` on those platforms
16/// either has no `close()` method (macOS) or isn't returned at all
17/// (Windows's `show()` returns `Result<()>`), so we don't track there and
18/// the active-list / cancel methods stay as the existing stubs.
19#[cfg(target_os = "linux")]
20struct ActiveEntry {
21    caller_id: i32,
22    handle: notify_rust::NotificationHandle,
23    title: Option<String>,
24    body: Option<String>,
25}
26
27// Signature must match the iOS/Android `init` so the cfg-gated call sites in `lib.rs::init` compile uniformly.
28#[allow(clippy::unnecessary_wraps)]
29pub fn init<R: Runtime, C: DeserializeOwned>(
30    app: &AppHandle<R>,
31    _api: PluginApi<R, C>,
32) -> crate::Result<Notifications<R>> {
33    Ok(Notifications {
34        app: app.clone(),
35        #[cfg(target_os = "linux")]
36        active: std::sync::Mutex::new(std::collections::HashMap::new()),
37        #[cfg(target_os = "linux")]
38        active_counter: std::sync::atomic::AtomicU64::new(0),
39        #[cfg(all(target_os = "linux", feature = "push-notifications"))]
40        unifiedpush: tokio::sync::OnceCell::new(),
41    })
42}
43
44/// Access to the notification APIs.
45///
46/// You can get an instance of this type via [`NotificationsExt`](crate::NotificationsExt)
47pub struct Notifications<R: Runtime> {
48    app: AppHandle<R>,
49    /// Currently-displayed notifications, keyed by an internal monotonic
50    /// counter (not the caller-supplied id, so multiple notifications with
51    /// the same id coexist without evicting each other). Holding the handles
52    /// keeps the popups visible and lets `cancel`/`cancel_all`/
53    /// `remove_active`/`active` work without leaking. Entries are removed by
54    /// explicit cancel; expired/auto-dismissed notifications may linger
55    /// because notify-rust doesn't expose a non-consuming "closed" callback.
56    #[cfg(target_os = "linux")]
57    active: std::sync::Mutex<std::collections::HashMap<u64, ActiveEntry>>,
58    #[cfg(target_os = "linux")]
59    active_counter: std::sync::atomic::AtomicU64,
60    #[cfg(all(target_os = "linux", feature = "push-notifications"))]
61    unifiedpush: tokio::sync::OnceCell<std::sync::Arc<crate::unifiedpush::UnifiedPushState>>,
62}
63
64#[cfg(target_os = "linux")]
65fn active_lock_err(e: impl std::fmt::Display) -> crate::Error {
66    crate::Error::Io(std::io::Error::other(format!(
67        "active notifications mutex poisoned: {e}"
68    )))
69}
70
71#[cfg(target_os = "linux")]
72impl<R: Runtime> Notifications<R> {
73    /// Finds every tracked notification whose caller id is in `caller_ids`,
74    /// removes them from the active map, and dispatches `handle.close()` on
75    /// the blocking pool so the command call returns quickly.
76    fn close_by_caller_ids(&self, caller_ids: &[i32]) -> crate::Result<()> {
77        let mut to_close: Vec<ActiveEntry> = Vec::new();
78        {
79            let mut active = self.active.lock().map_err(active_lock_err)?;
80            // Move the map out, partition entries into "close" vs "keep" in
81            // one pass, then put the kept ones back. Avoids the borrow-
82            // checker dance of iter-then-remove (which would need a
83            // throwaway `Vec<u64>` of keys) without holding the lock any
84            // longer than necessary.
85            let kept: std::collections::HashMap<u64, ActiveEntry> = std::mem::take(&mut *active)
86                .into_iter()
87                .filter_map(|(k, entry)| {
88                    if caller_ids.contains(&entry.caller_id) {
89                        to_close.push(entry);
90                        None
91                    } else {
92                        Some((k, entry))
93                    }
94                })
95                .collect();
96            *active = kept;
97        }
98        for entry in to_close {
99            tauri::async_runtime::spawn_blocking(move || entry.handle.close());
100        }
101        Ok(())
102    }
103}
104
105#[cfg(all(target_os = "linux", feature = "push-notifications"))]
106impl<R: Runtime> Notifications<R> {
107    async fn unifiedpush_state(
108        &self,
109    ) -> crate::Result<&std::sync::Arc<crate::unifiedpush::UnifiedPushState>> {
110        self.unifiedpush
111            .get_or_try_init(|| {
112                let displayer = Self::build_push_displayer(self.app.clone());
113                crate::unifiedpush::UnifiedPushState::new(&self.app, Some(displayer))
114            })
115            .await
116    }
117
118    /// Builds the `PushDisplayer` callback handed to `UnifiedPushState`. The
119    /// callback runs `notify_rust::Notification::show()` on a blocking thread
120    /// and routes the resulting handle into the same `active` map that local
121    /// notifications use, so push toasts:
122    ///   * Stay visible (handle is held → D-Bus connection stays alive →
123    ///     daemons don't dismiss-on-disconnect).
124    ///   * Show up in [`Notifications::active`] alongside local notifications.
125    ///   * Can be cancelled via the existing `cancel`/`cancel_all` methods
126    ///     (caller id is `0` because `UnifiedPush` messages don't carry one).
127    fn build_push_displayer(app: AppHandle<R>) -> crate::unifiedpush::PushDisplayer {
128        std::sync::Arc::new(move |title: Option<String>, body: Option<String>| {
129            let app = app.clone();
130            let identifier = app.config().identifier.clone();
131            tauri::async_runtime::spawn_blocking(move || {
132                let notification = match imp::build_notification(
133                    title.as_deref(),
134                    body.as_deref(),
135                    None,
136                    &identifier,
137                ) {
138                    Ok(n) => n,
139                    Err(e) => {
140                        log::warn!("Failed to build push notification: {e}");
141                        return;
142                    }
143                };
144                match notification.show() {
145                    Ok(handle) => {
146                        use std::sync::atomic::Ordering;
147                        use tauri::Manager;
148                        let state = app.state::<Self>();
149                        let entry_id = state.active_counter.fetch_add(1, Ordering::Relaxed);
150                        let entry = ActiveEntry {
151                            caller_id: 0,
152                            handle,
153                            title,
154                            body,
155                        };
156                        let lock = state.active.lock();
157                        match lock {
158                            Ok(mut active) => {
159                                active.insert(entry_id, entry);
160                            }
161                            Err(poisoned) => {
162                                log::warn!("active notifications mutex was poisoned; recovering");
163                                poisoned.into_inner().insert(entry_id, entry);
164                            }
165                        }
166                    }
167                    Err(e) => log::warn!("Failed to show push notification toast: {e}"),
168                }
169            });
170        })
171    }
172}
173
174// `async` and `Result` mirror the mobile/macOS plugin API so callers can `.await` and `?` uniformly.
175impl<R: Runtime> crate::NotificationsBuilder<R> {
176    pub async fn show(self) -> crate::Result<()> {
177        let caller_id = self.data.id;
178        let title = self
179            .data
180            .title
181            .or_else(|| self.app.config().product_name.clone());
182        let body = self.data.body;
183        let icon = self.data.icon;
184        let identifier = self.app.config().identifier.clone();
185        let app = self.app.clone();
186
187        let notification = imp::build_notification(
188            title.as_deref(),
189            body.as_deref(),
190            icon.as_deref(),
191            &identifier,
192        )?;
193
194        // `notify_rust::Notification::show()` is sync and runs an internal
195        // blocking D-Bus call (via zbus's `block_on`). Calling it inside
196        // `async_runtime::spawn` panics with "Cannot start a runtime from
197        // within a runtime"; `spawn_blocking` parks it on a blocking thread.
198        // We `.await` the join so we can capture the handle for tracking and
199        // surface any error to the caller.
200        let join_result = tauri::async_runtime::spawn_blocking(move || notification.show())
201            .await
202            .map_err(|e| {
203                crate::Error::Io(std::io::Error::other(format!(
204                    "notification spawn_blocking join error: {e}"
205                )))
206            })?;
207
208        match join_result {
209            #[cfg(target_os = "linux")]
210            Ok(handle) => {
211                use std::sync::atomic::Ordering;
212                use tauri::Manager;
213                let state = app.state::<Notifications<R>>();
214                let entry_id = state.active_counter.fetch_add(1, Ordering::Relaxed);
215                let entry = ActiveEntry {
216                    caller_id,
217                    handle,
218                    title,
219                    body,
220                };
221                // Take the lock into a binding so its `MutexGuard` temporary
222                // doesn't outlive `state` in the `match` arms.
223                let lock_result = state.active.lock();
224                match lock_result {
225                    Ok(mut active) => {
226                        active.insert(entry_id, entry);
227                    }
228                    Err(poisoned) => {
229                        log::warn!("active notifications mutex was poisoned; recovering");
230                        poisoned.into_inner().insert(entry_id, entry);
231                    }
232                }
233            }
234            // macOS: drop the `NotificationHandle` here. Daemon doesn't
235            // dismiss popups on sender disconnect, so no leak workaround
236            // needed.
237            #[cfg(target_os = "macos")]
238            Ok(_) => {
239                let _ = (caller_id, title, body, app);
240            }
241            // Windows: `Notification::show()` returns `Result<()>`. The
242            // explicit unit pattern keeps clippy's `ignored_unit_patterns`
243            // happy.
244            #[cfg(target_os = "windows")]
245            Ok(()) => {
246                let _ = (caller_id, title, body, app);
247            }
248            // Propagate the underlying `notify-rust` failure (missing
249            // notification daemon, D-Bus permission denied, etc.) instead of
250            // swallowing it — matches the mobile/macOS behavior and lets JS
251            // callers handle delivery failures.
252            Err(e) => {
253                return Err(crate::Error::Io(std::io::Error::other(format!(
254                    "Failed to show notification: {e}"
255                ))));
256            }
257        }
258
259        Ok(())
260    }
261}
262
263// `async` mirrors the mobile/macOS plugin API so callers can `.await` uniformly.
264#[allow(clippy::unused_async)]
265impl<R: Runtime> Notifications<R> {
266    pub fn builder(&self) -> NotificationsBuilder<R> {
267        NotificationsBuilder::new(self.app.clone())
268    }
269
270    pub async fn request_permission(&self) -> crate::Result<PermissionState> {
271        Ok(PermissionState::Granted)
272    }
273
274    /// On Linux with the `push-notifications` feature this registers with the
275    /// selected (or first available) `UnifiedPush` distributor and returns the
276    /// endpoint URL. Apps that need endpoint stability across launches should
277    /// call [`set_token`](Self::set_token) before this with a persisted token.
278    pub async fn register_for_push_notifications(&self) -> crate::Result<String> {
279        #[cfg(all(target_os = "linux", feature = "push-notifications"))]
280        {
281            let state = self.unifiedpush_state().await?;
282            state.register().await
283        }
284        #[cfg(not(all(target_os = "linux", feature = "push-notifications")))]
285        {
286            Err(crate::Error::Io(std::io::Error::other(
287                "Push notifications are not supported on desktop platforms",
288            )))
289        }
290    }
291
292    /// Sync signature preserved for source compatibility — callers that need
293    /// the Linux `UnifiedPush` unregister path should use
294    /// [`unregister_for_push_notifications_async`] instead.
295    pub fn unregister_for_push_notifications(&self) -> crate::Result<()> {
296        Err(crate::Error::Io(std::io::Error::other(
297            "Push notifications are not supported on desktop platforms",
298        )))
299    }
300
301    /// Async unregister used by the Tauri command bridge. On Linux with the
302    /// `push-notifications` feature this calls
303    /// `org.unifiedpush.Distributor1.Unregister` and clears the in-memory
304    /// active registration.
305    pub async fn unregister_for_push_notifications_async(&self) -> crate::Result<()> {
306        #[cfg(all(target_os = "linux", feature = "push-notifications"))]
307        {
308            if let Some(state) = self.unifiedpush.get() {
309                state.unregister().await?;
310            }
311            Ok(())
312        }
313        #[cfg(not(all(target_os = "linux", feature = "push-notifications")))]
314        {
315            Err(crate::Error::Io(std::io::Error::other(
316                "Push notifications are not supported on desktop platforms",
317            )))
318        }
319    }
320
321    /// Lists currently running `UnifiedPush` distributors. Linux-only.
322    #[cfg(all(target_os = "linux", feature = "push-notifications"))]
323    pub async fn list_distributors(&self) -> crate::Result<Vec<String>> {
324        let state = self.unifiedpush_state().await?;
325        state.list_distributors().await
326    }
327
328    /// Pins the chosen `UnifiedPush` distributor for this process. Linux-only.
329    #[cfg(all(target_os = "linux", feature = "push-notifications"))]
330    pub async fn set_distributor(&self, name: String) -> crate::Result<()> {
331        let state = self.unifiedpush_state().await?;
332        state.set_distributor(name).await
333    }
334
335    /// Sets the `UnifiedPush` client token used on subsequent register calls.
336    /// Pass the same token across launches to keep the endpoint URL stable.
337    /// Linux-only.
338    #[cfg(all(target_os = "linux", feature = "push-notifications"))]
339    pub async fn set_token(&self, token: String) -> crate::Result<()> {
340        let state = self.unifiedpush_state().await?;
341        state.set_token(token).await
342    }
343
344    pub async fn permission_state(&self) -> crate::Result<PermissionState> {
345        Ok(PermissionState::Granted)
346    }
347
348    pub async fn pending(&self) -> crate::Result<Vec<crate::PendingNotification>> {
349        Err(crate::Error::Io(std::io::Error::other(
350            "Pending notifications are not supported with notify-rust",
351        )))
352    }
353
354    /// Linux: returns the currently-tracked notifications. The list is
355    /// populated by [`NotificationsBuilder::show`] and pruned by
356    /// `cancel`/`cancel_all`/`remove_active`. Entries dismissed by the user
357    /// or expired by the OS may linger until the next explicit cancel call,
358    /// since notify-rust doesn't expose a non-consuming "closed" callback.
359    ///
360    /// macOS / Windows: still unsupported.
361    pub async fn active(&self) -> crate::Result<Vec<crate::ActiveNotification>> {
362        #[cfg(target_os = "linux")]
363        {
364            let active = self.active.lock().map_err(active_lock_err)?;
365            Ok(active
366                .values()
367                .map(|entry| {
368                    crate::ActiveNotification::new(
369                        entry.caller_id,
370                        entry.title.clone(),
371                        entry.body.clone(),
372                    )
373                })
374                .collect())
375        }
376        #[cfg(not(target_os = "linux"))]
377        {
378            Err(crate::Error::Io(std::io::Error::other(
379                "Active notifications are not supported with notify-rust",
380            )))
381        }
382    }
383
384    pub fn set_click_listener_active(&self, _active: bool) -> crate::Result<()> {
385        Err(crate::Error::Io(std::io::Error::other(
386            "Click listeners are not supported with notify-rust",
387        )))
388    }
389
390    /// Linux: closes every tracked notification whose caller-supplied id
391    /// appears in `ids` and removes it from the active map.
392    /// macOS / Windows: unsupported.
393    // Existing public signature; switching to `&[i32]` would be breaking.
394    #[allow(clippy::needless_pass_by_value)]
395    pub fn remove_active(&self, ids: Vec<i32>) -> crate::Result<()> {
396        #[cfg(target_os = "linux")]
397        {
398            self.close_by_caller_ids(&ids)
399        }
400        #[cfg(not(target_os = "linux"))]
401        {
402            let _ = ids;
403            Err(crate::Error::Io(std::io::Error::other(
404                "Removing active notifications is not supported with notify-rust",
405            )))
406        }
407    }
408
409    pub fn remove_all_active(&self) -> crate::Result<()> {
410        Err(crate::Error::Io(std::io::Error::other(
411            "Removing active notifications is not supported with notify-rust",
412        )))
413    }
414
415    /// Same semantics as [`remove_active`](Self::remove_active) on Linux;
416    /// macOS / Windows: unsupported.
417    // Existing public signature; switching to `&[i32]` would be breaking.
418    #[allow(clippy::needless_pass_by_value)]
419    pub fn cancel(&self, notifications: Vec<i32>) -> crate::Result<()> {
420        #[cfg(target_os = "linux")]
421        {
422            self.close_by_caller_ids(&notifications)
423        }
424        #[cfg(not(target_os = "linux"))]
425        {
426            let _ = notifications;
427            Err(crate::Error::Io(std::io::Error::other(
428                "Canceling notifications is not supported with notify-rust",
429            )))
430        }
431    }
432
433    /// Linux: closes every tracked notification.
434    /// macOS / Windows: unsupported.
435    pub fn cancel_all(&self) -> crate::Result<()> {
436        #[cfg(target_os = "linux")]
437        {
438            let drained: Vec<ActiveEntry> = {
439                let mut active = self.active.lock().map_err(active_lock_err)?;
440                active.drain().map(|(_, v)| v).collect()
441            };
442            for entry in drained {
443                // `handle.close()` runs a blocking platform call; push it
444                // off the current thread so the command returns quickly.
445                tauri::async_runtime::spawn_blocking(move || entry.handle.close());
446            }
447            Ok(())
448        }
449        #[cfg(not(target_os = "linux"))]
450        {
451            Err(crate::Error::Io(std::io::Error::other(
452                "Canceling notifications is not supported with notify-rust",
453            )))
454        }
455    }
456
457    pub fn register_action_types(&self, _types: Vec<crate::ActionType>) -> crate::Result<()> {
458        Err(crate::Error::Io(std::io::Error::other(
459            "Action types are not supported with notify-rust",
460        )))
461    }
462
463    pub fn create_channel(&self, _channel: crate::Channel) -> crate::Result<()> {
464        Err(crate::Error::Io(std::io::Error::other(
465            "Notification channels are not supported with notify-rust",
466        )))
467    }
468
469    pub fn delete_channel(&self, _id: impl Into<String>) -> crate::Result<()> {
470        Err(crate::Error::Io(std::io::Error::other(
471            "Notification channels are not supported with notify-rust",
472        )))
473    }
474
475    pub fn list_channels(&self) -> crate::Result<Vec<crate::Channel>> {
476        Err(crate::Error::Io(std::io::Error::other(
477            "Notification channels are not supported with notify-rust",
478        )))
479    }
480}
481
482mod imp {
483    //! Helpers for assembling the cross-platform `notify_rust::Notification`
484    //! before handing it off to a blocking thread for delivery.
485
486    #[cfg(windows)]
487    use std::path::MAIN_SEPARATOR as SEP;
488
489    /// Builds a fully-configured `notify_rust::Notification` from the parts
490    /// the cross-platform builder produced. Returns an error only on Windows
491    /// if `current_exe` lookup fails; other platforms are infallible — the
492    /// `Result` wrapper exists for the Windows branch only.
493    #[allow(clippy::unnecessary_wraps)]
494    pub fn build_notification(
495        title: Option<&str>,
496        body: Option<&str>,
497        icon: Option<&str>,
498        identifier: &str,
499    ) -> crate::Result<notify_rust::Notification> {
500        let mut notification = notify_rust::Notification::new();
501        if let Some(body) = body {
502            notification.body(body);
503        }
504        if let Some(title) = title {
505            notification.summary(title);
506        }
507        if let Some(icon) = icon {
508            notification.icon(icon);
509        } else {
510            notification.auto_icon();
511        }
512
513        #[cfg(windows)]
514        {
515            let exe = tauri::utils::platform::current_exe()?;
516            let exe_dir = exe.parent().expect("failed to get exe directory");
517            let curr_dir = exe_dir.display().to_string();
518            // Only set System.AppUserModel.ID on the installed app, not when
519            // running from `cargo`'s target dirs.
520            if !(curr_dir.ends_with(format!("{SEP}target{SEP}debug").as_str())
521                || curr_dir.ends_with(format!("{SEP}target{SEP}release").as_str()))
522            {
523                notification.app_id(identifier);
524            }
525        }
526        #[cfg(target_os = "macos")]
527        {
528            let _ = notify_rust::set_application(if tauri::is_dev() {
529                "com.apple.Terminal"
530            } else {
531                identifier
532            });
533        }
534        // `identifier` is used by the cfg-gated Windows/macOS branches above
535        // — silence the unused-parameter warning on Linux.
536        #[cfg(target_os = "linux")]
537        let _ = identifier;
538
539        Ok(notification)
540    }
541}