tauri-plugin-deeprespond-im 1.0.0-alpha

深应智能 IM 的 Tauri 插件:本地存储、安全存储、窗口间的锁与消息、系统事件、通知、角标、文件
//! macOS:UNUserNotificationCenter(objc2-user-notifications)。
//!
//! - 替换:同一 identifier(通知的 id)的新通知替换旧的;threadIdentifier 按会话归组。
//! - 点击和按钮:经代理的 didReceiveNotificationResponse 交回;内容放在 userInfo 中,应用退出后点击启动应用时同样交回。
//!   代理在 setup 中设置(早于 applicationDidFinishLaunching),启动后 5 秒内收到的点击按“启动点击”处理。
//! - 前台时也显示横幅(willPresentNotification)。
//! - 必须从 .app 包中运行:直接运行可执行文件时 UNUserNotificationCenter 会抛出异常,这时通知不可用,只记警告。
//!
//! Objective-C 对象和块都不是 Send:每个操作在同步函数中完成,只把结果经 oneshot 交回异步的命令。

use std::collections::BTreeMap;
use std::ptr::NonNull;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};

use block2::RcBlock;
use objc2::rc::Retained;
use objc2::runtime::{AnyObject, Bool, NSObject, NSObjectProtocol, ProtocolObject};
use objc2::{define_class, msg_send, AnyThread};
use objc2_foundation::{ns_string, NSArray, NSBundle, NSDictionary, NSError, NSSet, NSString};
use objc2_user_notifications::{
    UNAuthorizationOptions, UNAuthorizationStatus, UNMutableNotificationContent, UNNotification, UNNotificationAction, UNNotificationActionOptions,
    UNNotificationCategory, UNNotificationCategoryOptions, UNNotificationDefaultActionIdentifier, UNNotificationDismissActionIdentifier,
    UNNotificationPresentationOptions, UNNotificationRequest, UNNotificationResponse, UNNotificationSettings, UNNotificationSound, UNUserNotificationCenter,
    UNUserNotificationCenterDelegate,
};
use tauri::{AppHandle, Runtime};
use tokio::sync::oneshot;

use super::{Clear, Payload, Request, Sink};
use crate::error::{Error, Result};

/// 进程启动后这么久内收到的点击视为“由点击启动”。
const LAUNCH_WINDOW: Duration = Duration::from_secs(5);
/// 来电通知的类别,启动时就登记(上次运行留下的来电通知也有按钮)。
const CALL_CATEGORY: &str = "drim.call";

static SINK: OnceLock<Sink> = OnceLock::new();
static STARTED: OnceLock<Instant> = OnceLock::new();

define_class!(
    // SAFETY:NSObject 没有子类化的要求;Delegate 没有实现 Drop
    #[unsafe(super(NSObject))]
    #[name = "DeeprespondImNotificationDelegate"]
    struct Delegate;

    unsafe impl NSObjectProtocol for Delegate {}

    unsafe impl UNUserNotificationCenterDelegate for Delegate {
        // 应用在前台时也显示横幅、放进通知中心、播放声音
        #[unsafe(method(userNotificationCenter:willPresentNotification:withCompletionHandler:))]
        fn will_present(&self, _center: &UNUserNotificationCenter, _notification: &UNNotification, handler: &block2::DynBlock<dyn Fn(UNNotificationPresentationOptions)>) {
            handler.call((UNNotificationPresentationOptions::Banner | UNNotificationPresentationOptions::List | UNNotificationPresentationOptions::Sound,));
        }

        #[unsafe(method(userNotificationCenter:didReceiveNotificationResponse:withCompletionHandler:))]
        fn did_receive(&self, _center: &UNUserNotificationCenter, response: &UNNotificationResponse, handler: &block2::DynBlock<dyn Fn()>) {
            on_response(response);
            handler.call(());
        }
    }
);

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

fn on_response(response: &UNNotificationResponse) {
    let action = response.actionIdentifier();
    // SAFETY:框架导出的常量字符串
    let (default, dismiss) = unsafe { (UNNotificationDefaultActionIdentifier, UNNotificationDismissActionIdentifier) };
    if action.isEqualToString(dismiss) {
        return;
    }
    let action = (!action.isEqualToString(default)).then(|| action.to_string());
    let info = response.notification().request().content().userInfo();
    let key: &AnyObject = ns_string!("drim");
    let Some(text) = info.objectForKey(key).and_then(|v| v.downcast::<NSString>().ok()) else { return };
    let Ok(payload) = serde_json::from_str::<Payload>(&text.to_string()) else { return };
    let launched = STARTED.get().is_some_and(|t| t.elapsed() < LAUNCH_WINDOW);
    if let Some(sink) = SINK.get() {
        sink(payload.with_action(action), launched);
    }
}

/// 应用是从 .app 包中运行的(UNUserNotificationCenter 的前提)。
fn bundled() -> bool {
    let bundle = NSBundle::mainBundle();
    bundle.bundleIdentifier().is_some() && bundle.bundlePath().to_string().ends_with(".app")
}

fn center() -> Retained<UNUserNotificationCenter> {
    UNUserNotificationCenter::currentNotificationCenter()
}

