mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
co-authored by
npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7
parent
05150c1188
commit
7334ad1e16
Generated
+3
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user