1mod server;
13
14#[cfg(test)]
15mod tests;
16
17use std::cell::RefCell;
18use std::future::poll_fn;
19use std::panic::{self, AssertUnwindSafe, Location};
20use std::pin::Pin;
21use std::rc::Rc;
22use std::sync::Once;
23use std::time::{Duration, Instant, SystemTime};
24
25use calloop::RegistrationToken;
26use calloop::timer::TimeoutAction;
27use crabshell_reactive::{Owner, ReadSignal, Signal, untrack};
28use crabshell_ui::{Cx, IconSource};
29use zbus::export::futures_core::Stream;
30use zbus::fdo::{RequestNameFlags, RequestNameReply};
31use zbus::message::Type as Kind;
32use zbus::{Connection, Message, MessageStream};
33
34use crate::Shared;
35use crate::dbus::BUS;
36use crate::dbus::set_changed;
37use crate::lines::panic_message;
38
39const NOTIFICATIONS: &str = "org.freedesktop.Notifications";
40const NOTIFICATIONS_PATH: &str = "/org/freedesktop/Notifications";
41
42pub(crate) const POPUP_TIMEOUT: Duration = Duration::from_secs(5);
44
45const MAX_KEPT: usize = 256;
48
49const MAX_PIXELS_KEPT: usize = 8 * 1024 * 1024;
51
52#[derive(Clone, Copy, Debug, PartialEq, Eq)]
55pub enum CloseReason {
56 Expired,
59 Dismissed,
62 CloseRequested,
64}
65
66impl CloseReason {
67 fn code(self) -> u32 {
69 match self {
70 CloseReason::Expired => 1,
71 CloseReason::Dismissed => 2,
72 CloseReason::CloseRequested => 3,
73 }
74 }
75}
76
77#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
79pub enum Urgency {
80 Low,
82 #[default]
84 Normal,
85 Critical,
88}
89
90#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
92pub enum Timeout {
93 #[default]
95 Default,
96 Never,
98 After(Duration),
100}
101
102#[derive(Clone, Debug, PartialEq)]
104pub struct Notification {
105 pub id: u32,
109 pub app_name: String,
111 pub app_icon: String,
114 pub summary: String,
116 pub body: String,
119 pub actions: Vec<(String, String)>,
122 pub urgency: Urgency,
124 pub timeout: Timeout,
126 pub image: Option<IconSource>,
129 pub desktop_entry: String,
131 pub category: String,
133 pub resident: bool,
135 pub transient: bool,
138 pub received: SystemTime,
140}
141
142#[derive(Clone)]
145pub struct Notifications {
146 service: Option<Rc<Service>>,
148 list: ReadSignal<Vec<Notification>>,
149 popups: ReadSignal<Vec<Notification>>,
150 owner: Owner,
153}
154
155impl Notifications {
156 pub fn list(&self) -> ReadSignal<Vec<Notification>> {
160 self.list
161 }
162
163 pub fn popups(&self) -> ReadSignal<Vec<Notification>> {
168 self.popups
169 }
170
171 pub fn dismiss(&self, id: u32) {
174 if let Some(service) = &self.service {
175 service.close(&[id], CloseReason::Dismissed);
176 }
177 }
178
179 pub fn invoke(&self, id: u32, key: &str) {
183 if let Some(service) = &self.service {
184 service.invoke(id, key);
185 }
186 }
187
188 pub fn clear(&self) {
190 if let Some(service) = &self.service {
191 let state = service.state.borrow();
192 let history = state.as_ref().map(|state| state.history.as_slice());
193 let ids: Vec<u32> = history.unwrap_or_default().iter().map(|n| n.id).collect();
194 drop(state);
195 service.close(&ids, CloseReason::Dismissed);
196 }
197 }
198
199 #[track_caller]
209 pub fn on_closed(&self, handler: impl Fn(&Notification, CloseReason) + 'static) {
210 let at = Location::caller();
211 if self.owner.is_disposed() {
212 return tracing::warn!(
213 "notifications on_closed (called at {at}) on a context that is gone does nothing"
214 );
215 }
216 if let Some(service) = &self.service {
217 let id = service.listen(Rc::new(handler), at);
218 self.owner.signal(Unlisten(Rc::clone(service), id));
220 }
221 }
222}
223
224type CloseHandler = Rc<dyn Fn(&Notification, CloseReason)>;
226
227#[derive(Clone)]
229struct Listener {
230 id: u64,
231 handler: CloseHandler,
232 at: &'static Location<'static>,
234}
235
236struct Unlisten(Rc<Service>, u64);
238
239impl Drop for Unlisten {
240 fn drop(&mut self) {
241 drop(self.0.unlisten(self.1));
243 }
244}
245
246pub(crate) fn notifications(cx: &Cx) -> Notifications {
248 let Some(service) = cx.service::<Service>() else {
249 static LOGGED: Once = Once::new();
250 LOGGED.call_once(|| {
251 tracing::warn!("cx.notifications() needs the services the shell provides; none arrive");
252 });
253 return Notifications {
254 service: None,
255 list: cx.signal(Vec::new()).read_only(),
256 popups: cx.signal(Vec::new()).read_only(),
257 owner: cx.owner(),
258 };
259 };
260 let (list, popups) = service.start();
261 Notifications {
262 service: Some(service),
263 list,
264 popups,
265 owner: cx.owner(),
266 }
267}
268
269pub(crate) struct Service {
271 shared: Rc<Shared>,
272 state: RefCell<Option<State>>,
273}
274
275struct State {
276 list: Signal<Vec<Notification>>,
277 popups: Signal<Vec<Notification>>,
278 connection: Option<Connection>,
280 serving: bool,
282 history: Vec<Notification>,
284 showing: Vec<(u32, Option<Instant>)>,
287 next_id: u32,
289 timer: Option<RegistrationToken>,
291 listeners: Vec<Listener>,
293 next_listener: u64,
295}
296
297impl State {
298 fn new_id(&mut self) -> u32 {
301 loop {
302 let id = self.next_id;
303 self.next_id = id.checked_add(1).unwrap_or(1);
304 if !self.history.iter().any(|kept| kept.id == id) {
305 return id;
306 }
307 }
308 }
309}
310
311enum Event {
313 Closed(u32, CloseReason),
315 Invoked(u32, String),
317}
318
319impl Service {
320 pub(crate) fn new(shared: Rc<Shared>) -> Service {
321 Service {
322 shared,
323 state: RefCell::new(None),
324 }
325 }
326
327 fn start(self: &Rc<Self>) -> (ReadSignal<Vec<Notification>>, ReadSignal<Vec<Notification>>) {
330 if let Some(state) = &*self.state.borrow() {
331 return (state.list.read_only(), state.popups.read_only());
332 }
333 let list = self.shared.root.signal(Vec::new());
334 let popups = self.shared.root.signal(Vec::new());
335 *self.state.borrow_mut() = Some(State {
336 list,
337 popups,
338 connection: None,
339 serving: false,
340 history: Vec::new(),
341 showing: Vec::new(),
342 next_id: 1,
343 timer: None,
344 listeners: Vec::new(),
345 next_listener: 0,
346 });
347 match self.shared.config.session_bus.clone() {
348 Some(address) => self.shared.spawn(run(Rc::clone(self), address)),
349 None => tracing::info!("no session bus, so no notifications arrive"),
350 }
351 (list.read_only(), popups.read_only())
352 }
353
354 fn alive(&self) -> bool {
357 let state = self.state.borrow();
358 let alive = state.as_ref().and_then(|state| state.list.try_with(|_| ()));
359 alive.is_some()
360 }
361
362 fn serving(&self) -> bool {
363 let state = self.state.borrow();
364 state.as_ref().is_some_and(|state| state.serving)
365 }
366
367 fn notify(self: &Rc<Self>, replaces: u32, mut notification: Notification) -> u32 {
370 let now = Instant::now();
371 let expires = match (notification.urgency, notification.timeout) {
372 (Urgency::Critical, _) | (_, Timeout::Never) => None,
373 (_, Timeout::Default) => Some(now + self.shared.config.popup_timeout),
374 (_, Timeout::After(after)) => Some(now + after),
375 };
376 let (id, dropped) = {
377 let mut state = self.state.borrow_mut();
378 let Some(state) = state.as_mut() else {
379 return 0;
380 };
381 let id = match replaces {
384 0 => state.new_id(),
385 id => id,
386 };
387 notification.id = id;
388 match state.history.iter().position(|kept| kept.id == id) {
389 Some(at) => state.history[at] = notification,
390 None => state.history.push(notification),
391 }
392 match state.showing.iter_mut().find(|(shown, _)| *shown == id) {
393 Some(shown) => shown.1 = expires,
394 None => state.showing.insert(0, (id, expires)),
395 }
396 let excess = state.history.len().saturating_sub(MAX_KEPT);
397 let dropped: Vec<Notification> = state.history.drain(..excess).collect();
398 let history = &state.history;
399 state
400 .showing
401 .retain(|(id, _)| history.iter().any(|kept| kept.id == *id));
402 keep_pixels_within(&mut state.history, MAX_PIXELS_KEPT);
403 (id, dropped)
404 };
405 self.closed(Vec::new(), dropped, CloseReason::Expired);
406 id
407 }
408
409 fn close(self: &Rc<Self>, ids: &[u32], reason: CloseReason) -> bool {
412 let taken = self.take(ids);
413 if taken.is_empty() {
414 return false;
415 }
416 self.closed(Vec::new(), taken, reason);
417 true
418 }
419
420 fn invoke(self: &Rc<Self>, id: u32, key: &str) {
423 let resident = self.state.borrow().as_ref().and_then(|state| {
424 let kept = state.history.iter().find(|kept| kept.id == id)?;
425 let offered = kept.actions.iter().any(|(action, _)| action == key);
426 offered.then_some(kept.resident)
427 });
428 let Some(resident) = resident else {
429 return tracing::warn!(id, key, "no such notification action");
430 };
431 let events = vec![Event::Invoked(id, key.to_owned())];
432 let taken = if resident {
433 Vec::new()
434 } else {
435 self.take(&[id])
436 };
437 self.closed(events, taken, CloseReason::Dismissed);
438 }
439
440 fn take(&self, ids: &[u32]) -> Vec<Notification> {
443 let mut state = self.state.borrow_mut();
444 let Some(state) = state.as_mut() else {
445 return Vec::new();
446 };
447 let (taken, kept): (Vec<_>, Vec<_>) = std::mem::take(&mut state.history)
448 .into_iter()
449 .partition(|kept| ids.contains(&kept.id));
450 state.history = kept;
451 state.showing.retain(|(id, _)| !ids.contains(id));
452 taken
453 }
454
455 fn closed(
458 self: &Rc<Self>,
459 mut events: Vec<Event>,
460 closed: Vec<Notification>,
461 reason: CloseReason,
462 ) {
463 events.extend(closed.iter().map(|gone| Event::Closed(gone.id, reason)));
464 self.emit(events);
465 self.changed();
466 self.deliver(&closed, reason);
467 }
468
469 fn listen(&self, handler: CloseHandler, at: &'static Location<'static>) -> u64 {
471 let mut state = self.state.borrow_mut();
472 let state = state
473 .as_mut()
474 .expect("invariant: started by cx.notifications()");
475 state.next_listener += 1;
476 let id = state.next_listener;
477 state.listeners.push(Listener { id, handler, at });
478 id
479 }
480
481 fn unlisten(&self, id: u64) -> Option<Listener> {
483 let mut state = self.state.borrow_mut();
484 let listeners = &mut state.as_mut()?.listeners;
485 let index = listeners.iter().position(|listener| listener.id == id)?;
486 Some(listeners.remove(index))
487 }
488
489 fn listening(&self, id: u64) -> bool {
490 let state = self.state.borrow();
491 let mut listeners = state.iter().flat_map(|state| &state.listeners);
492 listeners.any(|listener| listener.id == id)
493 }
494
495 fn deliver(&self, closed: &[Notification], reason: CloseReason) {
501 let listeners = match self.state.borrow().as_ref() {
502 Some(state) if !closed.is_empty() => state.listeners.clone(),
503 _ => return,
504 };
505 for gone in closed {
506 for Listener { id, handler, at } in &listeners {
507 if !self.listening(*id) {
509 continue;
510 }
511 let run = AssertUnwindSafe(|| untrack(|| handler(gone, reason)));
512 if let Err(payload) = panic::catch_unwind(run) {
513 let message = panic_message(payload.as_ref());
514 tracing::error!("notifications on_closed (called at {at}) panicked: {message}");
515 drop(self.unlisten(*id));
516 }
517 }
518 }
519 }
520
521 fn expire(self: &Rc<Self>) -> TimeoutAction {
524 if let Some(state) = self.state.borrow_mut().as_mut() {
525 state.timer = None;
527 }
528 if !self.alive() {
529 return TimeoutAction::Drop;
530 }
531 let now = Instant::now();
532 let transient = {
533 let mut state = self.state.borrow_mut();
534 let Some(state) = state.as_mut() else {
535 return TimeoutAction::Drop;
536 };
537 let (expired, showing) = std::mem::take(&mut state.showing)
538 .into_iter()
539 .partition::<Vec<_>, _>(|(_, expires)| expires.is_some_and(|at| at <= now));
540 state.showing = showing;
541 let history = state.history.iter();
542 let transient = history.filter(|kept| kept.transient);
543 transient
544 .map(|kept| kept.id)
545 .filter(|id| expired.iter().any(|(gone, _)| gone == id))
546 .collect::<Vec<_>>()
547 };
548 let taken = self.take(&transient);
549 self.closed(Vec::new(), taken, CloseReason::Expired);
550 TimeoutAction::Drop
551 }
552
553 fn changed(self: &Rc<Self>) {
556 let shown = self.state.borrow().as_ref().map(|state| {
558 let popups = state.showing.iter().filter_map(|(id, _)| {
559 let kept = state.history.iter().find(|kept| kept.id == *id);
560 kept.cloned()
561 });
562 let popups = popups.collect::<Vec<_>>();
563 (state.list, state.history.clone(), state.popups, popups)
564 });
565 if let Some((list_signal, list, popups_signal, popups)) = shown {
566 set_changed(list_signal, list);
567 set_changed(popups_signal, popups);
568 }
569 self.schedule();
570 }
571
572 fn schedule(self: &Rc<Self>) {
575 let Some(event_loop) = self.shared.event_loop() else {
576 return;
577 };
578 let mut state = self.state.borrow_mut();
579 let Some(state) = state.as_mut() else {
580 return;
581 };
582 if let Some(token) = state.timer.take() {
583 event_loop.remove(token);
584 }
585 let Some(next) = state.showing.iter().filter_map(|(_, at)| *at).min() else {
586 return;
587 };
588 let service = Rc::clone(self);
589 let tick = Box::new(move || service.expire());
590 state.timer = event_loop.insert_timer(next, tick);
591 }
592
593 fn emit(&self, events: Vec<Event>) {
595 let connection = {
596 let state = self.state.borrow();
597 let serving = state.as_ref().filter(|state| state.serving);
598 serving.and_then(|state| state.connection.clone())
599 };
600 let Some(connection) = connection.filter(|_| !events.is_empty()) else {
601 return;
602 };
603 self.shared.spawn(async move {
604 for event in events {
605 let (path, interface) = (NOTIFICATIONS_PATH, NOTIFICATIONS);
606 let sent = match &event {
607 Event::Closed(id, reason) => {
608 let body = (*id, reason.code());
609 let signal = "NotificationClosed";
610 let emit = connection.emit_signal(None::<&str>, path, interface, signal, &body);
611 emit.await
612 }
613 Event::Invoked(id, key) => {
614 let body = (*id, key.as_str());
615 let signal = "ActionInvoked";
616 let emit = connection.emit_signal(None::<&str>, path, interface, signal, &body);
617 emit.await
618 }
619 };
620 if let Err(error) = sent {
621 tracing::warn!(%error, "a notification signal wasn't sent");
622 }
623 }
624 });
625 }
626
627 async fn on_message(self: &Rc<Self>, connection: &Connection, message: &Message) {
629 match message.header().message_type() {
630 Kind::MethodCall => server::answer(self, connection, message).await,
631 Kind::Signal => self.on_signal(message),
632 _ => {}
633 }
634 }
635
636 fn on_signal(&self, message: &Message) {
638 let header = message.header();
639 let from_bus = header.sender().is_some_and(|sender| sender.as_str() == BUS);
640 let ours = message.body().deserialize::<&str>().ok() == Some(NOTIFICATIONS);
641 if !from_bus || !ours {
642 return;
643 }
644 let serving = match header.member().map(|member| member.as_str()) {
645 Some("NameAcquired") => {
646 tracing::info!("serving notifications");
647 true
648 }
649 Some("NameLost") => {
650 tracing::info!("another program took over notifications; waiting for it to leave");
651 false
652 }
653 _ => return,
654 };
655 if let Some(state) = self.state.borrow_mut().as_mut() {
656 state.serving = serving;
657 }
658 }
659}
660
661async fn run(service: Rc<Service>, address: zbus::Address) {
664 let builder = zbus::connection::Builder::address(address);
665 let connection = match builder.build().await {
666 Ok(connection) => connection,
667 Err(error) => {
668 return tracing::info!(%error, "can't reach the session bus, so no notifications arrive");
669 }
670 };
671 let mut messages = MessageStream::from(&connection);
673 if let Some(state) = service.state.borrow_mut().as_mut() {
674 state.connection = Some(connection.clone());
675 }
676 let flags = RequestNameFlags::ALLOW_REPLACEMENT;
679 match connection
680 .request_name_with_flags(NOTIFICATIONS, flags)
681 .await
682 {
683 Ok(RequestNameReply::InQueue) => {
684 tracing::info!("another program serves notifications; waiting for it to leave");
685 }
686 Ok(_) => {}
687 Err(error) => return tracing::warn!(%error, "couldn't ask for the notifications name"),
688 }
689 loop {
690 let next = poll_fn(|cx| Pin::new(&mut messages).poll_next(cx)).await;
691 if !service.alive() {
692 return;
693 }
694 match next {
695 Some(Ok(message)) => service.on_message(&connection, &message).await,
696 Some(Err(error)) => tracing::warn!(%error, "a bad message on the session bus"),
697 None => break,
698 }
699 }
700 tracing::warn!("the session bus closed, so no more notifications arrive");
701 if let Some(state) = service.state.borrow_mut().as_mut() {
702 state.serving = false;
703 state.connection = None;
704 }
705}
706
707fn keep_pixels_within(history: &mut [Notification], budget: usize) {
710 let mut total = 0;
711 for kept in history.iter_mut().rev() {
712 let Some(IconSource::Pixels(pixels)) = &kept.image else {
713 continue;
714 };
715 let bytes = pixels.data.len();
716 if total + bytes <= budget {
717 total += bytes;
718 } else {
719 tracing::debug!(
720 id = kept.id,
721 "past the pictures' budget; showing the app icon"
722 );
723 kept.image = server::icon(&kept.app_icon);
724 }
725 }
726}