/// 按钮:(id, 标题, 是否为破坏性操作)。
type ActionSpec = (String, String, bool);

pub(super) struct Backend {
    available: bool,
    warned: AtomicBool,
    /// 已登记的类别:setNotificationCategories 整体替换,每次都要交出全部。
    categories: Mutex<BTreeMap<String, Vec<ActionSpec>>>,
}

impl Backend {
    pub fn new<R: Runtime>(_app: &AppHandle<R>, _identifier: &str, sink: Sink) -> Self {
        let _ = SINK.set(sink);
        let _ = STARTED.set(Instant::now());
        let available = bundled();
        let backend = Self { available, warned: AtomicBool::new(false), categories: Mutex::new(BTreeMap::new()) };
        if !available {
            log::warn!("应用不是从 .app 包中运行(开发时直接运行可执行文件),macOS 系统通知不可用");
            return backend;
        }
        let delegate = Delegate::new();
        center().setDelegate(Some(ProtocolObject::from_ref(&*delegate)));
        // 通知中心对代理是弱引用:代理必须一直活着
        std::mem::forget(delegate);
        backend.categories.lock().unwrap().insert(
            CALL_CATEGORY.into(),
            vec![("accept".into(), "接听".into(), false), ("decline".into(), "拒绝".into(), true)],
        );
        register_categories(&backend.categories.lock().unwrap());
        backend
    }

    pub async fn show(&self, req: &Request, payload: &Payload) -> Result<()> {
        if !self.available {
            if !self.warned.swap(true, Ordering::Relaxed) {
                log::warn!("macOS 系统通知不可用(不是从 .app 包中运行),通知不显示");
            }
            return Ok(());
        }
        // 第一次显示通知前请求权限;用户拒绝过的不再弹出请求(《2.功能概述文档》第 8.3 节)
        let granted = match status().await {
            Some(s) if s == UNAuthorizationStatus::Denied => false,
            Some(s) if s == UNAuthorizationStatus::NotDetermined => request_authorization().await,
            _ => true,
        };
        if !granted {
            return Err(Error::permission_denied("notification_denied"));
        }
        let category = self.category_for(req);
        let rx = add_request(req, payload, category.as_deref())?;
        match rx.await {
            Ok(Some(message)) => Err(Error::internal(format!("macOS 通知:{message}"))),
            _ => Ok(()),
        }
    }

    /// 按钮组合对应的类别:没有按钮的不用类别;新的组合登记后再用。
    fn category_for(&self, req: &Request) -> Option<String> {
        let actions = req.effective_actions();
        if actions.is_empty() {
            return None;
        }
        let specs: Vec<ActionSpec> = actions.into_iter().map(|a| (a.id.clone(), a.title, a.id == "decline")).collect();
        if req.kind == "call" && specs.iter().map(|s| s.0.as_str()).eq(["accept", "decline"]) {
            return Some(CALL_CATEGORY.into());
        }
        let key = specs.iter().map(|(id, title, _)| format!("{id}\u{1f}{title}")).collect::<Vec<_>>().join("\u{1e}");
        let id = format!("drim.{}", super::toast::hash_key("category", &key));
        let mut categories = self.categories.lock().unwrap();
        if !categories.contains_key(&id) {
            categories.insert(id.clone(), specs);
            register_categories(&categories);
        }
        Some(id)
    }

    pub async fn clear(&self, target: &Clear) -> Result<()> {
        if !self.available {
            return Ok(());
        }
        match target {
            Clear::Id(id) => remove_ids(std::slice::from_ref(id)),
            Clear::Conversation(cid) => {
                // 按 userInfo 中的会话找,上次运行时显示的也能清除
                let ids = delivered_of(cid.clone()).await.unwrap_or_default();
                remove_ids(&ids);
            }
            Clear::All => {
                let center = center();
                center.removeAllDeliveredNotifications();
                center.removeAllPendingNotificationRequests();
            }
        }
        Ok(())
    }

    pub async fn permission(&self) -> &'static str {
        if !self.available {
            return "denied";
        }
        match status().await {
            Some(s) if s == UNAuthorizationStatus::NotDetermined => "default",
            Some(s) if s == UNAuthorizationStatus::Denied => "denied",
            Some(_) => "granted",
            None => "default",
        }
    }

    pub async fn request_permission(&self) -> &'static str {
        if !self.available {
            return "denied";
        }
        match status().await {
            Some(s) if s == UNAuthorizationStatus::Denied => "denied",
            Some(s) if s == UNAuthorizationStatus::NotDetermined => {
                if request_authorization().await {
                    "granted"
                } else {
                    "denied"
                }
            }
            _ => "granted",
        }
    }
}

/// 把一个完成回调的结果经 oneshot 交出(块可能被调用多次,只交第一次)。
fn once<T: Send + 'static>() -> (Mutex<Option<oneshot::Sender<T>>>, oneshot::Receiver<T>) {
    let (tx, rx) = oneshot::channel();
    (Mutex::new(Some(tx)), rx)
}

