mac-usernotifications 0.1.0

Thin wrapper around macOS UserNotifications.
Documentation
//! [`UNUserNotificationCenterDelegate`] implementation:
//! receives user responses and controls foreground presentation.
//!
//! `NotificationDelegate` is a single Objective-C object that lives for the whole process lifetime.
//! It's installed on `UNUserNotificationCenter` once at worker startup via [`install`].
//!
//! **Foreground presentation:** macOS silently drops notifications when the app is in the foreground unless the delegate implements `willPresentNotification`.
//!  This delegate returns `.banner | .sound` so foreground and background notifications look the same.
//!
//! **Response handling:** callers register a `oneshot::Sender<NotificationResponse>` keyed by request identifier via [`register_response_sender`].
//! When macOS fires `didReceiveNotificationResponse`, the delegate looks up and fires that sender.
//!
//! Senders for notifications the user never touched are dropped when the app exits.
//! The receiver resolves to `Err(Canceled)`, which callers treat as a cancelled state.
//!
//! [`UNUserNotificationCenterDelegate`]: https://developer.apple.com/documentation/usernotifications/unusernotificationcenterdelegate

use std::{
    collections::HashMap,
    sync::{Mutex, OnceLock},
};

use futures_channel::oneshot;
use objc2::{AnyThread, define_class, rc::Retained, runtime::AnyObject};
use objc2_foundation::{NSObject, NSObjectProtocol};
use objc2_user_notifications::{
    UNNotification, UNNotificationPresentationOptions, UNNotificationResponse,
    UNTextInputNotificationResponse, UNUserNotificationCenter, UNUserNotificationCenterDelegate,
};

use crate::response::NotificationResponse;

/// Global map: request-id -> response sender.
static PENDING: OnceLock<Mutex<HashMap<String, oneshot::Sender<NotificationResponse>>>> =
    OnceLock::new();

fn pending() -> &'static Mutex<HashMap<String, oneshot::Sender<NotificationResponse>>> {
    PENDING.get_or_init(|| Mutex::new(HashMap::new()))
}

pub(crate) fn register_response_sender(
    request_id: String,
    tx: oneshot::Sender<NotificationResponse>,
) {
    pending()
        .lock()
        .expect("pending map poisoned")
        .insert(request_id, tx);
}

pub(crate) fn deregister_response_sender(request_id: &str) {
    let removed = pending()
        .lock()
        .expect("pending map poisoned")
        .remove(request_id);
    if removed.is_some() {
        log::debug!("deregistered pending sender for {request_id:?}");
    }
}

define_class!(
    #[unsafe(super(NSObject))]
    #[name = "MacUserNotificationsDelegate"]
    pub struct NotificationDelegate;

    unsafe impl NSObjectProtocol for NotificationDelegate {}

    unsafe impl UNUserNotificationCenterDelegate for NotificationDelegate {
        /// Called by macOS just before a notification is displayed while the
        /// app is in the foreground.
        ///
        /// Without this method macOS silently discards the notification banner
        /// and sound. We return `Banner | Sound` so foreground notifications
        /// behave identically to background ones.
        #[unsafe(method(userNotificationCenter:willPresentNotification:withCompletionHandler:))]
        fn will_present_notification(
            &self,
            _center: &UNUserNotificationCenter,
            notification: &UNNotification,
            completion_handler: &block2::DynBlock<dyn Fn(UNNotificationPresentationOptions)>,
        ) {
            let request_id = notification.request().identifier().to_string();
            log::debug!("willPresentNotification request_id={request_id:?}");

            completion_handler.call((UNNotificationPresentationOptions::Banner
                | UNNotificationPresentationOptions::Sound,));
        }

        #[unsafe(method(userNotificationCenter:didReceiveNotificationResponse:withCompletionHandler:))]
        fn did_receive_response(
            &self,
            _center: &UNUserNotificationCenter,
            response: &UNNotificationResponse,
            completion_handler: &block2::DynBlock<dyn Fn()>,
        ) {
            let request_id = response.notification().request().identifier().to_string();
            let action_id = response.actionIdentifier().to_string();

            log::debug!(
                "didReceiveNotificationResponse \
                 request_id={request_id:?} action={action_id:?}"
            );

            let reply_text: Option<String> = {
                let any: &AnyObject = response.as_ref();
                any.downcast_ref::<UNTextInputNotificationResponse>()
                    .map(|tr| tr.userText().to_string())
            };
            if let Some(ref text) = reply_text {
                log::debug!("reply text = {text:?}");
            }

            if let Some(tx) = pending()
                .lock()
                .expect("pending map poisoned")
                .remove(&request_id)
            {
                let resp =
                    NotificationResponse::from_objc(request_id.clone(), action_id, reply_text);
                if tx.send(resp).is_err() {
                    log::warn!(
                        "receiver for request {request_id:?} \
                         was already dropped"
                    );
                }
            } else {
                log::debug!(
                    "no pending sender for request {request_id:?} \
                     (fire-and-forget notification)"
                );
            }

            completion_handler.call(());
        }
    }
);

impl NotificationDelegate {
    fn new() -> Retained<Self> {
        let this = Self::alloc().set_ivars(());
        unsafe { objc2::msg_send![super(this), init] }
    }
}

pub(crate) fn install() {
    static DELEGATE: OnceLock<Retained<NotificationDelegate>> = OnceLock::new();
    DELEGATE.get_or_init(|| {
        log::debug!("installing NotificationDelegate");
        let delegate = NotificationDelegate::new();
        let center = UNUserNotificationCenter::currentNotificationCenter();
        center.setDelegate(Some(objc2::runtime::ProtocolObject::from_ref(&*delegate)));
        delegate
    });
}