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
157#[cfg(feature = "blocking-wrappers")]
158pub use futures_lite::future::block_on;
159
160/// Set the application which delivers or schedules a notification
161/// A [`RawWakerVTable`](std::task::RawWakerVTable) whose `wake` calls [`CFRunLoop::wake_up`](objc2_core_foundation::CFRunLoop::wake_up) on the main run loop.
162///
163/// The data pointer is always null; the main run loop is a global.
164mod runloop_waker {
165 use std::task::{RawWaker, RawWakerVTable};
166
167 unsafe fn wake(_: *const ()) {
168 if let Some(run_loop) = objc2_core_foundation::CFRunLoop::main() {
169 run_loop.wake_up();
170 }
171 }
172
173 unsafe fn clone(ptr: *const ()) -> RawWaker {
174 RawWaker::new(ptr, &VTABLE)
175 }
176
177 unsafe fn drop(_: *const ()) {}
178
179 pub static VTABLE: RawWakerVTable = RawWakerVTable::new(clone, wake, wake, drop);
180}
181
182/// Run a future to completion on the main thread while pumping [`NSRunLoop`](https://developer.apple.com/documentation/foundation/nsrunloop).
183///
184/// ## Thread Safety
185/// **Must be called from the main thread.**
186/// Uses a waker that calls [`CFRunLoop::wake_up`](objc2_core_foundation::CFRunLoop::wake_up) on the main run loop,
187/// so the run loop sleep is interrupted as soon as the future signals readiness, no busy-polling.
188/// GUI apps (`Tauri`, `AppKit`, `SwiftUI`) pump [`RunLoop`](https://developer.apple.com/documentation/foundation/runloop) automatically.
189///
190/// **Use [`dynamic_block_on`] instead if the main thread may not be running.**
191pub fn block_on_main<F: Future>(future: F) -> F::Output {
192 use std::task::{Context, Poll, RawWaker, Waker};
193
194 let raw = RawWaker::new(std::ptr::null(), &runloop_waker::VTABLE);
195 // SAFETY: the vtable functions are correct and the null data pointer is intentional
196 // (wake_up targets the global main run loop, no per-waker state needed).
197 let waker = unsafe { Waker::from_raw(raw) };
198 let mut cx = Context::from_waker(&waker);
199
200 let mut future = std::pin::pin!(future);
201 let run_loop = NSRunLoop::mainRunLoop();
202 loop {
203 if let Poll::Ready(output) = future.as_mut().poll(&mut cx) {
204 return output;
205 }
206 // Sleep up to 1 s; the waker interrupts this early whenever the future is ready.
207 let until = NSDate::dateWithTimeIntervalSinceNow(1.0);
208 unsafe { run_loop.runMode_beforeDate(NSDefaultRunLoopMode, &until) };
209 }
210}
211
212/// Block on the future, using [`block_on_main`] if the current thread is the main thread.
213///
214/// Returns `None` if the main thread is not pumping.
215pub fn block_on_current<F: Future>(future: F) -> Result<F::Output, Error> {
216 if objc2::MainThreadMarker::new().is_some() {
217 // we are on the main thread, so we can safely block on the future
218 Ok(block_on_main(future))
219 } else if main_run_loop_is_running() {
220 // we can safely block on the future here, as we know the main thread is pumping
221 Ok(block_on(future))
222 } else {
223 log::error!("main thread is not pumping");
224 Err(Error::MainThreadNotRunning)
225 }
226}
227
228/// Returns `true` if the main thread's run loop is active and able to deliver callbacks.
229///
230/// macOS always delivers notification responses on the main thread's run loop.
231/// If nothing is running it (no AppKit/SwiftUI framework, no explicit [`run_main_loop_while`] call),
232/// response futures will never resolve.
233pub(crate) fn main_run_loop_is_running() -> bool {
234 objc2_core_foundation::CFRunLoop::main().is_some_and(|rl| rl.is_waiting())
235}
236
237/// Verify the process has a bundle identifier.
238///
239/// [`UNUserNotificationCenter`](https://developer.apple.com/documentation/usernotifications/unusernotificationcenter) requires this and crashes without it.
240pub fn check_bundle() -> Result<(), Error> {
241 NSBundle::mainBundle()
242 .bundleIdentifier()
243 .ok_or(Error::NoBundleIdentifier)?;
244 Ok(())
245}