fn send<T>(slot: &Mutex<Option<oneshot::Sender<T>>>, value: T) {
    if let Some(tx) = slot.lock().unwrap().take() {
        let _ = tx.send(value);
    }
}

async fn status() -> Option<UNAuthorizationStatus> {
    let rx = {
        let (tx, rx) = once::<isize>();
        let block = RcBlock::new(move |settings: NonNull<UNNotificationSettings>| {
            // SAFETY:框架交来的设置对象在回调期间有效
            let status = unsafe { settings.as_ref() }.authorizationStatus();
            send(&tx, status.0);
        });
        center().getNotificationSettingsWithCompletionHandler(&block);
        rx
    };
    rx.await.ok().map(UNAuthorizationStatus)
}

async fn request_authorization() -> bool {
    let rx = {
        let (tx, rx) = once::<bool>();
        let block = RcBlock::new(move |granted: Bool, _error: *mut NSError| send(&tx, granted.as_bool()));
        center().requestAuthorizationWithOptions_completionHandler(UNAuthorizationOptions::Alert | UNAuthorizationOptions::Sound | UNAuthorizationOptions::Badge, &block);
        rx
    };
    rx.await.unwrap_or(false)
}

fn add_request(req: &Request, payload: &Payload, category: Option<&str>) -> Result<oneshot::Receiver<Option<String>>> {
    let content = UNMutableNotificationContent::new();
    content.setTitle(&NSString::from_str(&req.title));
    content.setBody(&NSString::from_str(&req.body));
    content.setThreadIdentifier(&NSString::from_str(req.group_key()));
    if req.sound {
        content.setSound(Some(&UNNotificationSound::defaultSound()));
    }
    if let Some(category) = category {
        content.setCategoryIdentifier(&NSString::from_str(category));
    }
    let json = NSString::from_str(&serde_json::to_string(payload)?);
    let info = NSDictionary::<NSString, NSString>::from_slices(&[ns_string!("drim")], &[&*json]);
    // SAFETY:userInfo 要求键值都是可以序列化的属性列表对象,NSString 满足;只是把泛型参数换成 AnyObject
    unsafe {
        let info: Retained<NSDictionary> = Retained::cast_unchecked(info);
        content.setUserInfo(&info);
    }
    let request = UNNotificationRequest::requestWithIdentifier_content_trigger(&NSString::from_str(&req.id), &content, None);
    let (tx, rx) = once::<Option<String>>();
    let block = RcBlock::new(move |error: *mut NSError| {
        // SAFETY:error 为空或是有效的 NSError
        let message = unsafe { error.as_ref() }.map(|e| e.localizedDescription().to_string());
        send(&tx, message);
    });
    center().addNotificationRequest_withCompletionHandler(&request, Some(&*block));
    Ok(rx)
}

fn register_categories(categories: &BTreeMap<String, Vec<ActionSpec>>) {
    let list: Vec<Retained<UNNotificationCategory>> = categories
        .iter()
        .map(|(id, specs)| {
            let actions: Vec<Retained<UNNotificationAction>> = specs
                .iter()
                .map(|(action_id, title, destructive)| {
                    let options = if *destructive { UNNotificationActionOptions::Destructive } else { UNNotificationActionOptions::Foreground };
                    UNNotificationAction::actionWithIdentifier_title_options(&NSString::from_str(action_id), &NSString::from_str(title), options)
                })
                .collect();
            UNNotificationCategory::categoryWithIdentifier_actions_intentIdentifiers_options(
                &NSString::from_str(id),
                &NSArray::from_retained_slice(&actions),
                &NSArray::new(),
                UNNotificationCategoryOptions(0),
            )
        })
        .collect();
    center().setNotificationCategories(&NSSet::from_retained_slice(&list));
}

fn remove_ids(ids: &[String]) {
    if ids.is_empty() {
        return;
    }
    let list: Vec<Retained<NSString>> = ids.iter().map(|id| NSString::from_str(id)).collect();
    let array = NSArray::from_retained_slice(&list);
    let center = center();
    center.removeDeliveredNotificationsWithIdentifiers(&array);
    center.removePendingNotificationRequestsWithIdentifiers(&array);
}

/// 通知中心里属于这个会话的通知的 identifier。
async fn delivered_of(conversation_id: String) -> Option<Vec<String>> {
    let rx = {
        let (tx, rx) = once::<Vec<String>>();
        let block = RcBlock::new(move |list: NonNull<NSArray<UNNotification>>| {
            // SAFETY:框架交来的数组在回调期间有效
            let list = unsafe { list.as_ref() };
            let key: &AnyObject = ns_string!("drim");
            let ids = list
                .iter()
                .filter_map(|n| {
                    let request = n.request();
                    let text = request.content().userInfo().objectForKey(key)?.downcast::<NSString>().ok()?;
                    let payload: Payload = serde_json::from_str(&text.to_string()).ok()?;
                    (payload.conversation_id.as_deref() == Some(conversation_id.as_str())).then(|| request.identifier().to_string())
                })
                .collect();
            send(&tx, ids);
        });
        center().getDeliveredNotificationsWithCompletionHandler(&block);
        rx
    };
    rx.await.ok()
}