cranpose_services/
notifier.rs1use std::sync::{Arc, OnceLock};
11
12use cranpose_core::{CompositionLocal, CompositionLocalProvider, compositionLocalOfWithPolicy};
13use cranpose_macros::composable;
14
15use crate::registry::ServiceRegistry;
16
17#[derive(Clone, Debug, PartialEq, Eq)]
19pub struct NotifyRequest {
20 pub id: String,
23 pub title: String,
25 pub body: String,
27 pub ongoing: bool,
30 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
75#[cfg(not(all(
76 feature = "notifier-native",
77 not(target_arch = "wasm32"),
78 not(target_os = "android"),
79 not(target_os = "ios")
80)))]
81struct NoopNotifier;
82
83#[cfg(not(all(
84 feature = "notifier-native",
85 not(target_arch = "wasm32"),
86 not(target_os = "android"),
87 not(target_os = "ios")
88)))]
89impl Notifier for NoopNotifier {
90 fn request_permission(&self) {}
91 fn notify(&self, _request: NotifyRequest) {}
92 fn cancel(&self, _id: &str) {}
93}
94
95static PLATFORM_NOTIFIER: ServiceRegistry<dyn Notifier> = ServiceRegistry::new();
96static DEFAULT_NOTIFIER: OnceLock<NotifierRef> = OnceLock::new();
97
98struct PlatformNotifier;
99
100fn registered_notifier() -> NotifierRef {
101 PLATFORM_NOTIFIER
102 .get_or_warn("notifier")
103 .unwrap_or_else(|| {
104 #[cfg(all(
105 feature = "notifier-native",
106 not(target_arch = "wasm32"),
107 not(target_os = "android"),
108 not(target_os = "ios")
109 ))]
110 {
111 Arc::new(desktop::DesktopNotifier)
112 }
113 #[cfg(not(all(
114 feature = "notifier-native",
115 not(target_arch = "wasm32"),
116 not(target_os = "android"),
117 not(target_os = "ios")
118 )))]
119 {
120 Arc::new(NoopNotifier)
121 }
122 })
123}
124
125impl Notifier for PlatformNotifier {
126 fn request_permission(&self) {
127 registered_notifier().request_permission();
128 }
129
130 fn notify(&self, request: NotifyRequest) {
131 registered_notifier().notify(request);
132 }
133
134 fn cancel(&self, id: &str) {
135 registered_notifier().cancel(id);
136 }
137}
138
139pub fn set_platform_notifier(notifier: NotifierRef) {
141 PLATFORM_NOTIFIER.set(notifier);
142}
143
144pub fn clear_platform_notifier() {
146 PLATFORM_NOTIFIER.clear();
147}
148
149pub fn default_notifier() -> NotifierRef {
152 Arc::clone(DEFAULT_NOTIFIER.get_or_init(|| Arc::new(PlatformNotifier)))
153}
154
155#[cfg(all(
156 feature = "notifier-native",
157 not(target_arch = "wasm32"),
158 not(target_os = "android"),
159 not(target_os = "ios")
160))]
161mod desktop {
162 use std::process::{Command, Stdio};
163
164 use super::{Notifier, NotifyRequest};
165
166 pub(super) struct DesktopNotifier;
167
168 #[cfg(target_os = "linux")]
169 fn numeric_id(id: &str) -> u32 {
170 let mut hash: u32 = 0x811c_9dc5;
171 for byte in id.as_bytes() {
172 hash ^= u32::from(*byte);
173 hash = hash.wrapping_mul(0x0100_0193);
174 }
175 hash.max(1)
176 }
177
178 fn spawn_silent(mut command: Command) {
179 let _ = command
180 .stdin(Stdio::null())
181 .stdout(Stdio::null())
182 .stderr(Stdio::null())
183 .spawn();
184 }
185
186 impl Notifier for DesktopNotifier {
187 fn request_permission(&self) {}
188
189 fn notify(&self, request: NotifyRequest) {
190 #[cfg(target_os = "linux")]
191 {
192 let mut command = crate::windowless_command("notify-send");
193 command
194 .arg("--replace-id")
195 .arg(numeric_id(&request.id).to_string())
196 .arg("--app-name")
197 .arg("cranpose")
198 .arg(&request.title)
199 .arg(&request.body);
200 spawn_silent(command);
201 }
202 #[cfg(target_os = "macos")]
203 {
204 let script = format!(
205 "display notification \"{}\" with title \"{}\"",
206 request.body.replace('\\', "\\\\").replace('"', "\\\""),
207 request.title.replace('\\', "\\\\").replace('"', "\\\"")
208 );
209 let mut command = crate::windowless_command("osascript");
210 command.arg("-e").arg(script);
211 spawn_silent(command);
212 }
213 #[cfg(target_os = "windows")]
214 {
215 let script = format!(
216 "[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null; \
217 $t = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02); \
218 $n = $t.GetElementsByTagName('text'); \
219 $n.Item(0).AppendChild($t.CreateTextNode('{}')) > $null; \
220 $n.Item(1).AppendChild($t.CreateTextNode('{}')) > $null; \
221 [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('cranpose').Show([Windows.UI.Notifications.ToastNotification]::new($t))",
222 request.title.replace('\'', "''"),
223 request.body.replace('\'', "''")
224 );
225 let mut command = crate::windowless_command("powershell");
226 command.arg("-NoProfile").arg("-Command").arg(script);
227 spawn_silent(command);
228 }
229 }
230
231 #[allow(unused_variables, reason = "only Linux reads the id")]
232 fn cancel(&self, id: &str) {
233 #[cfg(target_os = "linux")]
234 {
235 let mut command = crate::windowless_command("gdbus");
236 command.args([
237 "call",
238 "--session",
239 "--dest",
240 "org.freedesktop.Notifications",
241 "--object-path",
242 "/org/freedesktop/Notifications",
243 "--method",
244 "org.freedesktop.Notifications.CloseNotification",
245 ]);
246 command.arg(numeric_id(id).to_string());
247 spawn_silent(command);
248 }
249 }
250 }
251}
252
253pub fn local_notifier() -> CompositionLocal<NotifierRef> {
256 crate::composition_locals::cached_local(
257 |locals| &locals.notifier,
258 || compositionLocalOfWithPolicy(default_notifier, Arc::ptr_eq),
259 )
260}
261
262#[composable]
264pub fn ProvideNotifier(content: impl FnOnce()) {
265 let notifier = cranpose_core::remember(default_notifier).with(|state| Arc::clone(state));
266 let local = local_notifier();
267 CompositionLocalProvider([local.provides(notifier)], move || {
268 content();
269 });
270}
271
272#[cfg(test)]
273#[path = "tests/notifier_tests.rs"]
274mod tests;