compose_rust/notification.rs
1//! Notifications: calling the user from outside the window.
2//!
3//! A transient message speaks inside the window, for a few seconds, about something the user
4//! just did. A notification speaks outside it, about something the user did not do (a long
5//! job finished, something is waiting for an answer), and stays until the user clears it.
6//! Its moment is precisely when nobody is looking at the window.
7//!
8//! The Host does not call the platform. The Renderer owns the platform, so what crosses is a
9//! command record in an ordinary batch, and what comes back is an ordinary event. That keeps
10//! one way in to the Host (the Renderer's UI thread) and keeps every platform binding on the
11//! side that already has the window.
12//!
13//! A notification is usually a worker's news: the session ended on a worker thread, so that
14//! is where the application learns it. [`NotificationSender`] is `Send` for that reason.
15//! What it posts waits in a queue inside the Host, the Host asks for a frame the way it does
16//! for any worker's change, and the next batch carries it. Nothing here is a queue on the
17//! boundary: the boundary still only sees a batch handed over inside a call.
18
19use crate::schema::{NotificationImportance, NotificationPermission, NotificationPresentation};
20use std::cell::{Cell, RefCell};
21use std::rc::Rc;
22use std::sync::atomic::{AtomicBool, Ordering};
23use std::sync::{Arc, Mutex, MutexGuard};
24
25/// One notification, built and then posted.
26///
27/// ```ignore
28/// Notification::new("Session finished")
29/// .body("refactor-parser: 214 tests passed")
30/// .key(format!("session/{id}"))
31/// .channel("Sessions")
32/// .presentation(NotificationPresentation::WhenInactive)
33/// .post();
34/// ```
35///
36/// Nothing here names the application or its icon. The platform puts those on from the
37/// application's own bundle, which is where the window's icon comes from as well.
38#[derive(Clone, Debug, PartialEq, Eq)]
39pub struct Notification {
40 pub(crate) key: String,
41 pub(crate) title: String,
42 pub(crate) body: String,
43 pub(crate) channel: String,
44 pub(crate) actions: [String; 2],
45 pub(crate) importance: NotificationImportance,
46 pub(crate) presentation: NotificationPresentation,
47}
48
49impl Notification {
50 pub fn new(title: impl Into<String>) -> Self {
51 Self {
52 key: String::new(),
53 title: title.into(),
54 body: String::new(),
55 channel: String::new(),
56 actions: [String::new(), String::new()],
57 importance: NotificationImportance::Normal,
58 presentation: NotificationPresentation::Always,
59 }
60 }
61
62 /// The text under the title.
63 pub fn body(mut self, body: impl Into<String>) -> Self {
64 self.body = body.into();
65 self
66 }
67
68 /// The application's own name for this notification.
69 ///
70 /// Posting again under the same key replaces the one already showing, so a session
71 /// whose state keeps changing has one notification that is kept current rather than a
72 /// pile of them. The key is also what comes back when the user presses it, and what
73 /// [`withdraw_notification`] takes.
74 ///
75 /// Without one the Renderer names it, and a notification named that way can be neither
76 /// replaced nor withdrawn.
77 pub fn key(mut self, key: impl Into<String>) -> Self {
78 self.key = key.into();
79 self
80 }
81
82 /// The group the user can turn off on its own, in the application's own words.
83 ///
84 /// Android lists these in its settings, so the name is something a person reads. Empty
85 /// is the application's default group. A platform without groups ignores it.
86 pub fn channel(mut self, channel: impl Into<String>) -> Self {
87 self.channel = channel.into();
88 self
89 }
90
91 /// The first button. Pressing it reports action 1, and does not bring the window up:
92 /// a button on a notification exists to be dealt with without opening anything.
93 pub fn action_1(mut self, label: impl Into<String>) -> Self {
94 self.actions[0] = label.into();
95 self
96 }
97
98 /// The second button, which reports action 2. Two is the most every platform shows
99 /// the same way.
100 pub fn action_2(mut self, label: impl Into<String>) -> Self {
101 self.actions[1] = label.into();
102 self
103 }
104
105 /// How much this matters. What that is worth in sound or in getting through Do Not
106 /// Disturb is the platform's answer.
107 pub fn importance(mut self, importance: NotificationImportance) -> Self {
108 self.importance = importance;
109 self
110 }
111
112 /// Whether it is shown while the application's window is the active one.
113 pub fn presentation(mut self, presentation: NotificationPresentation) -> Self {
114 self.presentation = presentation;
115 self
116 }
117
118 /// Hands the notification over.
119 ///
120 /// From a component or an event handler it goes out with the batch that call produces.
121 /// From any other thread it waits in the Host and the Host asks for a frame, which is
122 /// what [`NotificationSender::post`] does as well.
123 pub fn post(self) {
124 submit(NotificationCommand::Post(self));
125 }
126}
127
128/// Takes back the notification posted under `key`.
129///
130/// An approval the user already gave inside the window leaves "waiting for approval" in the
131/// notification centre saying something that is no longer true. This is how that stops.
132pub fn withdraw_notification(key: impl Into<String>) {
133 submit(NotificationCommand::Withdraw(key.into()));
134}
135
136/// Asks for permission to show notifications now, rather than on the first one posted.
137///
138/// For a settings screen with a switch that says "notify me when a session finishes":
139/// asking at the moment the user turns it on is asking when the user knows what for. The
140/// answer arrives through [`notification_permission`] and the readers registered with
141/// [`subscribe_permission`].
142///
143/// A browser only lets a page ask from inside something the user did. Called from the
144/// handler of that press, the request rides out in the batch the press produced and is
145/// applied on the same call stack, so it is still inside it.
146pub fn request_notification_permission() {
147 submit(NotificationCommand::RequestPermission);
148}
149
150/// A handle a worker thread posts notifications through.
151///
152/// `Send`, `Copy` and free to make: the queue it feeds belongs to the Host, so every handle
153/// reaches the same one. Nothing it does calls the Renderer. It queues, and the Host asks
154/// for a frame in which the queue is written into the batch.
155///
156/// ```ignore
157/// let notifications = NotificationSender::new();
158/// std::thread::spawn(move || {
159/// run_session();
160/// notifications.post(Notification::new("Session finished").key("session/7"));
161/// });
162/// ```
163#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
164pub struct NotificationSender {
165 _private: (),
166}
167
168impl NotificationSender {
169 pub const fn new() -> Self {
170 Self { _private: () }
171 }
172
173 pub fn post(&self, notification: Notification) {
174 submit(NotificationCommand::Post(notification));
175 }
176
177 pub fn withdraw(&self, key: impl Into<String>) {
178 submit(NotificationCommand::Withdraw(key.into()));
179 }
180
181 pub fn request_permission(&self) {
182 submit(NotificationCommand::RequestPermission);
183 }
184}
185
186/// What the queue holds, in the order it was asked for. A post followed by a withdrawal of
187/// the same key has to reach the Renderer in that order, so the three share one queue.
188#[derive(Clone, Debug, PartialEq, Eq)]
189pub(crate) enum NotificationCommand {
190 Post(Notification),
191 Withdraw(String),
192 RequestPermission,
193}
194
195/// Commands waiting for the next batch. Shared across threads, because a worker posts.
196static QUEUE: Mutex<Vec<NotificationCommand>> = Mutex::new(Vec::new());
197
198/// Whether the queue has anything in it, read without taking the lock.
199///
200/// Every batch looks, and almost every batch finds nothing. A flag read first keeps that
201/// look to one atomic load and no lock and no allocation.
202static PENDING: AtomicBool = AtomicBool::new(false);
203
204fn queue() -> MutexGuard<'static, Vec<NotificationCommand>> {
205 // A worker that panicked while holding the lock does not make the queue wrong: a push
206 // either happened or did not. Refusing every later notification would.
207 QUEUE.lock().unwrap_or_else(|poison| poison.into_inner())
208}
209
210fn submit(command: NotificationCommand) {
211 queue().push(command);
212 PENDING.store(true, Ordering::Release);
213 // Inside a Host call on the UI thread, the batch that call is about to produce carries
214 // it, so asking for a frame would only buy an empty one.
215 if !crate::boundary::in_host_call() {
216 crate::boundary::request_frame_for_notifications();
217 }
218}
219
220/// Hands every queued command to `emit` and empties the queue.
221///
222/// The buffer is moved out and put back, so posting through a steady stream reuses one
223/// allocation. Returns how many posts went out, so the Host can say once, in a debug build,
224/// that a platform which cannot show them is taking them.
225pub(crate) fn drain(mut emit: impl FnMut(&NotificationCommand)) -> usize {
226 if !PENDING.swap(false, Ordering::AcqRel) {
227 return 0;
228 }
229 let mut taken = std::mem::take(&mut *queue());
230 let mut posts = 0;
231 for command in &taken {
232 if matches!(command, NotificationCommand::Post(_)) {
233 posts += 1;
234 }
235 emit(command);
236 }
237 taken.clear();
238 let mut guard = queue();
239 if guard.is_empty() {
240 *guard = taken;
241 } else {
242 // Something was posted while these were being written. It is still waiting, so the
243 // flag has to say so.
244 PENDING.store(true, Ordering::Release);
245 }
246 posts
247}
248
249/// What the user pressed.
250#[derive(Clone, Debug, PartialEq, Eq)]
251pub struct NotificationActivation {
252 /// The key the notification was posted under.
253 pub key: String,
254 /// 0 for the notification itself, 1 or 2 for one of its buttons.
255 pub action: u32,
256}
257
258impl NotificationActivation {
259 /// True when the notification itself was pressed rather than one of its buttons. The
260 /// Renderer has already brought the window up.
261 pub fn is_body(&self) -> bool {
262 self.action == 0
263 }
264
265 /// Which button was pressed, if one was.
266 pub fn action_button(&self) -> Option<u32> {
267 (self.action != 0).then_some(self.action)
268 }
269}
270
271struct Subscriber {
272 id: u64,
273 notify: Arc<dyn Fn() + Send + Sync>,
274}
275
276/// What an activation is handed to. Reference counted so the list can be copied out
277/// before any of them runs: a handler may add or remove one.
278type ActivationHandler = Rc<dyn Fn(NotificationActivation)>;
279
280thread_local! {
281 /// Both sides start from here, so the Renderer only has to report a difference.
282 static PERMISSION: Cell<NotificationPermission> =
283 const { Cell::new(NotificationPermission::NotDetermined) };
284 static PERMISSION_SUBSCRIBERS: RefCell<Vec<Subscriber>> = const { RefCell::new(Vec::new()) };
285 static ACTIVATION_HANDLERS: RefCell<Vec<(u64, ActivationHandler)>> =
286 const { RefCell::new(Vec::new()) };
287 static NEXT_ID: Cell<u64> = const { Cell::new(1) };
288}
289
290/// Whether a post that a platform could not show has already been explained.
291static EXPLAINED: AtomicBool = AtomicBool::new(false);
292/// Whether anything has been posted yet, so an `Unsupported` that arrives afterwards can
293/// still be explained once.
294static POSTED: AtomicBool = AtomicBool::new(false);
295
296fn next_id() -> u64 {
297 NEXT_ID.with(|next| {
298 let id = next.get();
299 next.set(id + 1);
300 id
301 })
302}
303
304/// The permission the Renderer last reported. Available outside a component too.
305pub fn notification_permission() -> NotificationPermission {
306 PERMISSION.with(Cell::get)
307}
308
309/// Records the Renderer's answer and wakes the components that asked about it.
310///
311/// Returns whether any component was woken, which is what decides whether the Host renders.
312pub(crate) fn publish_permission(state: NotificationPermission) -> bool {
313 if state == NotificationPermission::Unsupported && POSTED.load(Ordering::Acquire) {
314 explain_unsupported();
315 }
316 if PERMISSION.with(Cell::get) == state {
317 return false;
318 }
319 PERMISSION.with(|current| current.set(state));
320 PERMISSION_SUBSCRIBERS.with_borrow(|subscribers| {
321 for subscriber in subscribers {
322 (subscriber.notify)();
323 }
324 !subscribers.is_empty()
325 })
326}
327
328/// Records that `posts` notifications went out in a batch, and says once, in a debug build,
329/// why none of them will appear when this run cannot show any.
330pub(crate) fn note_posted(posts: usize) {
331 if posts == 0 {
332 return;
333 }
334 POSTED.store(true, Ordering::Release);
335 if notification_permission() == NotificationPermission::Unsupported {
336 explain_unsupported();
337 }
338}
339
340fn explain_unsupported() {
341 if cfg!(debug_assertions) && !EXPLAINED.swap(true, Ordering::AcqRel) {
342 eprintln!("compose-rust: {}", unsupported_reason());
343 }
344}
345
346/// What usually stands between this platform and a notification, for the one debug line.
347///
348/// Worked out from the platform this was built for, because the Host is never told the
349/// Renderer's reason, only that there is one.
350pub fn unsupported_reason() -> &'static str {
351 if cfg!(target_os = "macos") || cfg!(target_os = "ios") {
352 "notifications are not shown in this run: it has no bundle identifier, so the \
353 notification centre will not take them. A signed application bundle has one; a \
354 development run of a bare executable does not."
355 } else if cfg!(target_os = "windows") {
356 "notifications are not shown in this run: no Start menu shortcut carries an \
357 AppUserModelID for this executable, and toasts need one. An installed package \
358 (MSIX, or an installer that makes the shortcut) has it; an unpacked executable \
359 does not."
360 } else if cfg!(target_os = "android") {
361 "notifications are not shown in this run: the system has no notification service \
362 for this application."
363 } else if cfg!(target_family = "wasm") {
364 "notifications are not shown in this run: the page is not a secure context (HTTPS \
365 or localhost), or this browser has no Notifications API."
366 } else {
367 "notifications are not shown in this run: nothing on the session bus owns \
368 org.freedesktop.Notifications, so there is no notification daemon to show them."
369 }
370}
371
372/// Hands an activation to every component that asked for one.
373///
374/// Returns whether anyone was listening. Nobody listening is not an error: the event is
375/// dropped, which is what an application that never asked expects.
376pub(crate) fn activate(activation: NotificationActivation) -> bool {
377 // Copied out first. A handler may mount or unmount a component that listens, and that
378 // edits the list being walked.
379 let handlers: Vec<ActivationHandler> = ACTIVATION_HANDLERS
380 .with_borrow(|handlers| handlers.iter().map(|(_, h)| h.clone()).collect());
381 for handler in &handlers {
382 handler(activation.clone());
383 }
384 !handlers.is_empty()
385}
386
387/// Forgets the permission, the listeners and anything still queued. Used between tests.
388#[doc(hidden)]
389pub fn reset_notifications() {
390 queue().clear();
391 PENDING.store(false, Ordering::Release);
392 PERMISSION.with(|current| current.set(NotificationPermission::NotDetermined));
393 PERMISSION_SUBSCRIBERS.with_borrow_mut(Vec::clear);
394 ACTIVATION_HANDLERS.with_borrow_mut(Vec::clear);
395 POSTED.store(false, Ordering::Release);
396 EXPLAINED.store(false, Ordering::Release);
397}
398
399/// A registration for changes of the notification permission. Dropping it ends the
400/// registration, so a runtime keeps it for as long as the reader it wakes is alive.
401pub struct PermissionSubscription {
402 id: u64,
403}
404
405/// Registers `notify` to be called each time the Renderer reports a different permission.
406/// The Dioxus adapter's `use_notification_permission` hook holds one.
407pub fn subscribe_permission(notify: Arc<dyn Fn() + Send + Sync>) -> PermissionSubscription {
408 let id = next_id();
409 PERMISSION_SUBSCRIBERS
410 .with_borrow_mut(|subscribers| subscribers.push(Subscriber { id, notify }));
411 PermissionSubscription { id }
412}
413
414impl Drop for PermissionSubscription {
415 fn drop(&mut self) {
416 PERMISSION_SUBSCRIBERS.with_borrow_mut(|subscribers| {
417 subscribers.retain(|subscriber| subscriber.id != self.id);
418 });
419 }
420}
421
422/// An activation handler's registration. Dropping it removes the handler.
423pub struct ActivationSubscription {
424 id: u64,
425}
426
427impl Drop for ActivationSubscription {
428 fn drop(&mut self) {
429 ACTIVATION_HANDLERS.with_borrow_mut(|handlers| handlers.retain(|(id, _)| *id != self.id));
430 }
431}
432
433/// Runs `handler` when the user presses a notification this application posted, for as
434/// long as the returned registration is kept.
435///
436/// One registration for the whole application rather than a callback per notification. A
437/// notification stays in the notification centre for hours, long after whatever posted it
438/// is gone, so what it carries back is the key it was posted under. The Dioxus adapter's
439/// `use_notification_activated` hook holds one of these for a component.
440///
441/// With no handler registered, an activation is dropped.
442pub fn on_activation(handler: Rc<dyn Fn(NotificationActivation)>) -> ActivationSubscription {
443 let id = next_id();
444 ACTIVATION_HANDLERS.with_borrow_mut(|handlers| handlers.push((id, handler)));
445 ActivationSubscription { id }
446}
447
448#[cfg(test)]
449mod tests {
450 use super::*;
451
452 #[test]
453 fn fr36_a_notification_says_nothing_it_was_not_told() {
454 let notification = Notification::new("title");
455 assert_eq!(notification.key, "");
456 assert_eq!(notification.body, "");
457 assert_eq!(notification.channel, "");
458 assert_eq!(notification.actions, [String::new(), String::new()]);
459 assert_eq!(notification.importance, NotificationImportance::Normal);
460 assert_eq!(notification.presentation, NotificationPresentation::Always);
461 }
462
463 #[test]
464 fn fr36_an_activation_knows_whether_a_button_was_pressed() {
465 let body = NotificationActivation {
466 key: "k".to_owned(),
467 action: 0,
468 };
469 let button = NotificationActivation {
470 key: "k".to_owned(),
471 action: 2,
472 };
473 assert!(body.is_body());
474 assert_eq!(body.action_button(), None);
475 assert!(!button.is_body());
476 assert_eq!(button.action_button(), Some(2));
477 }
478}