From 21a9f065f0780da85034f215268e9d33028f7f97 Mon Sep 17 00:00:00 2001 From: Vincent Date: Tue, 1 Sep 2026 13:25:02 +0200 Subject: [PATCH] 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". --- .../specs/2026-09-01-work-app-design.md | 18 +++++ src-tauri/src/commands.rs | 70 ++++++++++++++++++- src-tauri/src/webviews.rs | 47 +++++++++++-- 3 files changed, 129 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/specs/2026-09-01-work-app-design.md b/docs/superpowers/specs/2026-09-01-work-app-design.md index 2d11e08..e1afc11 100644 --- a/docs/superpowers/specs/2026-09-01-work-app-design.md +++ b/docs/superpowers/specs/2026-09-01-work-app-design.md @@ -184,6 +184,24 @@ click handler, which is the only thing that knows which message the notification about. This is why notifications are raised through `mac-notification-sys` rather than Tauri's notification plugin: the plugin cannot report a click. +Three things had to be right for a banner to actually appear on screen, and each was +wrong at first: + +1. **Identity.** `set_application` has to be called with the bundle identifier. Left alone, + mac-notification-sys looks up an application named `"use_default"`, fails, and posts as + `com.apple.Finder` — so notifications arrive wearing another app's alert style. +2. **Presentation while frontmost.** `NSUserNotificationCenter` suppresses the banner + whenever the posting app is in front, delivering it silently to Notification Centre + instead, unless the delegate implements `shouldPresentNotification:`. The crate's + delegate implements only delivery and activation, so the missing method is added to its + class at runtime. Without it, Gmail notifying while you sit in Odoo — the same window, + still frontmost — would never be seen. +3. **Waiting for the click.** `send_notification` only blocks for a response when the + options ask it to. Passing `None` returns immediately with `NotificationResponse::None`, + so no click is ever observed. Waiting costs a parked thread, and a notification left in + Notification Centre never resolves, so the number of waiters is capped at 32; past that + the notification still appears but cannot be clicked through. + Service-worker push is **not** covered — only notifications a page raises while it is open. ## Zoom diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index c81e532..b0d0bc1 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -446,13 +446,34 @@ pub fn notification_status(app: AppHandle) -> String { }; let app_state = app.state::(); 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::(); diff --git a/src-tauri/src/webviews.rs b/src-tauri/src/webviews.rs index 3511adb..3467b66 100644 --- a/src-tauri/src/webviews.rs +++ b/src-tauri/src/webviews.rs @@ -6,6 +6,7 @@ //! the app first. use std::collections::HashMap; +use std::sync::atomic::{AtomicUsize, Ordering}; use serde::Serialize; use tauri::{ @@ -181,21 +182,52 @@ fn notify( ) { // The app's own name leads, or a notification from four tools in one // window says nothing about which one wants you. + // Waiting for a click costs a parked thread, and a notification left + // sitting in Notification Centre never resolves — so the wait is capped. + // Past the cap the notification still appears, it just cannot be clicked + // through, which is a far better failure than an unbounded thread count. + const MAX_WAITING: usize = 32; + static WAITING: AtomicUsize = AtomicUsize::new(0); + let heading = if app_name.is_empty() { "Work".to_string() } else { app_name.to_string() }; let subtitle = title.to_string(); let message = body.to_string(); let handle = handle.clone(); let app_id = app_id.to_string(); - std::thread::spawn(move || { + let waiting = WAITING.fetch_add(1, Ordering::SeqCst) < MAX_WAITING; + if !waiting { + WAITING.fetch_sub(1, Ordering::SeqCst); + } + + let spawned = std::thread::Builder::new() + // Small, because these park rather than compute, and there may be many. + .stack_size(512 * 1024) + .spawn(move || { let state = handle.state::(); + // Recorded before the call, not after: `wait_for_click` blocks until + // the user acts, so waiting for the return made a notification that + // was sitting on screen read as "none yet". + *state.last_notification.lock().unwrap() = format!("{heading} / {subtitle} → raised"); + + // `wait_for_click` is what makes this block until the user acts. + // Without it the call returns immediately with `None`, and no click is + // ever observed — the notification appears, and clicking it does + // nothing at all. + let mut options = mac_notification_sys::Notification::new(); + options.wait_for_click(waiting); + let response = mac_notification_sys::send_notification( &heading, if subtitle.is_empty() { None } else { Some(&subtitle) }, &message, - None, + Some(&options), ); + if waiting { + WAITING.fetch_sub(1, Ordering::SeqCst); + } + match response { Ok(mac_notification_sys::NotificationResponse::Click) => { *state.last_notification.lock().unwrap() = @@ -205,9 +237,9 @@ fn notify( NotificationClick { app_id, notification_id }, ); } - Ok(_) => { + Ok(other) => { *state.last_notification.lock().unwrap() = - format!("{heading} / {subtitle} → raised"); + format!("{heading} / {subtitle} → {other:?}"); } Err(e) => { eprintln!("could not raise a notification: {e}"); @@ -215,6 +247,13 @@ fn notify( } } }); + + if let Err(e) = spawned { + eprintln!("could not start a notification thread: {e}"); + if waiting { + WAITING.fetch_sub(1, Ordering::SeqCst); + } + } } /// Turns on WKWebView's two-finger back and forward swipes.