Skip to main content

fission_shell_winit/
notifications.rs

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/// Installs the event-loop bridge used by the native notification delegate.
56#[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
65/// Host-side notification provider used by the shell capability registry.
66pub trait NotificationHost: Send + Sync + 'static {
67    /// Requests permission for notification features such as alerts, badges, or sound.
68    ///
69    /// Implementations should map the typed request to the platform prompt and
70    /// return the resulting settings without assuming permission was granted.
71    fn request_permission(
72        &self,
73        request: NotificationPermissionRequest,
74    ) -> Result<NotificationSettings, NotificationError>;
75
76    /// Returns current notification settings without showing a platform prompt.
77    ///
78    /// Use this to report permission state, delivery support, scheduling support,
79    /// badge support, and push support to reducers.
80    fn settings(&self) -> Result<NotificationSettings, NotificationError>;
81
82    /// Displays an immediate local notification.
83    ///
84    /// `request` contains the stable id, visible text, badge, sound, deep link,
85    /// and action buttons. Return a receipt only after the host accepted the
86    /// notification request.
87    fn show(&self, request: NotificationRequest) -> Result<NotificationReceipt, NotificationError>;
88
89    /// Schedules a local notification for later delivery.
90    ///
91    /// Implementations should persist or hand off the schedule according to the
92    /// platform notification model and return an error when scheduled delivery is
93    /// unavailable.
94    fn schedule(
95        &self,
96        request: NotificationRequest,
97    ) -> Result<NotificationReceipt, NotificationError>;
98
99    /// Cancels one notification by id.
100    ///
101    /// `request.id` is the id originally used to show or schedule the
102    /// notification. Hosts may treat an already-missing notification as success.
103    fn cancel(&self, request: CancelNotificationRequest) -> Result<(), NotificationError>;
104
105    /// Cancels all notifications owned by this app where the platform allows it.
106    fn cancel_all(&self) -> Result<(), NotificationError>;
107
108    /// Sets or clears the app badge count.
109    ///
110    /// `None` clears the badge. `Some(count)` asks the host to show the supplied
111    /// count using the target platform badge mechanism.
112    fn set_badge_count(&self, request: SetBadgeCountRequest) -> Result<(), NotificationError>;
113
114    /// Registers this app instance for remote or push notification delivery.
115    ///
116    /// Provider credentials remain in host configuration. The request carries
117    /// public registration inputs and the result returns token or endpoint data.
118    fn register_push(
119        &self,
120        request: PushRegistrationRequest,
121    ) -> Result<PushRegistration, NotificationError>;
122
123    /// Removes or invalidates this app instance from remote notification delivery.
124    fn unregister_push(&self) -> Result<(), NotificationError>;
125}
126
127/// Default provider used until a shell installs a platform-specific host.
128#[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/// Minimal in-process host useful for smoke tests and non-OS environments.
188#[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        // Badge, sound, list and banner presentation while the app is foregrounded.
660        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}