1#[cfg(target_os = "macos")]
2use block::{Block, ConcreteBlock};
3#[cfg(target_os = "macos")]
4use fission_core::NotificationResponse;
5use fission_core::{
6 CancelNotificationRequest, NotificationError, NotificationPermission,
7 NotificationPermissionRequest, NotificationReceipt, NotificationRequest, NotificationSchedule,
8 NotificationSettings, PushPlatform, PushRegistration, PushRegistrationRequest,
9 SetBadgeCountRequest, CANCEL_ALL_NOTIFICATIONS, CANCEL_NOTIFICATION, GET_NOTIFICATION_SETTINGS,
10 REGISTER_PUSH_NOTIFICATIONS, REQUEST_NOTIFICATION_PERMISSION, SCHEDULE_NOTIFICATION,
11 SET_BADGE_COUNT, SHOW_NOTIFICATION, UNREGISTER_PUSH_NOTIFICATIONS,
12};
13use fission_shell::async_host::AsyncRegistry;
14#[cfg(target_os = "macos")]
15use objc::declare::ClassDecl;
16#[cfg(target_os = "macos")]
17use objc::runtime::{Class, Object, Protocol, Sel};
18#[cfg(target_os = "ios")]
19use objc::{class, msg_send, sel, sel_impl};
20#[cfg(target_os = "macos")]
21use objc::{class, msg_send, sel, sel_impl};
22#[cfg(target_os = "macos")]
23use std::ffi::CStr;
24#[cfg(any(target_os = "ios", target_os = "macos"))]
25use std::os::raw::c_void;
26#[cfg(not(target_os = "ios"))]
27use std::process::Command;
28use std::sync::Arc;
29#[cfg(target_os = "macos")]
30use std::sync::{Condvar, Mutex, OnceLock};
31
32#[cfg(target_os = "ios")]
33#[link(name = "UIKit", kind = "framework")]
34extern "C" {}
35
36#[cfg(target_os = "macos")]
37#[link(name = "AppKit", kind = "framework")]
38extern "C" {}
39
40#[cfg(target_os = "macos")]
41#[link(name = "Foundation", kind = "framework")]
42extern "C" {}
43
44#[cfg(target_os = "macos")]
45#[link(name = "UserNotifications", kind = "framework")]
46extern "C" {}
47
48#[cfg(target_os = "macos")]
49type NotificationResponseHandler = Arc<dyn Fn(NotificationResponse) + Send + Sync>;
50
51#[cfg(target_os = "macos")]
52static NOTIFICATION_RESPONSE_HANDLER: OnceLock<Mutex<Option<NotificationResponseHandler>>> =
53 OnceLock::new();
54
55#[cfg(target_os = "macos")]
57pub(crate) fn install_notification_response_handler(handler: NotificationResponseHandler) {
58 let slot = NOTIFICATION_RESPONSE_HANDLER.get_or_init(|| Mutex::new(None));
59 if let Ok(mut current) = slot.lock() {
60 *current = Some(handler);
61 }
62 install_macos_notification_delegate();
63}
64
65pub trait NotificationHost: Send + Sync + 'static {
67 fn request_permission(
72 &self,
73 request: NotificationPermissionRequest,
74 ) -> Result<NotificationSettings, NotificationError>;
75
76 fn settings(&self) -> Result<NotificationSettings, NotificationError>;
81
82 fn show(&self, request: NotificationRequest) -> Result<NotificationReceipt, NotificationError>;
88
89 fn schedule(
95 &self,
96 request: NotificationRequest,
97 ) -> Result<NotificationReceipt, NotificationError>;
98
99 fn cancel(&self, request: CancelNotificationRequest) -> Result<(), NotificationError>;
104
105 fn cancel_all(&self) -> Result<(), NotificationError>;
107
108 fn set_badge_count(&self, request: SetBadgeCountRequest) -> Result<(), NotificationError>;
113
114 fn register_push(
119 &self,
120 request: PushRegistrationRequest,
121 ) -> Result<PushRegistration, NotificationError>;
122
123 fn unregister_push(&self) -> Result<(), NotificationError>;
125}
126
127#[derive(Debug, Default)]
129pub struct UnsupportedNotificationHost;
130
131impl NotificationHost for UnsupportedNotificationHost {
132 fn request_permission(
133 &self,
134 _request: NotificationPermissionRequest,
135 ) -> Result<NotificationSettings, NotificationError> {
136 Ok(NotificationSettings {
137 permission: NotificationPermission::Unsupported,
138 ..Default::default()
139 })
140 }
141
142 fn settings(&self) -> Result<NotificationSettings, NotificationError> {
143 Ok(NotificationSettings {
144 permission: NotificationPermission::Unsupported,
145 ..Default::default()
146 })
147 }
148
149 fn show(
150 &self,
151 _request: NotificationRequest,
152 ) -> Result<NotificationReceipt, NotificationError> {
153 Err(NotificationError::unsupported("show"))
154 }
155
156 fn schedule(
157 &self,
158 _request: NotificationRequest,
159 ) -> Result<NotificationReceipt, NotificationError> {
160 Err(NotificationError::unsupported("schedule"))
161 }
162
163 fn cancel(&self, _request: CancelNotificationRequest) -> Result<(), NotificationError> {
164 Err(NotificationError::unsupported("cancel"))
165 }
166
167 fn cancel_all(&self) -> Result<(), NotificationError> {
168 Err(NotificationError::unsupported("cancel_all"))
169 }
170
171 fn set_badge_count(&self, _request: SetBadgeCountRequest) -> Result<(), NotificationError> {
172 Err(NotificationError::unsupported("set_badge_count"))
173 }
174
175 fn register_push(
176 &self,
177 _request: PushRegistrationRequest,
178 ) -> Result<PushRegistration, NotificationError> {
179 Err(NotificationError::unsupported("register_push"))
180 }
181
182 fn unregister_push(&self) -> Result<(), NotificationError> {
183 Err(NotificationError::unsupported("unregister_push"))
184 }
185}
186
187#[derive(Debug, Default)]
189pub struct MemoryNotificationHost;
190
191impl NotificationHost for MemoryNotificationHost {
192 fn request_permission(
193 &self,
194 request: NotificationPermissionRequest,
195 ) -> Result<NotificationSettings, NotificationError> {
196 Ok(NotificationSettings {
197 permission: NotificationPermission::Granted,
198 alerts: request.alerts,
199 badge: request.badge,
200 sound: request.sound,
201 scheduling: true,
202 push: false,
203 })
204 }
205
206 fn settings(&self) -> Result<NotificationSettings, NotificationError> {
207 Ok(NotificationSettings {
208 permission: NotificationPermission::Granted,
209 alerts: true,
210 badge: true,
211 sound: true,
212 scheduling: true,
213 push: false,
214 })
215 }
216
217 fn show(&self, request: NotificationRequest) -> Result<NotificationReceipt, NotificationError> {
218 Ok(NotificationReceipt {
219 id: request.id,
220 scheduled: false,
221 delivered: true,
222 })
223 }
224
225 fn schedule(
226 &self,
227 request: NotificationRequest,
228 ) -> Result<NotificationReceipt, NotificationError> {
229 Ok(NotificationReceipt {
230 id: request.id,
231 scheduled: !matches!(request.schedule, NotificationSchedule::Immediate),
232 delivered: matches!(request.schedule, NotificationSchedule::Immediate),
233 })
234 }
235
236 fn cancel(&self, _request: CancelNotificationRequest) -> Result<(), NotificationError> {
237 Ok(())
238 }
239
240 fn cancel_all(&self) -> Result<(), NotificationError> {
241 Ok(())
242 }
243
244 fn set_badge_count(&self, _request: SetBadgeCountRequest) -> Result<(), NotificationError> {
245 Ok(())
246 }
247
248 fn register_push(
249 &self,
250 _request: PushRegistrationRequest,
251 ) -> Result<PushRegistration, NotificationError> {
252 Ok(PushRegistration {
253 platform: PushPlatform::Other("memory".into()),
254 token: "memory-push-token".into(),
255 endpoint: None,
256 p256dh_key: None,
257 auth_secret: None,
258 })
259 }
260
261 fn unregister_push(&self) -> Result<(), NotificationError> {
262 Ok(())
263 }
264}
265
266#[derive(Debug, Default)]
267pub struct NativeNotificationHost;
268
269pub(crate) fn native_notification_host() -> impl NotificationHost {
270 NativeNotificationHost
271}
272
273impl NativeNotificationHost {
274 #[cfg(any(test, not(target_os = "macos")))]
275 fn supported() -> bool {
276 cfg!(target_os = "ios")
277 || cfg!(target_os = "macos")
278 || (cfg!(target_os = "linux") && command_exists("notify-send"))
279 }
280
281 #[cfg(any(test, not(target_os = "macos")))]
282 fn native_settings() -> NotificationSettings {
283 if Self::supported() {
284 NotificationSettings {
285 permission: NotificationPermission::Granted,
286 alerts: true,
287 badge: cfg!(any(target_os = "ios", target_os = "macos")),
288 sound: true,
289 scheduling: cfg!(any(target_os = "ios", target_os = "macos"))
290 || (cfg!(target_os = "linux") && command_exists("notify-send")),
291 push: false,
292 }
293 } else {
294 NotificationSettings {
295 permission: NotificationPermission::Unsupported,
296 ..Default::default()
297 }
298 }
299 }
300
301 fn show_now(&self, request: &NotificationRequest) -> Result<(), NotificationError> {
302 #[cfg(target_os = "ios")]
303 {
304 ios_register_local_notifications();
305 ios_show_local_notification(request, None);
306 return Ok(());
307 }
308
309 #[cfg(not(target_os = "ios"))]
310 {
311 if cfg!(target_os = "macos") {
312 #[cfg(target_os = "macos")]
313 {
314 macos_deliver_notification(request, None)?;
315 return Ok(());
316 }
317 }
318
319 if cfg!(target_os = "linux") {
320 if !command_exists("notify-send") {
321 return Err(NotificationError::unsupported("show"));
322 }
323 Command::new("notify-send")
324 .arg(&request.title)
325 .arg(&request.body)
326 .spawn()
327 .map_err(notification_command_error)?
328 .wait()
329 .map_err(notification_command_error)?;
330 return Ok(());
331 }
332
333 if cfg!(target_os = "windows") {
334 return Err(NotificationError::unsupported("show_windows_toast"));
335 }
336
337 Err(NotificationError::unsupported("show"))
338 }
339 }
340}
341
342impl NotificationHost for NativeNotificationHost {
343 fn request_permission(
344 &self,
345 _request: NotificationPermissionRequest,
346 ) -> Result<NotificationSettings, NotificationError> {
347 #[cfg(target_os = "macos")]
348 {
349 macos_request_notification_permission()
350 }
351 #[cfg(not(target_os = "macos"))]
352 {
353 #[cfg(target_os = "ios")]
354 ios_register_local_notifications();
355 Ok(Self::native_settings())
356 }
357 }
358
359 fn settings(&self) -> Result<NotificationSettings, NotificationError> {
360 #[cfg(target_os = "macos")]
361 {
362 macos_notification_settings()
363 }
364 #[cfg(not(target_os = "macos"))]
365 {
366 Ok(Self::native_settings())
367 }
368 }
369
370 fn show(&self, request: NotificationRequest) -> Result<NotificationReceipt, NotificationError> {
371 match request.schedule {
372 NotificationSchedule::Immediate => {
373 self.show_now(&request)?;
374 Ok(NotificationReceipt {
375 id: request.id,
376 scheduled: false,
377 delivered: true,
378 })
379 }
380 _ => Err(NotificationError::unsupported("schedule")),
381 }
382 }
383
384 fn schedule(
385 &self,
386 request: NotificationRequest,
387 ) -> Result<NotificationReceipt, NotificationError> {
388 match request.schedule {
389 NotificationSchedule::Immediate => self.show(request),
390 #[cfg(target_os = "ios")]
391 NotificationSchedule::AfterMillis(ms) => {
392 ios_register_local_notifications();
393 ios_show_local_notification(&request, Some(ms as f64 / 1000.0));
394 Ok(NotificationReceipt {
395 id: request.id,
396 scheduled: true,
397 delivered: false,
398 })
399 }
400 #[cfg(target_os = "ios")]
401 NotificationSchedule::AtUnixMillis(ms) => {
402 let now_ms = std::time::SystemTime::now()
403 .duration_since(std::time::UNIX_EPOCH)
404 .map(|duration| duration.as_millis() as u64)
405 .unwrap_or(ms);
406 ios_register_local_notifications();
407 ios_show_local_notification(
408 &request,
409 Some(ms.saturating_sub(now_ms) as f64 / 1000.0),
410 );
411 Ok(NotificationReceipt {
412 id: request.id,
413 scheduled: true,
414 delivered: false,
415 })
416 }
417 #[cfg(not(target_os = "ios"))]
418 NotificationSchedule::AfterMillis(ms) => {
419 if cfg!(target_os = "macos") {
420 #[cfg(target_os = "macos")]
421 {
422 macos_deliver_notification(&request, Some(ms as f64 / 1000.0))?;
423 return Ok(NotificationReceipt {
424 id: request.id,
425 scheduled: true,
426 delivered: false,
427 });
428 }
429 }
430 if !(cfg!(target_os = "linux") && command_exists("notify-send")) {
431 return Err(NotificationError::unsupported("schedule"));
432 }
433 let id = request.id.clone();
434 let request = request.clone();
435 std::thread::spawn(move || {
436 std::thread::sleep(std::time::Duration::from_millis(ms));
437 let host = NativeNotificationHost;
438 let _ = host.show_now(&request);
439 });
440 Ok(NotificationReceipt {
441 id,
442 scheduled: true,
443 delivered: false,
444 })
445 }
446 #[cfg(not(target_os = "ios"))]
447 NotificationSchedule::AtUnixMillis(ms) => {
448 let now_ms = std::time::SystemTime::now()
449 .duration_since(std::time::UNIX_EPOCH)
450 .map(|duration| duration.as_millis() as u64)
451 .unwrap_or(ms);
452 if cfg!(target_os = "macos") {
453 #[cfg(target_os = "macos")]
454 {
455 macos_deliver_notification(
456 &request,
457 Some(ms.saturating_sub(now_ms) as f64 / 1000.0),
458 )?;
459 return Ok(NotificationReceipt {
460 id: request.id,
461 scheduled: true,
462 delivered: false,
463 });
464 }
465 }
466 if !(cfg!(target_os = "linux") && command_exists("notify-send")) {
467 return Err(NotificationError::unsupported("schedule"));
468 }
469 let id = request.id.clone();
470 let request = request.clone();
471 std::thread::spawn(move || {
472 std::thread::sleep(std::time::Duration::from_millis(ms.saturating_sub(now_ms)));
473 let host = NativeNotificationHost;
474 let _ = host.show_now(&request);
475 });
476 Ok(NotificationReceipt {
477 id,
478 scheduled: true,
479 delivered: false,
480 })
481 }
482 }
483 }
484
485 fn cancel(&self, request: CancelNotificationRequest) -> Result<(), NotificationError> {
486 #[cfg(target_os = "macos")]
487 {
488 macos_cancel_notification(&request.id.0);
489 Ok(())
490 }
491 #[cfg(not(target_os = "macos"))]
492 {
493 let _ = request;
494 Err(NotificationError::unsupported("cancel"))
495 }
496 }
497
498 fn cancel_all(&self) -> Result<(), NotificationError> {
499 #[cfg(target_os = "macos")]
500 {
501 macos_cancel_all_notifications();
502 Ok(())
503 }
504 #[cfg(not(target_os = "macos"))]
505 {
506 Err(NotificationError::unsupported("cancel_all"))
507 }
508 }
509
510 fn set_badge_count(&self, request: SetBadgeCountRequest) -> Result<(), NotificationError> {
511 #[cfg(target_os = "ios")]
512 {
513 ios_set_badge_count(request.count);
514 return Ok(());
515 }
516 #[cfg(target_os = "macos")]
517 {
518 macos_set_badge_count(request.count);
519 return Ok(());
520 }
521 #[cfg(not(any(target_os = "ios", target_os = "macos")))]
522 {
523 let _ = request;
524 Err(NotificationError::unsupported("set_badge_count"))
525 }
526 }
527
528 fn register_push(
529 &self,
530 _request: PushRegistrationRequest,
531 ) -> Result<PushRegistration, NotificationError> {
532 Err(NotificationError::unsupported("register_push"))
533 }
534
535 fn unregister_push(&self) -> Result<(), NotificationError> {
536 Err(NotificationError::unsupported("unregister_push"))
537 }
538}
539
540#[cfg(target_os = "ios")]
541fn ios_register_local_notifications() {
542 unsafe {
543 let app: *mut objc::runtime::Object = msg_send![class!(UIApplication), sharedApplication];
544 if app.is_null() {
545 return;
546 }
547 let settings: *mut objc::runtime::Object = msg_send![
548 class!(UIUserNotificationSettings),
549 settingsForTypes: 7usize
550 categories: std::ptr::null_mut::<objc::runtime::Object>()
551 ];
552 if !settings.is_null() {
553 let _: () = msg_send![app, registerUserNotificationSettings: settings];
554 }
555 }
556}
557
558#[cfg(target_os = "ios")]
559fn ios_show_local_notification(request: &NotificationRequest, delay_seconds: Option<f64>) {
560 unsafe {
561 let notification: *mut objc::runtime::Object = msg_send![class!(UILocalNotification), new];
562 if notification.is_null() {
563 return;
564 }
565 let title = ns_string(&request.title);
566 let body = ns_string(&request.body);
567 let _: () = msg_send![notification, setAlertTitle: title];
568 let _: () = msg_send![notification, setAlertBody: body];
569 if !matches!(request.sound, fission_core::NotificationSound::Silent) {
570 let default_sound: *mut objc::runtime::Object =
571 msg_send![class!(UILocalNotification), defaultSoundName];
572 let _: () = msg_send![notification, setSoundName: default_sound];
573 }
574 if let Some(badge) = request.badge {
575 let _: () = msg_send![notification, setApplicationIconBadgeNumber: badge as isize];
576 }
577 let app: *mut objc::runtime::Object = msg_send![class!(UIApplication), sharedApplication];
578 if app.is_null() {
579 return;
580 }
581 if let Some(delay) = delay_seconds {
582 let date: *mut objc::runtime::Object =
583 msg_send![class!(NSDate), dateWithTimeIntervalSinceNow: delay.max(0.0)];
584 let _: () = msg_send![notification, setFireDate: date];
585 let _: () = msg_send![app, scheduleLocalNotification: notification];
586 } else {
587 let _: () = msg_send![app, presentLocalNotificationNow: notification];
588 }
589 }
590}
591
592#[cfg(target_os = "ios")]
593fn ios_set_badge_count(count: Option<u32>) {
594 unsafe {
595 let app: *mut objc::runtime::Object = msg_send![class!(UIApplication), sharedApplication];
596 if !app.is_null() {
597 let _: () = msg_send![app, setApplicationIconBadgeNumber: count.unwrap_or(0) as isize];
598 }
599 }
600}
601
602#[cfg(target_os = "macos")]
603fn install_macos_notification_delegate() {
604 static DELEGATE: OnceLock<usize> = OnceLock::new();
605
606 unsafe {
607 let center: *mut Object =
608 msg_send![class!(UNUserNotificationCenter), currentNotificationCenter];
609 if center.is_null() {
610 return;
611 }
612 let delegate = *DELEGATE.get_or_init(|| {
613 let delegate: *mut Object = msg_send![macos_notification_delegate_class(), new];
614 delegate as usize
615 }) as *mut Object;
616 if !delegate.is_null() {
617 let _: () = msg_send![center, setDelegate: delegate];
618 }
619 }
620}
621
622#[cfg(target_os = "macos")]
623fn macos_notification_delegate_class() -> &'static Class {
624 static CLASS: OnceLock<usize> = OnceLock::new();
625 let ptr = *CLASS.get_or_init(|| {
626 let superclass = class!(NSObject);
627 let mut decl = ClassDecl::new("FissionNotificationCenterDelegate", superclass)
628 .expect("register FissionNotificationCenterDelegate");
629 if let Some(protocol) = Protocol::get("UNUserNotificationCenterDelegate") {
630 decl.add_protocol(protocol);
631 }
632 unsafe {
633 decl.add_method(
634 sel!(userNotificationCenter:willPresentNotification:withCompletionHandler:),
635 macos_notification_will_present
636 as extern "C" fn(&mut Object, Sel, *mut Object, *mut Object, *mut c_void),
637 );
638 decl.add_method(
639 sel!(userNotificationCenter:didReceiveNotificationResponse:withCompletionHandler:),
640 macos_notification_did_receive_response
641 as extern "C" fn(&mut Object, Sel, *mut Object, *mut Object, *mut c_void),
642 );
643 }
644 decl.register() as *const Class as usize
645 });
646 unsafe { &*(ptr as *const Class) }
647}
648
649#[cfg(target_os = "macos")]
650extern "C" fn macos_notification_will_present(
651 _this: &mut Object,
652 _cmd: Sel,
653 _center: *mut Object,
654 _notification: *mut Object,
655 completion_handler: *mut c_void,
656) {
657 let completion_handler = completion_handler.cast::<Block<(usize,), ()>>();
658 if let Some(completion_handler) = unsafe { completion_handler.as_ref() } {
659 unsafe { completion_handler.call((1usize | 2usize | 8usize | 16usize,)) };
661 }
662}
663
664#[cfg(target_os = "macos")]
665extern "C" fn macos_notification_did_receive_response(
666 _this: &mut Object,
667 _cmd: Sel,
668 _center: *mut Object,
669 response: *mut Object,
670 completion_handler: *mut c_void,
671) {
672 if let Some(response) = unsafe { decode_macos_notification_response(response) } {
673 let handler = NOTIFICATION_RESPONSE_HANDLER
674 .get()
675 .and_then(|slot| slot.lock().ok())
676 .and_then(|handler| handler.clone());
677 if let Some(handler) = handler {
678 handler(response);
679 }
680 }
681 let completion_handler = completion_handler.cast::<Block<(), ()>>();
682 if let Some(completion_handler) = unsafe { completion_handler.as_ref() } {
683 unsafe { completion_handler.call(()) };
684 }
685}
686
687#[cfg(target_os = "macos")]
688unsafe fn decode_macos_notification_response(
689 response: *mut Object,
690) -> Option<NotificationResponse> {
691 if response.is_null() {
692 return None;
693 }
694 let notification: *mut Object = msg_send![response, notification];
695 let request: *mut Object = msg_send![notification, request];
696 if request.is_null() {
697 return None;
698 }
699 let identifier: *mut Object = msg_send![request, identifier];
700 let notification_id = ns_string_to_string(identifier)?;
701 let action_identifier: *mut Object = msg_send![response, actionIdentifier];
702 let action_id = ns_string_to_string(action_identifier).and_then(normalize_action_id);
703 let content: *mut Object = msg_send![request, content];
704 let user_info: *mut Object = msg_send![content, userInfo];
705 let deep_link = if user_info.is_null() {
706 None
707 } else {
708 let key = ns_string("fission_deep_link");
709 let value: *mut Object = msg_send![user_info, objectForKey: key];
710 ns_string_to_string(value)
711 };
712 let user_text = if msg_send![response, respondsToSelector: sel!(userText)] {
713 let value: *mut Object = msg_send![response, userText];
714 ns_string_to_string(value)
715 } else {
716 None
717 };
718 Some(NotificationResponse {
719 notification_id: fission_core::NotificationId::new(notification_id),
720 action_id,
721 deep_link,
722 user_text,
723 })
724}
725
726#[cfg(target_os = "macos")]
727fn normalize_action_id(action_id: String) -> Option<String> {
728 match action_id.as_str() {
729 "com.apple.UNNotificationDefaultActionIdentifier"
730 | "com.apple.UNNotificationDismissActionIdentifier" => None,
731 _ => Some(action_id),
732 }
733}
734
735#[cfg(target_os = "macos")]
736fn macos_request_notification_permission() -> Result<NotificationSettings, NotificationError> {
737 let pair = Arc::new((Mutex::new(None), Condvar::new()));
738 let pair_for_block = pair.clone();
739 let block = ConcreteBlock::new(move |granted: bool, _error: *mut objc::runtime::Object| {
740 let (lock, cvar) = &*pair_for_block;
741 if let Ok(mut result) = lock.lock() {
742 *result = Some(granted);
743 cvar.notify_all();
744 }
745 })
746 .copy();
747 unsafe {
748 let center: *mut objc::runtime::Object =
749 msg_send![class!(UNUserNotificationCenter), currentNotificationCenter];
750 if center.is_null() {
751 return Err(NotificationError::unsupported("notifications"));
752 }
753 let options = 1usize | 2usize | 4usize;
754 let _: () = msg_send![
755 center,
756 requestAuthorizationWithOptions: options
757 completionHandler: &*block
758 ];
759 }
760 let (lock, cvar) = &*pair;
761 let guard = lock.lock().unwrap();
762 let (guard, _) = cvar
763 .wait_timeout_while(guard, std::time::Duration::from_secs(30), |value| {
764 value.is_none()
765 })
766 .unwrap();
767 let granted = (*guard).unwrap_or(false);
768 Ok(NotificationSettings {
769 permission: if granted {
770 NotificationPermission::Granted
771 } else {
772 NotificationPermission::Denied
773 },
774 alerts: granted,
775 badge: granted,
776 sound: granted,
777 scheduling: granted,
778 push: false,
779 })
780}
781
782#[cfg(target_os = "macos")]
783fn macos_notification_settings() -> Result<NotificationSettings, NotificationError> {
784 let pair = Arc::new((Mutex::new(None), Condvar::new()));
785 let pair_for_block = pair.clone();
786 let block = ConcreteBlock::new(move |settings: *mut objc::runtime::Object| {
787 let status: i64 = if settings.is_null() {
788 0
789 } else {
790 unsafe { msg_send![settings, authorizationStatus] }
791 };
792 let permission = match status {
793 2 => NotificationPermission::Granted,
794 3 | 4 => NotificationPermission::Provisional,
795 1 => NotificationPermission::Denied,
796 _ => NotificationPermission::NotDetermined,
797 };
798 let enabled = matches!(
799 permission,
800 NotificationPermission::Granted | NotificationPermission::Provisional
801 );
802 let (lock, cvar) = &*pair_for_block;
803 if let Ok(mut result) = lock.lock() {
804 *result = Some(NotificationSettings {
805 permission,
806 alerts: enabled,
807 badge: enabled,
808 sound: enabled,
809 scheduling: enabled,
810 push: false,
811 });
812 cvar.notify_all();
813 }
814 })
815 .copy();
816 unsafe {
817 let center: *mut objc::runtime::Object =
818 msg_send![class!(UNUserNotificationCenter), currentNotificationCenter];
819 if center.is_null() {
820 return Err(NotificationError::unsupported("notifications"));
821 }
822 let _: () = msg_send![center, getNotificationSettingsWithCompletionHandler: &*block];
823 }
824 let (lock, cvar) = &*pair;
825 let guard = lock.lock().unwrap();
826 let (guard, _) = cvar
827 .wait_timeout_while(guard, std::time::Duration::from_secs(30), |value| {
828 value.is_none()
829 })
830 .unwrap();
831 Ok(guard.clone().unwrap_or(NotificationSettings {
832 permission: NotificationPermission::NotDetermined,
833 ..Default::default()
834 }))
835}
836
837#[cfg(target_os = "macos")]
838fn macos_deliver_notification(
839 request: &NotificationRequest,
840 delay_seconds: Option<f64>,
841) -> Result<(), NotificationError> {
842 let settings = macos_notification_settings()?;
843 match settings.permission {
844 NotificationPermission::Granted | NotificationPermission::Provisional => {}
845 NotificationPermission::NotDetermined => {
846 return Err(NotificationError::new(
847 "permission_not_determined",
848 "request macOS notification permission before showing a notification",
849 ));
850 }
851 NotificationPermission::Denied => {
852 return Err(NotificationError::new(
853 "permission_denied",
854 "macOS notification permission is not granted",
855 ));
856 }
857 NotificationPermission::Unsupported => {
858 return Err(NotificationError::unsupported("show"));
859 }
860 }
861
862 let pair = Arc::new((Mutex::new(None), Condvar::new()));
863 let pair_for_block = pair.clone();
864 let block = ConcreteBlock::new(move |error: *mut objc::runtime::Object| {
865 let message = if error.is_null() {
866 None
867 } else {
868 Some(macos_error_description(error))
869 };
870 let (lock, cvar) = &*pair_for_block;
871 if let Ok(mut result) = lock.lock() {
872 *result = Some(message);
873 cvar.notify_all();
874 }
875 })
876 .copy();
877
878 unsafe {
879 let content: *mut objc::runtime::Object =
880 msg_send![class!(UNMutableNotificationContent), new];
881 if content.is_null() {
882 return Err(NotificationError::unsupported("notification_content"));
883 }
884 let title = ns_string(&request.title);
885 let body = ns_string(&request.body);
886 let _: () = msg_send![content, setTitle: title];
887 let _: () = msg_send![content, setBody: body];
888 if let Some(subtitle) = request.subtitle.as_deref() {
889 let subtitle = ns_string(subtitle);
890 let _: () = msg_send![content, setSubtitle: subtitle];
891 }
892 if !matches!(request.sound, fission_core::NotificationSound::Silent) {
893 let sound: *mut objc::runtime::Object =
894 msg_send![class!(UNNotificationSound), defaultSound];
895 let _: () = msg_send![content, setSound: sound];
896 }
897 if let Some(badge) = request.badge {
898 let badge: *mut objc::runtime::Object =
899 msg_send![class!(NSNumber), numberWithUnsignedInteger: badge as usize];
900 let _: () = msg_send![content, setBadge: badge];
901 }
902 if let Some(deep_link) = request.deep_link.as_deref() {
903 let key = ns_string("fission_deep_link");
904 let value = ns_string(deep_link);
905 let user_info: *mut objc::runtime::Object =
906 msg_send![class!(NSDictionary), dictionaryWithObject: value forKey: key];
907 let _: () = msg_send![content, setUserInfo: user_info];
908 }
909
910 let trigger: *mut objc::runtime::Object = if let Some(delay) = delay_seconds {
911 msg_send![
912 class!(UNTimeIntervalNotificationTrigger),
913 triggerWithTimeInterval: delay.max(1.0)
914 repeats: false
915 ]
916 } else {
917 std::ptr::null_mut()
918 };
919 let identifier = ns_string(&request.id.0);
920 let notification_request: *mut objc::runtime::Object = msg_send![
921 class!(UNNotificationRequest),
922 requestWithIdentifier: identifier
923 content: content
924 trigger: trigger
925 ];
926 if notification_request.is_null() {
927 return Err(NotificationError::unsupported("notification_request"));
928 }
929 let center: *mut objc::runtime::Object =
930 msg_send![class!(UNUserNotificationCenter), currentNotificationCenter];
931 if center.is_null() {
932 return Err(NotificationError::unsupported("notifications"));
933 }
934 let _: () = msg_send![center, addNotificationRequest: notification_request withCompletionHandler: &*block];
935 }
936
937 let (lock, cvar) = &*pair;
938 let guard = lock.lock().unwrap();
939 let (guard, _) = cvar
940 .wait_timeout_while(guard, std::time::Duration::from_secs(30), |value| {
941 value.is_none()
942 })
943 .unwrap();
944 if let Some(Some(message)) = guard.clone() {
945 Err(NotificationError::new("host_error", message))
946 } else {
947 Ok(())
948 }
949}
950
951#[cfg(target_os = "macos")]
952fn macos_error_description(error: *mut objc::runtime::Object) -> String {
953 unsafe {
954 let description: *mut objc::runtime::Object = msg_send![error, localizedDescription];
955 ns_string_to_string(description).unwrap_or_else(|| "macOS notification error".into())
956 }
957}
958
959#[cfg(target_os = "macos")]
960fn macos_cancel_notification(id: &str) {
961 unsafe {
962 let center: *mut objc::runtime::Object =
963 msg_send![class!(UNUserNotificationCenter), currentNotificationCenter];
964 if center.is_null() {
965 return;
966 }
967 let identifier = ns_string(id);
968 let ids: *mut objc::runtime::Object =
969 msg_send![class!(NSArray), arrayWithObject: identifier];
970 let _: () = msg_send![center, removePendingNotificationRequestsWithIdentifiers: ids];
971 let _: () = msg_send![center, removeDeliveredNotificationsWithIdentifiers: ids];
972 }
973}
974
975#[cfg(target_os = "macos")]
976fn macos_cancel_all_notifications() {
977 unsafe {
978 let center: *mut objc::runtime::Object =
979 msg_send![class!(UNUserNotificationCenter), currentNotificationCenter];
980 if center.is_null() {
981 return;
982 }
983 let _: () = msg_send![center, removeAllPendingNotificationRequests];
984 let _: () = msg_send![center, removeAllDeliveredNotifications];
985 }
986}
987
988#[cfg(target_os = "macos")]
989fn macos_set_badge_count(count: Option<u32>) {
990 unsafe {
991 let app: *mut objc::runtime::Object = msg_send![class!(NSApplication), sharedApplication];
992 if app.is_null() {
993 return;
994 }
995 let dock_tile: *mut objc::runtime::Object = msg_send![app, dockTile];
996 if dock_tile.is_null() {
997 return;
998 }
999 let label = count
1000 .filter(|count| *count > 0)
1001 .map(|count| ns_string(&count.to_string()))
1002 .unwrap_or(std::ptr::null_mut());
1003 let _: () = msg_send![dock_tile, setBadgeLabel: label];
1004 }
1005}
1006
1007#[cfg(any(target_os = "ios", target_os = "macos"))]
1008fn ns_string(value: &str) -> *mut objc::runtime::Object {
1009 unsafe {
1010 let string: *mut objc::runtime::Object = msg_send![class!(NSString), alloc];
1011 msg_send![
1012 string,
1013 initWithBytes: value.as_ptr() as *const c_void
1014 length: value.len()
1015 encoding: 4usize
1016 ]
1017 }
1018}
1019
1020#[cfg(target_os = "macos")]
1021fn ns_string_to_string(value: *mut objc::runtime::Object) -> Option<String> {
1022 if value.is_null() {
1023 return None;
1024 }
1025 unsafe {
1026 let ptr: *const std::os::raw::c_char = msg_send![value, UTF8String];
1027 (!ptr.is_null()).then(|| CStr::from_ptr(ptr).to_string_lossy().into_owned())
1028 }
1029}
1030
1031fn command_exists(name: &str) -> bool {
1032 std::env::var_os("PATH")
1033 .and_then(|paths| {
1034 std::env::split_paths(&paths)
1035 .map(|path| path.join(name))
1036 .find(|path| path.is_file())
1037 })
1038 .is_some()
1039}
1040
1041#[cfg(not(target_os = "ios"))]
1042fn notification_command_error(error: std::io::Error) -> NotificationError {
1043 NotificationError::new("host_error", error.to_string())
1044}
1045
1046pub(crate) fn register_notification_capabilities(
1047 async_registry: &mut AsyncRegistry,
1048 host: Arc<dyn NotificationHost>,
1049) {
1050 let request_host = host.clone();
1051 async_registry.register_operation_capability(
1052 REQUEST_NOTIFICATION_PERMISSION,
1053 move |request, _| {
1054 let host = request_host.clone();
1055 async move { host.request_permission(request) }
1056 },
1057 );
1058
1059 let settings_host = host.clone();
1060 async_registry.register_operation_capability(GET_NOTIFICATION_SETTINGS, move |(), _| {
1061 let host = settings_host.clone();
1062 async move { host.settings() }
1063 });
1064
1065 let show_host = host.clone();
1066 async_registry.register_operation_capability(SHOW_NOTIFICATION, move |request, _| {
1067 let host = show_host.clone();
1068 async move { host.show(request) }
1069 });
1070
1071 let schedule_host = host.clone();
1072 async_registry.register_operation_capability(SCHEDULE_NOTIFICATION, move |request, _| {
1073 let host = schedule_host.clone();
1074 async move { host.schedule(request) }
1075 });
1076
1077 let cancel_host = host.clone();
1078 async_registry.register_operation_capability(CANCEL_NOTIFICATION, move |request, _| {
1079 let host = cancel_host.clone();
1080 async move { host.cancel(request) }
1081 });
1082
1083 let cancel_all_host = host.clone();
1084 async_registry.register_operation_capability(CANCEL_ALL_NOTIFICATIONS, move |(), _| {
1085 let host = cancel_all_host.clone();
1086 async move { host.cancel_all() }
1087 });
1088
1089 let badge_host = host.clone();
1090 async_registry.register_operation_capability(SET_BADGE_COUNT, move |request, _| {
1091 let host = badge_host.clone();
1092 async move { host.set_badge_count(request) }
1093 });
1094
1095 let push_host = host.clone();
1096 async_registry.register_operation_capability(REGISTER_PUSH_NOTIFICATIONS, move |request, _| {
1097 let host = push_host.clone();
1098 async move { host.register_push(request) }
1099 });
1100
1101 async_registry.register_operation_capability(UNREGISTER_PUSH_NOTIFICATIONS, move |(), _| {
1102 let host = host.clone();
1103 async move { host.unregister_push() }
1104 });
1105}
1106
1107#[cfg(test)]
1108mod tests {
1109 use super::*;
1110 use fission_core::NotificationId;
1111
1112 #[test]
1113 fn unsupported_host_reports_permission_without_panicking() {
1114 let host = UnsupportedNotificationHost;
1115 let settings = host
1116 .request_permission(NotificationPermissionRequest::default())
1117 .unwrap();
1118 assert_eq!(settings.permission, NotificationPermission::Unsupported);
1119 assert_eq!(
1120 host.show(NotificationRequest::default()).unwrap_err().code,
1121 "unsupported"
1122 );
1123 }
1124
1125 #[test]
1126 fn memory_host_returns_receipts() {
1127 let host = MemoryNotificationHost;
1128 let receipt = host
1129 .show(NotificationRequest {
1130 id: NotificationId::new("n1"),
1131 title: "Title".into(),
1132 body: "Body".into(),
1133 ..Default::default()
1134 })
1135 .unwrap();
1136 assert_eq!(receipt.id, NotificationId::new("n1"));
1137 assert!(receipt.delivered);
1138 }
1139
1140 #[test]
1141 fn native_host_settings_are_honest_about_support() {
1142 let settings = NativeNotificationHost::native_settings();
1143 if NativeNotificationHost::supported() {
1144 assert_eq!(settings.permission, NotificationPermission::Granted);
1145 assert!(settings.alerts);
1146 assert!(!settings.push);
1147 } else {
1148 assert_eq!(settings.permission, NotificationPermission::Unsupported);
1149 assert!(!settings.alerts);
1150 }
1151 }
1152
1153 #[cfg(target_os = "macos")]
1154 #[test]
1155 fn macos_default_notification_actions_are_not_exposed_as_product_actions() {
1156 assert_eq!(
1157 normalize_action_id("com.apple.UNNotificationDefaultActionIdentifier".into()),
1158 None
1159 );
1160 assert_eq!(
1161 normalize_action_id("com.apple.UNNotificationDismissActionIdentifier".into()),
1162 None
1163 );
1164 assert_eq!(
1165 normalize_action_id("approve".into()),
1166 Some("approve".into())
1167 );
1168 }
1169}