mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-21 16:02:20 +00:00
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:
Generated
-1
@@ -9841,7 +9841,6 @@ dependencies = [
|
||||
"ksni",
|
||||
"libc",
|
||||
"log",
|
||||
"mac-notification-sys",
|
||||
"memchr",
|
||||
"notify 8.2.0",
|
||||
"notify-rust",
|
||||
|
||||
+6
-8
@@ -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
@@ -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(¬ification);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user