Skip to main content

par_term/platform/
notify.rs

1//! Cross-platform desktop notification delivery.
2//!
3//! Abstracts over:
4//! - **macOS**: native `UNUserNotificationCenter` (bundled/signed apps), falling back to
5//!   the `osascript` AppleScript `display notification` command for bare `cargo run`
6//!   binaries or if the native path errors at runtime. See [`crate::platform::notify_macos`].
7//! - **Linux/BSD**: the `notify_rust` crate (XDG desktop notifications over D-Bus).
8//! - **Windows**: the `notify_rust` crate (no urgency/identity/click support there).
9//!
10//! All callers should use [`deliver_desktop_notification`] or
11//! [`deliver_desktop_notification_request`] rather than inline
12//! `#[cfg(target_os = ...)]` blocks, so platform differences live only here.
13//!
14//! ## Identity and click support
15//!
16//! [`NotificationRequest::identity`] and [`NotificationRequest::click_token`] are
17//! opt-in, cross-platform-safe extensions to the original notification API:
18//!
19//! | Platform | Identity (replace-in-place) | Click callback |
20//! |---|---|---|
21//! | Linux/BSD (XDG) | Yes — via a hashed numeric id (`.id(u32)`) | Yes — via `wait_for_action` on a detached thread |
22//! | macOS (native, bundled app) | Yes — via `UNNotificationRequest` identifier | Yes — via `UNUserNotificationCenterDelegate` |
23//! | macOS (osascript fallback) | No (ignored) | No (ignored) |
24//! | Windows | No (ignored) | No (ignored) |
25//!
26//! Click tokens are delivered asynchronously on a channel; call
27//! [`drain_notification_clicks`] once per frame from the event loop to collect them.
28
29#[cfg(target_os = "macos")]
30use std::sync::mpsc::Sender;
31use std::sync::{Mutex, OnceLock, mpsc};
32
33/// Escape a string for safe embedding inside an AppleScript double-quoted string.
34///
35/// AppleScript requires that backslashes, double-quotes, and newlines are escaped.
36/// The order of replacements matters: backslashes must be escaped *first* so that
37/// the subsequent replacements do not accidentally double-escape them.
38pub fn escape_for_applescript(s: &str) -> String {
39    s.replace('\\', "\\\\")
40        .replace('"', "\\\"")
41        .replace('\n', "\\n")
42        .replace('\r', "\\r")
43}
44
45/// Notification urgency level, local to the platform layer so this module has
46/// no dependency on any particular terminal library's urgency type. Callers
47/// map their own urgency representation onto this at the call site.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
49pub enum NotificationUrgency {
50    /// Low urgency — e.g. shown briefly, no interruption.
51    Low,
52    /// Normal urgency — the default.
53    #[default]
54    Normal,
55    /// Critical urgency — e.g. kept on-screen / given an audible cue.
56    Critical,
57}
58
59/// Parameters for delivering a desktop notification.
60///
61/// See the module docs for the per-platform identity/click support matrix.
62pub struct NotificationRequest<'a> {
63    /// Notification title / summary line (may be empty).
64    pub title: &'a str,
65    /// Notification body text.
66    pub message: &'a str,
67    /// How long the notification should be displayed on platforms that honor
68    /// an explicit timeout (macOS ignores this value; the OS controls duration).
69    pub timeout_ms: u32,
70    /// Notification urgency; affects timeout/presentation.
71    pub urgency: NotificationUrgency,
72    /// Stable identity: redelivering with the same identity REPLACES the
73    /// previous notification where the platform supports it (`None` disables
74    /// replacement — every call creates a distinct notification).
75    pub identity: Option<&'a str>,
76    /// When `Some`, a user click on the notification emits this token on the
77    /// click channel (drained via [`drain_notification_clicks`]). Platforms
78    /// without click support ignore it.
79    pub click_token: Option<u64>,
80}
81
82/// Deliver a native desktop notification.
83///
84/// Thin wrapper over [`deliver_desktop_notification_request`] with no
85/// identity/click-token association, kept so existing call sites compile
86/// unchanged.
87///
88/// # Arguments
89/// * `title`   – Notification title / summary line (may be empty).
90/// * `message` – Notification body text.
91/// * `timeout_ms` – How long the notification should be displayed on non-macOS
92///   platforms (macOS ignores this value; the OS controls notification duration).
93/// * `urgency` – Notification urgency; affects timeout/presentation (see platform blocks below).
94pub fn deliver_desktop_notification(
95    title: &str,
96    message: &str,
97    timeout_ms: u32,
98    urgency: NotificationUrgency,
99) {
100    deliver_desktop_notification_request(&NotificationRequest {
101        title,
102        message,
103        timeout_ms,
104        urgency,
105        identity: None,
106        click_token: None,
107    });
108}
109
110/// Deliver a native desktop notification, with optional identity (replacement)
111/// and click-token association. See the module docs for the per-platform
112/// support matrix.
113///
114/// Fire-and-forget on every platform: failures are logged as warnings and the
115/// function always returns normally.
116pub fn deliver_desktop_notification_request(req: &NotificationRequest<'_>) {
117    // Linux/BSD: notify_rust exposes `.urgency()`/`.id()`/`.action()` here, so
118    // map urgency to both the notification's urgency hint and its timeout
119    // (Critical stays on-screen), derive a stable numeric id from `identity`
120    // for XDG same-id replacement, and register a "default" action so a click
121    // can be observed via `wait_for_action` when `click_token` is set.
122    #[cfg(all(unix, not(target_os = "macos")))]
123    {
124        use notify_rust::{Notification, Timeout, Urgency as RustUrgency};
125        let notification_title = if !req.title.is_empty() {
126            req.title
127        } else {
128            "Terminal Notification"
129        };
130        let timeout = match req.urgency {
131            NotificationUrgency::Low => Timeout::Milliseconds(1500),
132            NotificationUrgency::Normal => Timeout::Milliseconds(req.timeout_ms),
133            NotificationUrgency::Critical => Timeout::Never,
134        };
135        let rust_urgency = match req.urgency {
136            NotificationUrgency::Low => RustUrgency::Low,
137            NotificationUrgency::Normal => RustUrgency::Normal,
138            NotificationUrgency::Critical => RustUrgency::Critical,
139        };
140        let mut notification = Notification::new();
141        notification
142            .summary(notification_title)
143            .body(req.message)
144            .timeout(timeout)
145            .urgency(rust_urgency);
146        if let Some(identity) = req.identity {
147            notification.id(fnv1a_u32(identity));
148        }
149        if req.click_token.is_some() {
150            notification.action("default", "Open");
151        }
152        match notification.show() {
153            Ok(handle) => {
154                if let Some(token) = req.click_token {
155                    let sender = click_sender();
156                    // Only spawn a wait thread when a click callback was
157                    // requested — fire-and-forget notifications don't need one.
158                    std::thread::spawn(move || {
159                        handle.wait_for_action(move |action: &str| {
160                            if action == "default" || action == "clicked" {
161                                let _ = sender.send(token);
162                            }
163                        });
164                    });
165                }
166            }
167            Err(e) => log::warn!("Failed to send desktop notification: {}", e),
168        }
169    }
170
171    // Windows: notify_rust's `.urgency()`/`.id()`/`.action()` builders are
172    // Linux/BSD-only, so identity and click_token are not surfaced here;
173    // timeout behavior is unchanged.
174    #[cfg(all(not(unix), not(target_os = "macos")))]
175    {
176        use notify_rust::Notification;
177        let _ = req.urgency;
178        let _ = req.identity;
179        let _ = req.click_token;
180        let notification_title = if !req.title.is_empty() {
181            req.title
182        } else {
183            "Terminal Notification"
184        };
185        if let Err(e) = Notification::new()
186            .summary(notification_title)
187            .body(req.message)
188            .timeout(notify_rust::Timeout::Milliseconds(req.timeout_ms))
189            .show()
190        {
191            log::warn!("Failed to send desktop notification: {}", e);
192        }
193    }
194
195    #[cfg(target_os = "macos")]
196    {
197        crate::platform::notify_macos::deliver(req);
198    }
199}
200
201/// FNV-1a hash of a UTF-8 string, used on Linux/BSD to derive a stable XDG
202/// numeric notification id from an opaque identity string — the freedesktop
203/// notification spec replaces an existing notification in place when a new
204/// one is shown with the same numeric id.
205#[cfg(all(unix, not(target_os = "macos")))]
206fn fnv1a_u32(s: &str) -> u32 {
207    let mut hash: u32 = 0x811c_9dc5;
208    for byte in s.as_bytes() {
209        hash ^= u32::from(*byte);
210        hash = hash.wrapping_mul(0x0100_0193);
211    }
212    hash
213}
214
215/// Global click-token channel shared by all notification backends.
216struct ClickChannel {
217    sender: mpsc::Sender<u64>,
218    receiver: Mutex<mpsc::Receiver<u64>>,
219}
220
221static CLICK_CHANNEL: OnceLock<ClickChannel> = OnceLock::new();
222
223fn click_channel() -> &'static ClickChannel {
224    CLICK_CHANNEL.get_or_init(|| {
225        let (sender, receiver) = mpsc::channel();
226        ClickChannel {
227            sender,
228            receiver: Mutex::new(receiver),
229        }
230    })
231}
232
233/// Clone a sender that notification backends use to report a click.
234#[cfg(target_os = "macos")]
235pub(crate) fn click_sender() -> Sender<u64> {
236    click_channel().sender.clone()
237}
238
239#[cfg(all(unix, not(target_os = "macos")))]
240fn click_sender() -> mpsc::Sender<u64> {
241    click_channel().sender.clone()
242}
243
244/// Non-blocking drain of click tokens emitted by notification backends.
245/// Safe to call every frame from the event loop.
246pub fn drain_notification_clicks() -> Vec<u64> {
247    let receiver = match click_channel().receiver.lock() {
248        Ok(guard) => guard,
249        Err(poisoned) => poisoned.into_inner(),
250    };
251    let mut tokens = Vec::new();
252    while let Ok(token) = receiver.try_recv() {
253        tokens.push(token);
254    }
255    tokens
256}