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}
55
56impl fmt::Debug for NotificationHandle {
57    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
58        formatter
59            .debug_struct("NotificationHandle")
60            .field("notification_id", &self.notification_id)
61            .field("timeout", &self.timeout)
62            .finish()
63    }
64}
65
66impl NotificationHandle {
67    /// The notification's request identifier.
68    ///
69    /// Can be passed to [`close_delivered`] or [`cancel_pending`] even when no
70    /// explicit ID was set via [`Notification::id`].
71    pub fn notification_id(&self) -> &str {
72        &self.notification_id
73    }
74
75    /// Wait for the user's response.
76    ///
77    /// Resolves when the user interacts with or dismisses the notification.
78    ///
79    /// If a timeout was set via [`Notification::timeout`], the notification is
80    /// automatically removed from the screen when it elapses and this returns
81    /// `Ok(response)` where [`NotificationResponse::is_timed_out`] is `true`.
82    ///
83    /// `UNUserNotificationCenter` has no native TTL; the close is performed
84    /// by this crate calling [`close_delivered`] when the timer fires.
85    pub async fn response(self) -> Result<NotificationResponse, Error> {
86        let notification_id = self.notification_id.clone();
87        let receiver = self.guard.into_receiver();
88
89        if let Some(duration) = self.timeout {
90            future::or(
91                async { receiver.await.map_err(|_| Error::NotificationRejected) },
92                async move {
93                    futures_timer::Delay::new(duration).await;
94                    close_delivered(&notification_id).await;
95                    Ok(NotificationResponse::timed_out(notification_id))
96                },
97            )
98            .await
99        } else {
100            receiver.await.map_err(|_| Error::NotificationRejected)
101        }
102    }
103}
104
105/// Core sending logic: dispatch to worker, register response sender, schedule request.
106fn send_inner(
107    notification: Notification,
108    response_tx: Option<oneshot::Sender<NotificationResponse>>,
109    request_id: String,
110) -> impl Future<Output = Result<(), Error>> + Send + 'static {
111    let (scheduled_tx, scheduled_rx) = worker::sender::<Result<(), Error>>();
112
113    worker::dispatch(move || {
114        let NotificationContent {
115            content,
116            actions,
117            trigger,
118        } = notification.build_content();
119        log::debug!("request_id={request_id:?}");
120
121        // Register a category when a response sender is present so that `CustomDismissAction` delivers a dismiss callback
122        // even for notifications without visible action buttons.
123        // An empty category (no buttons) is invisible to the user but lets us observe swipe-to-dismiss via `didReceiveNotificationResponse`.
124        if response_tx.is_some() || !actions.is_empty() {
125            let category_id = if actions.is_empty() {
126                "de.hoodie.mac-usernotifications.observe".to_owned()
127            } else {
128                let mut ids: Vec<&str> = actions.iter().map(|act| act.identifier()).collect();
129                ids.sort_unstable();
130                ids.join(",")
131            };
132            log::debug!("registering synthesised category {category_id:?}");
133            ActionCategory::from_actions(&category_id, actions).register_now();
134            content.setCategoryIdentifier(&NSString::from_str(&category_id));
135        }
136
137        if let Some(tx) = response_tx {
138            delegate::register_response_sender(request_id.clone(), tx);
139        }
140
141        use objc2_user_notifications::UNTimeIntervalNotificationTrigger;
142        let trigger_obj = trigger.map(|delay| {
143            UNTimeIntervalNotificationTrigger::triggerWithTimeInterval_repeats(
144                delay.as_secs_f64().max(0.1),
145                false,
146            )
147        });
148
149        let request = UNNotificationRequest::requestWithIdentifier_content_trigger(
150            &NSString::from_str(&request_id),
151            &content,
152            trigger_obj.as_deref().map(|val| &**val),
153        );
154
155        UNUserNotificationCenter::currentNotificationCenter()
156            .addNotificationRequest_withCompletionHandler(
157                &request,
158                Some(&RcBlock::new(move |err: *mut NSError| {
159                    log::debug!("completion handler fired (err.is_null={})", err.is_null());
160                    if let Some(err) = NonNull::new(err).map(|ptr| unsafe { ptr.as_ref() }) {
161                        let desc = err.localizedDescription();
162                        log::error!("notification request rejected: {desc}");
163                    }
164                    let result = if err.is_null() {
165                        Ok(())
166                    } else {
167                        Err(Error::NotificationRejected)
168                    };
169                    scheduled_tx.send(result);
170                })),
171            );
172    });
173
174    async move { scheduled_rx.await.map_err(Into::into).flatten() }
175}
176
177/// Remove an already-delivered notification from Notification Center by its identifier.
178///
179/// Fire-and-forget: dispatched to the worker thread, returns immediately.
180/// Has no effect if the identifier is unknown.
181#[cfg(feature = "blocking-wrappers")]
182pub fn close_delivered_blocking(notification_id: &str) {
183    let id = notification_id.to_owned();
184    worker::dispatch(move || {
185        let ids = NSArray::from_retained_slice(&[NSString::from_str(&id)]);
186        UNUserNotificationCenter::currentNotificationCenter()
187            .removeDeliveredNotificationsWithIdentifiers(&ids);
188        log::debug!("removed delivered notification {id:?}");
189    });
190}
191
192/// Cancel a pending (not yet delivered) notification by its identifier.
193///
194/// Fire-and-forget: dispatched to the worker thread, returns immediately.
195/// Has no effect if the identifier is unknown or already delivered.
196#[cfg(feature = "blocking-wrappers")]
197pub fn cancel_pending_blocking(notification_id: &str) {
198    let id = notification_id.to_owned();
199    worker::dispatch(move || {
200        let ids = NSArray::from_retained_slice(&[NSString::from_str(&id)]);
201        UNUserNotificationCenter::currentNotificationCenter()
202            .removePendingNotificationRequestsWithIdentifiers(&ids);
203        log::debug!("cancelled pending notification {id:?}");
204    });
205}
206
207/// Remove a delivered notification.
208///
209/// Returns a `Future` that resolves once the worker thread has executed the removal.
210///
211/// Unlike [`close_delivered`], this waits for the worker thread to execute the removal
212/// before resolving. Useful when you need to confirm the call was processed.
213pub fn close_delivered(notification_id: &str) -> impl Future<Output = ()> + Send + 'static {
214    let id = notification_id.to_owned();
215    let (tx, rx) = worker::sender::<()>();
216    worker::dispatch(move || {
217        let ids = NSArray::from_retained_slice(&[NSString::from_str(&id)]);
218        UNUserNotificationCenter::currentNotificationCenter()
219            .removeDeliveredNotificationsWithIdentifiers(&ids);
220        log::debug!("removed delivered notification {id:?}");
221        tx.send(());
222    });
223
224    async move { rx.await.unwrap_or(()) }
225}
226
227/// Cancel a pending notification.
228///
229/// Returns a `Future` that resolves once the worker thread has executed the cancellation.                 ...
230///
231/// Unlike [`cancel_pending`], this waits for the worker thread to execute the cancellation before resolving.
232pub fn cancel_pending(notification_id: &str) -> impl Future<Output = ()> + Send + 'static {
233    let id = notification_id.to_owned();
234    let (tx, rx) = worker::sender::<()>();
235    worker::dispatch(move || {
236        let ids = NSArray::from_retained_slice(&[NSString::from_str(&id)]);
237        UNUserNotificationCenter::currentNotificationCenter()
238            .removePendingNotificationRequestsWithIdentifiers(&ids);
239        log::debug!("cancelled pending notification {id:?}");
240        tx.send(());
241    });
242    async move { rx.await.unwrap_or(()) }
243}
244
245/// Return the identifiers of all pending (scheduled but not yet delivered) notifications.
246pub fn get_pending_notification_ids() -> impl Future<Output = Vec<String>> + Send + 'static {
247    let (tx, rx) = worker::sender::<Vec<String>>();
248    worker::dispatch(move || {
249        UNUserNotificationCenter::currentNotificationCenter()
250            .getPendingNotificationRequestsWithCompletionHandler(&RcBlock::new(
251                move |requests: NonNull<NSArray<UNNotificationRequest>>| {
252                    let ids: Vec<String> = unsafe { requests.as_ref() }
253                        .iter()
254                        .map(|req| req.identifier().to_string())
255                        .collect();
256                    tx.send(ids);
257                },
258            ));
259    });
260    async move { rx.await.unwrap_or_default() }
261}
262
263/// Return the identifiers of all delivered notifications currently visible in Notification Center.
264pub fn get_delivered_notification_ids() -> impl Future<Output = Vec<String>> + Send + 'static {
265    let (tx, rx) = worker::sender::<Vec<String>>();
266    worker::dispatch(move || {
267        UNUserNotificationCenter::currentNotificationCenter()
268            .getDeliveredNotificationsWithCompletionHandler(&RcBlock::new(
269                move |notifications: NonNull<NSArray<UNNotification>>| {
270                    let ids: Vec<String> = unsafe { notifications.as_ref() }
271                        .iter()
272                        .map(|notif| notif.request().identifier().to_string())
273                        .collect();
274                    tx.send(ids);
275                },
276            ));
277    });
278    async move { rx.await.unwrap_or_default() }
279}
280
281/// Schedule a notification for immediate delivery.
282///
283/// Returns a `Future` that resolves with the notification's request ID once the
284/// request is accepted by macOS. The ID can be used with [`close_delivered`] or
285/// [`cancel_pending`] even when no explicit ID was set via [`Notification::id`].
286///
287/// Prefer [`Notification::send`] for the high-level two-phase API.
288pub async fn send(notification: Notification) -> Result<String, Error> {
289    check_bundle()?;
290    let request_id = notification
291        .notification_id
292        .clone()
293        .unwrap_or_else(|| NSUUID::new().UUIDString().to_string());
294    let id_copy = request_id.clone();
295    send_inner(notification, None, request_id)
296        .await
297        .map(|()| id_copy)
298}
299
300/// Schedule a notification for immediate delivery, blocking the calling thread.
301///
302/// Returns the notification's request ID on success. See [`send`] for details.
303///
304/// Prefer [`Notification::send_blocking`] for the high-level two-phase API.
305#[cfg(feature = "blocking-wrappers")]
306pub fn send_blocking(notification: Notification) -> Result<String, Error> {
307    check_bundle()?;
308    let request_id = notification
309        .notification_id
310        .clone()
311        .unwrap_or_else(|| NSUUID::new().UUIDString().to_string());
312    let id_copy = request_id.clone();
313    future::block_on(send_inner(notification, None, request_id)).map(|()| id_copy)
314}
315
316/// Schedule a notification and return a [`NotificationHandle`] once it is delivered.
317///
318/// This is the two-phase entry point: the returned future resolves as soon as macOS
319/// accepts the notification request. Call [`.response().await`](NotificationHandle::response)
320/// on the handle to wait for the user's interaction, or drop the handle to ignore it.
321pub async fn send_and_wait_for_delivery(
322    notification: Notification,
323) -> Result<NotificationHandle, Error> {
324    check_bundle()?;
325    let request_id = notification
326        .notification_id
327        .clone()
328        .unwrap_or_else(|| NSUUID::new().UUIDString().to_string());
329
330    let (response_tx, response_rx) = oneshot::channel();
331    let timeout = notification.action_timeout;
332    let guard = PendingGuard::new(request_id.clone(), response_rx);
333
334    send_inner(notification, Some(response_tx), request_id.clone()).await?;
335
336    Ok(NotificationHandle {
337        notification_id: request_id,
338        guard,
339        timeout,
340    })
341}
342
343/// Schedule an actionable notification and wait for the user's response.
344///
345/// This collapses both phases (delivery + response) into one call. For the split
346/// two-phase API, use [`Notification::send`] followed by
347/// [`NotificationHandle::response`] instead.
348///
349/// `UNUserNotificationCenter` always delivers callbacks on the main thread's run loop.
350/// The main thread must pump `NSRunLoop` while awaiting this future. GUI apps do this
351/// automatically; CLI/Tokio apps should use [`crate::block_on_main`] or [`crate::run_main_loop_while`].
352///
353/// Timeout is set via [`Notification::timeout`]; `None` means wait indefinitely.
354/// If a timeout was set, the notification is auto-closed when it elapses and
355/// the response has [`NotificationResponse::is_timed_out`] set to `true`.
356pub async fn send_with_actions(notification: Notification) -> Result<NotificationResponse, Error> {
357    check_bundle()?;
358
359    let request_id = notification
360        .notification_id
361        .clone()
362        .unwrap_or_else(|| NSUUID::new().UUIDString().to_string());
363    let (response_tx, response_rx) = oneshot::channel();
364    let timeout = notification.action_timeout;
365    let guard = PendingGuard::new(request_id.clone(), response_rx);
366
367    send_inner(notification, Some(response_tx), request_id.clone()).await?;
368
369    NotificationHandle {
370        notification_id: request_id,
371        guard,
372        timeout,
373    }
374    .response()
375    .await
376}