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!(
#[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(());
unsafe { msg_send![super(this), init] }
}
}
fn on_response(response: &UNNotificationResponse) {
let action = response.actionIdentifier();
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);
}
}
fn bundled() -> bool {
let bundle = NSBundle::mainBundle();
bundle.bundleIdentifier().is_some() && bundle.bundlePath().to_string().ends_with(".app")
}
fn center() -> Retained<UNUserNotificationCenter> {
UNUserNotificationCenter::currentNotificationCenter()
}
type ActionSpec = (String, String, bool);
pub(super) struct Backend {
available: bool,
warned: AtomicBool,
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(());
}
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) => {
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",
}
}
}
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>| {
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]);
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| {
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);
}
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>>| {
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()
}