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