diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index afd119c84d..fbaa547a03 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 1ba814da47..bbf245e29a 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", "NSBundle", "NSObject", "NSProcessInfo", "NSString"] } +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 c13d96ff6d..79aa15f969 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`. @@ -13,13 +13,15 @@ //! 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. /// -/// 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( +pub async fn show_native_notification( app: tauri::AppHandle, title: String, body: Option, @@ -31,21 +33,24 @@ pub fn show_native_notification( Ok(()) } - #[cfg(not(target_os = "linux"))] + #[cfg(target_os = "macos")] + { + let _ = app; + crate::macos_notifications::show(title, body, target).await + } + + #[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()) } } #[cfg(target_os = "linux")] mod linux { + use super::NATIVE_NOTIFICATION_ACTIVATED_EVENT; 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. - const ACTIVATE_EVENT: &str = "native-notification-activated"; - pub fn show( app: tauri::AppHandle, title: String, @@ -96,7 +101,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/lib.rs b/desktop/src-tauri/src/lib.rs index a7c191c43b..d22b95224b 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 @@ -720,6 +725,12 @@ pub fn run() { remove_reaction, get_event, 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 new file mode 100644 index 0000000000..da2312b457 --- /dev/null +++ b/desktop/src-tauri/src/macos_notifications.rs @@ -0,0 +1,376 @@ +//! 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 std::{ + collections::VecDeque, + ptr::NonNull, + sync::{mpsc, Mutex, OnceLock}, + time::Duration, +}; + +use block2::{Block, RcBlock}; +use objc2::{ + define_class, msg_send, + rc::Retained, + runtime::{AnyObject, Bool, ProtocolObject}, + AnyThread, DefinedClass, +}; +use objc2_foundation::{NSBundle, NSDictionary, NSError, NSObject, NSObjectProtocol, NSString}; +use objc2_user_notifications::{ + UNAuthorizationOptions, UNAuthorizationStatus, UNMutableNotificationContent, + UNNotificationDefaultActionIdentifier, UNNotificationPresentationOptions, + UNNotificationRequest, UNNotificationResponse, UNNotificationSettings, + UNUserNotificationCenter, UNUserNotificationCenterDelegate, +}; +use tauri::{AppHandle, Emitter}; + +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 { + app: AppHandle, +} + +define_class!( + // 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 = AnyThread] + #[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, + ) { + // 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:))] + 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) { + queue_activation(target); + crate::tray_menu::show_main_window(&self.ivars().app); + if let Err(error) = self + .ivars() + .app + .emit(NATIVE_NOTIFICATION_ACTIVATED_EVENT, ()) + { + 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(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<()> { + 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 center = UNUserNotificationCenter::currentNotificationCenter(); + let delegate = NotificationDelegate::new(app.clone()); + let delegate: Retained> = + ProtocolObject::from_retained(delegate); + center.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(()) +} + +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> { + ensure_bundled_application()?; + if notification_permission_state_sync()? != NotificationPermissionState::Granted { + return Err("macOS notification permission is not granted".to_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); + 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)); + + 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) { + 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 { + NSBundle::mainBundle().bundleIdentifier().is_some() +} + +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::{ + 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() { + 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() { + 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 = + 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/hooks.ts b/desktop/src/features/notifications/hooks.ts index 72d1a03381..d70ac60b22 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 380521e0f2..dbc21d9d23 100644 --- a/desktop/src/features/notifications/lib/desktop.ts +++ b/desktop/src/features/notifications/lib/desktop.ts @@ -8,9 +8,12 @@ import { } from "@tauri-apps/plugin-notification"; import { isLinuxPlatform, isMacPlatform } from "@/shared/lib/platform"; -// Backend event emitted when the user clicks a native (Linux) 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"; +const MACOS_NOTIFICATION_PERMISSION_STATE = "notification_permission_state"; +const REQUEST_MACOS_NOTIFICATION_ACCESS = "request_notification_access"; export type DesktopNotificationPermissionState = | NotificationPermission @@ -120,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; } @@ -152,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; }); @@ -180,38 +212,73 @@ export async function listenForDesktopNotificationActions( let nativeUnlisten: (() => void) | null = null; if (isTauri()) { - try { - pluginListener = await onAction((notification) => { - const target = parseNotificationTarget( - notification.extra?.buzzNotificationTarget, + const usesMacActivationQueue = isMacPlatform(); + + if (!isLinuxPlatform() && !usesMacActivationQueue) { + try { + pluginListener = await onAction((notification) => { + const target = parseNotificationTarget( + notification.extra?.buzzNotificationTarget, + ); + if (!target) { + return; + } + + dispatchDesktopNotificationTarget(target); + }); + } catch { + pluginListener = null; + } + } + + // 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 (usesMacActivationQueue) { + const targets = await invoke( + TAKE_PENDING_MACOS_NOTIFICATION_ACTIVATIONS, ); - if (!target) { - return; + for (const pendingTarget of targets) { + const target = parseNotificationTarget(pendingTarget); + if (target) { + dispatchDesktopNotificationTarget(target); + } } + return; + } + const target = parseNotificationTarget(payload); + if (target) { 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). try { nativeUnlisten = await listen( NATIVE_NOTIFICATION_ACTIVATED_EVENT, (event) => { - const target = parseNotificationTarget(event.payload); - if (!target) { - return; - } - - dispatchDesktopNotificationTarget(target); + void dispatchNativeActivations(event.payload).catch((error) => { + console.error( + "Failed to dispatch native notification activation", + error, + ); + }); }, ); } catch { nativeUnlisten = null; } + + if (nativeUnlisten && usesMacActivationQueue) { + try { + await dispatchNativeActivations(); + } catch (error) { + console.error( + "Failed to drain pending macOS notification activations", + error, + ); + } + } } return () => { @@ -293,11 +360,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, @@ -306,7 +372,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. } }