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