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 let report = shared.has_listeners();
143 if report
144 && let Some(action_type) = self
145 .data
146 .action_type_id
147 .as_ref()
148 .and_then(|id| lock(&shared.action_types).get(id).cloned())
149 {
150 for action in action_type.actions() {
151 notification = notification.action(action.id(), action.title());
152 }
153 }
154 let id = self.data.id;
155 let data = serde_json::to_value(&self.data).unwrap_or(Value::Null);
156 #[cfg(all(unix, not(target_os = "macos")))]
160 notification.show_tracked(shared, id, data, report, self.on_close)?;
161 #[cfg(any(windows, target_os = "macos"))]
162 if report {
163 notification.show_tracked(shared, id, data)?;
164 } else {
165 notification.show()?;
166 }
167
168 Ok(())
169 }
170}
171
172impl<R: Runtime> Notification<R> {
173 pub fn builder(&self) -> NotificationBuilder<R> {
190 NotificationBuilder::new(self.app.clone())
191 }
192
193 pub fn request_permission(&self) -> crate::Result<PermissionState> {
198 Ok(PermissionState::Granted)
199 }
200
201 pub fn permission_state(&self) -> crate::Result<PermissionState> {
206 Ok(PermissionState::Granted)
207 }
208
209 pub fn register_action_types(&self, types: Vec<ActionType>) -> crate::Result<()> {
215 let mut registered = lock(&self.shared.action_types);
216 for action_type in types {
217 registered.insert(action_type.id().to_owned(), action_type);
218 }
219 Ok(())
220 }
221
222 pub fn remove_active(&self, notifications: Vec<i32>) -> crate::Result<()> {
229 imp::close(&self.shared, Some(¬ifications));
230 Ok(())
231 }
232
233 pub fn remove_all_active(&self) -> crate::Result<()> {
235 imp::close(&self.shared, None);
236 Ok(())
237 }
238
239 pub fn on_action<F: Fn(&ActionPerformed) + Send + Sync + 'static>(
249 &self,
250 handler: F,
251 ) -> crate::Result<()> {
252 lock(&self.shared.handlers).push(Arc::new(handler));
253 Ok(())
254 }
255
256 pub(crate) fn register_listener(
257 &self,
258 event: String,
259 channel: Channel<Value>,
260 webview: &tauri::Webview<R>,
261 ) {
262 lock(&self.shared.listeners).push(Listener {
263 event,
264 channel,
265 webview: webview.label().to_owned(),
266 window: webview.window().label().to_owned(),
267 });
268 }
269
270 pub(crate) fn remove_listener(&self, event: &str, channel_id: u32) {
271 lock(&self.shared.listeners)
272 .retain(|listener| !(listener.event == event && listener.channel.id() == channel_id));
273 }
274
275 pub(crate) fn remove_webview_listeners(&self, webview: &str) {
277 self.shared
278 .retain_listeners(|listener| listener.webview != webview);
279 }
280
281 pub(crate) fn remove_window_listeners(&self, window: &str) {
283 self.shared
284 .retain_listeners(|listener| listener.window != window);
285 }
286}
287
288mod imp {
289 #[cfg(windows)]
292 use std::path::MAIN_SEPARATOR as SEP;
293 use std::sync::Arc;
294 #[cfg(all(unix, not(target_os = "macos")))]
295 use std::{
296 collections::HashMap,
297 sync::{Mutex, OnceLock, Weak},
298 };
299
300 use serde_json::{Value, json};
301
302 use super::Shared;
303 #[cfg(all(unix, not(target_os = "macos")))]
304 use super::lock;
305
306 #[allow(dead_code)]
330 #[derive(Debug, Default)]
331 pub struct Notification {
332 body: Option<String>,
334 title: Option<String>,
336 icon: Option<String>,
338 sound: Option<String>,
340 identifier: String,
342 actions: Vec<(String, String)>,
344 }
345
346 #[cfg(all(unix, not(target_os = "macos")))]
350 #[derive(Default)]
351 pub(crate) struct Tracker {
352 connection: OnceLock<Option<zbus::blocking::Connection>>,
354 pub(super) shown: Mutex<HashMap<u32, (i32, Value)>>,
357 pub(super) on_close: Mutex<HashMap<u32, crate::OnClose>>,
359 }
360
361 pub(super) fn performed(action_id: &str, notification: &Value) -> Value {
363 json!({
364 "actionId": action_id,
365 "inputValue": null,
366 "notification": notification,
367 })
368 }
369
370 impl Notification {
371 pub fn new(identifier: impl Into<String>) -> Self {
373 Self {
374 identifier: identifier.into(),
375 ..Default::default()
376 }
377 }
378
379 #[must_use]
381 pub fn body(mut self, body: impl Into<String>) -> Self {
382 self.body = Some(body.into());
383 self
384 }
385
386 #[must_use]
388 pub fn title(mut self, title: impl Into<String>) -> Self {
389 self.title = Some(title.into());
390 self
391 }
392
393 #[must_use]
395 pub fn icon(mut self, icon: impl Into<String>) -> Self {
396 self.icon = Some(icon.into());
397 self
398 }
399
400 #[must_use]
402 pub fn sound(mut self, sound: impl Into<String>) -> Self {
403 self.sound = Some(sound.into());
404 self
405 }
406
407 #[must_use]
409 pub fn action(mut self, id: impl Into<String>, title: impl Into<String>) -> Self {
410 self.actions.push((id.into(), title.into()));
411 self
412 }
413
414 #[cfg(any(windows, target_os = "macos", feature = "windows7-compat"))]
435 pub fn show(self) -> crate::Result<()> {
436 let notification = self.build()?;
437 tauri::async_runtime::spawn(async move {
438 let _ = notification.show();
439 });
440
441 Ok(())
442 }
443
444 #[cfg(all(unix, not(target_os = "macos")))]
449 pub(crate) fn show_tracked(
450 self,
451 shared: Arc<Shared>,
452 id: i32,
453 data: Value,
454 report: bool,
455 on_close: Option<crate::OnClose>,
456 ) -> crate::Result<()> {
457 let mut notification = self.build()?;
458 if report {
459 notification.action("default", "");
463 }
464 tauri::async_runtime::spawn_blocking(move || {
465 track(notification, &shared, id, report.then_some(data), on_close)
466 });
467 Ok(())
468 }
469
470 #[cfg(any(windows, target_os = "macos"))]
474 pub(crate) fn show_tracked(
475 self,
476 shared: Arc<Shared>,
477 _id: i32,
478 data: Value,
479 ) -> crate::Result<()> {
480 let notification = self.build()?;
481 std::thread::Builder::new()
482 .name("notification".into())
483 .spawn(move || wait(notification, shared, data))?;
484 Ok(())
485 }
486
487 fn build(self) -> crate::Result<notify_rust::Notification> {
488 let mut notification = notify_rust::Notification::new();
489 for (id, title) in &self.actions {
490 notification.action(id, title);
491 }
492 if let Some(body) = self.body {
493 notification.body(&body);
494 }
495 if let Some(title) = self.title {
496 notification.summary(&title);
497 }
498 if let Some(icon) = self.icon {
499 notification.icon(&icon);
500 } else {
501 notification.auto_icon();
502 }
503 if let Some(sound) = self.sound {
504 notification.sound_name(&sound);
505 }
506 #[cfg(windows)]
507 {
508 let exe = tauri::utils::platform::current_exe()?;
509 let exe_dir = exe.parent().expect("failed to get exe directory");
510 let curr_dir = exe_dir.display().to_string();
511 if !(curr_dir.ends_with(format!("{SEP}target{SEP}debug").as_str())
513 || curr_dir.ends_with(format!("{SEP}target{SEP}release").as_str()))
514 {
515 notification.app_id(&self.identifier);
516 }
517 }
518 #[cfg(target_os = "macos")]
519 {
520 let _ = notify_rust::set_application(if tauri::is_dev() {
521 "com.apple.Terminal"
522 } else {
523 &self.identifier
524 });
525 }
526
527 Ok(notification)
528 }
529
530 #[cfg(feature = "windows7-compat")]
532 #[allow(dead_code)]
533 #[cfg_attr(docsrs, doc(cfg(feature = "windows7-compat")))]
534 #[deprecated = "Tauri no longer supports Windows 7, use `Self::show` instead."]
535 pub fn notify<R: tauri::Runtime>(self, _app: &tauri::AppHandle<R>) -> crate::Result<()> {
536 self.show()
537 }
538 }
539
540 #[cfg(all(unix, not(target_os = "macos")))]
542 pub(super) const BUS: (&str, &str, &str) = (
543 "org.freedesktop.Notifications",
544 "/org/freedesktop/Notifications",
545 "org.freedesktop.Notifications",
546 );
547
548 #[cfg(all(unix, not(target_os = "macos")))]
551 fn track(
552 notification: notify_rust::Notification,
553 shared: &Arc<Shared>,
554 id: i32,
555 data: Option<Value>,
556 on_close: Option<crate::OnClose>,
557 ) {
558 let tracker = &shared.tracker;
559 let Some(connection) = tracker
560 .connection
561 .get_or_init(|| listen(Arc::downgrade(shared)))
562 else {
563 let _ = notification.show();
564 return;
565 };
566 let mut shown = lock(&tracker.shown);
569 if let Ok(server_id) = notify(connection, ¬ification) {
570 if let Some(data) = data {
571 shown.insert(server_id, (id, data));
572 }
573 if let Some(on_close) = on_close {
574 lock(&tracker.on_close).insert(server_id, on_close);
575 }
576 }
577 }
578
579 #[cfg(all(unix, not(target_os = "macos")))]
583 fn notify(
584 connection: &zbus::blocking::Connection,
585 notification: ¬ify_rust::Notification,
586 ) -> zbus::Result<u32> {
587 let hints = notification
589 .hints
590 .iter()
591 .filter_map(|hint| match hint {
592 notify_rust::Hint::SoundName(name) => {
593 Some(("sound-name", zbus::zvariant::Value::from(name.as_str())))
594 }
595 _ => None,
596 })
597 .collect::<HashMap<_, _>>();
598 let (destination, path, interface) = BUS;
599 connection
600 .call_method(
601 Some(destination),
602 path,
603 Some(interface),
604 "Notify",
605 &(
606 ¬ification.appname,
607 0u32,
608 ¬ification.icon,
609 ¬ification.summary,
610 ¬ification.body,
611 ¬ification.actions,
612 hints,
613 -1i32,
614 ),
615 )?
616 .body()
617 .deserialize()
618 }
619
620 #[cfg(all(unix, not(target_os = "macos")))]
625 fn listen(shared: Weak<Shared>) -> Option<zbus::blocking::Connection> {
626 let (_, path, interface) = BUS;
627 let connection = zbus::blocking::Connection::session().ok()?;
628 let rule = zbus::MatchRule::builder()
629 .msg_type(zbus::message::Type::Signal)
630 .path(path)
631 .ok()?
632 .interface(interface)
633 .ok()?
634 .build();
635 let signals =
636 zbus::blocking::MessageIterator::for_match_rule(rule, &connection, None).ok()?;
637 std::thread::Builder::new()
638 .name("notification-actions".into())
639 .spawn(move || {
640 for signal in signals.flatten() {
641 let Some(shared) = shared.upgrade() else {
642 return;
643 };
644 on_signal(&shared, &signal);
645 }
646 })
647 .ok()?;
648 Some(connection)
649 }
650
651 #[cfg(all(unix, not(target_os = "macos")))]
654 pub(super) fn on_signal(shared: &Shared, signal: &zbus::message::Message) {
655 let header = signal.header();
656 let body = signal.body();
657 let (server_id, action) = match header.member().map(|member| member.as_str()) {
658 Some("ActionInvoked") => match body.deserialize::<(u32, String)>() {
659 Ok((server_id, action)) => (server_id, action),
660 Err(_) => return,
661 },
662 Some("NotificationClosed") => {
663 if let Ok((server_id, _reason)) = body.deserialize::<(u32, u32)>() {
664 lock(&shared.tracker.shown).remove(&server_id);
665 let on_close = lock(&shared.tracker.on_close).remove(&server_id);
667 if let Some(on_close) = on_close {
668 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(on_close.0));
671 }
672 }
673 return;
674 }
675 _ => return,
676 };
677 let data = lock(&shared.tracker.shown)
679 .get(&server_id)
680 .map(|(_, data)| data.clone());
681 if let Some(data) = data {
682 let action = if action == "default" { "tap" } else { &action };
683 shared.dispatch(performed(action, &data));
684 }
685 }
686
687 #[cfg(all(unix, not(target_os = "macos")))]
690 pub(super) fn close(shared: &Shared, ids: Option<&[i32]>) {
691 let Some(Some(connection)) = shared.tracker.connection.get() else {
692 return;
693 };
694 let server_ids = lock(&shared.tracker.shown)
695 .iter()
696 .filter(|(_, (id, _))| ids.is_none_or(|ids| ids.contains(id)))
697 .map(|(server_id, _)| *server_id)
698 .collect::<Vec<_>>();
699 let (destination, path, interface) = BUS;
700 for server_id in server_ids {
701 let _ = connection.call_method(
702 Some(destination),
703 path,
704 Some(interface),
705 "CloseNotification",
706 &(server_id,),
707 );
708 }
709 }
710
711 #[cfg(any(windows, target_os = "macos"))]
713 pub(super) fn close(_shared: &Shared, _ids: Option<&[i32]>) {}
714
715 #[cfg(any(windows, target_os = "macos"))]
718 fn wait(notification: notify_rust::Notification, shared: Arc<Shared>, data: Value) {
719 let Ok(handle) = notification.show() else {
720 return;
721 };
722 let _ = handle.wait_for_response(|response: ¬ify_rust::NotificationResponse| {
723 let action = match response {
724 notify_rust::NotificationResponse::Default => "tap",
725 notify_rust::NotificationResponse::Action(action) => action.as_str(),
726 _ => return,
727 };
728 shared.dispatch(performed(action, &data));
729 });
730 }
731}
732
733#[cfg(test)]
734mod tests {
735 use std::sync::{Arc, Mutex};
736
737 use super::*;
738 use crate::NotificationData;
739
740 #[test]
741 fn a_performed_action_reaches_the_rust_handlers_with_its_notification() {
742 let shared = Shared::default();
743 assert!(!shared.has_listeners());
744 let seen = Arc::new(Mutex::new(Vec::new()));
745 let record = Arc::clone(&seen);
746 lock(&shared.handlers).push(Arc::new(move |performed: &ActionPerformed| {
747 record.lock().unwrap().push((
748 performed.action_id().to_owned(),
749 performed.notification().map(|n| n.id()),
750 performed
751 .notification()
752 .and_then(|n| n.title().map(str::to_owned)),
753 ));
754 }));
755 assert!(shared.has_listeners());
756
757 let data = NotificationData {
758 id: 7,
759 title: Some("Standup".into()),
760 ..Default::default()
761 };
762 let data = serde_json::to_value(&data).unwrap();
763 shared.dispatch(imp::performed("tap", &data));
764 shared.dispatch(imp::performed("snooze", &data));
765 assert_eq!(
766 *seen.lock().unwrap(),
767 [
768 ("tap".to_owned(), Some(7), Some("Standup".to_owned())),
769 ("snooze".to_owned(), Some(7), Some("Standup".to_owned())),
770 ]
771 );
772 }
773
774 #[test]
775 fn an_unreadable_notification_still_reports_the_action() {
776 let performed: ActionPerformed = serde_json::from_value(serde_json::json!({
777 "actionId": "tap",
778 "inputValue": null,
779 "notification": { "id": "not a number" },
780 }))
781 .unwrap();
782 assert_eq!(performed.action_id(), "tap");
783 assert!(performed.notification().is_none());
784 }
785
786 fn listener(webview: &str, window: &str) -> Listener {
787 Listener {
788 event: ACTION_PERFORMED.into(),
789 channel: Channel::new(|_| Ok(())),
790 webview: webview.into(),
791 window: window.into(),
792 }
793 }
794
795 #[test]
796 fn the_listeners_of_a_gone_page_stop_tracking() {
797 let shared = Shared::default();
798 lock(&shared.listeners).extend([listener("main", "main"), listener("side", "other")]);
799 shared.retain_listeners(|listener| listener.webview != "main");
800 assert!(shared.has_listeners());
801 shared.retain_listeners(|listener| listener.window != "other");
802 assert!(!shared.has_listeners());
803 }
804
805 #[cfg(all(unix, not(target_os = "macos")))]
806 fn signal(
807 member: &str,
808 body: &(impl serde::Serialize + zbus::zvariant::DynamicType),
809 ) -> zbus::message::Message {
810 let (_, path, interface) = imp::BUS;
811 zbus::message::Message::signal(path, interface, member)
812 .unwrap()
813 .build(body)
814 .unwrap()
815 }
816
817 #[cfg(all(unix, not(target_os = "macos")))]
818 #[test]
819 fn the_signals_of_a_tracked_notification_are_reported_until_it_closes() {
820 let shared = Shared::default();
821 let seen = Arc::new(Mutex::new(Vec::new()));
822 let record = Arc::clone(&seen);
823 lock(&shared.handlers).push(Arc::new(move |performed: &ActionPerformed| {
824 record
825 .lock()
826 .unwrap()
827 .push(performed.action_id().to_owned());
828 }));
829 lock(&shared.tracker.shown).insert(41, (7, serde_json::json!({ "id": 7 })));
830
831 imp::on_signal(&shared, &signal("ActionInvoked", &(41u32, "default")));
832 imp::on_signal(&shared, &signal("ActionInvoked", &(41u32, "snooze")));
833 imp::on_signal(&shared, &signal("ActionInvoked", &(42u32, "default")));
835 imp::on_signal(&shared, &signal("NotificationClosed", &(41u32, 2u32)));
836 imp::on_signal(&shared, &signal("ActionInvoked", &(41u32, "default")));
837
838 assert_eq!(*seen.lock().unwrap(), ["tap", "snooze"]);
839 assert!(lock(&shared.tracker.shown).is_empty());
840 }
841
842 #[cfg(all(unix, not(target_os = "macos")))]
843 #[test]
844 fn on_close_runs_once_without_the_lock_held() {
845 let shared = Arc::new(Shared::default());
846 let closed = Arc::new(Mutex::new(0));
847 let (inner, count) = (Arc::clone(&shared), Arc::clone(&closed));
848 lock(&shared.tracker.on_close).insert(
849 41,
850 crate::OnClose(Box::new(move || {
851 assert!(lock(&inner.tracker.on_close).is_empty());
853 *count.lock().unwrap() += 1;
854 })),
855 );
856 lock(&shared.tracker.on_close).insert(42, crate::OnClose(Box::new(|| panic!("boom"))));
857
858 imp::on_signal(&shared, &signal("NotificationClosed", &(42u32, 2u32)));
859 imp::on_signal(&shared, &signal("NotificationClosed", &(41u32, 2u32)));
860 imp::on_signal(&shared, &signal("NotificationClosed", &(41u32, 2u32)));
861
862 assert_eq!(*closed.lock().unwrap(), 1);
863 }
864
865 #[test]
866 fn a_handler_may_register_another_handler() {
867 let shared = Arc::new(Shared::default());
868 let inner = Arc::clone(&shared);
869 lock(&shared.handlers).push(Arc::new(move |_: &ActionPerformed| {
870 lock(&inner.handlers).push(Arc::new(|_: &ActionPerformed| {}));
871 }));
872 shared.dispatch(imp::performed("tap", &Value::Null));
873 assert_eq!(lock(&shared.handlers).len(), 2);
874 }
875}