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