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