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;
14
15use crate::registry::ServiceRegistry;
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    /// The first line of the notification.
24    pub title: String,
25    /// The text below the title.
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    /// The link the app is opened with when the user taps the notification;
31    /// it arrives through [`crate::rememberOpenedLinks`].
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
75#[cfg(not(all(
76    feature = "notifier-native",
77    not(target_arch = "wasm32"),
78    not(target_os = "android"),
79    not(target_os = "ios")
80)))]
81struct NoopNotifier;
82
83#[cfg(not(all(
84    feature = "notifier-native",
85    not(target_arch = "wasm32"),
86    not(target_os = "android"),
87    not(target_os = "ios")
88)))]
89impl Notifier for NoopNotifier {
90    fn request_permission(&self) {}
91    fn notify(&self, _request: NotifyRequest) {}
92    fn cancel(&self, _id: &str) {}
93}
94
95static PLATFORM_NOTIFIER: ServiceRegistry<dyn Notifier> = ServiceRegistry::new();
96static DEFAULT_NOTIFIER: OnceLock<NotifierRef> = OnceLock::new();
97
98struct PlatformNotifier;
99
100fn registered_notifier() -> NotifierRef {
101    PLATFORM_NOTIFIER
102        .get_or_warn("notifier")
103        .unwrap_or_else(|| {
104            #[cfg(all(
105                feature = "notifier-native",
106                not(target_arch = "wasm32"),
107                not(target_os = "android"),
108                not(target_os = "ios")
109            ))]
110            {
111                Arc::new(desktop::DesktopNotifier)
112            }
113            #[cfg(not(all(
114                feature = "notifier-native",
115                not(target_arch = "wasm32"),
116                not(target_os = "android"),
117                not(target_os = "ios")
118            )))]
119            {
120                Arc::new(NoopNotifier)
121            }
122        })
123}
124
125impl Notifier for PlatformNotifier {
126    fn request_permission(&self) {
127        registered_notifier().request_permission();
128    }
129
130    fn notify(&self, request: NotifyRequest) {
131        registered_notifier().notify(request);
132    }
133
134    fn cancel(&self, id: &str) {
135        registered_notifier().cancel(id);
136    }
137}
138
139/// Installs a platform notifier, replacing any previous one.
140pub fn set_platform_notifier(notifier: NotifierRef) {
141    PLATFORM_NOTIFIER.set(notifier);
142}
143
144/// Removes any registered platform notifier (tests and teardown).
145pub fn clear_platform_notifier() {
146    PLATFORM_NOTIFIER.clear();
147}
148
149/// Returns the notifier that forwards to the installed platform notifier. Without one, it uses
150/// the desktop notifier when the `notifier-native` feature is on, and otherwise does nothing.
151pub fn default_notifier() -> NotifierRef {
152    DEFAULT_NOTIFIER
153        .get_or_init(|| Arc::new(PlatformNotifier))
154        .clone()
155}
156
157#[cfg(all(
158    feature = "notifier-native",
159    not(target_arch = "wasm32"),
160    not(target_os = "android"),
161    not(target_os = "ios")
162))]
163mod desktop {
164    use std::process::{Command, Stdio};
165
166    use super::{Notifier, NotifyRequest};
167
168    pub(super) struct DesktopNotifier;
169
170    #[cfg(target_os = "linux")]
171    fn numeric_id(id: &str) -> u32 {
172        let mut hash: u32 = 0x811c_9dc5;
173        for byte in id.as_bytes() {
174            hash ^= u32::from(*byte);
175            hash = hash.wrapping_mul(0x0100_0193);
176        }
177        hash.max(1)
178    }
179
180    fn spawn_silent(mut command: Command) {
181        let _ = command
182            .stdin(Stdio::null())
183            .stdout(Stdio::null())
184            .stderr(Stdio::null())
185            .spawn();
186    }
187
188    impl Notifier for DesktopNotifier {
189        fn request_permission(&self) {}
190
191        fn notify(&self, request: NotifyRequest) {
192            #[cfg(target_os = "linux")]
193            {
194                let mut command = crate::windowless_command("notify-send");
195                command
196                    .arg("--replace-id")
197                    .arg(numeric_id(&request.id).to_string())
198                    .arg("--app-name")
199                    .arg("cranpose")
200                    .arg(&request.title)
201                    .arg(&request.body);
202                spawn_silent(command);
203            }
204            #[cfg(target_os = "macos")]
205            {
206                let script = format!(
207                    "display notification \"{}\" with title \"{}\"",
208                    request.body.replace('\\', "\\\\").replace('"', "\\\""),
209                    request.title.replace('\\', "\\\\").replace('"', "\\\"")
210                );
211                let mut command = crate::windowless_command("osascript");
212                command.arg("-e").arg(script);
213                spawn_silent(command);
214            }
215            #[cfg(target_os = "windows")]
216            {
217                let script = format!(
218                    "[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null; \
219                     $t = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02); \
220                     $n = $t.GetElementsByTagName('text'); \
221                     $n.Item(0).AppendChild($t.CreateTextNode('{}')) > $null; \
222                     $n.Item(1).AppendChild($t.CreateTextNode('{}')) > $null; \
223                     [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('cranpose').Show([Windows.UI.Notifications.ToastNotification]::new($t))",
224                    request.title.replace('\'', "''"),
225                    request.body.replace('\'', "''")
226                );
227                let mut command = crate::windowless_command("powershell");
228                command.arg("-NoProfile").arg("-Command").arg(script);
229                spawn_silent(command);
230            }
231        }
232
233        #[allow(unused_variables)]
234        fn cancel(&self, id: &str) {
235            #[cfg(target_os = "linux")]
236            {
237                let mut command = crate::windowless_command("gdbus");
238                command.args([
239                    "call",
240                    "--session",
241                    "--dest",
242                    "org.freedesktop.Notifications",
243                    "--object-path",
244                    "/org/freedesktop/Notifications",
245                    "--method",
246                    "org.freedesktop.Notifications.CloseNotification",
247                ]);
248                command.arg(numeric_id(id).to_string());
249                spawn_silent(command);
250            }
251        }
252    }
253}
254
255/// The notifier a composable reads. It is the platform notifier unless a composition local
256/// provider above it supplies another one.
257pub fn local_notifier() -> CompositionLocal<NotifierRef> {
258    crate::composition_locals::cached_local(
259        |locals| &locals.notifier,
260        || compositionLocalOfWithPolicy(default_notifier, Arc::ptr_eq),
261    )
262}
263
264/// Provides the platform notifier to `content` through [`local_notifier`].
265#[composable]
266pub fn ProvideNotifier(content: impl FnOnce()) {
267    let notifier = cranpose_core::remember(default_notifier).with(|state| state.clone());
268    let local = local_notifier();
269    CompositionLocalProvider([local.provides(notifier)], move || {
270        content();
271    });
272}
273
274#[cfg(test)]
275#[path = "tests/notifier_tests.rs"]
276mod tests;