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 DEFAULT_NOTIFIER
153 .get_or_init(|| Arc::new(PlatformNotifier))
154 .clone()
155}
156
157#[cfg(all(
158 feature = "notifier-native",
159 not(target_arch = "wasm32"),
160 not(target_os = "android"),
161 not(target_os = "ios")
162))]
163mod desktop {
164 use std::process::{Command, Stdio};
165
166 use super::{Notifier, NotifyRequest};
167
168 pub(super) struct DesktopNotifier;
169
170 #[cfg(target_os = "linux")]
171 fn numeric_id(id: &str) -> u32 {
172 let mut hash: u32 = 0x811c_9dc5;
173 for byte in id.as_bytes() {
174 hash ^= u32::from(*byte);
175 hash = hash.wrapping_mul(0x0100_0193);
176 }
177 hash.max(1)
178 }
179
180 fn spawn_silent(mut command: Command) {
181 let _ = command
182 .stdin(Stdio::null())
183 .stdout(Stdio::null())
184 .stderr(Stdio::null())
185 .spawn();
186 }
187
188 impl Notifier for DesktopNotifier {
189 fn request_permission(&self) {}
190
191 fn notify(&self, request: NotifyRequest) {
192 #[cfg(target_os = "linux")]
193 {
194 let mut command = crate::windowless_command("notify-send");
195 command
196 .arg("--replace-id")
197 .arg(numeric_id(&request.id).to_string())
198 .arg("--app-name")
199 .arg("cranpose")
200 .arg(&request.title)
201 .arg(&request.body);
202 spawn_silent(command);
203 }
204 #[cfg(target_os = "macos")]
205 {
206 let script = format!(
207 "display notification \"{}\" with title \"{}\"",
208 request.body.replace('\\', "\\\\").replace('"', "\\\""),
209 request.title.replace('\\', "\\\\").replace('"', "\\\"")
210 );
211 let mut command = crate::windowless_command("osascript");
212 command.arg("-e").arg(script);
213 spawn_silent(command);
214 }
215 #[cfg(target_os = "windows")]
216 {
217 let script = format!(
218 "[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null; \
219 $t = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02); \
220 $n = $t.GetElementsByTagName('text'); \
221 $n.Item(0).AppendChild($t.CreateTextNode('{}')) > $null; \
222 $n.Item(1).AppendChild($t.CreateTextNode('{}')) > $null; \
223 [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('cranpose').Show([Windows.UI.Notifications.ToastNotification]::new($t))",
224 request.title.replace('\'', "''"),
225 request.body.replace('\'', "''")
226 );
227 let mut command = crate::windowless_command("powershell");
228 command.arg("-NoProfile").arg("-Command").arg(script);
229 spawn_silent(command);
230 }
231 }
232
233 #[allow(unused_variables)]
234 fn cancel(&self, id: &str) {
235 #[cfg(target_os = "linux")]
236 {
237 let mut command = crate::windowless_command("gdbus");
238 command.args([
239 "call",
240 "--session",
241 "--dest",
242 "org.freedesktop.Notifications",
243 "--object-path",
244 "/org/freedesktop/Notifications",
245 "--method",
246 "org.freedesktop.Notifications.CloseNotification",
247 ]);
248 command.arg(numeric_id(id).to_string());
249 spawn_silent(command);
250 }
251 }
252 }
253}
254
255pub fn local_notifier() -> CompositionLocal<NotifierRef> {
258 crate::composition_locals::cached_local(
259 |locals| &locals.notifier,
260 || compositionLocalOfWithPolicy(default_notifier, Arc::ptr_eq),
261 )
262}
263
264#[composable]
266pub fn ProvideNotifier(content: impl FnOnce()) {
267 let notifier = cranpose_core::remember(default_notifier).with(|state| state.clone());
268 let local = local_notifier();
269 CompositionLocalProvider([local.provides(notifier)], move || {
270 content();
271 });
272}
273
274#[cfg(test)]
275#[path = "tests/notifier_tests.rs"]
276mod tests;