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 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(¢er)?;
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 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 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 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}