1use std::{
6 collections::HashMap,
7 sync::{Arc, Mutex, MutexGuard, PoisonError},
8};
9
10use serde::de::DeserializeOwned;
11use serde_json::Value;
12use tauri::{
13 AppHandle, Manager, Runtime,
14 ipc::Channel,
15 plugin::{PermissionState, PluginApi},
16};
17
18use crate::{ActionPerformed, ActionType, NotificationBuilder};
19
20const ACTION_PERFORMED: &str = "actionPerformed";
22
23pub fn init<R: Runtime, C: DeserializeOwned>(
25 app: &AppHandle<R>,
26 _api: PluginApi<R, C>,
27) -> crate::Result<Notification<R>> {
28 Ok(Notification {
29 app: app.clone(),
30 shared: Arc::default(),
31 })
32}
33
34type ActionHandler = Arc<dyn Fn(&ActionPerformed) + Send + Sync>;
35
36struct Listener {
38 event: String,
39 channel: Channel<Value>,
40 webview: String,
41 window: String,
42}
43
44#[derive(Default)]
47pub(crate) struct Shared {
48 action_types: Mutex<HashMap<String, ActionType>>,
49 handlers: Mutex<Vec<ActionHandler>>,
50 listeners: Mutex<Vec<Listener>>,
51 #[cfg(all(unix, not(target_os = "macos")))]
52 tracker: imp::Tracker,
53}
54
55fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
56 mutex.lock().unwrap_or_else(PoisonError::into_inner)
57}
58
59impl Shared {
60 fn has_listeners(&self) -> bool {
61 !lock(&self.handlers).is_empty()
62 || lock(&self.listeners)
63 .iter()
64 .any(|listener| listener.event == ACTION_PERFORMED)
65 }
66
67 fn retain_listeners(&self, keep: impl Fn(&Listener) -> bool) {
68 lock(&self.listeners).retain(keep);
69 }
70
71 fn dispatch(&self, payload: Value) {
74 if let Ok(performed) = serde_json::from_value::<ActionPerformed>(payload.clone()) {
75 let handlers = lock(&self.handlers).clone();
76 for handler in handlers {
77 handler(&performed);
78 }
79 }
80 for listener in lock(&self.listeners).iter() {
81 if listener.event == ACTION_PERFORMED {
82 let _ = listener.channel.send(payload.clone());
83 }
84 }
85 }
86}
87
88pub struct Notification<R: Runtime> {
92 app: AppHandle<R>,
93 shared: Arc<Shared>,
94}
95
96impl<R: Runtime> crate::NotificationBuilder<R> {
97 pub fn show(self) -> crate::Result<()> {
122 let shared = Arc::clone(&self.app.state::<Notification<R>>().shared);
123 let mut notification = imp::Notification::new(self.app.config().identifier.clone());
124
125 if let Some(title) = self
126 .data
127 .title
128 .clone()
129 .or_else(|| self.app.config().product_name.clone())
130 {
131 notification = notification.title(title);
132 }
133 if let Some(body) = self.data.body.clone() {
134 notification = notification.body(body);
135 }
136 if let Some(icon) = self.data.icon.clone() {
137 notification = notification.icon(icon);
138 }
139 if let Some(sound) = self.data.sound.clone() {
140 notification = notification.sound(sound);
141 }
142 if shared.has_listeners() {
143 if let Some(action_type) = self
144 .data
145 .action_type_id
146 .as_ref()
147 .and_then(|id| lock(&shared.action_types).get(id).cloned())
148 {
149 for action in action_type.actions() {
150 notification = notification.action(action.id(), action.title());
151 }
152 }
153 let id = self.data.id;
154 let data = serde_json::to_value(&self.data).unwrap_or(Value::Null);
155 notification.show_tracked(shared, id, data)?;
156 } else {
157 notification.show()?;
158 }
159
160 Ok(())
161 }
162}
163
164impl<R: Runtime> Notification<R> {
165 pub fn builder(&self) -> NotificationBuilder<R> {
182 NotificationBuilder::new(self.app.clone())
183 }
184
185 pub fn request_permission(&self) -> crate::Result<PermissionState> {
190 Ok(PermissionState::Granted)
191 }
192
193 pub fn permission_state(&self) -> crate::Result<PermissionState> {
198 Ok(PermissionState::Granted)
199 }
200
201 pub fn register_action_types(&self, types: Vec<ActionType>) -> crate::Result<()> {
207 let mut registered = lock(&self.shared.action_types);
208 for action_type in types {
209 registered.insert(action_type.id().to_owned(), action_type);
210 }
211 Ok(())
212 }
213
214 pub fn remove_active(&self, notifications: Vec<i32>) -> crate::Result<()> {
221 imp::close(&self.shared, Some(¬ifications));
222 Ok(())
223 }
224
225 pub fn remove_all_active(&self) -> crate::Result<()> {
227 imp::close(&self.shared, None);
228 Ok(())
229 }
230
231 pub fn on_action<F: Fn(&ActionPerformed) + Send + Sync + 'static>(
241 &self,
242 handler: F,
243 ) -> crate::Result<()> {
244 lock(&self.shared.handlers).push(Arc::new(handler));
245 Ok(())
246 }
247
248 pub(crate) fn register_listener(
249 &self,
250 event: String,
251 channel: Channel<Value>,
252 webview: &tauri::Webview<R>,
253 ) {
254 lock(&self.shared.listeners).push(Listener {
255 event,
256 channel,
257 webview: webview.label().to_owned(),
258 window: webview.window().label().to_owned(),
259 });
260 }
261
262 pub(crate) fn remove_listener(&self, event: &str, channel_id: u32) {
263 lock(&self.shared.listeners)
264 .retain(|listener| !(listener.event == event && listener.channel.id() == channel_id));
265 }
266
267 pub(crate) fn remove_webview_listeners(&self, webview: &str) {
269 self.shared
270 .retain_listeners(|listener| listener.webview != webview);
271 }
272
273 pub(crate) fn remove_window_listeners(&self, window: &str) {
275 self.shared
276 .retain_listeners(|listener| listener.window != window);
277 }
278}
279
280mod imp {
281 #[cfg(windows)]
284 use std::path::MAIN_SEPARATOR as SEP;
285 use std::sync::Arc;
286 #[cfg(all(unix, not(target_os = "macos")))]
287 use std::{
288 collections::HashMap,
289 sync::{Mutex, OnceLock, Weak},
290 };
291
292 use serde_json::{Value, json};
293
294 use super::Shared;
295 #[cfg(all(unix, not(target_os = "macos")))]
296 use super::lock;
297
298 #[allow(dead_code)]
322 #[derive(Debug, Default)]
323 pub struct Notification {
324 body: Option<String>,
326 title: Option<String>,
328 icon: Option<String>,
330 sound: Option<String>,
332 identifier: String,
334 actions: Vec<(String, String)>,
336 }
337
338 #[cfg(all(unix, not(target_os = "macos")))]
342 #[derive(Default)]
343 pub(crate) struct Tracker {
344 connection: OnceLock<Option<zbus::blocking::Connection>>,
346 pub(super) shown: Mutex<HashMap<u32, (i32, Value)>>,
349 }
350
351 pub(super) fn performed(action_id: &str, notification: &Value) -> Value {
353 json!({
354 "actionId": action_id,
355 "inputValue": null,
356 "notification": notification,
357 })
358 }
359
360 impl Notification {
361 pub fn new(identifier: impl Into<String>) -> Self {
363 Self {
364 identifier: identifier.into(),
365 ..Default::default()
366 }
367 }
368
369 #[must_use]
371 pub fn body(mut self, body: impl Into<String>) -> Self {
372 self.body = Some(body.into());
373 self
374 }
375
376 #[must_use]
378 pub fn title(mut self, title: impl Into<String>) -> Self {
379 self.title = Some(title.into());
380 self
381 }
382
383 #[must_use]
385 pub fn icon(mut self, icon: impl Into<String>) -> Self {
386 self.icon = Some(icon.into());
387 self
388 }
389
390 #[must_use]
392 pub fn sound(mut self, sound: impl Into<String>) -> Self {
393 self.sound = Some(sound.into());
394 self
395 }
396
397 #[must_use]
399 pub fn action(mut self, id: impl Into<String>, title: impl Into<String>) -> Self {
400 self.actions.push((id.into(), title.into()));
401 self
402 }
403
404 pub fn show(self) -> crate::Result<()> {
425 let notification = self.build()?;
426 tauri::async_runtime::spawn(async move {
427 let _ = notification.show();
428 });
429
430 Ok(())
431 }
432
433 #[cfg(all(unix, not(target_os = "macos")))]
437 pub(crate) fn show_tracked(
438 self,
439 shared: Arc<Shared>,
440 id: i32,
441 data: Value,
442 ) -> crate::Result<()> {
443 let mut notification = self.build()?;
444 notification.action("default", "");
447 tauri::async_runtime::spawn_blocking(move || track(notification, &shared, id, data));
448 Ok(())
449 }
450
451 #[cfg(any(windows, target_os = "macos"))]
455 pub(crate) fn show_tracked(
456 self,
457 shared: Arc<Shared>,
458 _id: i32,
459 data: Value,
460 ) -> crate::Result<()> {
461 let notification = self.build()?;
462 std::thread::Builder::new()
463 .name("notification".into())
464 .spawn(move || wait(notification, shared, data))?;
465 Ok(())
466 }
467
468 fn build(self) -> crate::Result<notify_rust::Notification> {
469 let mut notification = notify_rust::Notification::new();
470 for (id, title) in &self.actions {
471 notification.action(id, title);
472 }
473 if let Some(body) = self.body {
474 notification.body(&body);
475 }
476 if let Some(title) = self.title {
477 notification.summary(&title);
478 }
479 if let Some(icon) = self.icon {
480 notification.icon(&icon);
481 } else {
482 notification.auto_icon();
483 }
484 if let Some(sound) = self.sound {
485 notification.sound_name(&sound);
486 }
487 #[cfg(windows)]
488 {
489 let exe = tauri::utils::platform::current_exe()?;
490 let exe_dir = exe.parent().expect("failed to get exe directory");
491 let curr_dir = exe_dir.display().to_string();
492 if !(curr_dir.ends_with(format!("{SEP}target{SEP}debug").as_str())
494 || curr_dir.ends_with(format!("{SEP}target{SEP}release").as_str()))
495 {
496 notification.app_id(&self.identifier);
497 }
498 }
499 #[cfg(target_os = "macos")]
500 {
501 let _ = notify_rust::set_application(if tauri::is_dev() {
502 "com.apple.Terminal"
503 } else {
504 &self.identifier
505 });
506 }
507
508 Ok(notification)
509 }
510
511 #[cfg(feature = "windows7-compat")]
513 #[allow(dead_code)]
514 #[cfg_attr(docsrs, doc(cfg(feature = "windows7-compat")))]
515 #[deprecated = "Tauri no longer supports Windows 7, use `Self::show` instead."]
516 pub fn notify<R: tauri::Runtime>(self, _app: &tauri::AppHandle<R>) -> crate::Result<()> {
517 self.show()
518 }
519 }
520
521 #[cfg(all(unix, not(target_os = "macos")))]
523 pub(super) const BUS: (&str, &str, &str) = (
524 "org.freedesktop.Notifications",
525 "/org/freedesktop/Notifications",
526 "org.freedesktop.Notifications",
527 );
528
529 #[cfg(all(unix, not(target_os = "macos")))]
532 fn track(notification: notify_rust::Notification, shared: &Arc<Shared>, id: i32, data: Value) {
533 let tracker = &shared.tracker;
534 let Some(connection) = tracker
535 .connection
536 .get_or_init(|| listen(Arc::downgrade(shared)))
537 else {
538 let _ = notification.show();
539 return;
540 };
541 let mut shown = lock(&tracker.shown);
544 if let Ok(server_id) = notify(connection, ¬ification) {
545 shown.insert(server_id, (id, data));
546 }
547 }
548
549 #[cfg(all(unix, not(target_os = "macos")))]
553 fn notify(
554 connection: &zbus::blocking::Connection,
555 notification: ¬ify_rust::Notification,
556 ) -> zbus::Result<u32> {
557 let hints = notification
559 .hints
560 .iter()
561 .filter_map(|hint| match hint {
562 notify_rust::Hint::SoundName(name) => {
563 Some(("sound-name", zbus::zvariant::Value::from(name.as_str())))
564 }
565 _ => None,
566 })
567 .collect::<HashMap<_, _>>();
568 let (destination, path, interface) = BUS;
569 connection
570 .call_method(
571 Some(destination),
572 path,
573 Some(interface),
574 "Notify",
575 &(
576 ¬ification.appname,
577 0u32,
578 ¬ification.icon,
579 ¬ification.summary,
580 ¬ification.body,
581 ¬ification.actions,
582 hints,
583 -1i32,
584 ),
585 )?
586 .body()
587 .deserialize()
588 }
589
590 #[cfg(all(unix, not(target_os = "macos")))]
595 fn listen(shared: Weak<Shared>) -> Option<zbus::blocking::Connection> {
596 let (_, path, interface) = BUS;
597 let connection = zbus::blocking::Connection::session().ok()?;
598 let rule = zbus::MatchRule::builder()
599 .msg_type(zbus::message::Type::Signal)
600 .path(path)
601 .ok()?
602 .interface(interface)
603 .ok()?
604 .build();
605 let signals =
606 zbus::blocking::MessageIterator::for_match_rule(rule, &connection, None).ok()?;
607 std::thread::Builder::new()
608 .name("notification-actions".into())
609 .spawn(move || {
610 for signal in signals.flatten() {
611 let Some(shared) = shared.upgrade() else {
612 return;
613 };
614 on_signal(&shared, &signal);
615 }
616 })
617 .ok()?;
618 Some(connection)
619 }
620
621 #[cfg(all(unix, not(target_os = "macos")))]
624 pub(super) fn on_signal(shared: &Shared, signal: &zbus::message::Message) {
625 let header = signal.header();
626 let body = signal.body();
627 let (server_id, action) = match header.member().map(|member| member.as_str()) {
628 Some("ActionInvoked") => match body.deserialize::<(u32, String)>() {
629 Ok((server_id, action)) => (server_id, action),
630 Err(_) => return,
631 },
632 Some("NotificationClosed") => {
633 if let Ok((server_id, _reason)) = body.deserialize::<(u32, u32)>() {
634 lock(&shared.tracker.shown).remove(&server_id);
635 }
636 return;
637 }
638 _ => return,
639 };
640 let data = lock(&shared.tracker.shown)
642 .get(&server_id)
643 .map(|(_, data)| data.clone());
644 if let Some(data) = data {
645 let action = if action == "default" { "tap" } else { &action };
646 shared.dispatch(performed(action, &data));
647 }
648 }
649
650 #[cfg(all(unix, not(target_os = "macos")))]
653 pub(super) fn close(shared: &Shared, ids: Option<&[i32]>) {
654 let Some(Some(connection)) = shared.tracker.connection.get() else {
655 return;
656 };
657 let server_ids = lock(&shared.tracker.shown)
658 .iter()
659 .filter(|(_, (id, _))| ids.is_none_or(|ids| ids.contains(id)))
660 .map(|(server_id, _)| *server_id)
661 .collect::<Vec<_>>();
662 let (destination, path, interface) = BUS;
663 for server_id in server_ids {
664 let _ = connection.call_method(
665 Some(destination),
666 path,
667 Some(interface),
668 "CloseNotification",
669 &(server_id,),
670 );
671 }
672 }
673
674 #[cfg(any(windows, target_os = "macos"))]
676 pub(super) fn close(_shared: &Shared, _ids: Option<&[i32]>) {}
677
678 #[cfg(any(windows, target_os = "macos"))]
681 fn wait(notification: notify_rust::Notification, shared: Arc<Shared>, data: Value) {
682 let Ok(handle) = notification.show() else {
683 return;
684 };
685 let _ = handle.wait_for_response(|response: ¬ify_rust::NotificationResponse| {
686 let action = match response {
687 notify_rust::NotificationResponse::Default => "tap",
688 notify_rust::NotificationResponse::Action(action) => action.as_str(),
689 _ => return,
690 };
691 shared.dispatch(performed(action, &data));
692 });
693 }
694}
695
696#[cfg(test)]
697mod tests {
698 use std::sync::{Arc, Mutex};
699
700 use super::*;
701 use crate::NotificationData;
702
703 #[test]
704 fn a_performed_action_reaches_the_rust_handlers_with_its_notification() {
705 let shared = Shared::default();
706 assert!(!shared.has_listeners());
707 let seen = Arc::new(Mutex::new(Vec::new()));
708 let record = Arc::clone(&seen);
709 lock(&shared.handlers).push(Arc::new(move |performed: &ActionPerformed| {
710 record.lock().unwrap().push((
711 performed.action_id().to_owned(),
712 performed.notification().map(|n| n.id()),
713 performed
714 .notification()
715 .and_then(|n| n.title().map(str::to_owned)),
716 ));
717 }));
718 assert!(shared.has_listeners());
719
720 let data = NotificationData {
721 id: 7,
722 title: Some("Standup".into()),
723 ..Default::default()
724 };
725 let data = serde_json::to_value(&data).unwrap();
726 shared.dispatch(imp::performed("tap", &data));
727 shared.dispatch(imp::performed("snooze", &data));
728 assert_eq!(
729 *seen.lock().unwrap(),
730 [
731 ("tap".to_owned(), Some(7), Some("Standup".to_owned())),
732 ("snooze".to_owned(), Some(7), Some("Standup".to_owned())),
733 ]
734 );
735 }
736
737 #[test]
738 fn an_unreadable_notification_still_reports_the_action() {
739 let performed: ActionPerformed = serde_json::from_value(serde_json::json!({
740 "actionId": "tap",
741 "inputValue": null,
742 "notification": { "id": "not a number" },
743 }))
744 .unwrap();
745 assert_eq!(performed.action_id(), "tap");
746 assert!(performed.notification().is_none());
747 }
748
749 fn listener(webview: &str, window: &str) -> Listener {
750 Listener {
751 event: ACTION_PERFORMED.into(),
752 channel: Channel::new(|_| Ok(())),
753 webview: webview.into(),
754 window: window.into(),
755 }
756 }
757
758 #[test]
759 fn the_listeners_of_a_gone_page_stop_tracking() {
760 let shared = Shared::default();
761 lock(&shared.listeners).extend([listener("main", "main"), listener("side", "other")]);
762 shared.retain_listeners(|listener| listener.webview != "main");
763 assert!(shared.has_listeners());
764 shared.retain_listeners(|listener| listener.window != "other");
765 assert!(!shared.has_listeners());
766 }
767
768 #[cfg(all(unix, not(target_os = "macos")))]
769 fn signal(
770 member: &str,
771 body: &(impl serde::Serialize + zbus::zvariant::DynamicType),
772 ) -> zbus::message::Message {
773 let (_, path, interface) = imp::BUS;
774 zbus::message::Message::signal(path, interface, member)
775 .unwrap()
776 .build(body)
777 .unwrap()
778 }
779
780 #[cfg(all(unix, not(target_os = "macos")))]
781 #[test]
782 fn the_signals_of_a_tracked_notification_are_reported_until_it_closes() {
783 let shared = Shared::default();
784 let seen = Arc::new(Mutex::new(Vec::new()));
785 let record = Arc::clone(&seen);
786 lock(&shared.handlers).push(Arc::new(move |performed: &ActionPerformed| {
787 record
788 .lock()
789 .unwrap()
790 .push(performed.action_id().to_owned());
791 }));
792 lock(&shared.tracker.shown).insert(41, (7, serde_json::json!({ "id": 7 })));
793
794 imp::on_signal(&shared, &signal("ActionInvoked", &(41u32, "default")));
795 imp::on_signal(&shared, &signal("ActionInvoked", &(41u32, "snooze")));
796 imp::on_signal(&shared, &signal("ActionInvoked", &(42u32, "default")));
798 imp::on_signal(&shared, &signal("NotificationClosed", &(41u32, 2u32)));
799 imp::on_signal(&shared, &signal("ActionInvoked", &(41u32, "default")));
800
801 assert_eq!(*seen.lock().unwrap(), ["tap", "snooze"]);
802 assert!(lock(&shared.tracker.shown).is_empty());
803 }
804
805 #[test]
806 fn a_handler_may_register_another_handler() {
807 let shared = Arc::new(Shared::default());
808 let inner = Arc::clone(&shared);
809 lock(&shared.handlers).push(Arc::new(move |_: &ActionPerformed| {
810 lock(&inner.handlers).push(Arc::new(|_: &ActionPerformed| {}));
811 }));
812 shared.dispatch(imp::performed("tap", &Value::Null));
813 assert_eq!(lock(&shared.handlers).len(), 2);
814 }
815}