Skip to main content

mac_usernotifications/
lib.rs

1//! A Rust wrapper around [`UNUserNotificationCenter`](https://developer.apple.com/documentation/usernotifications/unusernotificationcenter),
2//! designed for use in [notify-rust](https://docs.rs/notify-rust).
3//!
4//! # Bundling Requirement
5//!
6//! Contrary to [mac-notification-sys](https://docs.rs/mac-notification-sys),
7//! this crate requires that the binary is bundled  and be code-signed,
8//! an ad-hoc signature is sufficient.
9//! See the bundled examples for how to set this up with `cargo-bundle`.
10//!
11//! # Quick start
12//!
13//! ```no_run
14//! # use mac_usernotifications::{Action, blocking, block_on_main, Notification, check_bundle};
15//! # use std::time::Duration;
16//! # fn main() {
17//! // 1. verify the process has a bundle identifier
18//! check_bundle().unwrap();
19//!
20//! // 2. verify user gave permission
21//! blocking::request_auth().unwrap();
22//!
23//! // 3a. fire-and-forgeta (handle.notification_id() has the UUID for later use)
24//! let handle = Notification::new()
25//!     .title("Hello")
26//!     .message("World")
27//!     .send_blocking()
28//!     .unwrap();
29//!
30//! println!("notification id: {}", handle.notification_id());
31//!
32//! // 3b. actionable: use block_on_main to drive the run loop while waiting for the response
33//! let response = block_on_main(async {
34//!     Notification::new()
35//!         .title("Pick one")
36//!         .action(Action::button("ok", "OK"))
37//!         .action(Action::button("cancel", "Cancel"))
38//!         .timeout(Duration::from_secs(30)) // 4. always set a timeout for actionable notifications
39//!         .send()
40//!         .await?
41//!         .response()
42//!         .await
43//! }).unwrap();
44//!
45//! println!("User chose: {}", response.action_identifier);
46//! # }
47//! ```
48//!
49//! # Threading model
50//!
51//! macOS delivers [`didReceiveNotificationResponse`](https://developer.apple.com/documentation/usernotifications/unusernotificationcenterdelegate/usernotificationcenter(_:didreceive:withcompletionhandler:)) on the main thread's [`NSRunLoop`](https://developer.apple.com/documentation/foundation/nsrunloop),
52//! regardless of which thread the delegate was registered from ([Apple docs](https://developer.apple.com/documentation/usernotifications/unusernotificationcenterdelegate)).
53//! The main thread's run loop must be spinning whenever you expect the user to interact with a notification.
54//!
55//! This crate uses a lazily-created worker thread for all Objective-C calls, to enable async APIs.
56//!
57//! ## GUI apps (`AppKit` / `SwiftUI` / `Tauri`)
58//!
59//! The framework drives the main run loop automatically. `send`, `send_blocking`, and
60//! `response().await` all work from any thread without extra setup.
61//!
62//! ## Headless / background-only apps
63//!
64//! No framework drives the run loop, so you must do it yourself. Use [`block_on_main`]
65//! to run an async expression on the main thread while pumping the run loop:
66//!
67//! ```no_run
68//! # use mac_usernotifications::{Notification, block_on_main};
69//! # fn main() {
70//! let response = block_on_main(async {
71//!     Notification::new()
72//!         .title("Hello")
73//!         .send().await?
74//!         .response().await
75//! });
76//! # }
77//! ```
78//!
79//! Or for Tokio, keep the main thread free for [`run_main_loop_while`] and run the
80//! runtime entirely on background threads. See `examples/simple_tokio.rs`.
81//!
82//! ## Known pitfalls
83//!
84//! | Scenario | Symptom | Fix |
85//! |---|---|---|
86//! | `response().await` called with nothing pumping the main run loop | future never resolves | Use [`block_on_main`] or [`run_main_loop_while`] on the main thread |
87//! | `#[tokio::main]` — main thread is inside Tokio | callbacks never fire | Use `new_multi_thread()` and keep main free for [`run_main_loop_while`] |
88//! | User clicks **"Clear All"** in Notification Center | future never resolves | Always set a timeout via [`Notification::timeout`] for actionable notifications |
89
90#![warn(missing_docs)]
91#![forbid(trivial_numeric_casts, unused_import_braces)]
92#![warn(unstable_features)]
93#![deny(
94    missing_copy_implementations,
95    missing_debug_implementations,
96    trivial_casts,
97    unused_qualifications
98)]
99#![warn(
100    clippy::doc_markdown,
101    clippy::semicolon_if_nothing_returned,
102    clippy::single_match_else,
103    clippy::inconsistent_struct_constructor,
104    clippy::map_unwrap_or,
105    clippy::match_same_arms
106)]
107
108use objc2_foundation::{NSBundle, NSDate, NSDefaultRunLoopMode, NSRunLoop};
109use std::future::Future;
110
111mod auth;
112mod delegate;
113mod error;
114mod interrupt;
115mod notification;
116mod send;
117mod settings;
118pub mod sound;
119mod worker;
120
121pub mod action;
122pub mod response;
123
124pub use crate::{
125    action::Action,
126    auth::{get_notification_settings, request_auth},
127    error::Error,
128    interrupt::InterruptionLevel,
129    notification::Notification,
130    response::{CloseReason, NotificationResponse},
131    send::{
132        NotificationHandle, cancel_pending, close_delivered, get_delivered_notification_ids,
133        get_pending_notification_ids, send, send_with_actions,
134    },
135    settings::{AuthorizationStatus, NotificationSettingStatus, NotificationSettings},
136};
137
138#[cfg(feature = "blocking-wrappers")]
139pub mod blocking {
140    //! Blocking wrappers for fire-and-forget operations.
141    //!
142    //! These are safe to call from any thread. None of them wait for a
143    //! notification response — use [`crate::block_on_main`] with the async
144    //! API for that.
145    pub use crate::{
146        auth::{
147            get_notification_settings_blocking as get_notification_settings,
148            request_auth_blocking as request_auth,
149        },
150        send::{
151            cancel_pending_blocking as cancel_pending, close_delivered_blocking as close_delivered,
152            send_blocking as send,
153        },
154    };
155}
156
157pub use futures_lite::future::block_on;
158
159/// Set the application which delivers or schedules a notification
160/// A [`RawWakerVTable`](std::task::RawWakerVTable) whose `wake` calls [`CFRunLoop::wake_up`](objc2_core_foundation::CFRunLoop::wake_up) on the main run loop.
161///
162/// The data pointer is always null; the main run loop is a global.
163mod runloop_waker {
164    use std::task::{RawWaker, RawWakerVTable};
165
166    unsafe fn wake(_: *const ()) {
167        if let Some(run_loop) = objc2_core_foundation::CFRunLoop::main() {
168            run_loop.wake_up();
169        }
170    }
171
172    unsafe fn clone(ptr: *const ()) -> RawWaker {
173        RawWaker::new(ptr, &VTABLE)
174    }
175
176    unsafe fn drop(_: *const ()) {}
177
178    pub static VTABLE: RawWakerVTable = RawWakerVTable::new(clone, wake, wake, drop);
179}
180
181/// Run a future to completion on the main thread while pumping [`NSRunLoop`](https://developer.apple.com/documentation/foundation/nsrunloop).
182///
183/// ## Thread Safety
184/// **Must be called from the main thread.**
185/// Uses a waker that calls [`CFRunLoop::wake_up`](objc2_core_foundation::CFRunLoop::wake_up) on the main run loop,
186/// so the run loop sleep is interrupted as soon as the future signals readiness, no busy-polling.
187/// GUI apps (`Tauri`, `AppKit`, `SwiftUI`) pump [`RunLoop`](https://developer.apple.com/documentation/foundation/runloop) automatically.
188///
189/// **Use [`dynamic_block_on`] instead if the main thread may not be running.**
190pub fn block_on_main<F: Future>(future: F) -> F::Output {
191    use std::task::{Context, Poll, RawWaker, Waker};
192
193    let raw = RawWaker::new(std::ptr::null(), &runloop_waker::VTABLE);
194    // SAFETY: the vtable functions are correct and the null data pointer is intentional
195    // (wake_up targets the global main run loop, no per-waker state needed).
196    let waker = unsafe { Waker::from_raw(raw) };
197    let mut cx = Context::from_waker(&waker);
198
199    let mut future = std::pin::pin!(future);
200    let run_loop = NSRunLoop::mainRunLoop();
201    loop {
202        if let Poll::Ready(output) = future.as_mut().poll(&mut cx) {
203            return output;
204        }
205        // Sleep up to 1 s; the waker interrupts this early whenever the future is ready.
206        let until = NSDate::dateWithTimeIntervalSinceNow(1.0);
207        unsafe { run_loop.runMode_beforeDate(NSDefaultRunLoopMode, &until) };
208    }
209}
210
211/// Block on the future, using [`block_on_main`] if the current thread is the main thread.
212///
213/// Returns `None` if the main thread is not pumping.
214pub fn block_on_current<F: Future>(future: F) -> Result<F::Output, Error> {
215    if objc2::MainThreadMarker::new().is_some() {
216        // we are on the main thread, so we can safely block on the future
217        Ok(block_on_main(future))
218    } else if main_run_loop_is_running() {
219        // we can safely block on the future here, as we know the main thread is pumping
220        Ok(block_on(future))
221    } else {
222        log::error!("main thread is not pumping");
223        Err(Error::MainThreadNotRunning)
224    }
225}
226
227/// Returns `true` if the main thread's run loop is active and able to deliver callbacks.
228///
229/// macOS always delivers notification responses on the main thread's run loop.
230/// If nothing is running it (no AppKit/SwiftUI framework, no explicit [`run_main_loop_while`] call),
231/// response futures will never resolve.
232pub(crate) fn main_run_loop_is_running() -> bool {
233    objc2_core_foundation::CFRunLoop::main().is_some_and(|rl| rl.is_waiting())
234}
235
236/// Verify the process has a bundle identifier.
237///
238/// [`UNUserNotificationCenter`](https://developer.apple.com/documentation/usernotifications/unusernotificationcenter) requires this and crashes without it.
239pub fn check_bundle() -> Result<(), Error> {
240    NSBundle::mainBundle()
241        .bundleIdentifier()
242        .ok_or(Error::NoBundleIdentifier)?;
243    Ok(())
244}