Make notifications appear on screen, and be clickable

Three separate faults, all mine:

set_application was never called, so mac-notification-sys looked up an
app named "use_default", failed, and posted every notification as
com.apple.Finder - wearing Finder's alert style rather than this app's.

NSUserNotificationCenter suppresses the banner whenever the posting app
is frontmost unless the delegate implements shouldPresentNotification:.
The crate's delegate implements only delivery and activation, so the
method is added to its class at runtime. Without it, Gmail notifying
while you sit in Odoo - same window, still frontmost - is never seen,
which is the case the whole design exists for.

send_notification only waits for a response when the options ask it to.
Passing None returned instantly with NotificationResponse::None, so the
click branch was unreachable. Waiting parks a thread and a notification
left unread never resolves, so waiters are capped at 32.

The diagnostic now records the raise before blocking, since with
wait_for_click a notification sitting on screen otherwise read as
"none yet".
This commit is contained in:
2026-09-01 13:25:02 +02:00
parent 3525d454bf
commit 21a9f065f0
3 changed files with 129 additions and 6 deletions
+68 -2
View File
@@ -446,13 +446,34 @@ pub fn notification_status(app: AppHandle) -> String {
};
let app_state = app.state::<AppState>();
let last = app_state.last_notification.lock().unwrap().clone();
let diag = app_state.diag.lock().unwrap().clone();
let from_page = if last.is_empty() { "none yet".into() } else { last };
format!("permission: {state} · direct: {raised} · from page: {from_page}")
// The probe's own report, which distinguishes "the page never ran" from
// "the page ran and the message did not arrive".
let probe = if diag.is_empty() { "no reply".into() } else { diag };
format!("permission: {state} · direct: {raised} · from page: {from_page} · probe: {probe}")
}
/// Asks macOS for notification permission, once, at startup.
/// Asks macOS for notification permission, and claims this app's identity.
///
/// The identity has to be set explicitly. Left alone, mac-notification-sys
/// looks up an application literally named "use_default", fails, and falls back
/// to `com.apple.Finder` — so every notification arrives as Finder, wearing
/// Finder's alert style, which is why they land silently in Notification Centre
/// instead of on screen. It is registered before the first notification because
/// the crate's own lazy fallback runs once and cannot be corrected afterwards.
pub fn ensure_notification_permission(app: &AppHandle) {
use tauri_plugin_notification::NotificationExt;
#[cfg(target_os = "macos")]
{
let bundle = app.config().identifier.clone();
if let Err(e) = mac_notification_sys::set_application(&bundle) {
eprintln!("could not claim {bundle} for notifications: {e}");
}
present_notifications_while_frontmost();
}
let granted = matches!(
app.notification().permission_state(),
Ok(tauri_plugin_notification::PermissionState::Granted)
@@ -557,6 +578,51 @@ pub fn set_zoom(app_id: String, zoom: f64, app: AppHandle, state: State<'_, AppS
Ok(state.cfg())
}
/// Makes macOS show a banner even when this app is the frontmost one.
///
/// `NSUserNotificationCenter` suppresses the banner whenever the posting
/// application is in front, delivering it silently to Notification Centre
/// instead, unless the delegate says otherwise. That default is wrong here: an
/// app showing Odoo is still frontmost when Gmail — a hidden webview in the
/// same window — has something to say, and that notification is the entire
/// point of keeping the other apps loaded.
///
/// The delegate belongs to mac-notification-sys and implements only delivery
/// and activation, so the missing method is added to its class at runtime.
#[cfg(target_os = "macos")]
fn present_notifications_while_frontmost() {
use objc2::ffi::class_addMethod;
use objc2::runtime::{AnyClass, AnyObject, Bool, Sel};
extern "C" fn should_present(
_this: *mut AnyObject,
_cmd: Sel,
_center: *mut AnyObject,
_notification: *mut AnyObject,
) -> Bool {
Bool::YES
}
let Some(class) = AnyClass::get(c"NotificationCenterDelegate") else {
eprintln!("notification delegate class is missing; banners will stay silent");
return;
};
let added = unsafe {
class_addMethod(
class as *const AnyClass as *mut AnyClass,
objc2::sel!(userNotificationCenter:shouldPresentNotification:),
std::mem::transmute::<*const (), unsafe extern "C-unwind" fn()>(
should_present as *const (),
),
c"c@:@@".as_ptr(),
)
};
if !added.as_bool() {
eprintln!("could not force banner presentation; notifications may stay silent");
}
}
/// Reloads whichever app is showing, for the menu bar's Reload item.
pub fn reload_active(app: &AppHandle) {
let state = app.state::<AppState>();