Skip to main content

mac_usernotifications/
send.rs

1use crate::{
2    Error,
3    action::ActionCategory,
4    check_bundle, delegate,
5    notification::{Notification, NotificationContent},
6    response::NotificationResponse,
7    worker,
8};
9use block2::RcBlock;
10use futures_channel::oneshot;
11use futures_lite::future;
12use objc2_foundation::{NSArray, NSError, NSString, NSUUID};
13use objc2_user_notifications::{UNNotification, UNNotificationRequest, UNUserNotificationCenter};
14use std::{fmt, future::Future, ptr::NonNull, time::Duration};
15
16/// Couples a `request_id` to a response receiver, auto-deregistering on drop.
17struct PendingGuard {
18    request_id: String,
19    rx: oneshot::Receiver<NotificationResponse>,
20}
21
22impl PendingGuard {
23    fn new(request_id: String, rx: oneshot::Receiver<NotificationResponse>) -> Self {
24        Self { request_id, rx }
25    }
26
27    /// Consume the guard and return the receiver, skipping deregistration.
28    fn into_receiver(self) -> oneshot::Receiver<NotificationResponse> {
29        let manual = std::mem::ManuallyDrop::new(self);
30        // SAFETY: we are consuming `self` via ManuallyDrop, so Drop won't run.
31        // We move `rx` out of the ManuallyDrop'd value before it is forgotten.
32        unsafe { std::ptr::read(&manual.rx) }
33    }
34}
35
36impl Drop for PendingGuard {
37    fn drop(&mut self) {
38        // If the guard is dropped without being consumed (timeout, cancel),
39        // clean up the sender so the PENDING map doesn't leak.
40        delegate::deregister_response_sender(&self.request_id);
41    }
42}
43
44/// A notification that has been delivered to the system but not yet responded to.
45///
46/// Call [`.response().await`](NotificationHandle::response) to wait for the user's interaction,
47/// or drop it to stop observing.
48/// The notification stays visible but the response sender is cleaned up.
49///
50pub struct NotificationHandle {
51    notification_id: String,
52    guard: PendingGuard,
53    timeout: Option<Duration>,
54    /// `false` when no action buttons were registered, meaning macOS will not fire
55    /// `didReceiveNotificationResponse` on a silent dismiss. In that case
56    /// [`response`](NotificationHandle::response) falls back to polling
57    /// `deliveredNotifications` to detect when the notification disappears.
58    has_actions: bool,
59}
60
61impl fmt::Debug for NotificationHandle {
62    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
63        formatter
64            .debug_struct("NotificationHandle")
65            .field("notification_id", &self.notification_id)
66            .field("timeout", &self.timeout)
67            .field("has_actions", &self.has_actions)
68            .finish()
69    }
70}
71
72impl NotificationHandle {
73    /// The notification's request identifier.
74    ///
75    /// Can be passed to [`close_delivered`] or [`cancel_pending`] even when no
76    /// explicit ID was set via [`Notification::id`].
77    pub fn notification_id(&self) -> &str {
78        &self.notification_id
79    }
80
81    /// Wait for the user's response.
82    ///
83    /// Resolves when the user interacts with or dismisses the notification.
84    ///
85    /// If a timeout was set via [`Notification::timeout`], the notification is
86    /// automatically removed from the screen when it elapses and this returns
87    /// `Ok(response)` where [`NotificationResponse::is_timed_out`] is `true`.
88    ///
89    /// `UNUserNotificationCenter` has no native TTL; the close is performed
90    /// by this crate calling [`close_delivered`] when the timer fires.
91    pub async fn response(self) -> Result<NotificationResponse, Error> {
92        let notification_id = self.notification_id.clone();
93        let receiver = self.guard.into_receiver();
94        let delegate_future = async { receiver.await.map_err(|_| Error::NotificationRejected) };
95
96        if let Some(duration) = self.timeout {
97            future::or(delegate_future, async move {
98                futures_timer::Delay::new(duration).await;
99                close_delivered(&notification_id).await;
100                Ok(NotificationResponse::timed_out(notification_id))
101            })
102            .await
103        } else if !self.has_actions {
104            // No action buttons → macOS never fires didReceiveNotificationResponse on
105            // a silent dismiss. Race the delegate future against a poll loop that
106            // resolves as soon as the notification disappears from deliveredNotifications.
107            future::or(delegate_future, poll_until_dismissed(notification_id)).await
108        } else {
109            delegate_future.await
110        }
111    }
112}
113
114const DISMISS_POLL_INTERVAL: Duration = Duration::from_millis(500);
115
116/// Resolves with a synthetic `Dismissed` response once `notification_id` is no longer
117/// present in `deliveredNotifications`.
118///
119/// Used as a fallback for buttonless notifications where macOS does not fire
120/// `didReceiveNotificationResponse` on a silent dismiss.
121async fn poll_until_dismissed(notification_id: String) -> Result<NotificationResponse, Error> {
122    loop {
123        futures_timer::Delay::new(DISMISS_POLL_INTERVAL).await;
124        let delivered = get_delivered_notification_ids().await;
125        if !delivered.contains(&notification_id) {
126            return Ok(NotificationResponse::dismissed(notification_id));
127        }
128    }
129}
130
131/// Core sending logic: dispatch to worker, register response sender, schedule request.
132fn send_inner(
133    notification: Notification,
134    response_tx: Option<oneshot::Sender<NotificationResponse>>,
135    request_id: String,
136) -> impl Future<Output = Result<(), Error>> + Send + 'static {
137    let (scheduled_tx, scheduled_rx) = worker::sender::<Result<(), Error>>();
138
139    worker::dispatch(move || {
140        let NotificationContent {
141            content,
142            actions,
143            trigger,
144        } = notification.build_content();
145        log::debug!("request_id={request_id:?}");
146
147        // Only register a category (and thus CustomDismissAction) when the notification
148        // has explicit action buttons. Registering CustomDismissAction on a buttonless
149        // notification tells macOS to relaunch the .app bundle on dismiss — causing an
150        // infinite restart loop for fire-and-forget notifications.
151        if !actions.is_empty() {
152            let mut ids: Vec<&str> = actions.iter().map(|act| act.identifier()).collect();
153            ids.sort_unstable();
154            let category_id = ids.join(",");
155            log::debug!("registering synthesised category {category_id:?}");
156            ActionCategory::from_actions(&category_id, actions).register_now();
157            content.setCategoryIdentifier(&NSString::from_str(&category_id));
158        }
159
160        if let Some(tx) = response_tx {
161            delegate::register_response_sender(request_id.clone(), tx);
162        }
163
164        use objc2_user_notifications::UNTimeIntervalNotificationTrigger;
165        let trigger_obj = trigger.map(|delay| {
166            UNTimeIntervalNotificationTrigger::triggerWithTimeInterval_repeats(
167                delay.as_secs_f64().max(0.1),
168                false,
169            )
170        });
171
172        let request = UNNotificationRequest::requestWithIdentifier_content_trigger(
173            &NSString::from_str(&request_id),
174            &content,
175            trigger_obj.as_deref().map(|val| &**val),
176        );
177
178        UNUserNotificationCenter::currentNotificationCenter()
179            .addNotificationRequest_withCompletionHandler(
180                &request,
181                Some(&RcBlock::new(move |err: *mut NSError| {
182                    log::trace!("send completed (err.is_null={})", err.is_null());
183                    if let Some(err) = NonNull::new(err).map(|ptr| unsafe { ptr.as_ref() }) {
184                        let desc = err.localizedDescription();
185                        log::error!("notification request rejected: {desc}");
186                    }
187                    let result = if err.is_null() {
188                        Ok(())
189                    } else {
190                        Err(Error::NotificationRejected)
191                    };
192                    scheduled_tx.send(result);
193                })),
194            );
195    });
196
197    async move { scheduled_rx.await.map_err(Into::into).flatten() }
198}
199
200/// Remove an already-delivered notification from Notification Center by its identifier.
201///
202/// Fire-and-forget: dispatched to the worker thread, returns immediately.
203/// Has no effect if the identifier is unknown.
204#[cfg(feature = "blocking-wrappers")]
205pub fn close_delivered_blocking(notification_id: &str) {
206    let id = notification_id.to_owned();
207    worker::dispatch(move || {
208        let ids = NSArray::from_retained_slice(&[NSString::from_str(&id)]);
209        UNUserNotificationCenter::currentNotificationCenter()
210            .removeDeliveredNotificationsWithIdentifiers(&ids);
211        log::debug!("removed delivered notification {id:?}");
212    });
213}
214
215/// Cancel a pending (not yet delivered) notification by its identifier.
216///
217/// Fire-and-forget: dispatched to the worker thread, returns immediately.
218/// Has no effect if the identifier is unknown or already delivered.
219#[cfg(feature = "blocking-wrappers")]
220pub fn cancel_pending_blocking(notification_id: &str) {
221    let id = notification_id.to_owned();
222    worker::dispatch(move || {
223        let ids = NSArray::from_retained_slice(&[NSString::from_str(&id)]);
224        UNUserNotificationCenter::currentNotificationCenter()
225            .removePendingNotificationRequestsWithIdentifiers(&ids);
226        log::debug!("cancelled pending notification {id:?}");
227    });
228}
229
230/// Remove a delivered notification.
231///
232/// Returns a `Future` that resolves once the worker thread has executed the removal.
233///
234/// Unlike [`close_delivered`], this waits for the worker thread to execute the removal
235/// before resolving. Useful when you need to confirm the call was processed.
236pub fn close_delivered(notification_id: &str) -> impl Future<Output = ()> + Send + 'static {
237    let id = notification_id.to_owned();
238    let (tx, rx) = worker::sender::<()>();
239    worker::dispatch(move || {
240        let ids = NSArray::from_retained_slice(&[NSString::from_str(&id)]);
241        UNUserNotificationCenter::currentNotificationCenter()
242            .removeDeliveredNotificationsWithIdentifiers(&ids);
243        log::debug!("removed delivered notification {id:?}");
244        tx.send(());
245    });
246
247    async move { rx.await.unwrap_or(()) }
248}
249
250/// Cancel a pending notification.
251///
252/// Returns a `Future` that resolves once the worker thread has executed the cancellation.                 ...
253///
254/// Unlike [`cancel_pending`], this waits for the worker thread to execute the cancellation before resolving.
255pub fn cancel_pending(notification_id: &str) -> impl Future<Output = ()> + Send + 'static {
256    let id = notification_id.to_owned();
257    let (tx, rx) = worker::sender::<()>();
258    worker::dispatch(move || {
259        let ids = NSArray::from_retained_slice(&[NSString::from_str(&id)]);
260        UNUserNotificationCenter::currentNotificationCenter()
261            .removePendingNotificationRequestsWithIdentifiers(&ids);
262        log::debug!("cancelled pending notification {id:?}");
263        tx.send(());
264    });
265    async move { rx.await.unwrap_or(()) }
266}
267
268/// Return the identifiers of all pending (scheduled but not yet delivered) notifications.
269pub fn get_pending_notification_ids() -> impl Future<Output = Vec<String>> + Send + 'static {
270    let (tx, rx) = worker::sender::<Vec<String>>();
271    worker::dispatch(move || {
272        UNUserNotificationCenter::currentNotificationCenter()
273            .getPendingNotificationRequestsWithCompletionHandler(&RcBlock::new(
274                move |requests: NonNull<NSArray<UNNotificationRequest>>| {
275                    let ids: Vec<String> = unsafe { requests.as_ref() }
276                        .iter()
277                        .map(|req| req.identifier().to_string())
278                        .collect();
279                    tx.send(ids);
280                },
281            ));
282    });
283    async move { rx.await.unwrap_or_default() }
284}
285
286/// Return the identifiers of all delivered notifications currently visible in Notification Center.
287pub fn get_delivered_notification_ids() -> impl Future<Output = Vec<String>> + Send + 'static {
288    let (tx, rx) = worker::sender::<Vec<String>>();
289    worker::dispatch(move || {
290        UNUserNotificationCenter::currentNotificationCenter()
291            .getDeliveredNotificationsWithCompletionHandler(&RcBlock::new(
292                move |notifications: NonNull<NSArray<UNNotification>>| {
293                    let ids: Vec<String> = unsafe { notifications.as_ref() }
294                        .iter()
295                        .map(|notif| notif.request().identifier().to_string())
296                        .collect();
297                    tx.send(ids);
298                },
299            ));
300    });
301    async move { rx.await.unwrap_or_default() }
302}
303
304/// Schedule a notification for immediate delivery.
305///
306/// Returns a `Future` that resolves with the notification's request ID once the
307/// request is accepted by macOS. The ID can be used with [`close_delivered`] or
308/// [`cancel_pending`] even when no explicit ID was set via [`Notification::id`].
309///
310/// Prefer [`Notification::send`] for the high-level two-phase API.
311pub async fn send(notification: Notification) -> Result<String, Error> {
312    check_bundle()?;
313    let request_id = notification
314        .notification_id
315        .clone()
316        .unwrap_or_else(|| NSUUID::new().UUIDString().to_string());
317    let id_copy = request_id.clone();
318    send_inner(notification, None, request_id)
319        .await
320        .map(|()| id_copy)
321}
322
323/// Schedule a notification for immediate delivery, blocking the calling thread.
324///
325/// Returns the notification's request ID on success. See [`send`] for details.
326///
327/// Prefer [`Notification::send_blocking`] for the high-level two-phase API.
328#[cfg(feature = "blocking-wrappers")]
329pub fn send_blocking(notification: Notification) -> Result<String, Error> {
330    check_bundle()?;
331    let request_id = notification
332        .notification_id
333        .clone()
334        .unwrap_or_else(|| NSUUID::new().UUIDString().to_string());
335    let id_copy = request_id.clone();
336    future::block_on(send_inner(notification, None, request_id)).map(|()| id_copy)
337}
338
339/// Schedule a notification and return a [`NotificationHandle`] once it is delivered.
340///
341/// This is the two-phase entry point: the returned future resolves as soon as macOS
342/// accepts the notification request. Call [`.response().await`](NotificationHandle::response)
343/// on the handle to wait for the user's interaction, or drop the handle to ignore it.
344pub async fn send_and_wait_for_delivery(
345    notification: Notification,
346) -> Result<NotificationHandle, Error> {
347    check_bundle()?;
348    let request_id = notification
349        .notification_id
350        .clone()
351        .unwrap_or_else(|| NSUUID::new().UUIDString().to_string());
352
353    let (response_tx, response_rx) = oneshot::channel();
354    let timeout = notification.action_timeout;
355    let has_actions = !notification.actions.is_empty();
356    let guard = PendingGuard::new(request_id.clone(), response_rx);
357
358    send_inner(notification, Some(response_tx), request_id.clone()).await?;
359
360    Ok(NotificationHandle {
361        notification_id: request_id,
362        guard,
363        timeout,
364        has_actions,
365    })
366}
367
368/// Schedule an actionable notification and wait for the user's response.
369///
370/// This collapses both phases (delivery + response) into one call. For the split
371/// two-phase API, use [`Notification::send`] followed by
372/// [`NotificationHandle::response`] instead.
373///
374/// `UNUserNotificationCenter` always delivers callbacks on the main thread's run loop.
375/// The main thread must pump `NSRunLoop` while awaiting this future. GUI apps do this
376/// automatically; CLI/Tokio apps should use [`crate::block_on_main`] or [`crate::run_main_loop_while`].
377///
378/// Timeout is set via [`Notification::timeout`]; `None` means wait indefinitely.
379/// If a timeout was set, the notification is auto-closed when it elapses and
380/// the response has [`NotificationResponse::is_timed_out`] set to `true`.
381pub async fn send_with_actions(notification: Notification) -> Result<NotificationResponse, Error> {
382    check_bundle()?;
383
384    let request_id = notification
385        .notification_id
386        .clone()
387        .unwrap_or_else(|| NSUUID::new().UUIDString().to_string());
388    let (response_tx, response_rx) = oneshot::channel();
389    let timeout = notification.action_timeout;
390    let has_actions = !notification.actions.is_empty();
391    let guard = PendingGuard::new(request_id.clone(), response_rx);
392
393    send_inner(notification, Some(response_tx), request_id.clone()).await?;
394
395    NotificationHandle {
396        notification_id: request_id,
397        guard,
398        timeout,
399        has_actions,
400    }
401    .response()
402    .await
403}