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,
25 pub body: String,
26 pub ongoing: bool,
29 pub deeplink: Option<String>,
31}
32
33impl NotifyRequest {
34 pub fn new(id: impl Into<String>, title: impl Into<String>, body: impl Into<String>) -> Self {
35 Self {
36 id: id.into(),
37 title: title.into(),
38 body: body.into(),
39 ongoing: false,
40 deeplink: None,
41 }
42 }
43
44 pub fn ongoing(mut self, ongoing: bool) -> Self {
45 self.ongoing = ongoing;
46 self
47 }
48
49 pub fn with_deeplink(mut self, deeplink: impl Into<String>) -> Self {
50 self.deeplink = Some(deeplink.into());
51 self
52 }
53}
54
55pub trait Notifier: Send + Sync {
58 fn request_permission(&self);
61 fn notify(&self, request: NotifyRequest);
63 fn cancel(&self, id: &str);
65}
66
67pub type NotifierRef = Arc<dyn Notifier>;
68
69fn pending_deeplink_slot() -> &'static Mutex<Option<String>> {
70 static SLOT: OnceLock<Mutex<Option<String>>> = OnceLock::new();
71 SLOT.get_or_init(|| Mutex::new(None))
72}
73
74pub fn push_notification_deeplink(link: String) {
78 *pending_deeplink_slot().lock() = Some(link);
79}
80
81pub fn take_notification_deeplink() -> Option<String> {
84 pending_deeplink_slot().lock().take()
85}
86
87#[cfg(not(all(
88 feature = "notifier-native",
89 not(target_arch = "wasm32"),
90 not(target_os = "android"),
91 not(target_os = "ios")
92)))]
93struct NoopNotifier;
94
95#[cfg(not(all(
96 feature = "notifier-native",
97 not(target_arch = "wasm32"),
98 not(target_os = "android"),
99 not(target_os = "ios")
100)))]
101impl Notifier for NoopNotifier {
102 fn request_permission(&self) {}
103 fn notify(&self, _request: NotifyRequest) {}
104 fn cancel(&self, _id: &str) {}
105}
106
107static PLATFORM_NOTIFIER: ServiceRegistry<dyn Notifier> = ServiceRegistry::new();
108static DEFAULT_NOTIFIER: OnceLock<NotifierRef> = OnceLock::new();
109
110struct PlatformNotifier;
111
112fn registered_notifier() -> NotifierRef {
113 PLATFORM_NOTIFIER
114 .get_or_warn("notifier")
115 .unwrap_or_else(|| {
116 #[cfg(all(
117 feature = "notifier-native",
118 not(target_arch = "wasm32"),
119 not(target_os = "android"),
120 not(target_os = "ios")
121 ))]
122 {
123 Arc::new(desktop::DesktopNotifier)
124 }
125 #[cfg(not(all(
126 feature = "notifier-native",
127 not(target_arch = "wasm32"),
128 not(target_os = "android"),
129 not(target_os = "ios")
130 )))]
131 {
132 Arc::new(NoopNotifier)
133 }
134 })
135}
136
137impl Notifier for PlatformNotifier {
138 fn request_permission(&self) {
139 registered_notifier().request_permission();
140 }
141
142 fn notify(&self, request: NotifyRequest) {
143 registered_notifier().notify(request);
144 }
145
146 fn cancel(&self, id: &str) {
147 registered_notifier().cancel(id);
148 }
149}
150
151pub fn set_platform_notifier(notifier: NotifierRef) {
152 PLATFORM_NOTIFIER.set(notifier);
153}
154
155pub fn clear_platform_notifier() {
156 PLATFORM_NOTIFIER.clear();
157}
158
159pub fn default_notifier() -> NotifierRef {
160 DEFAULT_NOTIFIER
161 .get_or_init(|| Arc::new(PlatformNotifier))
162 .clone()
163}
164
165#[cfg(all(
166 feature = "notifier-native",
167 not(target_arch = "wasm32"),
168 not(target_os = "android"),
169 not(target_os = "ios")
170))]
171mod desktop {
172 use std::process::{Command, Stdio};
173
174 use super::{Notifier, NotifyRequest};
175
176 pub(super) struct DesktopNotifier;
177
178 #[cfg(target_os = "linux")]
179 fn numeric_id(id: &str) -> u32 {
180 let mut hash: u32 = 0x811c_9dc5;
181 for byte in id.as_bytes() {
182 hash ^= u32::from(*byte);
183 hash = hash.wrapping_mul(0x0100_0193);
184 }
185 hash.max(1)
186 }
187
188 fn spawn_silent(mut command: Command) {
189 let _ = command
190 .stdin(Stdio::null())
191 .stdout(Stdio::null())
192 .stderr(Stdio::null())
193 .spawn();
194 }
195
196 impl Notifier for DesktopNotifier {
197 fn request_permission(&self) {}
198
199 fn notify(&self, request: NotifyRequest) {
200 #[cfg(target_os = "linux")]
201 {
202 let mut command = crate::windowless_command("notify-send");
203 command
204 .arg("--replace-id")
205 .arg(numeric_id(&request.id).to_string())
206 .arg("--app-name")
207 .arg("cranpose")
208 .arg(&request.title)
209 .arg(&request.body);
210 spawn_silent(command);
211 }
212 #[cfg(target_os = "macos")]
213 {
214 let script = format!(
215 "display notification \"{}\" with title \"{}\"",
216 request.body.replace('\\', "\\\\").replace('"', "\\\""),
217 request.title.replace('\\', "\\\\").replace('"', "\\\"")
218 );
219 let mut command = crate::windowless_command("osascript");
220 command.arg("-e").arg(script);
221 spawn_silent(command);
222 }
223 #[cfg(target_os = "windows")]
224 {
225 let script = format!(
226 "[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null; \
227 $t = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02); \
228 $n = $t.GetElementsByTagName('text'); \
229 $n.Item(0).AppendChild($t.CreateTextNode('{}')) > $null; \
230 $n.Item(1).AppendChild($t.CreateTextNode('{}')) > $null; \
231 [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('cranpose').Show([Windows.UI.Notifications.ToastNotification]::new($t))",
232 request.title.replace('\'', "''"),
233 request.body.replace('\'', "''")
234 );
235 let mut command = crate::windowless_command("powershell");
236 command.arg("-NoProfile").arg("-Command").arg(script);
237 spawn_silent(command);
238 }
239 }
240
241 #[allow(unused_variables)]
242 fn cancel(&self, id: &str) {
243 #[cfg(target_os = "linux")]
244 {
245 let mut command = crate::windowless_command("gdbus");
246 command.args([
247 "call",
248 "--session",
249 "--dest",
250 "org.freedesktop.Notifications",
251 "--object-path",
252 "/org/freedesktop/Notifications",
253 "--method",
254 "org.freedesktop.Notifications.CloseNotification",
255 ]);
256 command.arg(numeric_id(id).to_string());
257 spawn_silent(command);
258 }
259 }
260 }
261}
262
263pub fn local_notifier() -> CompositionLocal<NotifierRef> {
264 crate::composition_locals::cached_local(
265 |locals| &locals.notifier,
266 || compositionLocalOfWithPolicy(default_notifier, Arc::ptr_eq),
267 )
268}
269
270#[composable]
271pub fn ProvideNotifier(content: impl FnOnce()) {
272 let notifier = cranpose_core::remember(default_notifier).with(|state| state.clone());
273 let local = local_notifier();
274 CompositionLocalProvider([local.provides(notifier)], move || {
275 content();
276 });
277}
278
279#[cfg(test)]
280#[path = "tests/notifier_tests.rs"]
281mod tests;