Skip to main content

mac_usernotifications/
notification.rs

1use crate::{Error, action::Action, check_bundle, interrupt::InterruptionLevel};
2
3use objc2_foundation::NSString;
4use objc2_user_notifications::UNMutableNotificationContent;
5
6use std::time::Duration;
7
8/// A notification with title, body, and optional subtitle / sound.
9///
10/// Construct with [`Notification::new`] and chain setter methods.
11///
12/// # Example
13///
14/// ```no_run
15/// use mac_usernotifications::Notification;
16///
17/// let n = Notification::new()
18///     .title("Title")
19///     .message("Body text")
20///     .subtitle("A subtitle")
21///     .sound("Submarine");
22/// ```
23#[derive(Debug, Default)]
24pub struct Notification {
25    pub(crate) title: String,
26    pub(crate) body: String,
27    pub(crate) subtitle: Option<String>,
28    pub(crate) sound: Option<String>,
29    pub(crate) actions: Vec<Action>,
30
31    /// Timeout for user interaction (actionable notifications only).
32    /// `None` means wait indefinitely.
33    pub(crate) action_timeout: Option<Duration>,
34
35    /// Optional path to an image to attach to the notification.
36    pub(crate) image_path: Option<String>,
37
38    /// Optional delay from now before the notification fires.
39    pub(crate) trigger: Option<Duration>,
40
41    /// Groups related notifications in Notification Center.
42    pub(crate) thread_id: Option<String>,
43
44    /// Optional explicit request identifier for this notification.
45    ///
46    /// Re-using an existing identifier replaces the displayed notification in-place.
47    pub(crate) notification_id: Option<String>,
48
49    /// Optional badge number for the app icon. `0` clears the badge.
50    pub(crate) badge: Option<u32>,
51
52    /// Controls foreground/Focus interruption behavior (macOS 12+).
53    pub(crate) interruption_level: Option<InterruptionLevel>,
54
55    /// Sorting hint within a notification group, from 0.0 to 1.0.
56    pub(crate) relevance_score: Option<f64>,
57}
58
59impl Notification {
60    /// Create a new `Notification`.
61    pub fn new() -> Self {
62        Self::default()
63    }
64
65    /// Set the title.
66    pub fn title(mut self, title: impl ToString) -> Self {
67        self.title = title.to_string();
68        self
69    }
70
71    /// Set the body text.
72    pub fn message(mut self, message: impl ToString) -> Self {
73        self.body = message.to_string();
74        self
75    }
76
77    /// Set a subtitle.
78    pub fn subtitle(mut self, subtitle: impl ToString) -> Self {
79        self.subtitle = Some(subtitle.to_string());
80        self
81    }
82
83    /// Set a subtitle if present.
84    pub fn maybe_subtitle(mut self, subtitle: Option<impl ToString>) -> Self {
85        if let Some(subtitle) = subtitle {
86            self.subtitle = Some(subtitle.to_string());
87        }
88        self
89    }
90
91    /// Add an action button (auto-registered with macOS).
92    pub fn action(mut self, action: impl Into<Action>) -> Self {
93        self.actions.push(action.into());
94        self
95    }
96
97    /// Automatically close the notification after `duration` if the user has not
98    /// interacted with it yet.
99    ///
100    /// When the timer fires, the notification banner is removed from the screen
101    /// and [`NotificationHandle::response`](crate::send::NotificationHandle::response)
102    /// resolves with `Ok(response)` where
103    /// [`NotificationResponse::is_timed_out`](crate::NotificationResponse::is_timed_out)
104    /// returns `true`.
105    ///
106    /// Without a timeout, "Clear All" in Notification Center causes an indefinite wait.
107    pub fn timeout(mut self, duration: Duration) -> Self {
108        self.action_timeout = Some(duration);
109        self
110    }
111
112    /// Play the default system sound.
113    pub fn default_sound(mut self) -> Self {
114        self.sound = Some("".to_string());
115        self
116    }
117
118    /// Play a named sound from the app bundle or system library.
119    pub fn sound<S: Into<String>>(mut self, sound: S) -> Self {
120        self.sound = Some(sound.into());
121        self
122    }
123
124    /// Play a named sound from the app bundle or system library, if present.
125    pub fn maybe_sound<S: Into<String>>(mut self, sound: Option<S>) -> Self {
126        if let Some(sound) = sound {
127            self.sound = Some(sound.into());
128        }
129        self
130    }
131
132    /// Attach an image to the notification by file path.
133    ///
134    /// macOS copies the file at `path` into its own notification data store.
135    /// The framework manages the stored copy; do not rely on the original path
136    /// remaining accessible after the notification is posted.
137    pub fn image_path(mut self, path: impl ToString) -> Self {
138        self.image_path = Some(path.to_string());
139        self
140    }
141
142    /// Schedule the notification to fire after the given delay from now.
143    pub fn schedule_in(mut self, delay: Duration) -> Self {
144        self.trigger = Some(delay);
145        self
146    }
147
148    /// Group this notification with others sharing the same thread identifier.
149    ///
150    /// macOS displays grouped notifications collapsed in Notification Center.
151    /// Use a stable per-conversation or per-topic string, e.g. `"chat.alice"`.
152    pub fn thread_id(mut self, id: impl ToString) -> Self {
153        self.thread_id = Some(id.to_string());
154        self
155    }
156
157    /// Set an explicit notification identifier.
158    ///
159    /// Posting a new notification with the same identifier replaces any previously
160    /// displayed notification with that ID. Useful for updating live statuses
161    /// (e.g. a progress indicator or a changing score).
162    ///
163    /// If not set, a fresh UUID is generated per [`send`](Self::send).
164    pub fn id(mut self, id: &str) -> Self {
165        self.notification_id = Some(id.to_owned());
166        self
167    }
168
169    /// Set the app icon badge number. Use `0` to clear the badge.
170    pub fn badge(mut self, count: u32) -> Self {
171        self.badge = Some(count);
172        self
173    }
174
175    /// Set the interruption level for this notification (macOS 12+).
176    ///
177    /// Controls whether the notification breaks through Focus modes.
178    /// See [`InterruptionLevel`] for the available levels.
179    pub fn interruption_level(mut self, level: InterruptionLevel) -> Self {
180        self.interruption_level = Some(level);
181        self
182    }
183
184    /// Set the relevance score for sorting within a notification group.
185    ///
186    /// Value between `0.0` and `1.0`. Higher scores appear first when notifications
187    /// are grouped by thread identifier.
188    pub fn relevance_score(mut self, score: f64) -> Self {
189        self.relevance_score = Some(score);
190        self
191    }
192
193    pub(crate) fn build_content(self) -> NotificationContent {
194        let content = UNMutableNotificationContent::new();
195        content.setTitle(&NSString::from_str(&self.title));
196        content.setBody(&NSString::from_str(&self.body));
197
198        let to_nsstring = |string: String| NSString::from_str(&string);
199
200        if let Some(ref sub) = self.subtitle.map(to_nsstring) {
201            content.setSubtitle(sub);
202        }
203
204        if let Some(ref id) = self.thread_id.map(to_nsstring) {
205            content.setThreadIdentifier(id);
206        }
207
208        if let Some(sound) = self.sound {
209            content.setSound(crate::sound::unnotificationsound(&sound).as_deref());
210        }
211
212        if let Some(ref path) = self.image_path {
213            use objc2_foundation::{NSArray, NSURL};
214            use objc2_user_notifications::UNNotificationAttachment;
215            log::debug!("attaching image: {path:?}");
216            let url = NSURL::fileURLWithPath(&NSString::from_str(path));
217
218            match unsafe {
219                UNNotificationAttachment::attachmentWithIdentifier_URL_options_error(
220                    &NSString::from_str("image"),
221                    &url,
222                    None,
223                )
224            } {
225                Ok(attachment) => {
226                    let attachments = NSArray::from_retained_slice(&[attachment]);
227                    content.setAttachments(&attachments);
228                }
229                Err(err) => {
230                    log::error!("failed to create notification attachment for {path:?}: {err:?}");
231                }
232            }
233        }
234
235        if let Some(count) = self.badge {
236            content.setBadge(Some(&objc2_foundation::NSNumber::new_u32(count)));
237        }
238
239        if let Some(level) = self.interruption_level {
240            content.setInterruptionLevel(level.to_un_level());
241        }
242
243        if let Some(score) = self.relevance_score {
244            content.setRelevanceScore(score);
245        }
246
247        NotificationContent {
248            content,
249            actions: self.actions,
250            trigger: self.trigger,
251        }
252    }
253}
254
255/// Sending
256impl Notification {
257    /// Send the notification and return a [`NotificationHandle`](`crate::NotificationHandle`) once it is delivered.
258    ///
259    /// The future resolves as soon as macOS accepts the notification request.
260    /// Call [`.response().await`](crate::send::NotificationHandle::response) on the handle to wait for the user's interaction,
261    /// or drop it to ignore the response.
262    ///
263    ///
264    /// `UNUserNotificationCenter` always delivers callbacks on the main thread's run loop.
265    /// The main thread must pump `NSRunLoop` while awaiting the response future.
266    /// GUI apps do this automatically; CLI/Tokio apps should use
267    /// [`crate::block_on_main`] or [`crate::run_main_loop_while`].
268    pub async fn send(self) -> Result<crate::send::NotificationHandle, Error> {
269        use crate::send::send_and_wait_for_delivery;
270        check_bundle()?;
271        send_and_wait_for_delivery(self).await
272    }
273
274    /// Send the notification, blocking until delivered, then return a [`NotificationHandle`](`crate::NotificationHandle`).
275    ///
276    /// Waits for macOS to accept the notification request, then returns a handle. Call
277    /// [`crate::block_on_main`]`(handle.response())` to block waiting for the user's
278    /// response, or drop the handle to ignore it.
279    #[cfg(feature = "blocking-wrappers")]
280    pub fn send_blocking(self) -> Result<crate::send::NotificationHandle, Error> {
281        use crate::send::send_and_wait_for_delivery;
282        check_bundle()?;
283        crate::block_on_current(send_and_wait_for_delivery(self))?
284    }
285}
286
287/// The result of building a [`Notification`] into its Objective-C content representation.
288///
289/// Returned by [`Notification::build_content`].
290pub(crate) struct NotificationContent {
291    pub(crate) content: objc2::rc::Retained<UNMutableNotificationContent>,
292    pub(crate) actions: Vec<Action>,
293    pub(crate) trigger: Option<Duration>,
294}
295
296#[cfg(test)]
297mod test_notification_builder {
298    use crate::sound;
299
300    use super::Notification;
301
302    #[test]
303    fn maybe_subtitle_sets_when_some_skips_when_none() {
304        let with = Notification::new().maybe_subtitle(Some("Sub"));
305        let without = Notification::new().maybe_subtitle(Option::<&str>::None);
306        assert_eq!(with.subtitle.as_deref(), Some("Sub"));
307        assert!(without.subtitle.is_none());
308    }
309
310    #[test]
311    fn maybe_sound_sets_when_some_skips_when_none() {
312        let with = Notification::new().maybe_sound(Some("Funk"));
313        let without = Notification::new().maybe_sound(Option::<&str>::None);
314        assert_eq!(with.sound, Some(sound::FUNK.into()));
315        assert!(without.sound.is_none());
316    }
317
318    #[test]
319    fn sound_via_str_resolves_named_variant() {
320        // Exercises the From<&str> → Sound conversion through the builder.
321        // A naive impl that always produces Custom would pass string equality
322        // checks but fail here.
323        let notif = Notification::new().sound("Basso");
324        assert_eq!(notif.sound, Some(sound::BASSO.into()));
325    }
326}