fix(desktop): route macos notification clicks (#4799)

## Summary
- deliver macOS notifications through `UNUserNotificationCenter`
- route notification clicks to the referenced channel or thread through
the existing frontend activation path
- preserve click targets across cold startup and frontend remounts with
a small process-wide activation queue
- keep Linux notification activation behavior unchanged

## Architecture
A single `UNUserNotificationCenterDelegate` is installed during Tauri
setup. Each notification stores its navigation target in `userInfo`. On
click, Rust queues the target before emitting a wake-up event; the
frontend atomically drains the queue and dispatches the existing
notification action. The queue is the source of truth, which prevents
cold-start loss and duplicate delivery.

## Validation
Verified at `a81241611d617becf7640bee6fe56b5cdb4d0fab`:
- Biome format/check and lint
- TypeScript typecheck
- repository and desktop-Tauri `cargo fmt --check`
- repository and desktop-Tauri Clippy with `-D warnings`
- full pre-push desktop tests and Tauri workspace checks/tests
- desktop production build

Manual macOS validation passed: after explicitly ad-hoc signing the
local bundle with `xyz.block.buzz.app`, the operator confirmed real
Notification Center delivery and click navigation.

<details>
<summary>Local macOS test procedure</summary>

Tauri's generated ad-hoc signing identifier is not accepted by
`UNUserNotificationCenter`. Re-sign the local bundle with its bundle
identifier and keep other Buzz copies closed:

```bash
just desktop-release-build
APP="$HOME/.cache/cargo-target/aarch64-apple-darwin/release/bundle/macos/Buzz.app"
codesign --force --deep --sign - \
  --identifier xyz.block.buzz.app \
  --entitlements desktop/src-tauri/Entitlements.plist \
  "$APP"
codesign --verify --deep --strict --verbose=2 "$APP"
pkill -x buzz-desktop || true
open -n "$APP"
```

</details>

Buzz channel: `55e2bfca-1b38-48fb-9dc2-584d400501f3`

---------

Signed-off-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz>
Signed-off-by: am <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz>
Co-authored-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz>
This commit is contained in:
Kalvin C
2026-08-05 13:29:41 -07:00
committed by GitHub
co-authored by npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7
parent 05150c1188
commit 7334ad1e16
7 changed files with 533 additions and 43 deletions
+3
View File
@@ -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",
]
+2 -1
View File
@@ -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"
+17 -12
View File
@@ -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<String>,
@@ -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);
});
});
}
+12 -1
View File
@@ -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,
@@ -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<Mutex<VecDeque<serde_json::Value>>> = 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<dyn Fn(UNNotificationPresentationOptions)>,
) {
// 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<dyn Fn()>,
) {
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<Self> {
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<dyn UNUserNotificationCenterDelegate>> =
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<NotificationPermissionState, String> {
ensure_bundled_application()?;
let (sender, receiver) = mpsc::sync_channel(1);
let handler = RcBlock::new(move |settings: NonNull<UNNotificationSettings>| {
// 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<NotificationPermissionState, String> {
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<NotificationPermissionState, String> {
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<NotificationPermissionState, String> {
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<String>,
target: Option<serde_json::Value>,
) -> 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::<NSString, NSString>::from_slices(&[&*key], &[&*value]);
// SAFETY: Both the key and value are property-list-safe NSString values.
unsafe {
let user_info =
Retained::cast_unchecked::<NSDictionary<AnyObject, AnyObject>>(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<String>,
target: Option<serde_json::Value>,
) -> 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<Vec<serde_json::Value>, 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<serde_json::Value> {
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::<NSString>().ok()?;
parse_target(&target.to_string())
}
fn parse_target(serialized: &str) -> Option<serde_json::Value> {
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());
}
}
@@ -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);
+100 -29
View File
@@ -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<DesktopNotificationPermissionState> {
if (!hasNotificationApi()) {
return "unsupported";
}
if (isTauri() && isMacPlatform()) {
try {
return await invoke<NotificationPermission>(
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<DesktopNotific
return pendingPermissionRequest;
}
pendingPermissionRequest = requestPermission().finally(() => {
const request =
isTauri() && isMacPlatform()
? invoke<NotificationPermission>(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,
);
if (!target) {
return;
}
const usesMacActivationQueue = isMacPlatform();
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<unknown>(
NATIVE_NOTIFICATION_ACTIVATED_EVENT,
(event) => {
const target = parseNotificationTarget(event.payload);
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<unknown[]>(
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);
}
};
try {
nativeUnlisten = await listen<unknown>(
NATIVE_NOTIFICATION_ACTIVATED_EVENT,
(event) => {
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.
}
}