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