Skip to main content

rightkit_tauri_shell/
notify.rs

1//! Native notifications, badge and sound policy.
2//!
3//! Donor: CodeRight `notifications.rs` (per-target sound cooldown, `navigate_path || title` key,
4//! non-negative badge, foreground attention cue, Do-Not-Disturb suppression through
5//! [`crate::presence`]). The delivery itself is a direct OS call instead of a plugin:
6//! `UNUserNotificationCenter` on macOS, which also gives the read-back used by QA
7//! ([`delivered`], [`authorization`]); a WinRT toast on Windows (the backend
8//! `tauri-plugin-notification` uses). Other platforms report `unsupported`; nothing is faked.
9//!
10//! Windows: a toast is attributed to an AppUserModelID (AUMID). An installed app owns one through
11//! its Start-menu shortcut (Tauri's NSIS/MSI installers set it to the bundle `identifier`); the
12//! shell passes `tauri.conf.json`'s `identifier` as the default and [`set_app_id`] overrides it.
13//! An unpackaged dev build has no AUMID, so toasts fall back to PowerShell's and carry its
14//! name and icon. Windows exposes neither a per-app permission prompt nor a delivered-toast
15//! read-back through this backend: [`authorization`] reports `authorized` and [`delivered`] is
16//! empty; [`clear_delivered`] is a no-op.
17//!
18//! Notification settings that apps persist (sound on/off, sound name, per-category toggles —
19//! HeardRight and MailRight keep these in their own stores) arrive on every request; the shell owns
20//! the delivery policy, the app owns its preferences.
21
22use std::collections::HashMap;
23use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
24use std::sync::Mutex;
25use std::time::{Duration, Instant};
26
27use serde::{Deserialize, Serialize};
28
29use crate::error::ShellError;
30
31/// Per-target notification-sound cooldown (CodeRight: Omnigent's value).
32pub const SOUND_THROTTLE_MS: u64 = 3_000;
33/// macOS system sound used when the caller does not choose one.
34pub const DEFAULT_SOUND: &str = "Glass";
35
36#[derive(Default)]
37pub struct NotificationState {
38    last_sound: Mutex<HashMap<String, Instant>>,
39    badge: AtomicU32,
40    seq: AtomicU64,
41}
42
43impl NotificationState {
44    pub fn new() -> Self {
45        Self::default()
46    }
47
48    /// True for the first call for a key and again once the cooldown has elapsed.
49    pub fn should_play_sound_at(&self, key: &str, now: Instant) -> bool {
50        let mut last = self.last_sound.lock().unwrap_or_else(|e| e.into_inner());
51        if last
52            .get(key)
53            .and_then(|at| now.checked_duration_since(*at))
54            .is_some_and(|d| d < Duration::from_millis(SOUND_THROTTLE_MS))
55        {
56            return false;
57        }
58        last.insert(key.to_owned(), now);
59        true
60    }
61
62    pub fn set_badge(&self, count: i64) -> u32 {
63        let n = normalize_badge(count);
64        self.badge.store(n, Ordering::Relaxed);
65        n
66    }
67
68    pub fn badge(&self) -> u32 {
69        self.badge.load(Ordering::Relaxed)
70    }
71
72    fn next_id(&self) -> String {
73        format!(
74            "rk-{}-{}",
75            std::process::id(),
76            self.seq.fetch_add(1, Ordering::Relaxed)
77        )
78    }
79}
80
81#[derive(Debug, Clone, Deserialize)]
82#[serde(rename_all = "camelCase")]
83pub struct NotificationRequest {
84    pub title: String,
85    #[serde(default)]
86    pub body: String,
87    /// Where the click should navigate; also the sound-throttle key (falls back to the title).
88    #[serde(default)]
89    pub navigate_path: Option<String>,
90    #[serde(default)]
91    pub sound_enabled: bool,
92    #[serde(default)]
93    pub sound_name: Option<String>,
94    /// Post even while the shell's Do-Not-Disturb (manual or an automatic meeting) is in effect.
95    /// Default `false`. The OS Focus mode is not bypassed.
96    #[serde(default)]
97    pub bypass_do_not_disturb: bool,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct NotificationPlan {
102    pub key: String,
103    pub play_sound: bool,
104    pub sound_name: Option<String>,
105}
106
107pub fn notification_key(navigate_path: Option<&str>, title: &str) -> String {
108    navigate_path
109        .filter(|p| !p.is_empty())
110        .unwrap_or(title)
111        .to_owned()
112}
113
114pub fn plan(
115    req: &NotificationRequest,
116    state: &NotificationState,
117    now: Instant,
118) -> NotificationPlan {
119    let key = notification_key(req.navigate_path.as_deref(), &req.title);
120    let play_sound = req.sound_enabled && state.should_play_sound_at(&key, now);
121    NotificationPlan {
122        key,
123        play_sound,
124        sound_name: play_sound.then(|| {
125            req.sound_name
126                .as_deref()
127                .filter(|n| !n.trim().is_empty())
128                .unwrap_or(DEFAULT_SOUND)
129                .to_owned()
130        }),
131    }
132}
133
134pub fn normalize_badge(count: i64) -> u32 {
135    count.clamp(0, i64::from(u32::MAX)) as u32
136}
137
138static APP_ID: Mutex<Option<String>> = Mutex::new(None);
139/// Toast attribution used before an app registers its own: PowerShell's AUMID (dev builds only).
140pub const FALLBACK_APP_ID: &str =
141    r"{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\WindowsPowerShell\v1.0\powershell.exe";
142
143/// Windows toast AppUserModelID. Call before the first notification; overrides the default the
144/// shell derives from the Tauri bundle identifier. Ignored off Windows.
145pub fn set_app_id(id: &str) {
146    *APP_ID.lock().unwrap_or_else(|e| e.into_inner()) =
147        Some(id.trim().to_owned()).filter(|s| !s.is_empty());
148}
149
150pub(crate) fn set_default_app_id(id: &str) {
151    let mut g = APP_ID.lock().unwrap_or_else(|e| e.into_inner());
152    if g.is_none() && !id.trim().is_empty() {
153        *g = Some(id.trim().to_owned());
154    }
155}
156
157/// The AUMID toasts will use (configured, else [`FALLBACK_APP_ID`]).
158pub fn app_id() -> String {
159    resolve_app_id(APP_ID.lock().unwrap_or_else(|e| e.into_inner()).as_deref())
160}
161
162pub fn resolve_app_id(configured: Option<&str>) -> String {
163    configured
164        .map(str::trim)
165        .filter(|s| !s.is_empty())
166        .unwrap_or(FALLBACK_APP_ID)
167        .to_owned()
168}
169
170/// Windows toast sounds by their short name; anything else (including macOS names such as
171/// `Glass`) maps to the default toast sound.
172pub fn windows_sound(name: &str) -> &'static str {
173    match name.trim().to_ascii_lowercase().as_str() {
174        "im" => "IM",
175        "mail" => "Mail",
176        "reminder" => "Reminder",
177        "sms" => "SMS",
178        _ => "Default",
179    }
180}
181
182/// Everything a Windows toast needs, built without touching the OS.
183#[derive(Debug, Clone, PartialEq, Eq)]
184pub struct ToastPayload {
185    pub app_id: String,
186    pub title: String,
187    pub body: String,
188    /// `None` is silent.
189    pub sound: Option<&'static str>,
190}
191
192pub fn toast_payload(
193    req: &NotificationRequest,
194    plan: &NotificationPlan,
195    app_id: Option<&str>,
196    quiet: bool,
197) -> ToastPayload {
198    ToastPayload {
199        app_id: resolve_app_id(app_id),
200        title: req.title.trim().to_owned(),
201        body: req.body.trim().to_owned(),
202        sound: plan
203            .sound_name
204            .as_deref()
205            .filter(|_| !quiet)
206            .map(windows_sound),
207    }
208}
209
210/// What happened to a request.
211#[derive(Debug, Clone, Serialize)]
212#[serde(rename_all = "camelCase")]
213pub struct NotifyOutcome {
214    pub delivered: bool,
215    /// Why nothing was posted (`dnd`, `empty-title`).
216    #[serde(skip_serializing_if = "Option::is_none")]
217    pub suppressed: Option<&'static str>,
218    pub id: Option<String>,
219    pub sound: bool,
220}
221
222/// One notification the OS reports as delivered to this app.
223#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
224#[serde(rename_all = "camelCase")]
225pub struct DeliveredNotification {
226    pub id: String,
227    pub title: String,
228    pub body: String,
229    pub thread: String,
230}
231
232#[derive(Debug, Clone, Serialize)]
233#[serde(rename_all = "camelCase")]
234pub struct NotifyStatus {
235    /// `authorized`, `denied`, `not-determined`, `provisional`, `ephemeral`, or `unsupported`.
236    pub authorization: String,
237    pub supported: bool,
238    pub delivered: Vec<DeliveredNotification>,
239    pub badge: u32,
240}
241
242/// Why a request is not posted before reaching the platform (`empty-title`, `dnd`), if it is not.
243/// `bypass_do_not_disturb` skips only the Do-Not-Disturb check.
244pub fn suppression(req: &NotificationRequest, dnd: bool) -> Option<&'static str> {
245    if req.title.trim().is_empty() {
246        Some("empty-title")
247    } else if dnd && !req.bypass_do_not_disturb {
248        Some("dnd")
249    } else {
250        None
251    }
252}
253
254/// Post a notification unless the request is empty or Do-Not-Disturb is in effect.
255pub fn post(
256    req: &NotificationRequest,
257    state: &NotificationState,
258    dnd: bool,
259) -> Result<NotifyOutcome, ShellError> {
260    if let Some(why) = suppression(req, dnd) {
261        return Ok(NotifyOutcome {
262            delivered: false,
263            suppressed: Some(why),
264            id: None,
265            sound: false,
266        });
267    }
268    let plan = plan(req, state, Instant::now());
269    let id = state.next_id();
270    platform::post(&id, req, &plan)?;
271    Ok(NotifyOutcome {
272        delivered: true,
273        suppressed: None,
274        id: Some(id),
275        sound: plan.play_sound,
276    })
277}
278
279pub fn authorization() -> String {
280    platform::authorization().unwrap_or_else(|e| format!("unavailable: {}", e.message()))
281}
282
283pub fn delivered() -> Result<Vec<DeliveredNotification>, ShellError> {
284    platform::delivered()
285}
286
287pub fn clear_delivered(ids: &[String]) -> Result<(), ShellError> {
288    platform::clear(ids)
289}
290
291pub fn status(state: &NotificationState) -> NotifyStatus {
292    NotifyStatus {
293        authorization: authorization(),
294        supported: platform::SUPPORTED,
295        delivered: delivered().unwrap_or_default(),
296        badge: state.badge(),
297    }
298}
299
300#[cfg(target_os = "macos")]
301mod platform {
302    use std::sync::mpsc;
303    use std::time::Duration;
304
305    use block2::RcBlock;
306    use objc2::rc::Retained;
307    use objc2::runtime::Bool;
308    use objc2_foundation::{NSArray, NSBundle, NSError, NSString};
309    use objc2_user_notifications::{
310        UNAuthorizationOptions, UNAuthorizationStatus, UNMutableNotificationContent,
311        UNNotification, UNNotificationInterruptionLevel, UNNotificationRequest,
312        UNNotificationSettings, UNNotificationSound, UNUserNotificationCenter,
313    };
314
315    use super::{DeliveredNotification, NotificationPlan, NotificationRequest};
316    use crate::error::ShellError;
317
318    pub const SUPPORTED: bool = true;
319    const REPLY: Duration = Duration::from_secs(10);
320    /// Authorization shows a system prompt on first use; give the person time to answer it.
321    const PROMPT: Duration = Duration::from_secs(60);
322
323    /// UserNotifications raises an Objective-C exception for a process without an app bundle.
324    fn center() -> Result<Retained<UNUserNotificationCenter>, ShellError> {
325        let bundle = NSBundle::mainBundle();
326        if bundle.bundleIdentifier().is_none() || !bundle.bundlePath().to_string().ends_with(".app")
327        {
328            return Err(ShellError::Unsupported(
329                "native notifications need an app bundle (.app) with a bundle identifier".into(),
330            ));
331        }
332        Ok(UNUserNotificationCenter::currentNotificationCenter())
333    }
334
335    fn settings(
336        center: &Retained<UNUserNotificationCenter>,
337    ) -> Result<UNAuthorizationStatus, ShellError> {
338        let (tx, rx) = mpsc::channel();
339        let block = RcBlock::new(move |s: std::ptr::NonNull<UNNotificationSettings>| {
340            // SAFETY: the OS passes a live settings object for the duration of the callback.
341            let _ = tx.send(unsafe { s.as_ref() }.authorizationStatus());
342        });
343        center.getNotificationSettingsWithCompletionHandler(&block);
344        rx.recv_timeout(REPLY)
345            .map_err(|_| ShellError::Timeout("notification settings did not answer".into()))
346    }
347
348    pub fn authorization() -> Result<String, ShellError> {
349        Ok(match settings(&center()?)? {
350            UNAuthorizationStatus::Authorized => "authorized",
351            UNAuthorizationStatus::Denied => "denied",
352            UNAuthorizationStatus::NotDetermined => "not-determined",
353            UNAuthorizationStatus::Provisional => "provisional",
354            UNAuthorizationStatus::Ephemeral => "ephemeral",
355            _ => "unknown",
356        }
357        .to_owned())
358    }
359
360    fn ensure_authorized(center: &Retained<UNUserNotificationCenter>) -> Result<(), ShellError> {
361        let status = settings(center)?;
362        if status == UNAuthorizationStatus::NotDetermined {
363            if crate::lifecycle::qa_background() {
364                return Err(ShellError::PermissionDenied("notification authorization is not determined; background QA never opens a permission dialog".into()));
365            }
366            let (tx, rx) = mpsc::channel();
367            let block = RcBlock::new(move |granted: Bool, _err: *mut NSError| {
368                let _ = tx.send(granted.as_bool());
369            });
370            center.requestAuthorizationWithOptions_completionHandler(
371                UNAuthorizationOptions::Alert
372                    | UNAuthorizationOptions::Sound
373                    | UNAuthorizationOptions::Badge,
374                &block,
375            );
376            let granted = rx.recv_timeout(PROMPT).map_err(|_| {
377                ShellError::Timeout("notification permission prompt was not answered".into())
378            })?;
379            if !granted {
380                return Err(denied());
381            }
382            return Ok(());
383        }
384        match status {
385            UNAuthorizationStatus::Denied => Err(denied()),
386            _ => Ok(()),
387        }
388    }
389
390    fn denied() -> ShellError {
391        ShellError::PermissionDenied("notifications are not allowed for this app; enable them in System Settings > Notifications".into())
392    }
393
394    pub fn post(
395        id: &str,
396        req: &NotificationRequest,
397        plan: &NotificationPlan,
398    ) -> Result<(), ShellError> {
399        let center = center()?;
400        ensure_authorized(&center)?;
401        let content = UNMutableNotificationContent::new();
402        content.setTitle(&NSString::from_str(&req.title));
403        content.setBody(&NSString::from_str(&req.body));
404        content.setThreadIdentifier(&NSString::from_str(&plan.key));
405        let quiet_qa = crate::lifecycle::qa_background();
406        if quiet_qa {
407            content.setInterruptionLevel(UNNotificationInterruptionLevel::Passive);
408            content.setSound(None);
409        } else if let Some(name) = &plan.sound_name {
410            content.setSound(Some(&UNNotificationSound::soundNamed(&NSString::from_str(
411                name,
412            ))));
413        }
414        let request = UNNotificationRequest::requestWithIdentifier_content_trigger(
415            &NSString::from_str(id),
416            &content,
417            None,
418        );
419        let (tx, rx) = mpsc::channel();
420        let block = RcBlock::new(move |err: *mut NSError| {
421            // SAFETY: a non-null error pointer is a live NSError for the duration of the callback.
422            let msg = (!err.is_null()).then(|| unsafe { &*err }.localizedDescription().to_string());
423            let _ = tx.send(msg);
424        });
425        center.addNotificationRequest_withCompletionHandler(&request, Some(&block));
426        match rx
427            .recv_timeout(REPLY)
428            .map_err(|_| ShellError::Timeout("notification center did not answer".into()))?
429        {
430            None => Ok(()),
431            Some(m) => Err(ShellError::Other(format!("show notification: {m}"))),
432        }
433    }
434
435    pub fn delivered() -> Result<Vec<DeliveredNotification>, ShellError> {
436        let center = center()?;
437        let (tx, rx) = mpsc::channel();
438        let block = RcBlock::new(move |list: std::ptr::NonNull<NSArray<UNNotification>>| {
439            // SAFETY: the OS passes a live array for the duration of the callback.
440            let list = unsafe { list.as_ref() };
441            let mut out = Vec::new();
442            for i in 0..list.count() {
443                let n = list.objectAtIndex(i);
444                let r = n.request();
445                let c = r.content();
446                out.push(DeliveredNotification {
447                    id: r.identifier().to_string(),
448                    title: c.title().to_string(),
449                    body: c.body().to_string(),
450                    thread: c.threadIdentifier().to_string(),
451                });
452            }
453            let _ = tx.send(out);
454        });
455        center.getDeliveredNotificationsWithCompletionHandler(&block);
456        rx.recv_timeout(REPLY)
457            .map_err(|_| ShellError::Timeout("delivered notifications did not answer".into()))
458    }
459
460    pub fn clear(ids: &[String]) -> Result<(), ShellError> {
461        let center = center()?;
462        if ids.is_empty() {
463            center.removeAllDeliveredNotifications();
464        } else {
465            let strings: Vec<Retained<NSString>> =
466                ids.iter().map(|s| NSString::from_str(s)).collect();
467            center.removeDeliveredNotificationsWithIdentifiers(&NSArray::from_retained_slice(
468                &strings,
469            ));
470        }
471        Ok(())
472    }
473}
474
475#[cfg(windows)]
476mod platform {
477    use tauri_winrt_notification::{Sound, Toast};
478
479    use super::{DeliveredNotification, NotificationPlan, NotificationRequest};
480    use crate::error::ShellError;
481
482    pub const SUPPORTED: bool = true;
483
484    /// Windows has no per-app permission prompt; Focus Assist is the user's, not read here.
485    pub fn authorization() -> Result<String, ShellError> {
486        Ok("authorized".into())
487    }
488
489    pub fn post(
490        _: &str,
491        req: &NotificationRequest,
492        plan: &NotificationPlan,
493    ) -> Result<(), ShellError> {
494        let quiet = crate::lifecycle::qa_background();
495        let p = super::toast_payload(req, plan, Some(&super::app_id()), quiet);
496        let sound = p.sound.map(|n| match n {
497            "IM" => Sound::IM,
498            "Mail" => Sound::Mail,
499            "Reminder" => Sound::Reminder,
500            "SMS" => Sound::SMS,
501            _ => Sound::Default,
502        });
503        Toast::new(&p.app_id)
504            .title(&p.title)
505            .text1(&p.body)
506            .sound(sound)
507            .show()
508            .map_err(|e| ShellError::Other(format!("show toast: {e}")))
509    }
510
511    pub fn delivered() -> Result<Vec<DeliveredNotification>, ShellError> {
512        Ok(Vec::new())
513    }
514
515    pub fn clear(_: &[String]) -> Result<(), ShellError> {
516        Ok(())
517    }
518}
519
520#[cfg(not(any(target_os = "macos", windows)))]
521mod platform {
522    use super::{DeliveredNotification, NotificationPlan, NotificationRequest};
523    use crate::error::ShellError;
524
525    pub const SUPPORTED: bool = false;
526
527    fn unsupported() -> ShellError {
528        ShellError::Unsupported("native notifications are implemented for macOS and Windows".into())
529    }
530    pub fn authorization() -> Result<String, ShellError> {
531        Ok("unsupported".into())
532    }
533    pub fn post(_: &str, _: &NotificationRequest, _: &NotificationPlan) -> Result<(), ShellError> {
534        Err(unsupported())
535    }
536    pub fn delivered() -> Result<Vec<DeliveredNotification>, ShellError> {
537        Err(unsupported())
538    }
539    pub fn clear(_: &[String]) -> Result<(), ShellError> {
540        Err(unsupported())
541    }
542}
543
544#[cfg(test)]
545mod tests {
546    use super::*;
547
548    fn req(title: &str, body: &str, sound: bool, name: Option<&str>) -> NotificationRequest {
549        NotificationRequest {
550            title: title.into(),
551            body: body.into(),
552            navigate_path: None,
553            sound_enabled: sound,
554            sound_name: name.map(Into::into),
555            bypass_do_not_disturb: false,
556        }
557    }
558
559    fn plan_for(r: &NotificationRequest) -> NotificationPlan {
560        plan(r, &NotificationState::new(), Instant::now())
561    }
562
563    #[test]
564    fn app_id_resolution() {
565        assert_eq!(
566            resolve_app_id(Some(" com.example.mail ")),
567            "com.example.mail"
568        );
569        assert_eq!(resolve_app_id(Some("  ")), FALLBACK_APP_ID);
570        assert_eq!(resolve_app_id(None), FALLBACK_APP_ID);
571    }
572
573    #[test]
574    fn sound_names_map_to_windows_sounds() {
575        assert_eq!(windows_sound("mail"), "Mail");
576        assert_eq!(windows_sound(" SMS "), "SMS");
577        assert_eq!(windows_sound("Glass"), "Default");
578    }
579
580    #[test]
581    fn toast_payload_is_built_from_request_and_plan() {
582        let r = req("  New mail ", " Hello ", true, Some("Mail"));
583        let p = toast_payload(&r, &plan_for(&r), Some("com.example.mail"), false);
584        assert_eq!(
585            p,
586            ToastPayload {
587                app_id: "com.example.mail".into(),
588                title: "New mail".into(),
589                body: "Hello".into(),
590                sound: Some("Mail"),
591            }
592        );
593    }
594
595    #[test]
596    fn toast_payload_is_silent_when_sound_off_throttled_or_quiet() {
597        let r = req("t", "b", false, None);
598        assert_eq!(toast_payload(&r, &plan_for(&r), None, false).sound, None);
599        let r = req("t", "b", true, None);
600        let p = plan_for(&r);
601        assert_eq!(toast_payload(&r, &p, None, false).sound, Some("Default"));
602        assert_eq!(toast_payload(&r, &p, None, true).sound, None);
603        assert_eq!(toast_payload(&r, &p, None, true).app_id, FALLBACK_APP_ID);
604    }
605
606    #[test]
607    fn empty_title_and_dnd_are_suppressed_before_the_platform() {
608        let s = NotificationState::new();
609        let out = post(&req(" ", "b", false, None), &s, false).unwrap();
610        assert_eq!(out.suppressed, Some("empty-title"));
611        let out = post(&req("t", "b", false, None), &s, true).unwrap();
612        assert_eq!(out.suppressed, Some("dnd"));
613    }
614
615    #[test]
616    fn support_flag_matches_platform() {
617        assert_eq!(
618            status(&NotificationState::new()).supported,
619            cfg!(any(target_os = "macos", windows))
620        );
621    }
622}