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}