mac_usernotifications/
notification.rs1use 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#[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 pub(crate) action_timeout: Option<Duration>,
34
35 pub(crate) image_path: Option<String>,
37
38 pub(crate) trigger: Option<Duration>,
40
41 pub(crate) thread_id: Option<String>,
43
44 pub(crate) notification_id: Option<String>,
48
49 pub(crate) badge: Option<u32>,
51
52 pub(crate) interruption_level: Option<InterruptionLevel>,
54
55 pub(crate) relevance_score: Option<f64>,
57}
58
59impl Notification {
60 pub fn new() -> Self {
62 Self::default()
63 }
64
65 pub fn title(mut self, title: impl ToString) -> Self {
67 self.title = title.to_string();
68 self
69 }
70
71 pub fn message(mut self, message: impl ToString) -> Self {
73 self.body = message.to_string();
74 self
75 }
76
77 pub fn subtitle(mut self, subtitle: impl ToString) -> Self {
79 self.subtitle = Some(subtitle.to_string());
80 self
81 }
82
83 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 pub fn action(mut self, action: impl Into<Action>) -> Self {
93 self.actions.push(action.into());
94 self
95 }
96
97 pub fn timeout(mut self, duration: Duration) -> Self {
108 self.action_timeout = Some(duration);
109 self
110 }
111
112 pub fn default_sound(mut self) -> Self {
114 self.sound = Some("".to_string());
115 self
116 }
117
118 pub fn sound<S: Into<String>>(mut self, sound: S) -> Self {
120 self.sound = Some(sound.into());
121 self
122 }
123
124 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 pub fn image_path(mut self, path: impl ToString) -> Self {
138 self.image_path = Some(path.to_string());
139 self
140 }
141
142 pub fn schedule_in(mut self, delay: Duration) -> Self {
144 self.trigger = Some(delay);
145 self
146 }
147
148 pub fn thread_id(mut self, id: impl ToString) -> Self {
153 self.thread_id = Some(id.to_string());
154 self
155 }
156
157 pub fn id(mut self, id: &str) -> Self {
165 self.notification_id = Some(id.to_owned());
166 self
167 }
168
169 pub fn badge(mut self, count: u32) -> Self {
171 self.badge = Some(count);
172 self
173 }
174
175 pub fn interruption_level(mut self, level: InterruptionLevel) -> Self {
180 self.interruption_level = Some(level);
181 self
182 }
183
184 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
255impl Notification {
257 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 #[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
287pub(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 let notif = Notification::new().sound("Basso");
324 assert_eq!(notif.sound, Some(sound::BASSO.into()));
325 }
326}