Skip to main content

cranpose_services/
notifier.rs

1//! Local notifications: ask permission and post user-visible notifications.
2//!
3//! The compiled-in default is a no-op; platform backends install a real
4//! notifier through [`set_platform_notifier`] (iOS `UNUserNotificationCenter`,
5//! Android `NotificationManager`, the web Notifications API). Desktop has a
6//! zero-dependency built-in behind the `notifier-native` feature (notify-send
7//! / osascript / PowerShell toast); CI without a notification service simply
8//! drops them.
9
10use std::{
11    cell::RefCell,
12    sync::{Arc, OnceLock},
13};
14
15use cranpose_core::{CompositionLocal, CompositionLocalProvider, compositionLocalOfWithPolicy};
16use cranpose_macros::composable;
17use parking_lot::Mutex;
18
19use crate::registry::ServiceRegistry;
20
21/// A local notification to post.
22#[derive(Clone, Debug, PartialEq, Eq)]
23pub struct NotifyRequest {
24    /// Stable identifier: re-posting with the same id replaces the previous
25    /// notification (used for progress updates).
26    pub id: String,
27    pub title: String,
28    pub body: String,
29    /// Whether this is an ongoing/progress notification (best-effort; platforms
30    /// without ongoing notifications post a normal one).
31    pub ongoing: bool,
32    /// Optional deep-link payload delivered to the app when the user taps it.
33    pub deeplink: Option<String>,
34}
35
36impl NotifyRequest {
37    pub fn new(id: impl Into<String>, title: impl Into<String>, body: impl Into<String>) -> Self {
38        Self {
39            id: id.into(),
40            title: title.into(),
41            body: body.into(),
42            ongoing: false,
43            deeplink: None,
44        }
45    }
46
47    pub fn ongoing(mut self, ongoing: bool) -> Self {
48        self.ongoing = ongoing;
49        self
50    }
51
52    pub fn with_deeplink(mut self, deeplink: impl Into<String>) -> Self {
53        self.deeplink = Some(deeplink.into());
54        self
55    }
56}
57
58/// Posts local notifications. Installed by the platform backend; the default is
59/// a no-op.
60pub trait Notifier: Send + Sync {
61    /// Request permission to show notifications (idempotent; safe to call more
62    /// than once).
63    fn request_permission(&self);
64    /// Post (or replace, by id) a local notification.
65    fn notify(&self, request: NotifyRequest);
66    /// Remove a delivered or pending notification by id.
67    fn cancel(&self, id: &str);
68}
69
70pub type NotifierRef = Arc<dyn Notifier>;
71
72fn pending_deeplink_slot() -> &'static Mutex<Option<String>> {
73    static SLOT: OnceLock<Mutex<Option<String>>> = OnceLock::new();
74    SLOT.get_or_init(|| Mutex::new(None))
75}
76
77/// Record the deep-link payload of a notification the user just tapped. Called
78/// by the platform backend's notification delegate; the app drains it via
79/// [`take_notification_deeplink`].
80pub fn push_notification_deeplink(link: String) {
81    *pending_deeplink_slot().lock() = Some(link);
82}
83
84/// Take (and clear) the deep-link payload of the most recently tapped
85/// notification, if any. Polled by the app's deep-link handling.
86pub fn take_notification_deeplink() -> Option<String> {
87    pending_deeplink_slot().lock().take()
88}
89
90#[cfg(not(all(
91    feature = "notifier-native",
92    not(target_arch = "wasm32"),
93    not(target_os = "android"),
94    not(target_os = "ios")
95)))]
96struct NoopNotifier;
97
98#[cfg(not(all(
99    feature = "notifier-native",
100    not(target_arch = "wasm32"),
101    not(target_os = "android"),
102    not(target_os = "ios")
103)))]
104impl Notifier for NoopNotifier {
105    fn request_permission(&self) {}
106    fn notify(&self, _request: NotifyRequest) {}
107    fn cancel(&self, _id: &str) {}
108}
109
110static PLATFORM_NOTIFIER: ServiceRegistry<dyn Notifier> = ServiceRegistry::new();
111static DEFAULT_NOTIFIER: OnceLock<NotifierRef> = OnceLock::new();
112
113struct PlatformNotifier;
114
115fn registered_notifier() -> NotifierRef {
116    PLATFORM_NOTIFIER
117        .get_or_warn("notifier")
118        .unwrap_or_else(|| {
119            #[cfg(all(
120                feature = "notifier-native",
121                not(target_arch = "wasm32"),
122                not(target_os = "android"),
123                not(target_os = "ios")
124            ))]
125            {
126                Arc::new(desktop::DesktopNotifier)
127            }
128            #[cfg(not(all(
129                feature = "notifier-native",
130                not(target_arch = "wasm32"),
131                not(target_os = "android"),
132                not(target_os = "ios")
133            )))]
134            {
135                Arc::new(NoopNotifier)
136            }
137        })
138}
139
140impl Notifier for PlatformNotifier {
141    fn request_permission(&self) {
142        registered_notifier().request_permission();
143    }
144
145    fn notify(&self, request: NotifyRequest) {
146        registered_notifier().notify(request);
147    }
148
149    fn cancel(&self, id: &str) {
150        registered_notifier().cancel(id);
151    }
152}
153
154pub fn set_platform_notifier(notifier: NotifierRef) {
155    PLATFORM_NOTIFIER.set(notifier);
156}
157
158pub fn clear_platform_notifier() {
159    PLATFORM_NOTIFIER.clear();
160}
161
162pub fn default_notifier() -> NotifierRef {
163    DEFAULT_NOTIFIER
164        .get_or_init(|| Arc::new(PlatformNotifier))
165        .clone()
166}
167
168/// Zero-dependency desktop notifications through the OS-native CLI surface:
169/// `notify-send` (Linux/BSD, libnotify), `osascript` (macOS), and a PowerShell
170/// toast (Windows). Replace-by-id and cancel are honored where the tool
171/// supports them (Linux); elsewhere they are best-effort. Deep-link taps are
172/// not observable through these CLIs, so `NotifyRequest::deeplink` is not
173/// delivered on desktop.
174#[cfg(all(
175    feature = "notifier-native",
176    not(target_arch = "wasm32"),
177    not(target_os = "android"),
178    not(target_os = "ios")
179))]
180mod desktop {
181    use std::process::{Command, Stdio};
182
183    use super::{Notifier, NotifyRequest};
184
185    pub(super) struct DesktopNotifier;
186
187    /// Stable numeric id for notify-send's replace-id from the request's
188    /// string id (FNV-1a folded to a positive u32; 0 means "no id" there).
189    #[cfg(target_os = "linux")]
190    fn numeric_id(id: &str) -> u32 {
191        let mut hash: u32 = 0x811c_9dc5;
192        for byte in id.as_bytes() {
193            hash ^= u32::from(*byte);
194            hash = hash.wrapping_mul(0x0100_0193);
195        }
196        hash.max(1)
197    }
198
199    fn spawn_silent(mut command: Command) {
200        let _ = command
201            .stdin(Stdio::null())
202            .stdout(Stdio::null())
203            .stderr(Stdio::null())
204            .spawn();
205    }
206
207    impl Notifier for DesktopNotifier {
208        fn request_permission(&self) {}
209
210        #[allow(unused_variables)]
211        fn notify(&self, request: NotifyRequest) {
212            #[cfg(target_os = "linux")]
213            {
214                let mut command = Command::new("notify-send");
215                command
216                    .arg("--replace-id")
217                    .arg(numeric_id(&request.id).to_string())
218                    .arg("--app-name")
219                    .arg("cranpose")
220                    .arg(&request.title)
221                    .arg(&request.body);
222                spawn_silent(command);
223            }
224            #[cfg(target_os = "macos")]
225            {
226                let script = format!(
227                    "display notification \"{}\" with title \"{}\"",
228                    request.body.replace('\\', "\\\\").replace('"', "\\\""),
229                    request.title.replace('\\', "\\\\").replace('"', "\\\"")
230                );
231                let mut command = Command::new("osascript");
232                command.arg("-e").arg(script);
233                spawn_silent(command);
234            }
235            #[cfg(target_os = "windows")]
236            {
237                let script = format!(
238                    "[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null; \
239                     $t = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02); \
240                     $n = $t.GetElementsByTagName('text'); \
241                     $n.Item(0).AppendChild($t.CreateTextNode('{}')) > $null; \
242                     $n.Item(1).AppendChild($t.CreateTextNode('{}')) > $null; \
243                     [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('cranpose').Show([Windows.UI.Notifications.ToastNotification]::new($t))",
244                    request.title.replace('\'', "''"),
245                    request.body.replace('\'', "''")
246                );
247                let mut command = Command::new("powershell");
248                command.arg("-NoProfile").arg("-Command").arg(script);
249                spawn_silent(command);
250            }
251        }
252
253        #[allow(unused_variables)]
254        fn cancel(&self, id: &str) {
255            #[cfg(target_os = "linux")]
256            {
257                let mut command = Command::new("gdbus");
258                command.args([
259                    "call",
260                    "--session",
261                    "--dest",
262                    "org.freedesktop.Notifications",
263                    "--object-path",
264                    "/org/freedesktop/Notifications",
265                    "--method",
266                    "org.freedesktop.Notifications.CloseNotification",
267                ]);
268                command.arg(numeric_id(id).to_string());
269                spawn_silent(command);
270            }
271        }
272    }
273}
274
275pub fn local_notifier() -> CompositionLocal<NotifierRef> {
276    thread_local! {
277        static LOCAL_NOTIFIER: RefCell<Option<CompositionLocal<NotifierRef>>> = const { RefCell::new(None) };
278    }
279
280    LOCAL_NOTIFIER.with(|cell| {
281        let mut local = cell.borrow_mut();
282        local
283            .get_or_insert_with(|| compositionLocalOfWithPolicy(default_notifier, Arc::ptr_eq))
284            .clone()
285    })
286}
287
288#[allow(non_snake_case)]
289#[composable]
290pub fn ProvideNotifier(content: impl FnOnce()) {
291    let notifier = cranpose_core::remember(default_notifier).with(|state| state.clone());
292    let local = local_notifier();
293    CompositionLocalProvider(vec![local.provides(notifier)], move || {
294        content();
295    });
296}
297
298#[cfg(test)]
299mod tests {
300    use parking_lot::Mutex;
301
302    use super::*;
303
304    #[test]
305    fn an_ongoing_request_is_the_one_a_user_cannot_swipe_away() {
306        let plain = NotifyRequest::new("scan", "Recognising", "3 of 12 pages");
307        assert!(!plain.ongoing, "a plain notification is dismissable");
308        assert_eq!(plain.id, "scan");
309        assert_eq!(plain.title, "Recognising");
310        assert_eq!(plain.body, "3 of 12 pages");
311        assert_eq!(plain.deeplink, None);
312
313        // Progress that is still running says so, which is what stops the
314        // platform letting the user swipe away work that is still going.
315        let ongoing = plain.clone().ongoing(true);
316        assert!(ongoing.ongoing);
317        // And a finished one goes back to being dismissable.
318        assert!(!ongoing.ongoing(false).ongoing);
319    }
320
321    #[derive(Default)]
322    struct Recorder {
323        posted: Mutex<Vec<String>>,
324    }
325    impl Notifier for Recorder {
326        fn request_permission(&self) {}
327        fn notify(&self, request: NotifyRequest) {
328            self.posted.lock().push(request.id);
329        }
330        fn cancel(&self, _id: &str) {}
331    }
332
333    #[test]
334    fn default_is_noop_then_registered_takes_over() {
335        let _guard = crate::registry::test_service_guard();
336        clear_platform_notifier();
337        // No panic on the no-op default.
338        default_notifier().notify(NotifyRequest::new("a", "t", "b"));
339        let rec = Arc::new(Recorder::default());
340        set_platform_notifier(rec.clone());
341        default_notifier().notify(NotifyRequest::new("done", "t", "b").with_deeplink("doc/1"));
342        assert_eq!(rec.posted.lock().as_slice(), &["done".to_string()]);
343        clear_platform_notifier();
344    }
345}