cranpose_services/
notifier.rs1use std::{
11 cell::RefCell,
12 sync::{Arc, OnceLock},
13};
14
15use cranpose_core::{CompositionLocal, CompositionLocalProvider, compositionLocalOfWithPolicy};
16use cranpose_macros::composable;
17use parking_lot::Mutex;
18
19use crate::registry::ServiceRegistry;
20
21#[derive(Clone, Debug, PartialEq, Eq)]
23pub struct NotifyRequest {
24 pub id: String,
27 pub title: String,
28 pub body: String,
29 pub ongoing: bool,
32 pub deeplink: Option<String>,
34}
35
36impl NotifyRequest {
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 pub fn ongoing(mut self, ongoing: bool) -> Self {
48 self.ongoing = ongoing;
49 self
50 }
51
52 pub fn with_deeplink(mut self, deeplink: impl Into<String>) -> Self {
53 self.deeplink = Some(deeplink.into());
54 self
55 }
56}
57
58pub trait Notifier: Send + Sync {
61 fn request_permission(&self);
64 fn notify(&self, request: NotifyRequest);
66 fn cancel(&self, id: &str);
68}
69
70pub type NotifierRef = Arc<dyn Notifier>;
71
72fn pending_deeplink_slot() -> &'static Mutex<Option<String>> {
73 static SLOT: OnceLock<Mutex<Option<String>>> = OnceLock::new();
74 SLOT.get_or_init(|| Mutex::new(None))
75}
76
77pub fn push_notification_deeplink(link: String) {
81 *pending_deeplink_slot().lock() = Some(link);
82}
83
84pub fn take_notification_deeplink() -> Option<String> {
87 pending_deeplink_slot().lock().take()
88}
89
90#[cfg(not(all(
91 feature = "notifier-native",
92 not(target_arch = "wasm32"),
93 not(target_os = "android"),
94 not(target_os = "ios")
95)))]
96struct NoopNotifier;
97
98#[cfg(not(all(
99 feature = "notifier-native",
100 not(target_arch = "wasm32"),
101 not(target_os = "android"),
102 not(target_os = "ios")
103)))]
104impl Notifier for NoopNotifier {
105 fn request_permission(&self) {}
106 fn notify(&self, _request: NotifyRequest) {}
107 fn cancel(&self, _id: &str) {}
108}
109
110static PLATFORM_NOTIFIER: ServiceRegistry<dyn Notifier> = ServiceRegistry::new();
111static DEFAULT_NOTIFIER: OnceLock<NotifierRef> = OnceLock::new();
112
113struct PlatformNotifier;
114
115fn registered_notifier() -> NotifierRef {
116 PLATFORM_NOTIFIER
117 .get_or_warn("notifier")
118 .unwrap_or_else(|| {
119 #[cfg(all(
120 feature = "notifier-native",
121 not(target_arch = "wasm32"),
122 not(target_os = "android"),
123 not(target_os = "ios")
124 ))]
125 {
126 Arc::new(desktop::DesktopNotifier)
127 }
128 #[cfg(not(all(
129 feature = "notifier-native",
130 not(target_arch = "wasm32"),
131 not(target_os = "android"),
132 not(target_os = "ios")
133 )))]
134 {
135 Arc::new(NoopNotifier)
136 }
137 })
138}
139
140impl Notifier for PlatformNotifier {
141 fn request_permission(&self) {
142 registered_notifier().request_permission();
143 }
144
145 fn notify(&self, request: NotifyRequest) {
146 registered_notifier().notify(request);
147 }
148
149 fn cancel(&self, id: &str) {
150 registered_notifier().cancel(id);
151 }
152}
153
154pub fn set_platform_notifier(notifier: NotifierRef) {
155 PLATFORM_NOTIFIER.set(notifier);
156}
157
158pub fn clear_platform_notifier() {
159 PLATFORM_NOTIFIER.clear();
160}
161
162pub fn default_notifier() -> NotifierRef {
163 DEFAULT_NOTIFIER
164 .get_or_init(|| Arc::new(PlatformNotifier))
165 .clone()
166}
167
168#[cfg(all(
169 feature = "notifier-native",
170 not(target_arch = "wasm32"),
171 not(target_os = "android"),
172 not(target_os = "ios")
173))]
174mod desktop {
175 use std::process::{Command, Stdio};
176
177 use super::{Notifier, NotifyRequest};
178
179 pub(super) struct DesktopNotifier;
180
181 #[cfg(target_os = "linux")]
182 fn numeric_id(id: &str) -> u32 {
183 let mut hash: u32 = 0x811c_9dc5;
184 for byte in id.as_bytes() {
185 hash ^= u32::from(*byte);
186 hash = hash.wrapping_mul(0x0100_0193);
187 }
188 hash.max(1)
189 }
190
191 fn spawn_silent(mut command: Command) {
192 let _ = command
193 .stdin(Stdio::null())
194 .stdout(Stdio::null())
195 .stderr(Stdio::null())
196 .spawn();
197 }
198
199 impl Notifier for DesktopNotifier {
200 fn request_permission(&self) {}
201
202 #[allow(unused_variables)]
203 fn notify(&self, request: NotifyRequest) {
204 #[cfg(target_os = "linux")]
205 {
206 let mut command = crate::windowless_command("notify-send");
207 command
208 .arg("--replace-id")
209 .arg(numeric_id(&request.id).to_string())
210 .arg("--app-name")
211 .arg("cranpose")
212 .arg(&request.title)
213 .arg(&request.body);
214 spawn_silent(command);
215 }
216 #[cfg(target_os = "macos")]
217 {
218 let script = format!(
219 "display notification \"{}\" with title \"{}\"",
220 request.body.replace('\\', "\\\\").replace('"', "\\\""),
221 request.title.replace('\\', "\\\\").replace('"', "\\\"")
222 );
223 let mut command = crate::windowless_command("osascript");
224 command.arg("-e").arg(script);
225 spawn_silent(command);
226 }
227 #[cfg(target_os = "windows")]
228 {
229 let script = format!(
230 "[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null; \
231 $t = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02); \
232 $n = $t.GetElementsByTagName('text'); \
233 $n.Item(0).AppendChild($t.CreateTextNode('{}')) > $null; \
234 $n.Item(1).AppendChild($t.CreateTextNode('{}')) > $null; \
235 [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('cranpose').Show([Windows.UI.Notifications.ToastNotification]::new($t))",
236 request.title.replace('\'', "''"),
237 request.body.replace('\'', "''")
238 );
239 let mut command = crate::windowless_command("powershell");
240 command.arg("-NoProfile").arg("-Command").arg(script);
241 spawn_silent(command);
242 }
243 }
244
245 #[allow(unused_variables)]
246 fn cancel(&self, id: &str) {
247 #[cfg(target_os = "linux")]
248 {
249 let mut command = crate::windowless_command("gdbus");
250 command.args([
251 "call",
252 "--session",
253 "--dest",
254 "org.freedesktop.Notifications",
255 "--object-path",
256 "/org/freedesktop/Notifications",
257 "--method",
258 "org.freedesktop.Notifications.CloseNotification",
259 ]);
260 command.arg(numeric_id(id).to_string());
261 spawn_silent(command);
262 }
263 }
264 }
265}
266
267pub fn local_notifier() -> CompositionLocal<NotifierRef> {
268 thread_local! {
269 static LOCAL_NOTIFIER: RefCell<Option<CompositionLocal<NotifierRef>>> = const { RefCell::new(None) };
270 }
271
272 LOCAL_NOTIFIER.with(|cell| {
273 let mut local = cell.borrow_mut();
274 local
275 .get_or_insert_with(|| compositionLocalOfWithPolicy(default_notifier, Arc::ptr_eq))
276 .clone()
277 })
278}
279
280#[composable]
281pub fn ProvideNotifier(content: impl FnOnce()) {
282 let notifier = cranpose_core::remember(default_notifier).with(|state| state.clone());
283 let local = local_notifier();
284 CompositionLocalProvider(vec![local.provides(notifier)], move || {
285 content();
286 });
287}
288
289#[cfg(test)]
290mod tests {
291 use parking_lot::Mutex;
292
293 use super::*;
294
295 #[test]
296 fn an_ongoing_request_is_the_one_a_user_cannot_swipe_away() {
297 let plain = NotifyRequest::new("scan", "Recognising", "3 of 12 pages");
298 assert!(!plain.ongoing, "a plain notification is dismissable");
299 assert_eq!(plain.id, "scan");
300 assert_eq!(plain.title, "Recognising");
301 assert_eq!(plain.body, "3 of 12 pages");
302 assert_eq!(plain.deeplink, None);
303
304 let ongoing = plain.ongoing(true);
305 assert!(ongoing.ongoing);
306 assert!(!ongoing.ongoing(false).ongoing);
307 }
308
309 #[derive(Default)]
310 struct Recorder {
311 posted: Mutex<Vec<String>>,
312 }
313 impl Notifier for Recorder {
314 fn request_permission(&self) {}
315 fn notify(&self, request: NotifyRequest) {
316 self.posted.lock().push(request.id);
317 }
318 fn cancel(&self, _id: &str) {}
319 }
320
321 #[test]
322 fn default_is_noop_then_registered_takes_over() {
323 let _guard = crate::registry::test_service_guard();
324 clear_platform_notifier();
325 default_notifier().notify(NotifyRequest::new("a", "t", "b"));
326 let rec = Arc::new(Recorder::default());
327 set_platform_notifier(rec.clone());
328 default_notifier().notify(NotifyRequest::new("done", "t", "b").with_deeplink("doc/1"));
329 assert_eq!(rec.posted.lock().as_slice(), &["done".to_string()]);
330 clear_platform_notifier();
331 }
332}