Skip to main content

tauri_plugin_background_service/
notifier.rs

1//! Thin wrapper around [`tauri_plugin_notification`] for fire-and-forget
2//! local notifications.
3//!
4//! Errors are logged but never propagated — callers should not need to
5//! handle notification failures.
6
7use crate::models::PluginConfig;
8use tauri::{AppHandle, Runtime};
9use tauri_plugin_notification::NotificationExt;
10
11/// Thin wrapper over `tauri-plugin-notification`.
12///
13/// Fire-and-forget: errors are logged via `log::warn!` and never propagated.
14#[derive(Clone)]
15pub struct Notifier<R: Runtime> {
16    pub(crate) app: AppHandle<R>,
17}
18
19impl<R: Runtime> Notifier<R> {
20    /// Show a local notification with the given title and body.
21    ///
22    /// Errors are logged but not returned — callers should not need to
23    /// handle notification failures.
24    pub fn show(&self, title: &str, body: &str) {
25        if let Err(e) = self
26            .app
27            .notification()
28            .builder()
29            .title(title)
30            .body(body)
31            .show()
32        {
33            log::warn!("background-service: notification failed: {e}");
34        }
35    }
36
37    /// Show a local notification with a stable string id.
38    ///
39    /// Repeated notifications with the same id replace the previous one
40    /// instead of stacking (platform-dependent best effort). Same warn-only
41    /// contract as [`Notifier::show`]: errors are logged, never propagated.
42    pub fn show_with_id(&self, id: &str, title: &str, body: &str) {
43        if let Err(e) = self
44            .app
45            .notification()
46            .builder()
47            .id(stable_notification_id(id))
48            .title(title)
49            .body(body)
50            .show()
51        {
52            log::warn!("background-service: notification {id} failed: {e}");
53        }
54    }
55}
56
57/// Map a string notification id onto the `i32` id the notification builder
58/// expects, deterministically (FNV-1a 32-bit), so the same string id keeps
59/// replacing the same notification across calls and process restarts.
60pub(crate) fn stable_notification_id(id: &str) -> i32 {
61    let mut hash: u32 = 0x811c_9dc5;
62    for byte in id.as_bytes() {
63        hash ^= u32::from(*byte);
64        hash = hash.wrapping_mul(0x0100_0193);
65    }
66    hash as i32
67}
68
69/// Which plugin-side lifecycle notifications are enabled (spec 01 D1).
70///
71/// Derived once from [`PluginConfig`] at actor spawn via
72/// [`NotifierPolicy::derive`]; the default is everything off.
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
74pub struct NotifierPolicy {
75    /// Notify when the OS pauses background delivery (timeout/expiration).
76    pub on_timeout: bool,
77    /// Notify when background delivery is restored after OS restart/boot.
78    pub on_recovery: bool,
79}
80
81impl NotifierPolicy {
82    /// Derive the effective policy from config and platform (DEC-002).
83    ///
84    /// Pure function so the Android suppression matrix is host-testable;
85    /// the call site passes `cfg!(target_os = "android")`.
86    ///
87    /// Android suppression rules:
88    /// - `on_timeout` is forced off when `androidOnTimeout == "notifyUser"`,
89    ///   because the Kotlin service already posts a native timeout
90    ///   notification on that path.
91    /// - `on_recovery` is forced off unconditionally, because the native
92    ///   BootReceiver recovery notification path is always active on Android.
93    pub fn derive(config: &PluginConfig, is_android: bool) -> Self {
94        let native_owns_timeout = is_android && config.android_on_timeout == "notifyUser";
95        Self {
96            on_timeout: config.notify_on_timeout && !native_owns_timeout,
97            on_recovery: config.notify_on_recovery && !is_android,
98        }
99    }
100}
101
102/// Dispatch seam for lifecycle notifications.
103///
104/// The manager actor talks to this trait instead of [`Notifier`] directly so
105/// tests can record notifications without a running Tauri app (the spec's
106/// test plan forbids `show()` calls in tests). The production sink is
107/// [`Notifier`] itself.
108pub trait NotifySink: Send + Sync {
109    /// Post a notification with replace-not-stack semantics for `id`.
110    fn notify(&self, id: &str, title: &str, body: &str);
111}
112
113impl<R: Runtime> NotifySink for Notifier<R> {
114    fn notify(&self, id: &str, title: &str, body: &str) {
115        self.show_with_id(id, title, body);
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122    use crate::models::PluginConfig;
123
124    /// Compile-time test: Notifier can be constructed and cloned from an AppHandle.
125    /// (Does not call show() because that requires a running Tauri app.)
126    #[allow(dead_code)]
127    fn notifier_clone_compiles<R: Runtime + Clone>(app: AppHandle<R>) {
128        let n = Notifier { app };
129        let _cloned = n.clone();
130    }
131
132    /// Compile-time test: show_with_id has the same warn-only, fire-and-forget
133    /// shape as show(). (Not called — requires a running Tauri app.)
134    #[allow(dead_code)]
135    fn notifier_show_with_id_compiles<R: Runtime>(n: &Notifier<R>) {
136        n.show_with_id("bg-timeout", "title", "body");
137    }
138
139    /// Compile-time test: Notifier is usable as a NotifySink trait object.
140    #[allow(dead_code)]
141    fn notifier_is_notify_sink<R: Runtime>(n: Notifier<R>) -> std::sync::Arc<dyn NotifySink> {
142        std::sync::Arc::new(n)
143    }
144
145    #[test]
146    fn stable_notification_id_is_deterministic() {
147        assert_eq!(
148            stable_notification_id("bg-timeout"),
149            stable_notification_id("bg-timeout")
150        );
151        assert_ne!(
152            stable_notification_id("bg-timeout"),
153            stable_notification_id("bg-recovery")
154        );
155    }
156
157    // ── NotifierPolicy::derive — DEC-002 suppression matrix ──────────
158
159    fn config(
160        notify_on_timeout: bool,
161        notify_on_recovery: bool,
162        android_on_timeout: &str,
163    ) -> PluginConfig {
164        PluginConfig {
165            notify_on_timeout,
166            notify_on_recovery,
167            android_on_timeout: android_on_timeout.into(),
168            ..Default::default()
169        }
170    }
171
172    #[test]
173    fn derive_desktop_honors_configured_keys() {
174        let policy = NotifierPolicy::derive(&config(true, true, "notifyUser"), false);
175        assert_eq!(
176            policy,
177            NotifierPolicy {
178                on_timeout: true,
179                on_recovery: true
180            }
181        );
182    }
183
184    #[test]
185    fn derive_desktop_defaults_off() {
186        let policy = NotifierPolicy::derive(&config(false, false, "notifyUser"), false);
187        assert_eq!(
188            policy,
189            NotifierPolicy {
190                on_timeout: false,
191                on_recovery: false
192            }
193        );
194    }
195
196    #[test]
197    fn derive_android_notify_user_suppresses_timeout() {
198        // Kotlin LifecycleService already posts the native timeout
199        // notification when androidOnTimeout == "notifyUser" (DEC-002).
200        let policy = NotifierPolicy::derive(&config(true, true, "notifyUser"), true);
201        assert_eq!(
202            policy,
203            NotifierPolicy {
204                on_timeout: false,
205                on_recovery: false
206            }
207        );
208    }
209
210    #[test]
211    fn derive_android_stop_keeps_timeout() {
212        // androidOnTimeout == "stop" posts no native notification, so the
213        // plugin-side timeout notice is allowed; recovery stays suppressed.
214        let policy = NotifierPolicy::derive(&config(true, true, "stop"), true);
215        assert_eq!(
216            policy,
217            NotifierPolicy {
218                on_timeout: true,
219                on_recovery: false
220            }
221        );
222    }
223
224    #[test]
225    fn derive_android_schedule_recovery_keeps_timeout() {
226        let policy = NotifierPolicy::derive(&config(true, true, "scheduleRecovery"), true);
227        assert_eq!(
228            policy,
229            NotifierPolicy {
230                on_timeout: true,
231                on_recovery: false
232            }
233        );
234    }
235
236    #[test]
237    fn derive_android_always_suppresses_recovery() {
238        // The Kotlin BootReceiver recovery notification path is always
239        // active on Android and has no config switch (DEC-002).
240        let policy = NotifierPolicy::derive(&config(false, true, "stop"), true);
241        assert_eq!(
242            policy,
243            NotifierPolicy {
244                on_timeout: false,
245                on_recovery: false
246            }
247        );
248    }
249
250    #[test]
251    fn derive_default_policy_is_all_off() {
252        assert_eq!(
253            NotifierPolicy::default(),
254            NotifierPolicy {
255                on_timeout: false,
256                on_recovery: false
257            }
258        );
259    }
260}