1use 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
31pub const SOUND_THROTTLE_MS: u64 = 3_000;
33pub 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 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 #[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 #[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);
139pub const FALLBACK_APP_ID: &str =
141 r"{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\WindowsPowerShell\v1.0\powershell.exe";
142
143pub 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
157pub 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
170pub 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#[derive(Debug, Clone, PartialEq, Eq)]
184pub struct ToastPayload {
185 pub app_id: String,
186 pub title: String,
187 pub body: String,
188 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#[derive(Debug, Clone, Serialize)]
212#[serde(rename_all = "camelCase")]
213pub struct NotifyOutcome {
214 pub delivered: bool,
215 #[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#[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 pub authorization: String,
237 pub supported: bool,
238 pub delivered: Vec<DeliveredNotification>,
239 pub badge: u32,
240}
241
242pub 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
254pub 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 const PROMPT: Duration = Duration::from_secs(60);
322
323 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 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(¢er()?)? {
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(¢er)?;
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 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 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 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}