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:
@@ -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
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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::<crate::commands::AppState>();
|
||||
// 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.
|
||||
|
||||
Reference in New Issue
Block a user