use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Mutex, Once, OnceLock};
use objc2::rc::Retained;
use objc2::runtime::{Bool, NSObjectProtocol, ProtocolObject};
use objc2::{define_class, extern_methods};
use objc2_foundation::{NSBundle, NSError, NSObject, NSString};
use objc2_user_notifications::{
UNAuthorizationOptions, UNMutableNotificationContent, UNNotification,
UNNotificationDismissActionIdentifier, UNNotificationPresentationOptions,
UNNotificationRequest, UNNotificationResponse, UNNotificationSound, UNUserNotificationCenter,
UNUserNotificationCenterDelegate,
};
use super::notify::{NotificationRequest, NotificationUrgency, escape_for_applescript};
static NATIVE_BUNDLE_PRESENT: OnceLock<bool> = OnceLock::new();
static NATIVE_DISABLED: AtomicBool = AtomicBool::new(false);
static WARN_ONCE: Once = Once::new();
static NATIVE_INIT: Once = Once::new();
static ANON_ID_COUNTER: AtomicU64 = AtomicU64::new(0);
static CLICK_TOKENS: OnceLock<Mutex<HashMap<String, u64>>> = OnceLock::new();
fn click_tokens() -> &'static Mutex<HashMap<String, u64>> {
CLICK_TOKENS.get_or_init(|| Mutex::new(HashMap::new()))
}
pub(super) fn deliver(req: &NotificationRequest<'_>) {
if native_backend_usable() {
deliver_native(req);
} else {
deliver_osascript(req);
}
}
fn native_backend_usable() -> bool {
let bundled =
*NATIVE_BUNDLE_PRESENT.get_or_init(|| NSBundle::mainBundle().bundleIdentifier().is_some());
bundled && !NATIVE_DISABLED.load(Ordering::Relaxed)
}
fn disable_native_after_error(context: &str) {
NATIVE_DISABLED.store(true, Ordering::Relaxed);
WARN_ONCE.call_once(|| {
log::warn!(
"macOS native notification backend failed ({context}); falling back to osascript for subsequent notifications"
);
});
}
define_class!(
#[unsafe(super = NSObject)]
struct NotificationDelegate;
unsafe impl NSObjectProtocol for NotificationDelegate {}
unsafe impl UNUserNotificationCenterDelegate for NotificationDelegate {
#[unsafe(method(userNotificationCenter:willPresentNotification:withCompletionHandler:))]
fn will_present_notification(
&self,
_center: &UNUserNotificationCenter,
_notification: &UNNotification,
completion_handler: &block2::DynBlock<dyn Fn(UNNotificationPresentationOptions)>,
) {
completion_handler.call((UNNotificationPresentationOptions::Banner
| UNNotificationPresentationOptions::Sound,));
}
#[unsafe(method(userNotificationCenter:didReceiveNotificationResponse:withCompletionHandler:))]
fn did_receive_notification_response(
&self,
_center: &UNUserNotificationCenter,
response: &UNNotificationResponse,
completion_handler: &block2::DynBlock<dyn Fn()>,
) {
let is_dismiss = unsafe { UNNotificationDismissActionIdentifier }.to_string()
== response.actionIdentifier().to_string();
if !is_dismiss {
let identifier = response.notification().request().identifier().to_string();
if let Ok(mut tokens) = click_tokens().lock()
&& let Some(token) = tokens.remove(&identifier)
{
let _ = super::notify::click_sender().send(token);
}
}
completion_handler.call(());
}
}
);
impl NotificationDelegate {
extern_methods!(
#[unsafe(method(new))]
fn new() -> Retained<Self>;
);
}
fn ensure_native_initialized() {
NATIVE_INIT.call_once(|| {
let center = UNUserNotificationCenter::currentNotificationCenter();
let delegate = NotificationDelegate::new();
center.setDelegate(Some(ProtocolObject::from_ref(&*delegate)));
std::mem::forget(delegate);
let options = UNAuthorizationOptions::Alert | UNAuthorizationOptions::Sound;
let completion = block2::RcBlock::new(|granted: Bool, error: *mut NSError| {
if !error.is_null() {
disable_native_after_error("requestAuthorization");
} else if !granted.as_bool() {
log::warn!("macOS notification authorization was denied by the user");
}
});
center.requestAuthorizationWithOptions_completionHandler(options, &completion);
});
}
fn deliver_native(req: &NotificationRequest<'_>) {
ensure_native_initialized();
let title = if !req.title.is_empty() {
req.title
} else {
"Terminal Notification"
};
let content = UNMutableNotificationContent::new();
content.setTitle(&NSString::from_str(title));
content.setBody(&NSString::from_str(req.message));
if req.urgency == NotificationUrgency::Critical {
content.setSound(Some(&UNNotificationSound::defaultSound()));
}
let identifier = match req.identity {
Some(identity) => identity.to_owned(),
None => format!(
"par-term-{}",
ANON_ID_COUNTER.fetch_add(1, Ordering::Relaxed)
),
};
if let Some(token) = req.click_token
&& let Ok(mut tokens) = click_tokens().lock()
{
tokens.insert(identifier.clone(), token);
}
let ns_identifier = NSString::from_str(&identifier);
let request = UNNotificationRequest::requestWithIdentifier_content_trigger(
&ns_identifier,
&content,
None, );
let has_click_token = req.click_token.is_some();
let failed_identifier = identifier;
let completion = block2::RcBlock::new(move |error: *mut NSError| {
if !error.is_null() {
disable_native_after_error("addNotificationRequest");
if has_click_token && let Ok(mut tokens) = click_tokens().lock() {
tokens.remove(&failed_identifier);
}
}
});
let center = UNUserNotificationCenter::currentNotificationCenter();
center.addNotificationRequest_withCompletionHandler(&request, Some(&completion));
}
fn deliver_osascript(req: &NotificationRequest<'_>) {
let title = if !req.title.is_empty() {
req.title
} else {
"Terminal Notification"
};
let escaped_title = escape_for_applescript(title);
let escaped_message = escape_for_applescript(req.message);
let script = if req.urgency == NotificationUrgency::Critical {
format!(
r#"display notification "{}" with title "{}" sound name "Basso""#,
escaped_message, escaped_title,
)
} else {
format!(
r#"display notification "{}" with title "{}""#,
escaped_message, escaped_title,
)
};
if let Err(e) = std::process::Command::new("osascript")
.arg("-e")
.arg(&script)
.output()
{
log::warn!("Failed to send macOS desktop notification: {}", e);
}
}