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