Skip to content

Commit 584dc5b

Browse files
AzimovSclaude
andcommitted
fix(notifications): restore macOS banner delivery via UNUserNotificationCenter
tauri-plugin-notification -> notify-rust -> mac-notification-sys uses the deprecated NSUserNotification API whose banner delivery is broken on modern macOS. Notifications land in Notification Center but no banner ever appears top-right. Route macOS notifications through UNUserNotificationCenter directly (via objc2-user- notifications). Install a UNUserNotificationCenterDelegate whose willPresent handler returns .banner|.list|.sound so foreground-app notifications show banners. Keep the existing tauri-plugin-notification path for Windows and Linux. First launch of the new build will re-prompt for notification permission (NS and UN grants live in separate slots). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ae62910 commit 584dc5b

5 files changed

Lines changed: 288 additions & 26 deletions

File tree

Cargo.lock

Lines changed: 31 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/src-tauri/Cargo.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,13 @@ time = { version = "0.3", features = ["formatting"] }
163163
reqwest = { version = "0.11", features = ["multipart", "json"] }
164164
core-graphics = "0.23"
165165
cidre = { git = "https://github.com/yury/cidre", rev = "a9587fa", features = ["av"] }
166+
167+
# UNUserNotificationCenter (modern replacement for deprecated NSUserNotification).
168+
# See docs/plans/2026-04-20-fix-macos-notification-banner-delivery-plan.md.
169+
objc2 = "0.6"
170+
objc2-foundation = "0.3"
171+
objc2-user-notifications = "0.3"
172+
block2 = "0.6"
166173
dasp = "0.11.0"
167174
futures-channel = "0.3.31"
168175

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
//! macOS shim over `UNUserNotificationCenter` (from `UserNotifications.framework`).
2+
//!
3+
//! Replaces the deprecated `NSUserNotification` path that `tauri-plugin-notification` →
4+
//! `notify-rust` → `mac-notification-sys` still uses. On modern macOS, `NSUserNotification`
5+
//! delivers to Notification Center but no banner fires. The modern UN API does.
6+
//!
7+
//! See `docs/plans/2026-04-20-fix-macos-notification-banner-delivery-plan.md`.
8+
9+
use anyhow::{anyhow, Result};
10+
use block2::RcBlock;
11+
use log::{error as log_error, info as log_info};
12+
use objc2::rc::Retained;
13+
use objc2::runtime::ProtocolObject;
14+
use objc2::{define_class, msg_send, AllocAnyThread};
15+
use objc2_foundation::{NSError, NSObject, NSObjectProtocol, NSString};
16+
use objc2_user_notifications::{
17+
UNAuthorizationOptions, UNMutableNotificationContent, UNNotification,
18+
UNNotificationInterruptionLevel, UNNotificationPresentationOptions, UNNotificationRequest,
19+
UNNotificationSound, UNUserNotificationCenter, UNUserNotificationCenterDelegate,
20+
};
21+
use once_cell::sync::OnceCell;
22+
use std::sync::Mutex;
23+
use tokio::sync::oneshot;
24+
use uuid::Uuid;
25+
26+
use crate::notifications::types::{Notification, NotificationPriority};
27+
28+
define_class!(
29+
// SAFETY: NSObject has no subclassing requirements; we add no ivars and no Drop.
30+
#[unsafe(super = NSObject)]
31+
#[ivars = ()]
32+
struct BannerDelegate;
33+
34+
unsafe impl NSObjectProtocol for BannerDelegate {}
35+
36+
unsafe impl UNUserNotificationCenterDelegate for BannerDelegate {
37+
// The linchpin: tell macOS to present banners even when our app is frontmost.
38+
// Without this, foreground-app notifications land in NC silently.
39+
#[unsafe(method(userNotificationCenter:willPresentNotification:withCompletionHandler:))]
40+
fn will_present_notification(
41+
&self,
42+
_center: &UNUserNotificationCenter,
43+
_notification: &UNNotification,
44+
completion_handler: &block2::DynBlock<dyn Fn(UNNotificationPresentationOptions)>,
45+
) {
46+
let options = UNNotificationPresentationOptions::Banner
47+
| UNNotificationPresentationOptions::List
48+
| UNNotificationPresentationOptions::Sound;
49+
completion_handler.call((options,));
50+
}
51+
}
52+
);
53+
54+
impl BannerDelegate {
55+
fn new() -> Retained<Self> {
56+
let this = Self::alloc().set_ivars(());
57+
unsafe { msg_send![super(this), init] }
58+
}
59+
}
60+
61+
// Retain the delegate for the lifetime of the app — `setDelegate:` is a weak property,
62+
// so dropping this would silently unwire our willPresent hook.
63+
static DELEGATE_CELL: OnceCell<Retained<BannerDelegate>> = OnceCell::new();
64+
65+
fn install_delegate_if_needed() {
66+
DELEGATE_CELL.get_or_init(|| {
67+
let delegate = BannerDelegate::new();
68+
let center = UNUserNotificationCenter::currentNotificationCenter();
69+
center.setDelegate(Some(ProtocolObject::from_ref(&*delegate)));
70+
log_info!(
71+
"Installed UNUserNotificationCenterDelegate (willPresent → Banner|List|Sound)"
72+
);
73+
delegate
74+
});
75+
}
76+
77+
/// Request notification authorization. Idempotent; if the user has already granted (or denied),
78+
/// macOS returns the stored decision without re-prompting.
79+
pub async fn request_authorization() -> Result<bool> {
80+
install_delegate_if_needed();
81+
82+
// Scope the ObjC handles (not Send) so they drop before we await.
83+
let (tx, rx) = oneshot::channel::<Result<bool>>();
84+
{
85+
let tx_slot: Mutex<Option<oneshot::Sender<Result<bool>>>> = Mutex::new(Some(tx));
86+
87+
let block = RcBlock::new(move |granted: objc2::runtime::Bool, error: *mut NSError| {
88+
let result = if !error.is_null() {
89+
let msg = unsafe { error_message(error) };
90+
log_error!("UN requestAuthorization error: {}", msg);
91+
Err(anyhow!("UN requestAuthorization error: {}", msg))
92+
} else {
93+
Ok(granted.as_bool())
94+
};
95+
if let Some(tx) = tx_slot.lock().ok().and_then(|mut guard| guard.take()) {
96+
let _ = tx.send(result);
97+
}
98+
});
99+
100+
let opts = UNAuthorizationOptions::Alert
101+
| UNAuthorizationOptions::Sound
102+
| UNAuthorizationOptions::Badge;
103+
let center = UNUserNotificationCenter::currentNotificationCenter();
104+
center.requestAuthorizationWithOptions_completionHandler(opts, &block);
105+
}
106+
107+
match rx.await {
108+
Ok(r) => r,
109+
Err(_) => Err(anyhow!("UN authorization completion dropped")),
110+
}
111+
}
112+
113+
/// Present a notification via `UNUserNotificationCenter`.
114+
pub async fn show(notification: &Notification) -> Result<()> {
115+
install_delegate_if_needed();
116+
117+
let id_str = notification
118+
.id
119+
.clone()
120+
.unwrap_or_else(|| Uuid::new_v4().to_string());
121+
let level = interruption_level(&notification.priority);
122+
log_info!(
123+
"UN present: id={} title={:?} level={:?}",
124+
id_str,
125+
notification.title,
126+
level
127+
);
128+
129+
// Scope the ObjC handles (not Send) so they drop before we await.
130+
let (tx, rx) = oneshot::channel::<Result<()>>();
131+
{
132+
let content = UNMutableNotificationContent::new();
133+
content.setTitle(&NSString::from_str(&notification.title));
134+
content.setBody(&NSString::from_str(&notification.body));
135+
if notification.sound {
136+
let sound = UNNotificationSound::defaultSound();
137+
content.setSound(Some(&sound));
138+
}
139+
content.setInterruptionLevel(level);
140+
141+
let id_ns = NSString::from_str(&id_str);
142+
let request =
143+
UNNotificationRequest::requestWithIdentifier_content_trigger(&id_ns, &content, None);
144+
145+
let tx_slot: Mutex<Option<oneshot::Sender<Result<()>>>> = Mutex::new(Some(tx));
146+
let block = RcBlock::new(move |error: *mut NSError| {
147+
let result = if error.is_null() {
148+
Ok(())
149+
} else {
150+
let msg = unsafe { error_message(error) };
151+
Err(anyhow!("UN addNotificationRequest failed: {}", msg))
152+
};
153+
if let Some(tx) = tx_slot.lock().ok().and_then(|mut guard| guard.take()) {
154+
let _ = tx.send(result);
155+
}
156+
});
157+
158+
let center = UNUserNotificationCenter::currentNotificationCenter();
159+
center.addNotificationRequest_withCompletionHandler(&request, Some(&block));
160+
}
161+
162+
match rx.await {
163+
Ok(r) => r,
164+
Err(_) => Err(anyhow!("UN addNotificationRequest completion dropped")),
165+
}
166+
}
167+
168+
/// Map our `NotificationPriority` to `UNNotificationInterruptionLevel`.
169+
///
170+
/// `Critical` maps to `TimeSensitive`, not `Critical`: the real Critical level requires Apple's
171+
/// Critical Alerts entitlement, which our ad-hoc-signed build does not have. Requesting it would
172+
/// fail silently at the OS layer.
173+
fn interruption_level(priority: &NotificationPriority) -> UNNotificationInterruptionLevel {
174+
match priority {
175+
NotificationPriority::Low => UNNotificationInterruptionLevel::Passive,
176+
NotificationPriority::Normal => UNNotificationInterruptionLevel::Active,
177+
NotificationPriority::High => UNNotificationInterruptionLevel::TimeSensitive,
178+
NotificationPriority::Critical => UNNotificationInterruptionLevel::TimeSensitive,
179+
}
180+
}
181+
182+
unsafe fn error_message(error: *mut NSError) -> String {
183+
if error.is_null() {
184+
return String::from("<null NSError>");
185+
}
186+
let err: &NSError = unsafe { &*error };
187+
err.localizedDescription().to_string()
188+
}

frontend/src-tauri/src/notifications/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ pub mod settings;
55
pub mod commands;
66
pub mod manager;
77

8+
#[cfg(target_os = "macos")]
9+
pub mod macos_un;
10+
811
// Re-export main types for easy access
912
pub use types::{
1013
Notification, NotificationType, NotificationPriority, NotificationTimeout

0 commit comments

Comments
 (0)