cranpose_services/
notifier.rs1use 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#[derive(Clone, Debug, PartialEq, Eq)]
20pub struct NotifyRequest {
21 pub id: String,
24 pub title: String,
26 pub body: String,
28 pub ongoing: bool,
31 pub deeplink: Option<String>,
33}
34
35impl NotifyRequest {
36 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 {
49 self.ongoing = ongoing;
50 self
51 }
52
53 pub fn with_deeplink(mut self, deeplink: impl Into<String>) -> Self {
55 self.deeplink = Some(deeplink.into());
56 self
57 }
58}
59
60pub trait Notifier: Send + Sync {
63 fn request_permission(&self);
66 fn notify(&self, request: NotifyRequest);
68 fn cancel(&self, id: &str);
70}
71
72pub 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
80pub fn push_notification_deeplink(link: String) {
84 *pending_deeplink_slot().lock() = Some(link);
85}
86
87pub 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
157pub fn set_platform_notifier(notifier: NotifierRef) {
159 PLATFORM_NOTIFIER.set(notifier);
160}
161
162pub fn clear_platform_notifier() {
164 PLATFORM_NOTIFIER.clear();
165}
166
167pub 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
273pub 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#[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;