Merge pull request #822 from l0ng-ai/fix/notification-poll

fix(notify): stop polling Notification Center from the UI thread
This commit is contained in:
l0ng-ai
2026-09-09 17:35:45 +08:00
committed by GitHub
4 changed files with 217 additions and 94 deletions
Generated
-1
View File
@@ -9841,7 +9841,6 @@ dependencies = [
"ksni",
"libc",
"log",
"mac-notification-sys",
"memchr",
"notify 8.2.0",
"notify-rust",
+6 -8
View File
@@ -121,9 +121,9 @@ reqwest_client = { git = "https://github.com/zed-industries/zed", rev = "1d217ee
# `capture` output through the same grid.
alacritty_terminal.workspace = true
# Desktop notifications driven by OSC 9 / OSC 777 escape sequences. Cross-platform;
# the macOS backend uses the deprecated NSUserNotification (weak — a completion
# toast is fine, revisit mac-notification-sys if it proves unusable).
# Desktop notifications driven by OSC 9 / OSC 777 escape sequences, on Linux and
# Windows. On macOS only its `set_application` is used — never `show`; see
# `terminal::remote::macos_notify` for why.
notify-rust = "4"
# Filesystem watcher for live config reload: watches `config.json` and reloads
@@ -220,11 +220,9 @@ objc2 = "0.6"
objc2-app-kit = { version = "0.3", features = ["NSApplication", "NSResponder", "NSAppearance", "NSGraphics", "NSImage"] }
# NSData feeds the runtime Dock icon for bare (non-bundled) binaries — see
# `set_dock_icon_for_bare_binary` in main.rs.
objc2-foundation = { version = "0.3", features = ["NSData"] }
# Clickable desktop notifications on macOS. `notify-rust` already pulls this
# crate transitively for basic display; we depend on it directly to use
# wait_for_click / NotificationResponse::Click.
mac-notification-sys = "0.6"
# NSUserNotification is the click-to-reveal notification path — see
# `terminal::remote::macos_notify` for why it is driven directly.
objc2-foundation = { version = "0.3", features = ["NSData", "NSString", "NSUserNotification"] }
# Dictionary-based Chinese word segmentation for double-click selection
# (`terminal::smart_select`), as a *fallback* where the OS has no tokenizer of
+208 -83
View File
@@ -2389,35 +2389,41 @@ pub(crate) fn notify_desktop(title: Option<&str>, body: &str) {
/// `u64` here is what lets a caller hand over a `pane_id` — a different number,
/// assigned by the daemon — and get a notification that reveals nothing.
///
/// Every other case (no pane, unsupported platform, no room left to wait for a
/// click) falls back to the plain `notify-rust` path below, which is why
/// macOS always goes through `macos_notify`, clickable or not. Elsewhere, every
/// other case (no pane, unsupported platform, Windows toast queue full) falls
/// back to the plain `notify-rust` path below, which is why
/// `try_clickable_notification` reports whether it took the job.
pub(crate) fn notify_desktop_for_pane(title: Option<&str>, body: &str, pane: Option<EntityId>) {
let summary = sanitize_notification_text(title.unwrap_or("tty7"), NOTIFY_TITLE_MAX);
let body = sanitize_notification_text(body, NOTIFY_BODY_MAX);
if let Some(pane) = pane
&& try_clickable_notification(&summary, &body, pane.as_u64())
#[cfg(target_os = "macos")]
{
return;
macos_notify::deliver(summary, body, pane.map(|p| p.as_u64()));
}
std::thread::spawn(move || {
#[cfg(target_os = "macos")]
ensure_notification_app();
let mut notif = notify_rust::Notification::new();
notif.summary(&summary).body(&body);
// Without our own AUMID, the Windows backend falls back to
// PowerShell's — icon and name included. Only set ours once the shell
// has indexed a shortcut carrying it: for an AUMID it does not know,
// `show()` reports success and drops the toast, so the ugly fallback
// beats the branded one every time we are not sure.
#[cfg(target_os = "windows")]
if let Some(app_id) = crate::core::aumid::toast_app_id() {
notif.app_id(app_id);
#[cfg(not(target_os = "macos"))]
{
if let Some(pane) = pane
&& try_clickable_notification(&summary, &body, pane.as_u64())
{
return;
}
let _ = notif.show();
});
std::thread::spawn(move || {
let mut notif = notify_rust::Notification::new();
notif.summary(&summary).body(&body);
// Without our own AUMID, the Windows backend falls back to
// PowerShell's — icon and name included. Only set ours once the
// shell has indexed a shortcut carrying it: for an AUMID it does
// not know, `show()` reports success and drops the toast, so the
// ugly fallback beats the branded one every time we are not sure.
#[cfg(target_os = "windows")]
if let Some(app_id) = crate::core::aumid::toast_app_id() {
notif.app_id(app_id);
}
let _ = notif.show();
});
}
}
/// Longest title / body we hand to a notification backend.
@@ -2448,58 +2454,152 @@ fn sanitize_notification_text(s: &str, max_chars: usize) -> String {
out
}
/// Deliver a click-to-reveal notification, reporting whether it was taken.
/// `false` means the caller should fall back to the plain notification path.
#[cfg(all(target_os = "macos", not(test)))]
fn try_clickable_notification(title: &str, body: &str, leaf_id: u64) -> bool {
use std::sync::atomic::{AtomicUsize, Ordering};
/// What a click on a macOS notification has to carry back: the pane to reveal.
///
/// It rides in the notification's `identifier`, which is the one field the
/// center hands back verbatim on activation without a dictionary round trip.
/// Shape: `tty7-pane-<pid>-<leaf>-<seq>`.
///
/// The pid is what makes a stale identifier fail closed. Notifications outlive
/// the process that sent them, and the center hands a click on one of those to
/// whatever process now owns the bundle id — a relaunched tty7, or a second
/// instance running alongside. A leaf id is a gpui entity id, which a fresh
/// process hands out again in the same order, so without the pid that click
/// would reveal an unrelated pane. The sequence number keeps two notifications
/// for the same pane apart — the center treats a repeated identifier as
/// "replace the earlier one".
#[cfg(any(target_os = "macos", test))]
const NOTIFICATION_ID_PREFIX: &str = "tty7-pane-";
// `mac-notification-sys` blocks the calling thread until the user acts on
// the notification, and its wait has no timeout: a banner nobody touches —
// the common case, since unclicked ones just pile up in Notification
// Center — parks its thread for the rest of the session. Cap how many can
// be outstanding and let the rest through as fire-and-forget, so a chatty
// agent cannot turn a session's notifications into a thread leak.
const MAX_PENDING_CLICKS: usize = 8;
static PENDING: AtomicUsize = AtomicUsize::new(0);
if PENDING
.fetch_update(Ordering::AcqRel, Ordering::Acquire, |n| {
(n < MAX_PENDING_CLICKS).then_some(n + 1)
})
.is_err()
{
return false;
}
let (title, body) = (title.to_string(), body.to_string());
std::thread::spawn(move || {
show_macos_toast(&title, &body, leaf_id);
PENDING.fetch_sub(1, Ordering::AcqRel);
});
true
#[cfg(any(target_os = "macos", test))]
fn notification_identifier(leaf_id: u64) -> String {
use std::sync::atomic::AtomicU64;
static SEQ: AtomicU64 = AtomicU64::new(0);
let seq = SEQ.fetch_add(1, Ordering::Relaxed);
let pid = std::process::id();
format!("{NOTIFICATION_ID_PREFIX}{pid}-{leaf_id}-{seq}")
}
#[cfg(all(target_os = "macos", not(test)))]
fn show_macos_toast(title: &str, body: &str, leaf_id: u64) {
use mac_notification_sys::{Notification, NotificationResponse};
#[cfg(any(target_os = "macos", test))]
fn leaf_id_in_identifier(identifier: &str) -> Option<u64> {
let rest = identifier.strip_prefix(NOTIFICATION_ID_PREFIX)?;
let (pid, rest) = rest.split_once('-')?;
if pid.parse::<u32>().ok()? != std::process::id() {
return None;
}
let (leaf_id, _seq) = rest.split_once('-')?;
leaf_id.parse().ok()
}
// `send` sets the delivering application on first use and keeps it under a
// `Once`, so whoever notifies first decides the name and icon for the whole
// session. Without this the default wins — `com.apple.Finder` — and every
// later notification, this path or `notify-rust`'s, claims to be Finder.
ensure_notification_app();
/// macOS notifications, straight to `NSUserNotificationCenter`.
///
/// This used to go through `mac-notification-sys` with `wait_for_click`, so a
/// click could reveal the pane. The way that crate notices a click is to park
/// the sending thread and, for every notification outstanding, add a repeating
/// 0.5 s timer to the *main* run loop that calls `deliveredNotifications` — a
/// synchronous XPC round trip — to see whether the banner is still there. A
/// banner nobody clicks stays in Notification Center, so its timer never goes
/// away. Sampled with nine outstanding: a fifth of the UI thread inside that
/// XPC, every window juddering, and each agent turn adding one more timer.
///
/// Here the click arrives through the center's delegate, which is what the
/// API is for: no parked thread, no timer, nothing on the main thread until
/// the user actually clicks. The pane rides in the notification's identifier.
///
/// Nothing else may touch the center on this platform. `mac-notification-sys`
/// installs a delegate of its own the first time it sends, the last
/// `setDelegate:` wins, and `notify-rust` is that crate on macOS — so its
/// `show` is never called here, only its `set_application`, which does not
/// install one (that is what names a bare `cargo run` binary to the center).
#[cfg(target_os = "macos")]
#[allow(
deprecated,
reason = "UNUserNotificationCenter needs a signed, entitled bundle; NSUserNotification is what a bare binary can use"
)]
mod macos_notify {
use objc2::rc::{Retained, autoreleasepool};
use objc2::runtime::ProtocolObject;
use objc2::{AnyThread, define_class, msg_send};
use objc2_foundation::{
NSObject, NSObjectProtocol, NSString, NSUserNotification, NSUserNotificationCenter,
NSUserNotificationCenterDelegate,
};
let response = Notification::new()
.title(title)
.message(body)
.wait_for_click(true)
.send();
define_class!(
// SAFETY: NSObject has no subclassing requirements; `Delegate` has no
// ivars and no `Drop`.
#[unsafe(super = NSObject)]
#[name = "Tty7NotificationDelegate"]
struct Delegate;
match response {
Ok(NotificationResponse::Click) => reveal_pane(leaf_id),
Ok(_) => {}
Err(e) => log::warn!("failed to show macOS notification: {e}"),
// SAFETY: `NSObjectProtocol` has no safety requirements.
unsafe impl NSObjectProtocol for Delegate {}
// SAFETY: `NSUserNotificationCenterDelegate` has no safety requirements.
unsafe impl NSUserNotificationCenterDelegate for Delegate {
/// Runs on the main thread, when the user clicks the banner or the
/// entry in Notification Center. A channel push, then the clicked
/// entry is dropped from the center — a one-way message, unlike the
/// `deliveredNotifications` round trip this module exists to avoid.
#[unsafe(method(userNotificationCenter:didActivateNotification:))]
fn did_activate(
&self,
center: &NSUserNotificationCenter,
notification: &NSUserNotification,
) {
if let Some(leaf_id) = notification
.identifier()
.and_then(|id| super::leaf_id_in_identifier(&id.to_string()))
{
super::reveal_pane(leaf_id);
}
center.removeDeliveredNotification(notification);
}
}
);
fn install_delegate() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
// Name the delivering application to the center before it is first
// touched: the center drops requests from a process with no bundle
// identity, which is what a bare `cargo run` binary is. This
// swizzles `-[NSBundle bundleIdentifier]` for the main bundle to
// `com.github.tty7` when LaunchServices knows that id (a bundled
// tty7.app, or a machine that has one installed); when it does not,
// the swizzle's own default, `com.apple.Terminal`, is what the
// center sees. It can only be called once per process, so there is
// no second chance to pass a different name.
let _ = notify_rust::set_application("com.github.tty7");
// SAFETY: `NSObject`'s `init` takes nothing and returns the object.
let delegate: Retained<Delegate> = unsafe { msg_send![Delegate::alloc(), init] };
let center = NSUserNotificationCenter::defaultUserNotificationCenter();
// SAFETY: `delegate` is a `Delegate`, which conforms to the protocol.
unsafe { center.setDelegate(Some(ProtocolObject::from_ref(&*delegate))) };
// The center holds its delegate unretained. Ours lives as long as
// the process, so it is simply never released.
std::mem::forget(delegate);
});
}
/// Hands the notification to the center and returns. `deliverNotification:`
/// is an XPC message, so it goes out on a short-lived thread rather than
/// from wherever the OSC sequence was parsed — never the UI thread.
pub(super) fn deliver(title: String, body: String, leaf_id: Option<u64>) {
std::thread::spawn(move || {
autoreleasepool(|_| {
install_delegate();
let notification = NSUserNotification::new();
notification.setTitle(Some(&NSString::from_str(&title)));
notification.setInformativeText(Some(&NSString::from_str(&body)));
if let Some(leaf_id) = leaf_id {
let id = super::notification_identifier(leaf_id);
notification.setIdentifier(Some(&NSString::from_str(&id)));
}
NSUserNotificationCenter::defaultUserNotificationCenter()
.deliverNotification(&notification);
});
});
}
}
@@ -2649,17 +2749,14 @@ fn show_windows_toast(
/// Ask the tray dispatch loop to bring `leaf_id` to the front. Runs on whatever
/// thread the platform hands the activation to, so it only touches the channel.
#[cfg(all(not(test), any(target_os = "macos", target_os = "windows")))]
#[cfg(any(target_os = "macos", all(target_os = "windows", not(test))))]
fn reveal_pane(leaf_id: u64) {
if let Some(tx) = crate::ui::tray::sender() {
let _ = tx.try_send(crate::ui::tray::TrayAction::RevealPane { leaf_id });
}
}
#[cfg(not(any(
all(target_os = "macos", not(test)),
all(target_os = "windows", not(test))
)))]
#[cfg(not(any(target_os = "macos", all(target_os = "windows", not(test)))))]
fn try_clickable_notification(_title: &str, _body: &str, _leaf_id: u64) -> bool {
// Linux notifications go through notify-rust; click-to-reveal would need a
// D-Bus action listener of its own.
@@ -2679,6 +2776,45 @@ fn xml_escape(s: &str) -> String {
mod notification_tests {
use super::*;
#[test]
fn a_notification_identifier_carries_its_pane_back_out() {
let id = notification_identifier(42);
assert_eq!(leaf_id_in_identifier(&id), Some(42));
}
#[test]
fn two_notifications_for_one_pane_do_not_replace_each_other() {
// The center replaces a delivered notification whose identifier repeats.
assert_ne!(notification_identifier(7), notification_identifier(7));
}
#[test]
fn a_foreign_identifier_reveals_nothing() {
let pid = std::process::id();
for id in [
String::new(),
"tty7-pane-".into(),
"tty7-pane-x-1".into(),
"tty7-pane-42".into(),
"other-42-1".into(),
format!("tty7-pane-{pid}"),
format!("tty7-pane-{pid}-42"),
format!("tty7-pane-{pid}-x-1"),
] {
assert_eq!(leaf_id_in_identifier(&id), None, "{id:?}");
}
}
#[test]
fn a_notification_from_another_process_reveals_nothing() {
// Notifications outlive the process that sent them, and gpui hands out
// the same entity ids again in a fresh process. A stale click must not
// land on whatever pane holds that id now.
let other_pid = std::process::id().wrapping_add(1);
let stale = format!("{NOTIFICATION_ID_PREFIX}{other_pid}-42-0");
assert_eq!(leaf_id_in_identifier(&stale), None);
}
#[test]
fn sanitizing_drops_control_bytes_but_keeps_line_breaks() {
let raw = "build \x1b[31mfailed\x07\nsee log\ttail";
@@ -2705,17 +2841,6 @@ mod notification_tests {
}
}
#[cfg(target_os = "macos")]
fn ensure_notification_app() {
use std::sync::Once;
static ONCE: Once = Once::new();
ONCE.call_once(|| {
if notify_rust::set_application("com.github.tty7").is_err() {
let _ = notify_rust::set_application("com.apple.Terminal");
}
});
}
struct OscNotifyScanner {
tok: OscTokenizer,
}
+3 -2
View File
@@ -43,8 +43,9 @@ pub(crate) fn icon_is_up() -> bool {
}
/// A sender for the current tray dispatch loop, if one is running.
// Only the platform notification callbacks call this, and those are compiled
// out of test builds so unit tests never raise a real toast.
// Only the platform notification callbacks call this. The Windows one is
// compiled out of test builds so unit tests never raise a real toast; the macOS
// one is not, but no test sends a notification there either.
#[cfg_attr(test, allow(dead_code))]
pub(crate) fn sender() -> Option<smol::channel::Sender<TrayAction>> {
SENDER.lock().ok()?.clone()