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/// Pump the main thread's [`RunLoop`](https://developer.apple.com/documentation/foundation/runloop) until `should_continue` returns `false`.
161///
162/// **Must be called from the main thread.** Required because [`UNUserNotificationCenter`](https://developer.apple.com/documentation/usernotifications/unusernotificationcenter)
163/// always delivers callbacks on the main thread's run loop; async runtimes that occupy
164/// the main thread will never fire callbacks.
165pub fn run_main_loop_while<F: Fn() -> bool>(should_continue: F) {
166 let run_loop = NSRunLoop::mainRunLoop();
167 while should_continue() {
168 let until = NSDate::dateWithTimeIntervalSinceNow(0.05);
169 unsafe { run_loop.runMode_beforeDate(NSDefaultRunLoopMode, &until) };
170 }
171}
172
173/// A [`RawWakerVTable`](std::task::RawWakerVTable) whose `wake` calls [`CFRunLoop::wake_up`](objc2_core_foundation::CFRunLoop::wake_up) on the main run loop.
174///
175/// The data pointer is always null; the main run loop is a global.
176mod runloop_waker {
177 use std::task::{RawWaker, RawWakerVTable};
178
179 unsafe fn wake(_: *const ()) {
180 if let Some(run_loop) = objc2_core_foundation::CFRunLoop::main() {
181 run_loop.wake_up();
182 }
183 }
184
185 unsafe fn clone(ptr: *const ()) -> RawWaker {
186 RawWaker::new(ptr, &VTABLE)
187 }
188
189 unsafe fn drop(_: *const ()) {}
190
191 pub static VTABLE: RawWakerVTable = RawWakerVTable::new(clone, wake, wake, drop);
192}
193
194/// Run a future to completion on the main thread while pumping [`NSRunLoop`](https://developer.apple.com/documentation/foundation/nsrunloop).
195///
196/// **Must be called from the main thread.** Uses a waker that calls
197/// [`CFRunLoop::wake_up`](objc2_core_foundation::CFRunLoop::wake_up) on the main run loop, so the run loop
198/// sleep is interrupted as soon as the future signals readiness — no busy-polling.
199/// GUI apps (`Tauri`, `AppKit`, `SwiftUI`) pump [`RunLoop`](https://developer.apple.com/documentation/foundation/runloop) automatically; CLI tools need this.
200pub fn block_on_main<F: Future>(future: F) -> F::Output {
201 use std::task::{Context, Poll, RawWaker, Waker};
202
203 let raw = RawWaker::new(std::ptr::null(), &runloop_waker::VTABLE);
204 // SAFETY: the vtable functions are correct and the null data pointer is intentional
205 // (wake_up targets the global main run loop, no per-waker state needed).
206 let waker = unsafe { Waker::from_raw(raw) };
207 let mut cx = Context::from_waker(&waker);
208
209 let mut future = std::pin::pin!(future);
210 let run_loop = NSRunLoop::mainRunLoop();
211 loop {
212 if let Poll::Ready(output) = future.as_mut().poll(&mut cx) {
213 return output;
214 }
215 // Sleep up to 1 s; the waker interrupts this early whenever the future is ready.
216 let until = NSDate::dateWithTimeIntervalSinceNow(1.0);
217 unsafe { run_loop.runMode_beforeDate(NSDefaultRunLoopMode, &until) };
218 }
219}
220
221/// Verify the process has a bundle identifier.
222///
223/// [`UNUserNotificationCenter`](https://developer.apple.com/documentation/usernotifications/unusernotificationcenter) requires this and crashes without it.
224pub fn check_bundle() -> Result<(), Error> {
225 NSBundle::mainBundle()
226 .bundleIdentifier()
227 .ok_or(Error::NoBundleIdentifier)?;
228 Ok(())
229}