From df2330b9b391999184244c93e2f87d2768a2b9cd Mon Sep 17 00:00:00 2001 From: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Date: Tue, 4 Aug 2026 15:07:28 -0700 Subject: [PATCH 1/7] fix(desktop): route macos notification clicks Co-authored-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Signed-off-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> --- desktop/src-tauri/Cargo.lock | 3 + desktop/src-tauri/Cargo.toml | 3 +- .../src-tauri/src/commands/notifications.rs | 18 +- desktop/src-tauri/src/lib.rs | 7 +- desktop/src-tauri/src/macos_notifications.rs | 171 ++++++++++++++++++ .../src/features/notifications/lib/desktop.ts | 41 +++-- 6 files changed, 215 insertions(+), 28 deletions(-) create mode 100644 desktop/src-tauri/src/macos_notifications.rs diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index afd119c84d9..fbaa547a032 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1104,6 +1104,7 @@ dependencies = [ "objc2", "objc2-app-kit", "objc2-foundation", + "objc2-user-notifications", "opus", "plist", "png 0.18.1", @@ -6722,6 +6723,8 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" dependencies = [ + "bitflags 2.13.0", + "block2", "objc2", "objc2-foundation", ] diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 1ba814da47e..03bc4880e4b 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -54,7 +54,8 @@ webkit2gtk = { version = "=2.0.2", features = ["v2_22"] } block2 = { version = "0.6", default-features = false, features = ["std"] } objc2 = { version = "0.6.4", default-features = false } objc2-app-kit = { version = "0.3.2", default-features = false, features = ["NSEvent", "NSHapticFeedback", "NSMenu", "NSMenuItem", "NSStatusItem", "block2"] } -objc2-foundation = { version = "0.3.2", default-features = false, features = ["NSProcessInfo", "NSString"] } +objc2-foundation = { version = "0.3.2", default-features = false, features = ["NSDictionary", "NSError", "NSObject", "NSProcessInfo", "NSString"] } +objc2-user-notifications = { version = "0.3.2", default-features = false, features = ["block2", "UNNotification", "UNNotificationContent", "UNNotificationRequest", "UNNotificationResponse", "UNNotificationTrigger", "UNUserNotificationCenter"] } keyring = { version = "3.6.3", default-features = false, features = ["apple-native", "vendored"], optional = true } security-framework = { version = "3.7.0", features = ["OSX_10_15"] } window-vibrancy = "0.6" diff --git a/desktop/src-tauri/src/commands/notifications.rs b/desktop/src-tauri/src/commands/notifications.rs index c13d96ff6da..5092d94534c 100644 --- a/desktop/src-tauri/src/commands/notifications.rs +++ b/desktop/src-tauri/src/commands/notifications.rs @@ -1,4 +1,4 @@ -//! Native (Linux) desktop-notification helper. +//! Native desktop-notification helpers. //! //! `tauri-plugin-notification` posts a notification by calling `notify_rust`'s //! `show()` and then immediately dropping the returned `NotificationHandle`. @@ -15,9 +15,9 @@ /// Show a desktop notification natively. /// -/// On Linux this uses the connection-preserving path described above. On other -/// platforms the bundled notification plugin already works correctly, so the -/// frontend never calls this and we simply report that it is unused. +/// Linux uses the connection-preserving D-Bus path described above. macOS uses +/// one application-lifetime `UNUserNotificationCenterDelegate`; it does not +/// allocate a listener or waiter for each notification. #[tauri::command] pub fn show_native_notification( app: tauri::AppHandle, @@ -31,10 +31,16 @@ pub fn show_native_notification( Ok(()) } - #[cfg(not(target_os = "linux"))] + #[cfg(target_os = "macos")] + { + let _ = app; + crate::macos_notifications::show(title, body, target) + } + + #[cfg(not(any(target_os = "linux", target_os = "macos")))] { let _ = (&app, &title, &body, &target); - Err("show_native_notification is only supported on Linux".to_string()) + Err("show_native_notification is only supported on Linux and macOS".to_string()) } } diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index a7c191c43b2..dc80d63a6ad 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -13,6 +13,8 @@ mod identity_storage; mod initial_window; mod key_backup; mod linux_media; +#[cfg(target_os = "macos")] +mod macos_notifications; mod managed_agents; mod media_proxy; #[cfg(feature = "mesh-llm")] @@ -309,7 +311,10 @@ pub fn run() { .setup(move |app| { let app_handle = app.handle().clone(); #[cfg(target_os = "macos")] - tray_menu::init(&app_handle)?; + { + tray_menu::init(&app_handle)?; + macos_notifications::init(&app_handle)?; + } // ── Phase 2: boot-time sentinel wipe ────────────────────────────── // Must run before migrations and identity resolution so the wipe diff --git a/desktop/src-tauri/src/macos_notifications.rs b/desktop/src-tauri/src/macos_notifications.rs new file mode 100644 index 00000000000..ad1c2748c6b --- /dev/null +++ b/desktop/src-tauri/src/macos_notifications.rs @@ -0,0 +1,171 @@ +//! Modern macOS notification delivery and activation routing. +//! +//! Apple delivers every notification response through one process-wide +//! `UNUserNotificationCenterDelegate`. The delegate is installed once during +//! app setup and retained for the process lifetime. Notification targets live +//! in `userInfo`, so there are no per-notification listeners, waiter threads, +//! or request maps to leak when Notification Center clears a notification. + +use block2::Block; +use objc2::{ + define_class, msg_send, + rc::Retained, + runtime::{AnyObject, ProtocolObject}, + DefinedClass, MainThreadMarker, MainThreadOnly, +}; +use objc2_foundation::{NSDictionary, NSObject, NSObjectProtocol, NSString}; +use objc2_user_notifications::{ + UNMutableNotificationContent, UNNotificationDefaultActionIdentifier, + UNNotificationPresentationOptions, UNNotificationRequest, UNNotificationResponse, + UNUserNotificationCenter, UNUserNotificationCenterDelegate, +}; +use tauri::{AppHandle, Emitter}; + +const ACTIVATE_EVENT: &str = "native-notification-activated"; +const TARGET_USER_INFO_KEY: &str = "buzzNotificationTarget"; + +struct NotificationDelegateIvars { + app: AppHandle, +} + +define_class!( + // SAFETY: NSObject has no subclassing requirements. The delegate is only + // created and used on the main thread and is retained for the app lifetime. + #[unsafe(super(NSObject))] + #[name = "BuzzNotificationCenterDelegate"] + #[thread_kind = MainThreadOnly] + #[ivars = NotificationDelegateIvars] + 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: &objc2_user_notifications::UNNotification, + completion_handler: &Block, + ) { + completion_handler + .call((UNNotificationPresentationOptions::Banner + | UNNotificationPresentationOptions::List,)); + } + + #[unsafe(method(userNotificationCenter:didReceiveNotificationResponse:withCompletionHandler:))] + fn did_receive_notification_response( + &self, + _center: &UNUserNotificationCenter, + response: &UNNotificationResponse, + completion_handler: &Block, + ) { + if &*response.actionIdentifier() == unsafe { UNNotificationDefaultActionIdentifier } { + if let Some(target) = target_from_response(response) { + crate::tray_menu::show_main_window(&self.ivars().app); + if let Err(error) = self.ivars().app.emit(ACTIVATE_EVENT, target) { + eprintln!( + "buzz-desktop: failed to emit macOS notification activation: {error}" + ); + } + } + } + + // Apple requires this for every response, including dismissals and + // malformed notifications that Buzz intentionally ignores. + completion_handler.call(()); + } + } +); + +impl NotificationDelegate { + fn new(main_thread: MainThreadMarker, app: AppHandle) -> Retained { + let delegate = main_thread + .alloc() + .set_ivars(NotificationDelegateIvars { app }); + unsafe { msg_send![super(delegate), init] } + } +} + +/// Install the one application-lifetime notification response delegate. +pub(crate) fn init(app: &AppHandle) -> tauri::Result<()> { + let Some(main_thread) = MainThreadMarker::new() else { + return Err(tauri::Error::FailedToReceiveMessage); + }; + + let delegate = NotificationDelegate::new(main_thread, app.clone()); + let delegate: Retained> = + ProtocolObject::from_retained(delegate); + UNUserNotificationCenter::currentNotificationCenter().setDelegate(Some(&delegate)); + + // UNUserNotificationCenter.delegate is weak. This object is deliberately + // process-lifetime state, matching the application-lifetime delegate Apple + // documents and avoiding mutable global or per-notification registrations. + std::mem::forget(delegate); + Ok(()) +} + +pub(crate) fn show( + title: String, + body: Option, + target: Option, +) -> Result<(), String> { + let content = UNMutableNotificationContent::new(); + content.setTitle(&NSString::from_str(&title)); + if let Some(body) = body { + content.setBody(&NSString::from_str(&body)); + } + + if let Some(target) = target { + let serialized = serde_json::to_string(&target) + .map_err(|error| format!("failed to serialize notification target: {error}"))?; + let key = NSString::from_str(TARGET_USER_INFO_KEY); + let value = NSString::from_str(&serialized); + let user_info = NSDictionary::::from_slices(&[&*key], &[&*value]); + // SAFETY: Both the key and value are property-list-safe NSString values. + unsafe { + let user_info = + Retained::cast_unchecked::>(user_info); + content.setUserInfo(&user_info); + } + } + + let identifier = NSString::from_str(&uuid::Uuid::new_v4().to_string()); + let request = + UNNotificationRequest::requestWithIdentifier_content_trigger(&identifier, &content, None); + UNUserNotificationCenter::currentNotificationCenter() + .addNotificationRequest_withCompletionHandler(&request, None); + Ok(()) +} + +fn target_from_response(response: &UNNotificationResponse) -> Option { + let user_info = response.notification().request().content().userInfo(); + let key = NSString::from_str(TARGET_USER_INFO_KEY); + let target = user_info.objectForKey(key.as_ref())?; + let target = target.downcast::().ok()?; + parse_target(&target.to_string()) +} + +fn parse_target(serialized: &str) -> Option { + serde_json::from_str(serialized).ok() +} + +#[cfg(test)] +mod tests { + use super::parse_target; + + #[test] + fn parses_opaque_notification_target() { + let target = + parse_target(r#"{"channelId":"channel","eventId":"event","threadRootId":"root"}"#) + .expect("valid target"); + + assert_eq!(target["channelId"], "channel"); + assert_eq!(target["eventId"], "event"); + assert_eq!(target["threadRootId"], "root"); + } + + #[test] + fn rejects_malformed_notification_target() { + assert!(parse_target("not-json").is_none()); + } +} diff --git a/desktop/src/features/notifications/lib/desktop.ts b/desktop/src/features/notifications/lib/desktop.ts index 380521e0f2d..a905a242765 100644 --- a/desktop/src/features/notifications/lib/desktop.ts +++ b/desktop/src/features/notifications/lib/desktop.ts @@ -8,7 +8,7 @@ import { } from "@tauri-apps/plugin-notification"; import { isLinuxPlatform, isMacPlatform } from "@/shared/lib/platform"; -// Backend event emitted when the user clicks a native (Linux) notification. +// Backend event emitted when the user clicks a native Linux or macOS notification. // See src-tauri/src/commands/notifications.rs. const NATIVE_NOTIFICATION_ACTIVATED_EVENT = "native-notification-activated"; @@ -180,23 +180,25 @@ export async function listenForDesktopNotificationActions( let nativeUnlisten: (() => void) | null = null; if (isTauri()) { - try { - pluginListener = await onAction((notification) => { - const target = parseNotificationTarget( - notification.extra?.buzzNotificationTarget, - ); - if (!target) { - return; - } - - dispatchDesktopNotificationTarget(target); - }); - } catch { - pluginListener = null; + if (!isLinuxPlatform() && !isMacPlatform()) { + try { + pluginListener = await onAction((notification) => { + const target = parseNotificationTarget( + notification.extra?.buzzNotificationTarget, + ); + if (!target) { + return; + } + + dispatchDesktopNotificationTarget(target); + }); + } catch { + pluginListener = null; + } } - // Clicks on Linux notifications come back via a backend event rather than - // the plugin's onAction (whose connection is torn down before it can fire). + // Native Linux and macOS clicks come back through one backend event. macOS + // uses one app-lifetime UNUserNotificationCenterDelegate. try { nativeUnlisten = await listen( NATIVE_NOTIFICATION_ACTIVATED_EVENT, @@ -293,11 +295,10 @@ export async function sendDesktopNotification( return false; } - // On Linux the bundled notification plugin posts via a D-Bus connection that - // it drops immediately; GNOME 46+ then dismisses the notification before it - // is seen. Route through a backend command that keeps the connection alive. + // Linux needs a retained D-Bus connection. macOS needs a native notification + // center delegate because the Tauri plugin does not deliver desktop clicks. // See src-tauri/src/commands/notifications.rs. - if (isTauri() && isLinuxPlatform()) { + if (isTauri() && (isLinuxPlatform() || isMacPlatform())) { try { await invoke("show_native_notification", { title: payload.title, From be1dc1b62f38d89d1d015fefff83a3ebc4df66e0 Mon Sep 17 00:00:00 2001 From: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Date: Tue, 4 Aug 2026 15:38:02 -0700 Subject: [PATCH 2/7] fix(desktop): harden macos notification delivery Co-authored-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Signed-off-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> --- desktop/src-tauri/Cargo.toml | 2 +- .../src-tauri/src/commands/notifications.rs | 5 +- desktop/src-tauri/src/macos_notifications.rs | 104 +++++++++++++----- 3 files changed, 83 insertions(+), 28 deletions(-) diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 03bc4880e4b..2ec743b74c1 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -54,7 +54,7 @@ webkit2gtk = { version = "=2.0.2", features = ["v2_22"] } block2 = { version = "0.6", default-features = false, features = ["std"] } objc2 = { version = "0.6.4", default-features = false } objc2-app-kit = { version = "0.3.2", default-features = false, features = ["NSEvent", "NSHapticFeedback", "NSMenu", "NSMenuItem", "NSStatusItem", "block2"] } -objc2-foundation = { version = "0.3.2", default-features = false, features = ["NSDictionary", "NSError", "NSObject", "NSProcessInfo", "NSString"] } +objc2-foundation = { version = "0.3.2", default-features = false, features = ["NSDictionary", "NSError", "NSBundle", "NSObject", "NSProcessInfo", "NSString"] } objc2-user-notifications = { version = "0.3.2", default-features = false, features = ["block2", "UNNotification", "UNNotificationContent", "UNNotificationRequest", "UNNotificationResponse", "UNNotificationTrigger", "UNUserNotificationCenter"] } keyring = { version = "3.6.3", default-features = false, features = ["apple-native", "vendored"], optional = true } security-framework = { version = "3.7.0", features = ["OSX_10_15"] } diff --git a/desktop/src-tauri/src/commands/notifications.rs b/desktop/src-tauri/src/commands/notifications.rs index 5092d94534c..40641c072c8 100644 --- a/desktop/src-tauri/src/commands/notifications.rs +++ b/desktop/src-tauri/src/commands/notifications.rs @@ -13,6 +13,8 @@ //! action, which we forward to the frontend so it can focus the window and //! route to the notification target. +pub(crate) const NATIVE_NOTIFICATION_ACTIVATED_EVENT: &str = "native-notification-activated"; + /// Show a desktop notification natively. /// /// Linux uses the connection-preserving D-Bus path described above. macOS uses @@ -50,7 +52,6 @@ mod linux { /// Emitted to the frontend when the user clicks a native notification. The /// payload is the opaque target object the frontend passed in. - const ACTIVATE_EVENT: &str = "native-notification-activated"; pub fn show( app: tauri::AppHandle, @@ -102,7 +103,7 @@ mod linux { // The frontend focuses the window on activation (the same path // every other platform uses), so we only forward the target. - let _ = app.emit(ACTIVATE_EVENT, target); + let _ = app.emit(NATIVE_NOTIFICATION_ACTIVATED_EVENT, target); }); }); } diff --git a/desktop/src-tauri/src/macos_notifications.rs b/desktop/src-tauri/src/macos_notifications.rs index ad1c2748c6b..0c81d4c8414 100644 --- a/desktop/src-tauri/src/macos_notifications.rs +++ b/desktop/src-tauri/src/macos_notifications.rs @@ -6,22 +6,25 @@ //! in `userInfo`, so there are no per-notification listeners, waiter threads, //! or request maps to leak when Notification Center clears a notification. -use block2::Block; +use std::sync::mpsc; + +use block2::{Block, RcBlock}; use objc2::{ define_class, msg_send, rc::Retained, - runtime::{AnyObject, ProtocolObject}, - DefinedClass, MainThreadMarker, MainThreadOnly, + runtime::{AnyObject, Bool, ProtocolObject}, + AnyThread, DefinedClass, }; -use objc2_foundation::{NSDictionary, NSObject, NSObjectProtocol, NSString}; +use objc2_foundation::{NSBundle, NSDictionary, NSError, NSObject, NSObjectProtocol, NSString}; use objc2_user_notifications::{ - UNMutableNotificationContent, UNNotificationDefaultActionIdentifier, + UNAuthorizationOptions, UNMutableNotificationContent, UNNotificationDefaultActionIdentifier, UNNotificationPresentationOptions, UNNotificationRequest, UNNotificationResponse, UNUserNotificationCenter, UNUserNotificationCenterDelegate, }; use tauri::{AppHandle, Emitter}; -const ACTIVATE_EVENT: &str = "native-notification-activated"; +use crate::commands::NATIVE_NOTIFICATION_ACTIVATED_EVENT; + const TARGET_USER_INFO_KEY: &str = "buzzNotificationTarget"; struct NotificationDelegateIvars { @@ -29,11 +32,12 @@ struct NotificationDelegateIvars { } define_class!( - // SAFETY: NSObject has no subclassing requirements. The delegate is only - // created and used on the main thread and is retained for the app lifetime. + // SAFETY: NSObject permits AnyThread subclasses, and AppHandle is Send + + // Sync. Apple does not guarantee a queue for notification delegate calls; + // both Tauri operations used by the callbacks are thread-safe. #[unsafe(super(NSObject))] #[name = "BuzzNotificationCenterDelegate"] - #[thread_kind = MainThreadOnly] + #[thread_kind = AnyThread] #[ivars = NotificationDelegateIvars] struct NotificationDelegate; @@ -47,9 +51,10 @@ define_class!( _notification: &objc2_user_notifications::UNNotification, completion_handler: &Block, ) { - completion_handler - .call((UNNotificationPresentationOptions::Banner - | UNNotificationPresentationOptions::List,)); + // Preserve the prior macOS behavior: foreground notifications are + // recorded by Notification Center without interrupting the user + // with a banner while Buzz is active. + completion_handler.call((UNNotificationPresentationOptions::empty(),)); } #[unsafe(method(userNotificationCenter:didReceiveNotificationResponse:withCompletionHandler:))] @@ -62,7 +67,11 @@ define_class!( if &*response.actionIdentifier() == unsafe { UNNotificationDefaultActionIdentifier } { if let Some(target) = target_from_response(response) { crate::tray_menu::show_main_window(&self.ivars().app); - if let Err(error) = self.ivars().app.emit(ACTIVATE_EVENT, target) { + if let Err(error) = self + .ivars() + .app + .emit(NATIVE_NOTIFICATION_ACTIVATED_EVENT, target) + { eprintln!( "buzz-desktop: failed to emit macOS notification activation: {error}" ); @@ -78,24 +87,42 @@ define_class!( ); impl NotificationDelegate { - fn new(main_thread: MainThreadMarker, app: AppHandle) -> Retained { - let delegate = main_thread - .alloc() - .set_ivars(NotificationDelegateIvars { app }); + fn new(app: AppHandle) -> Retained { + let delegate = Self::alloc().set_ivars(NotificationDelegateIvars { app }); unsafe { msg_send![super(delegate), init] } } } /// Install the one application-lifetime notification response delegate. pub(crate) fn init(app: &AppHandle) -> tauri::Result<()> { - let Some(main_thread) = MainThreadMarker::new() else { - return Err(tauri::Error::FailedToReceiveMessage); - }; + if !is_bundled_application() { + // UNUserNotificationCenter raises an Objective-C exception when the + // current process has no application bundle (notably `tauri dev`). + // objc2 cannot turn that exception into a Rust error, so do not call + // into the framework at all in this environment. + eprintln!( + "buzz-desktop: macOS notifications disabled because the process has no bundle identifier" + ); + return Ok(()); + } - let delegate = NotificationDelegate::new(main_thread, app.clone()); + let center = UNUserNotificationCenter::currentNotificationCenter(); + let delegate = NotificationDelegate::new(app.clone()); let delegate: Retained> = ProtocolObject::from_retained(delegate); - UNUserNotificationCenter::currentNotificationCenter().setDelegate(Some(&delegate)); + center.setDelegate(Some(&delegate)); + + let authorization_handler = RcBlock::new(|granted: Bool, error: *mut NSError| { + if let Some(error) = unsafe { error.as_ref() } { + eprintln!("buzz-desktop: macOS notification authorization failed: {error}"); + } else if !granted.as_bool() { + eprintln!("buzz-desktop: macOS notification authorization was denied"); + } + }); + center.requestAuthorizationWithOptions_completionHandler( + UNAuthorizationOptions::Alert | UNAuthorizationOptions::Sound, + &authorization_handler, + ); // UNUserNotificationCenter.delegate is weak. This object is deliberately // process-lifetime state, matching the application-lifetime delegate Apple @@ -109,6 +136,13 @@ pub(crate) fn show( body: Option, target: Option, ) -> Result<(), String> { + if !is_bundled_application() { + return Err( + "macOS notifications are unavailable when Buzz is not running from an app bundle" + .to_string(), + ); + } + let content = UNMutableNotificationContent::new(); content.setTitle(&NSString::from_str(&title)); if let Some(body) = body { @@ -132,9 +166,24 @@ pub(crate) fn show( let identifier = NSString::from_str(&uuid::Uuid::new_v4().to_string()); let request = UNNotificationRequest::requestWithIdentifier_content_trigger(&identifier, &content, None); + let (sender, receiver) = mpsc::sync_channel(1); + let delivery_handler = RcBlock::new(move |error: *mut NSError| { + let result = if let Some(error) = unsafe { error.as_ref() } { + Err(format!("failed to deliver macOS notification: {error}")) + } else { + Ok(()) + }; + let _ = sender.send(result); + }); UNUserNotificationCenter::currentNotificationCenter() - .addNotificationRequest_withCompletionHandler(&request, None); - Ok(()) + .addNotificationRequest_withCompletionHandler(&request, Some(&delivery_handler)); + receiver + .recv() + .map_err(|error| format!("macOS notification delivery callback failed: {error}"))? +} + +fn is_bundled_application() -> bool { + NSBundle::mainBundle().bundleIdentifier().is_some() } fn target_from_response(response: &UNNotificationResponse) -> Option { @@ -151,7 +200,12 @@ fn parse_target(serialized: &str) -> Option { #[cfg(test)] mod tests { - use super::parse_target; + use super::{is_bundled_application, parse_target}; + + #[test] + fn cargo_test_process_is_not_treated_as_bundled() { + assert!(!is_bundled_application()); + } #[test] fn parses_opaque_notification_target() { From 36647e0492482f115ee0117efce86eec6f1e6d90 Mon Sep 17 00:00:00 2001 From: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Date: Tue, 4 Aug 2026 16:04:09 -0700 Subject: [PATCH 3/7] fix(desktop): preserve macos notification activation Co-authored-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Signed-off-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> --- .../src-tauri/src/commands/notifications.rs | 3 - desktop/src-tauri/src/lib.rs | 2 + desktop/src-tauri/src/macos_notifications.rs | 75 ++++++++++++++----- .../src/features/notifications/lib/desktop.ts | 44 ++++++++--- 4 files changed, 92 insertions(+), 32 deletions(-) diff --git a/desktop/src-tauri/src/commands/notifications.rs b/desktop/src-tauri/src/commands/notifications.rs index 40641c072c8..3eedfc6e7df 100644 --- a/desktop/src-tauri/src/commands/notifications.rs +++ b/desktop/src-tauri/src/commands/notifications.rs @@ -50,9 +50,6 @@ pub fn show_native_notification( mod linux { use tauri::Emitter; - /// Emitted to the frontend when the user clicks a native notification. The - /// payload is the opaque target object the frontend passed in. - pub fn show( app: tauri::AppHandle, title: String, diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index dc80d63a6ad..517a32b07e9 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -725,6 +725,8 @@ pub fn run() { remove_reaction, get_event, show_native_notification, + #[cfg(target_os = "macos")] + macos_notifications::take_pending_activations, upload_media, pick_and_upload_media, pick_and_upload_image, diff --git a/desktop/src-tauri/src/macos_notifications.rs b/desktop/src-tauri/src/macos_notifications.rs index 0c81d4c8414..974c847d596 100644 --- a/desktop/src-tauri/src/macos_notifications.rs +++ b/desktop/src-tauri/src/macos_notifications.rs @@ -6,7 +6,10 @@ //! in `userInfo`, so there are no per-notification listeners, waiter threads, //! or request maps to leak when Notification Center clears a notification. -use std::sync::mpsc; +use std::{ + collections::VecDeque, + sync::{Mutex, OnceLock}, +}; use block2::{Block, RcBlock}; use objc2::{ @@ -26,6 +29,9 @@ use tauri::{AppHandle, Emitter}; use crate::commands::NATIVE_NOTIFICATION_ACTIVATED_EVENT; const TARGET_USER_INFO_KEY: &str = "buzzNotificationTarget"; +const MAX_PENDING_ACTIVATIONS: usize = 64; + +static PENDING_ACTIVATIONS: OnceLock>> = OnceLock::new(); struct NotificationDelegateIvars { app: AppHandle, @@ -51,10 +57,9 @@ define_class!( _notification: &objc2_user_notifications::UNNotification, completion_handler: &Block, ) { - // Preserve the prior macOS behavior: foreground notifications are - // recorded by Notification Center without interrupting the user - // with a banner while Buzz is active. - completion_handler.call((UNNotificationPresentationOptions::empty(),)); + // Preserve the prior macOS behavior: keep foreground notifications + // in Notification Center without interrupting the user with a banner. + completion_handler.call((UNNotificationPresentationOptions::List,)); } #[unsafe(method(userNotificationCenter:didReceiveNotificationResponse:withCompletionHandler:))] @@ -66,11 +71,12 @@ define_class!( ) { if &*response.actionIdentifier() == unsafe { UNNotificationDefaultActionIdentifier } { if let Some(target) = target_from_response(response) { + queue_activation(target); crate::tray_menu::show_main_window(&self.ivars().app); if let Err(error) = self .ivars() .app - .emit(NATIVE_NOTIFICATION_ACTIVATED_EVENT, target) + .emit(NATIVE_NOTIFICATION_ACTIVATED_EVENT, ()) { eprintln!( "buzz-desktop: failed to emit macOS notification activation: {error}" @@ -166,20 +172,35 @@ pub(crate) fn show( let identifier = NSString::from_str(&uuid::Uuid::new_v4().to_string()); let request = UNNotificationRequest::requestWithIdentifier_content_trigger(&identifier, &content, None); - let (sender, receiver) = mpsc::sync_channel(1); - let delivery_handler = RcBlock::new(move |error: *mut NSError| { - let result = if let Some(error) = unsafe { error.as_ref() } { - Err(format!("failed to deliver macOS notification: {error}")) - } else { - Ok(()) - }; - let _ = sender.send(result); + let delivery_handler = RcBlock::new(|error: *mut NSError| { + if let Some(error) = unsafe { error.as_ref() } { + eprintln!("buzz-desktop: failed to deliver macOS notification: {error}"); + } }); UNUserNotificationCenter::currentNotificationCenter() .addNotificationRequest_withCompletionHandler(&request, Some(&delivery_handler)); - receiver - .recv() - .map_err(|error| format!("macOS notification delivery callback failed: {error}"))? + Ok(()) +} + +fn queue_activation(target: serde_json::Value) { + let queue = PENDING_ACTIVATIONS.get_or_init(Default::default); + let Ok(mut queue) = queue.lock() else { + eprintln!("buzz-desktop: macOS notification activation queue is unavailable"); + return; + }; + if queue.len() == MAX_PENDING_ACTIVATIONS { + queue.pop_front(); + } + queue.push_back(target); +} + +#[tauri::command] +pub(crate) fn take_pending_activations() -> Result, String> { + let queue = PENDING_ACTIVATIONS.get_or_init(Default::default); + let mut queue = queue + .lock() + .map_err(|_| "macOS notification activation queue is unavailable".to_string())?; + Ok(queue.drain(..).collect()) } fn is_bundled_application() -> bool { @@ -200,7 +221,25 @@ fn parse_target(serialized: &str) -> Option { #[cfg(test)] mod tests { - use super::{is_bundled_application, parse_target}; + use super::{ + is_bundled_application, parse_target, queue_activation, take_pending_activations, + MAX_PENDING_ACTIVATIONS, + }; + + #[test] + fn activation_queue_is_bounded_and_drained() { + let _ = take_pending_activations(); + for index in 0..=MAX_PENDING_ACTIVATIONS { + queue_activation(serde_json::json!({ "index": index })); + } + + let activations = take_pending_activations().expect("activation queue"); + assert_eq!(activations.len(), MAX_PENDING_ACTIVATIONS); + assert_eq!(activations[0]["index"], 1); + assert!(take_pending_activations() + .expect("drained activation queue") + .is_empty()); + } #[test] fn cargo_test_process_is_not_treated_as_bundled() { diff --git a/desktop/src/features/notifications/lib/desktop.ts b/desktop/src/features/notifications/lib/desktop.ts index a905a242765..4bafa84f3ed 100644 --- a/desktop/src/features/notifications/lib/desktop.ts +++ b/desktop/src/features/notifications/lib/desktop.ts @@ -8,9 +8,10 @@ import { } from "@tauri-apps/plugin-notification"; import { isLinuxPlatform, isMacPlatform } from "@/shared/lib/platform"; -// Backend event emitted when the user clicks a native Linux or macOS notification. -// See src-tauri/src/commands/notifications.rs. +// Backend event emitted when a native Linux notification is clicked or a +// queued macOS activation becomes available. See src-tauri notification code. const NATIVE_NOTIFICATION_ACTIVATED_EVENT = "native-notification-activated"; +const TAKE_PENDING_MACOS_NOTIFICATION_ACTIVATIONS = "take_pending_activations"; export type DesktopNotificationPermissionState = | NotificationPermission @@ -197,20 +198,36 @@ export async function listenForDesktopNotificationActions( } } - // Native Linux and macOS clicks come back through one backend event. macOS - // uses one app-lifetime UNUserNotificationCenterDelegate. + // Linux forwards the target as the event payload. macOS queues targets in + // Rust first so cold-start clicks survive until this listener is mounted. try { - nativeUnlisten = await listen( - NATIVE_NOTIFICATION_ACTIVATED_EVENT, - (event) => { - const target = parseNotificationTarget(event.payload); - if (!target) { - return; + const dispatchNativeActivations = async (payload?: unknown) => { + if (isMacPlatform()) { + const targets = await invoke( + TAKE_PENDING_MACOS_NOTIFICATION_ACTIVATIONS, + ); + for (const pendingTarget of targets) { + const target = parseNotificationTarget(pendingTarget); + if (target) { + dispatchDesktopNotificationTarget(target); + } } + return; + } + const target = parseNotificationTarget(payload); + if (target) { dispatchDesktopNotificationTarget(target); + } + }; + + nativeUnlisten = await listen( + NATIVE_NOTIFICATION_ACTIVATED_EVENT, + (event) => { + void dispatchNativeActivations(event.payload); }, ); + await dispatchNativeActivations(); } catch { nativeUnlisten = null; } @@ -307,7 +324,12 @@ export async function sendDesktopNotification( }); return true; } catch { - return false; + if (!isMacPlatform()) { + return false; + } + // UNUserNotificationCenter is unavailable to the unbundled executable + // used by Tauri dev. Preserve the previous macOS development behavior by + // falling through to the notification plugin; packaged apps use native UN. } } From b495df1905cf0aa5dd5b9233755c98c75515dd7e Mon Sep 17 00:00:00 2001 From: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Date: Tue, 4 Aug 2026 16:14:46 -0700 Subject: [PATCH 4/7] fix: retain macos notification activation listener Co-authored-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Signed-off-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> --- .../src/features/notifications/lib/desktop.ts | 53 ++++++++++++------- 1 file changed, 34 insertions(+), 19 deletions(-) diff --git a/desktop/src/features/notifications/lib/desktop.ts b/desktop/src/features/notifications/lib/desktop.ts index 4bafa84f3ed..338c845b57b 100644 --- a/desktop/src/features/notifications/lib/desktop.ts +++ b/desktop/src/features/notifications/lib/desktop.ts @@ -200,37 +200,52 @@ export async function listenForDesktopNotificationActions( // Linux forwards the target as the event payload. macOS queues targets in // Rust first so cold-start clicks survive until this listener is mounted. - try { - const dispatchNativeActivations = async (payload?: unknown) => { - if (isMacPlatform()) { - const targets = await invoke( - TAKE_PENDING_MACOS_NOTIFICATION_ACTIVATIONS, - ); - for (const pendingTarget of targets) { - const target = parseNotificationTarget(pendingTarget); - if (target) { - dispatchDesktopNotificationTarget(target); - } + const dispatchNativeActivations = async (payload?: unknown) => { + if (isMacPlatform()) { + const targets = await invoke( + TAKE_PENDING_MACOS_NOTIFICATION_ACTIVATIONS, + ); + for (const pendingTarget of targets) { + const target = parseNotificationTarget(pendingTarget); + if (target) { + dispatchDesktopNotificationTarget(target); } - return; } + return; + } - const target = parseNotificationTarget(payload); - if (target) { - dispatchDesktopNotificationTarget(target); - } - }; + const target = parseNotificationTarget(payload); + if (target) { + dispatchDesktopNotificationTarget(target); + } + }; + try { nativeUnlisten = await listen( NATIVE_NOTIFICATION_ACTIVATED_EVENT, (event) => { - void dispatchNativeActivations(event.payload); + void dispatchNativeActivations(event.payload).catch((error) => { + console.error( + "Failed to dispatch native notification activation", + error, + ); + }); }, ); - await dispatchNativeActivations(); } catch { nativeUnlisten = null; } + + if (nativeUnlisten && isMacPlatform()) { + try { + await dispatchNativeActivations(); + } catch (error) { + console.error( + "Failed to drain pending macOS notification activations", + error, + ); + } + } } return () => { From d8bd9ad0e28c0de2394f3857b099314f763c4450 Mon Sep 17 00:00:00 2001 From: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Date: Tue, 4 Aug 2026 16:25:26 -0700 Subject: [PATCH 5/7] refactor: clarify macos activation queue selection Co-authored-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Signed-off-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> --- desktop/src/features/notifications/lib/desktop.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/desktop/src/features/notifications/lib/desktop.ts b/desktop/src/features/notifications/lib/desktop.ts index 338c845b57b..e89c971368a 100644 --- a/desktop/src/features/notifications/lib/desktop.ts +++ b/desktop/src/features/notifications/lib/desktop.ts @@ -181,7 +181,9 @@ export async function listenForDesktopNotificationActions( let nativeUnlisten: (() => void) | null = null; if (isTauri()) { - if (!isLinuxPlatform() && !isMacPlatform()) { + const usesMacActivationQueue = isMacPlatform(); + + if (!isLinuxPlatform() && !usesMacActivationQueue) { try { pluginListener = await onAction((notification) => { const target = parseNotificationTarget( @@ -201,7 +203,7 @@ export async function listenForDesktopNotificationActions( // Linux forwards the target as the event payload. macOS queues targets in // Rust first so cold-start clicks survive until this listener is mounted. const dispatchNativeActivations = async (payload?: unknown) => { - if (isMacPlatform()) { + if (usesMacActivationQueue) { const targets = await invoke( TAKE_PENDING_MACOS_NOTIFICATION_ACTIVATIONS, ); @@ -236,7 +238,7 @@ export async function listenForDesktopNotificationActions( nativeUnlisten = null; } - if (nativeUnlisten && isMacPlatform()) { + if (nativeUnlisten && usesMacActivationQueue) { try { await dispatchNativeActivations(); } catch (error) { From a81241611d617becf7640bee6fe56b5cdb4d0fab Mon Sep 17 00:00:00 2001 From: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Date: Tue, 4 Aug 2026 17:05:40 -0700 Subject: [PATCH 6/7] fix(desktop): import linux notification event constant Co-authored-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Signed-off-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> --- desktop/src-tauri/src/commands/notifications.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/desktop/src-tauri/src/commands/notifications.rs b/desktop/src-tauri/src/commands/notifications.rs index 3eedfc6e7df..39e4a1144d0 100644 --- a/desktop/src-tauri/src/commands/notifications.rs +++ b/desktop/src-tauri/src/commands/notifications.rs @@ -48,6 +48,7 @@ pub fn show_native_notification( #[cfg(target_os = "linux")] mod linux { + use super::NATIVE_NOTIFICATION_ACTIVATED_EVENT; use tauri::Emitter; pub fn show( From e4a4ad6a7270974d0da86fdf5a77d128593b4e8a Mon Sep 17 00:00:00 2001 From: am <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Date: Wed, 5 Aug 2026 12:59:40 -0700 Subject: [PATCH 7/7] fix(desktop): reflect native macos notification permission Co-authored-by: am <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Signed-off-by: am <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> --- desktop/src-tauri/Cargo.toml | 2 +- .../src-tauri/src/commands/notifications.rs | 4 +- desktop/src-tauri/src/lib.rs | 4 + desktop/src-tauri/src/macos_notifications.rs | 168 +++++++++++++++--- desktop/src/features/notifications/hooks.ts | 23 +++ .../src/features/notifications/lib/desktop.ts | 33 +++- 6 files changed, 202 insertions(+), 32 deletions(-) diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 2ec743b74c1..bbf245e29a9 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -55,7 +55,7 @@ block2 = { version = "0.6", default-features = false, features = ["std"] } objc2 = { version = "0.6.4", default-features = false } objc2-app-kit = { version = "0.3.2", default-features = false, features = ["NSEvent", "NSHapticFeedback", "NSMenu", "NSMenuItem", "NSStatusItem", "block2"] } objc2-foundation = { version = "0.3.2", default-features = false, features = ["NSDictionary", "NSError", "NSBundle", "NSObject", "NSProcessInfo", "NSString"] } -objc2-user-notifications = { version = "0.3.2", default-features = false, features = ["block2", "UNNotification", "UNNotificationContent", "UNNotificationRequest", "UNNotificationResponse", "UNNotificationTrigger", "UNUserNotificationCenter"] } +objc2-user-notifications = { version = "0.3.2", default-features = false, features = ["block2", "UNNotification", "UNNotificationContent", "UNNotificationRequest", "UNNotificationResponse", "UNNotificationSettings", "UNNotificationTrigger", "UNUserNotificationCenter"] } keyring = { version = "3.6.3", default-features = false, features = ["apple-native", "vendored"], optional = true } security-framework = { version = "3.7.0", features = ["OSX_10_15"] } window-vibrancy = "0.6" diff --git a/desktop/src-tauri/src/commands/notifications.rs b/desktop/src-tauri/src/commands/notifications.rs index 39e4a1144d0..79aa15f969a 100644 --- a/desktop/src-tauri/src/commands/notifications.rs +++ b/desktop/src-tauri/src/commands/notifications.rs @@ -21,7 +21,7 @@ pub(crate) const NATIVE_NOTIFICATION_ACTIVATED_EVENT: &str = "native-notificatio /// one application-lifetime `UNUserNotificationCenterDelegate`; it does not /// allocate a listener or waiter for each notification. #[tauri::command] -pub fn show_native_notification( +pub async fn show_native_notification( app: tauri::AppHandle, title: String, body: Option, @@ -36,7 +36,7 @@ pub fn show_native_notification( #[cfg(target_os = "macos")] { let _ = app; - crate::macos_notifications::show(title, body, target) + crate::macos_notifications::show(title, body, target).await } #[cfg(not(any(target_os = "linux", target_os = "macos")))] diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 517a32b07e9..d22b95224b0 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -727,6 +727,10 @@ pub fn run() { show_native_notification, #[cfg(target_os = "macos")] macos_notifications::take_pending_activations, + #[cfg(target_os = "macos")] + macos_notifications::notification_permission_state, + #[cfg(target_os = "macos")] + macos_notifications::request_notification_access, upload_media, pick_and_upload_media, pick_and_upload_image, diff --git a/desktop/src-tauri/src/macos_notifications.rs b/desktop/src-tauri/src/macos_notifications.rs index 974c847d596..da2312b457e 100644 --- a/desktop/src-tauri/src/macos_notifications.rs +++ b/desktop/src-tauri/src/macos_notifications.rs @@ -8,7 +8,9 @@ use std::{ collections::VecDeque, - sync::{Mutex, OnceLock}, + ptr::NonNull, + sync::{mpsc, Mutex, OnceLock}, + time::Duration, }; use block2::{Block, RcBlock}; @@ -20,8 +22,9 @@ use objc2::{ }; use objc2_foundation::{NSBundle, NSDictionary, NSError, NSObject, NSObjectProtocol, NSString}; use objc2_user_notifications::{ - UNAuthorizationOptions, UNMutableNotificationContent, UNNotificationDefaultActionIdentifier, - UNNotificationPresentationOptions, UNNotificationRequest, UNNotificationResponse, + UNAuthorizationOptions, UNAuthorizationStatus, UNMutableNotificationContent, + UNNotificationDefaultActionIdentifier, UNNotificationPresentationOptions, + UNNotificationRequest, UNNotificationResponse, UNNotificationSettings, UNUserNotificationCenter, UNUserNotificationCenterDelegate, }; use tauri::{AppHandle, Emitter}; @@ -31,6 +34,24 @@ use crate::commands::NATIVE_NOTIFICATION_ACTIVATED_EVENT; const TARGET_USER_INFO_KEY: &str = "buzzNotificationTarget"; const MAX_PENDING_ACTIVATIONS: usize = 64; +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum NotificationPermissionState { + Default, + Denied, + Granted, +} + +fn permission_state(status: UNAuthorizationStatus) -> NotificationPermissionState { + match status { + UNAuthorizationStatus::Denied => NotificationPermissionState::Denied, + UNAuthorizationStatus::Authorized + | UNAuthorizationStatus::Provisional + | UNAuthorizationStatus::Ephemeral => NotificationPermissionState::Granted, + _ => NotificationPermissionState::Default, + } +} + static PENDING_ACTIVATIONS: OnceLock>> = OnceLock::new(); struct NotificationDelegateIvars { @@ -118,18 +139,6 @@ pub(crate) fn init(app: &AppHandle) -> tauri::Result<()> { ProtocolObject::from_retained(delegate); center.setDelegate(Some(&delegate)); - let authorization_handler = RcBlock::new(|granted: Bool, error: *mut NSError| { - if let Some(error) = unsafe { error.as_ref() } { - eprintln!("buzz-desktop: macOS notification authorization failed: {error}"); - } else if !granted.as_bool() { - eprintln!("buzz-desktop: macOS notification authorization was denied"); - } - }); - center.requestAuthorizationWithOptions_completionHandler( - UNAuthorizationOptions::Alert | UNAuthorizationOptions::Sound, - &authorization_handler, - ); - // UNUserNotificationCenter.delegate is weak. This object is deliberately // process-lifetime state, matching the application-lifetime delegate Apple // documents and avoiding mutable global or per-notification registrations. @@ -137,16 +146,80 @@ pub(crate) fn init(app: &AppHandle) -> tauri::Result<()> { Ok(()) } -pub(crate) fn show( +fn ensure_bundled_application() -> Result<(), String> { + if is_bundled_application() { + Ok(()) + } else { + Err( + "macOS notifications are unavailable when Buzz is not running from an app bundle" + .to_string(), + ) + } +} + +fn notification_permission_state_sync() -> Result { + ensure_bundled_application()?; + + let (sender, receiver) = mpsc::sync_channel(1); + let handler = RcBlock::new(move |settings: NonNull| { + // SAFETY: Apple guarantees a live UNNotificationSettings object for + // the duration of this completion handler. + let status = unsafe { settings.as_ref() }.authorizationStatus(); + let _ = sender.send(permission_state(status)); + }); + UNUserNotificationCenter::currentNotificationCenter() + .getNotificationSettingsWithCompletionHandler(&handler); + + receiver + .recv_timeout(Duration::from_secs(10)) + .map_err(|_| "macOS notification settings request timed out".to_string()) +} + +#[tauri::command] +pub(crate) async fn notification_permission_state() -> Result { + tokio::task::spawn_blocking(notification_permission_state_sync) + .await + .map_err(|error| format!("macOS notification settings task failed: {error}"))? +} + +fn request_notification_access_sync() -> Result { + ensure_bundled_application()?; + + let (sender, receiver) = mpsc::sync_channel(1); + let handler = RcBlock::new(move |_granted: Bool, error: *mut NSError| { + let result = match unsafe { error.as_ref() } { + Some(error) => Err(format!("macOS notification authorization failed: {error}")), + None => Ok(()), + }; + let _ = sender.send(result); + }); + UNUserNotificationCenter::currentNotificationCenter() + .requestAuthorizationWithOptions_completionHandler( + UNAuthorizationOptions::Alert | UNAuthorizationOptions::Sound, + &handler, + ); + + receiver + .recv_timeout(Duration::from_secs(60)) + .map_err(|_| "macOS notification authorization request timed out".to_string())??; + notification_permission_state_sync() +} + +#[tauri::command] +pub(crate) async fn request_notification_access() -> Result { + tokio::task::spawn_blocking(request_notification_access_sync) + .await + .map_err(|error| format!("macOS notification authorization task failed: {error}"))? +} + +fn show_sync( title: String, body: Option, target: Option, ) -> Result<(), String> { - if !is_bundled_application() { - return Err( - "macOS notifications are unavailable when Buzz is not running from an app bundle" - .to_string(), - ); + ensure_bundled_application()?; + if notification_permission_state_sync()? != NotificationPermissionState::Granted { + return Err("macOS notification permission is not granted".to_string()); } let content = UNMutableNotificationContent::new(); @@ -172,14 +245,30 @@ pub(crate) fn show( let identifier = NSString::from_str(&uuid::Uuid::new_v4().to_string()); let request = UNNotificationRequest::requestWithIdentifier_content_trigger(&identifier, &content, None); - let delivery_handler = RcBlock::new(|error: *mut NSError| { - if let Some(error) = unsafe { error.as_ref() } { - eprintln!("buzz-desktop: failed to deliver macOS notification: {error}"); - } + let (sender, receiver) = mpsc::sync_channel(1); + let delivery_handler = RcBlock::new(move |error: *mut NSError| { + let result = match unsafe { error.as_ref() } { + Some(error) => Err(format!("failed to deliver macOS notification: {error}")), + None => Ok(()), + }; + let _ = sender.send(result); }); UNUserNotificationCenter::currentNotificationCenter() .addNotificationRequest_withCompletionHandler(&request, Some(&delivery_handler)); - Ok(()) + + receiver + .recv_timeout(Duration::from_secs(10)) + .map_err(|_| "macOS notification delivery request timed out".to_string())? +} + +pub(crate) async fn show( + title: String, + body: Option, + target: Option, +) -> Result<(), String> { + tokio::task::spawn_blocking(move || show_sync(title, body, target)) + .await + .map_err(|error| format!("macOS notification delivery task failed: {error}"))? } fn queue_activation(target: serde_json::Value) { @@ -222,9 +311,10 @@ fn parse_target(serialized: &str) -> Option { #[cfg(test)] mod tests { use super::{ - is_bundled_application, parse_target, queue_activation, take_pending_activations, - MAX_PENDING_ACTIVATIONS, + is_bundled_application, parse_target, permission_state, queue_activation, + take_pending_activations, NotificationPermissionState, MAX_PENDING_ACTIVATIONS, }; + use objc2_user_notifications::UNAuthorizationStatus; #[test] fn activation_queue_is_bounded_and_drained() { @@ -246,6 +336,28 @@ mod tests { assert!(!is_bundled_application()); } + #[test] + fn maps_native_authorization_states_to_frontend_contract() { + assert_eq!( + permission_state(UNAuthorizationStatus::NotDetermined), + NotificationPermissionState::Default + ); + assert_eq!( + permission_state(UNAuthorizationStatus::Denied), + NotificationPermissionState::Denied + ); + for status in [ + UNAuthorizationStatus::Authorized, + UNAuthorizationStatus::Provisional, + UNAuthorizationStatus::Ephemeral, + ] { + assert_eq!( + permission_state(status), + NotificationPermissionState::Granted + ); + } + } + #[test] fn parses_opaque_notification_target() { let target = diff --git a/desktop/src/features/notifications/hooks.ts b/desktop/src/features/notifications/hooks.ts index 72d1a033817..d70ac60b220 100644 --- a/desktop/src/features/notifications/hooks.ts +++ b/desktop/src/features/notifications/hooks.ts @@ -209,6 +209,29 @@ export function useNotificationSettings(pubkey?: string) { void refreshPermission(); }, [normalizedPubkey]); + React.useEffect(() => { + const refreshWhenVisible = () => { + if (document.visibilityState === "visible") { + void refreshPermission(); + } + }; + document.addEventListener("visibilitychange", refreshWhenVisible); + window.addEventListener("focus", refreshWhenVisible); + return () => { + document.removeEventListener("visibilitychange", refreshWhenVisible); + window.removeEventListener("focus", refreshWhenVisible); + }; + }, []); + + React.useEffect(() => { + if ( + settings.desktopEnabled && + (permission === "denied" || permission === "unsupported") + ) { + setSettings((current) => ({ ...current, desktopEnabled: false })); + } + }, [permission, settings.desktopEnabled]); + const setDesktopEnabled = React.useCallback(async (enabled: boolean) => { if (!enabled) { setErrorMessage(null); diff --git a/desktop/src/features/notifications/lib/desktop.ts b/desktop/src/features/notifications/lib/desktop.ts index e89c971368a..dbc21d9d23c 100644 --- a/desktop/src/features/notifications/lib/desktop.ts +++ b/desktop/src/features/notifications/lib/desktop.ts @@ -12,6 +12,8 @@ import { isLinuxPlatform, isMacPlatform } from "@/shared/lib/platform"; // queued macOS activation becomes available. See src-tauri notification code. const NATIVE_NOTIFICATION_ACTIVATED_EVENT = "native-notification-activated"; const TAKE_PENDING_MACOS_NOTIFICATION_ACTIVATIONS = "take_pending_activations"; +const MACOS_NOTIFICATION_PERMISSION_STATE = "notification_permission_state"; +const REQUEST_MACOS_NOTIFICATION_ACCESS = "request_notification_access"; export type DesktopNotificationPermissionState = | NotificationPermission @@ -121,11 +123,29 @@ function dispatchDesktopNotificationTarget(target: DesktopNotificationTarget) { ); } +function shouldUseMacDevelopmentFallback(error: unknown): boolean { + return String(error).includes("not running from an app bundle"); +} + export async function getDesktopNotificationPermissionState(): Promise { if (!hasNotificationApi()) { return "unsupported"; } + if (isTauri() && isMacPlatform()) { + try { + return await invoke( + MACOS_NOTIFICATION_PERMISSION_STATE, + ); + } catch (error) { + // The native API rejects the unbundled executable used by `tauri dev`. + // Preserve that development path through the plugin-backed shim. + if (!shouldUseMacDevelopmentFallback(error)) { + return "default"; + } + } + } + if (window.Notification.permission !== "default") { return window.Notification.permission; } @@ -153,7 +173,18 @@ export async function requestDesktopNotificationAccess(): Promise { + const request = + isTauri() && isMacPlatform() + ? invoke(REQUEST_MACOS_NOTIFICATION_ACCESS).catch( + (error) => { + if (shouldUseMacDevelopmentFallback(error)) { + return requestPermission(); + } + throw error; + }, + ) + : requestPermission(); + pendingPermissionRequest = request.finally(() => { pendingPermissionRequest = null; });