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 std::env::var_os("RIGHTKIT_QA_BACKGROUND").as_deref()
364                == Some(std::ffi::OsStr::new("1"))
365            {
366                return Err(ShellError::PermissionDenied("notification authorization is not determined; background QA never opens a permission dialog".into()));
367            }
368            let (tx, rx) = mpsc::channel();
369            let block = RcBlock::new(move |granted: Bool, _err: *mut NSError| {
370                let _ = tx.send(granted.as_bool());
371            });
372            center.requestAuthorizationWithOptions_completionHandler(
373                UNAuthorizationOptions::Alert
374                    | UNAuthorizationOptions::Sound
375                    | UNAuthorizationOptions::Badge,
376                &block,
377            );
378            let granted = rx.recv_timeout(PROMPT).map_err(|_| {
379                ShellError::Timeout("notification permission prompt was not answered".into())
380            })?;
381            if !granted {
382                return Err(denied());
383            }
384            return Ok(());
385        }
386        match status {
387            UNAuthorizationStatus::Denied => Err(denied()),
388            _ => Ok(()),
389        }
390    }
391
392    fn denied() -> ShellError {
393        ShellError::PermissionDenied("notifications are not allowed for this app; enable them in System Settings > Notifications".into())
394    }
395
396    pub fn post(
397        id: &str,
398        req: &NotificationRequest,
399        plan: &NotificationPlan,
400    ) -> Result<(), ShellError> {
401        let center = center()?;
402        ensure_authorized(&center)?;
403        let content = UNMutableNotificationContent::new();
404        content.setTitle(&NSString::from_str(&req.title));
405        content.setBody(&NSString::from_str(&req.body));
406        content.setThreadIdentifier(&NSString::from_str(&plan.key));
407        let quiet_qa = std::env::var_os("RIGHTKIT_QA_BACKGROUND").as_deref()
408            == Some(std::ffi::OsStr::new("1"));
409        if quiet_qa {
410            content.setInterruptionLevel(UNNotificationInterruptionLevel::Passive);
411            content.setSound(None);
412        } else if let Some(name) = &plan.sound_name {
413            content.setSound(Some(&UNNotificationSound::soundNamed(&NSString::from_str(
414                name,
415            ))));
416        }
417        let request = UNNotificationRequest::requestWithIdentifier_content_trigger(
418            &NSString::from_str(id),
419            &content,
420            None,
421        );
422        let (tx, rx) = mpsc::channel();
423        let block = RcBlock::new(move |err: *mut NSError| {
424            // SAFETY: a non-null error pointer is a live NSError for the duration of the callback.
425            let msg = (!err.is_null()).then(|| unsafe { &*err }.localizedDescription().to_string());
426            let _ = tx.send(msg);
427        });
428        center.addNotificationRequest_withCompletionHandler(&request, Some(&block));
429        match rx
430            .recv_timeout(REPLY)
431            .map_err(|_| ShellError::Timeout("notification center did not answer".into()))?
432        {
433            None => Ok(()),
434            Some(m) => Err(ShellError::Other(format!("show notification: {m}"))),
435        }
436    }
437
438    pub fn delivered() -> Result<Vec<DeliveredNotification>, ShellError> {
439        let center = center()?;
440        let (tx, rx) = mpsc::channel();
441        let block = RcBlock::new(move |list: std::ptr::NonNull<NSArray<UNNotification>>| {
442            // SAFETY: the OS passes a live array for the duration of the callback.
443            let list = unsafe { list.as_ref() };
444            let mut out = Vec::new();
445            for i in 0..list.count() {
446                let n = list.objectAtIndex(i);
447                let r = n.request();
448                let c = r.content();
449                out.push(DeliveredNotification {
450                    id: r.identifier().to_string(),
451                    title: c.title().to_string(),
452                    body: c.body().to_string(),
453                    thread: c.threadIdentifier().to_string(),
454                });
455            }
456            let _ = tx.send(out);
457        });
458        center.getDeliveredNotificationsWithCompletionHandler(&block);
459        rx.recv_timeout(REPLY)
460            .map_err(|_| ShellError::Timeout("delivered notifications did not answer".into()))
461    }
462
463    pub fn clear(ids: &[String]) -> Result<(), ShellError> {
464        let center = center()?;
465        if ids.is_empty() {
466            center.removeAllDeliveredNotifications();
467        } else {
468            let strings: Vec<Retained<NSString>> =
469                ids.iter().map(|s| NSString::from_str(s)).collect();
470            center.removeDeliveredNotificationsWithIdentifiers(&NSArray::from_retained_slice(
471                &strings,
472            ));
473        }
474        Ok(())
475    }
476}
477
478#[cfg(windows)]
479mod platform {
480    use tauri_winrt_notification::{Sound, Toast};
481
482    use super::{DeliveredNotification, NotificationPlan, NotificationRequest};
483    use crate::error::ShellError;
484
485    pub const SUPPORTED: bool = true;
486
487    /// Windows has no per-app permission prompt; Focus Assist is the user's, not read here.
488    pub fn authorization() -> Result<String, ShellError> {
489        Ok("authorized".into())
490    }
491
492    pub fn post(
493        _: &str,
494        req: &NotificationRequest,
495        plan: &NotificationPlan,
496    ) -> Result<(), ShellError> {
497        let quiet = std::env::var_os("RIGHTKIT_QA_BACKGROUND").as_deref()
498            == Some(std::ffi::OsStr::new("1"));
499        let p = super::toast_payload(req, plan, Some(&super::app_id()), quiet);
500        let sound = p.sound.map(|n| match n {
501            "IM" => Sound::IM,
502            "Mail" => Sound::Mail,
503            "Reminder" => Sound::Reminder,
504            "SMS" => Sound::SMS,
505            _ => Sound::Default,
506        });
507        Toast::new(&p.app_id)
508            .title(&p.title)
509            .text1(&p.body)
510            .sound(sound)
511            .show()
512            .map_err(|e| ShellError::Other(format!("show toast: {e}")))
513    }
514
515    pub fn delivered() -> Result<Vec<DeliveredNotification>, ShellError> {
516        Ok(Vec::new())
517    }
518
519    pub fn clear(_: &[String]) -> Result<(), ShellError> {
520        Ok(())
521    }
522}
523
524#[cfg(not(any(target_os = "macos", windows)))]
525mod platform {
526    use super::{DeliveredNotification, NotificationPlan, NotificationRequest};
527    use crate::error::ShellError;
528
529    pub const SUPPORTED: bool = false;
530
531    fn unsupported() -> ShellError {
532        ShellError::Unsupported("native notifications are implemented for macOS and Windows".into())
533    }
534    pub fn authorization() -> Result<String, ShellError> {
535        Ok("unsupported".into())
536    }
537    pub fn post(_: &str, _: &NotificationRequest, _: &NotificationPlan) -> Result<(), ShellError> {
538        Err(unsupported())
539    }
540    pub fn delivered() -> Result<Vec<DeliveredNotification>, ShellError> {
541        Err(unsupported())
542    }
543    pub fn clear(_: &[String]) -> Result<(), ShellError> {
544        Err(unsupported())
545    }
546}
547
548#[cfg(test)]
549mod tests {
550    use super::*;
551
552    fn req(title: &str, body: &str, sound: bool, name: Option<&str>) -> NotificationRequest {
553        NotificationRequest {
554            title: title.into(),
555            body: body.into(),
556            navigate_path: None,
557            sound_enabled: sound,
558            sound_name: name.map(Into::into),
559            bypass_do_not_disturb: false,
560        }
561    }
562
563    fn plan_for(r: &NotificationRequest) -> NotificationPlan {
564        plan(r, &NotificationState::new(), Instant::now())
565    }
566
567    #[test]
568    fn app_id_resolution() {
569        assert_eq!(
570            resolve_app_id(Some(" com.example.mail ")),
571            "com.example.mail"
572        );
573        assert_eq!(resolve_app_id(Some("  ")), FALLBACK_APP_ID);
574        assert_eq!(resolve_app_id(None), FALLBACK_APP_ID);
575    }
576
577    #[test]
578    fn sound_names_map_to_windows_sounds() {
579        assert_eq!(windows_sound("mail"), "Mail");
580        assert_eq!(windows_sound(" SMS "), "SMS");
581        assert_eq!(windows_sound("Glass"), "Default");
582    }
583
584    #[test]
585    fn toast_payload_is_built_from_request_and_plan() {
586        let r = req("  New mail ", " Hello ", true, Some("Mail"));
587        let p = toast_payload(&r, &plan_for(&r), Some("com.example.mail"), false);
588        assert_eq!(
589            p,
590            ToastPayload {
591                app_id: "com.example.mail".into(),
592                title: "New mail".into(),
593                body: "Hello".into(),
594                sound: Some("Mail"),
595            }
596        );
597    }
598
599    #[test]
600    fn toast_payload_is_silent_when_sound_off_throttled_or_quiet() {
601        let r = req("t", "b", false, None);
602        assert_eq!(toast_payload(&r, &plan_for(&r), None, false).sound, None);
603        let r = req("t", "b", true, None);
604        let p = plan_for(&r);
605        assert_eq!(toast_payload(&r, &p, None, false).sound, Some("Default"));
606        assert_eq!(toast_payload(&r, &p, None, true).sound, None);
607        assert_eq!(toast_payload(&r, &p, None, true).app_id, FALLBACK_APP_ID);
608    }
609
610    #[test]
611    fn empty_title_and_dnd_are_suppressed_before_the_platform() {
612        let s = NotificationState::new();
613        let out = post(&req(" ", "b", false, None), &s, false).unwrap();
614        assert_eq!(out.suppressed, Some("empty-title"));
615        let out = post(&req("t", "b", false, None), &s, true).unwrap();
616        assert_eq!(out.suppressed, Some("dnd"));
617    }
618
619    #[test]
620    fn support_flag_matches_platform() {
621        assert_eq!(
622            status(&NotificationState::new()).supported,
623            cfg!(any(target_os = "macos", windows))
624        );
625    }
626}