Skip to main content

crabshell_services/
notifications.rs

1//! A notification server, as the Desktop Notifications spec 1.2 has it:
2//! apps call `org.freedesktop.Notifications` on the session bus, and the
3//! shell keeps what they send.
4//!
5//! The shell queues for the name, so it serves while no other daemon,
6//! such as mako or dunst, does, and takes over when that one leaves.
7//! zbus has no object server here, so the calls are answered by hand on
8//! the main thread, in [`server`]. One stream reads every message the bus
9//! sends this connection, and one loop timer runs only while a popup
10//! waits to expire, so a quiet bus leaves the shell asleep.
11
12mod server;
13
14#[cfg(test)]
15mod tests;
16
17use std::cell::RefCell;
18use std::future::poll_fn;
19use std::pin::Pin;
20use std::rc::Rc;
21use std::sync::Once;
22use std::time::{Duration, Instant, SystemTime};
23
24use calloop::RegistrationToken;
25use calloop::timer::TimeoutAction;
26use crabshell_reactive::{ReadSignal, Signal};
27use crabshell_ui::{Cx, IconSource};
28use zbus::export::futures_core::Stream;
29use zbus::fdo::{RequestNameFlags, RequestNameReply};
30use zbus::message::Type as Kind;
31use zbus::{Connection, Message, MessageStream};
32
33use crate::Shared;
34use crate::dbus::BUS;
35use crate::dbus::set_changed;
36
37const NOTIFICATIONS: &str = "org.freedesktop.Notifications";
38const NOTIFICATIONS_PATH: &str = "/org/freedesktop/Notifications";
39
40/// How long a popup shows when its app leaves that to the server.
41pub(crate) const POPUP_TIMEOUT: Duration = Duration::from_secs(5);
42
43/// The most notifications kept. Past it the oldest goes, as if it
44/// expired.
45const MAX_KEPT: usize = 256;
46
47/// The most pixel bytes the history keeps: 128 pictures of 128 px.
48const MAX_PIXELS_KEPT: usize = 8 * 1024 * 1024;
49
50/// Why a notification closed, as `NotificationClosed` tells its app.
51const EXPIRED: u32 = 1;
52const DISMISSED: u32 = 2;
53const CLOSED: u32 = 3;
54
55/// How urgent a notification is, from its `urgency` hint.
56#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
57pub enum Urgency {
58  /// It can wait, such as a finished download.
59  Low,
60  /// The usual.
61  #[default]
62  Normal,
63  /// It needs the user now, such as a battery about to run out. Its
64  /// popup stays until dismissed.
65  Critical,
66}
67
68/// How long a notification's popup shows, as its app asks.
69#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
70pub enum Timeout {
71  /// As long as the server likes: 5 s.
72  #[default]
73  Default,
74  /// Until dismissed.
75  Never,
76  /// This long.
77  After(Duration),
78}
79
80/// One notification an app sent.
81#[derive(Clone, Debug, PartialEq)]
82pub struct Notification {
83  /// Never 0, and unique among those kept. The key to give
84  /// `children_keyed`, and what [`Notifications::dismiss`] and
85  /// [`Notifications::invoke`] take.
86  pub id: u32,
87  /// The app's name for itself, such as `Firefox`, or empty.
88  pub app_name: String,
89  /// The app's icon as it sent it: an icon name, a `file://` URI, or
90  /// empty.
91  pub app_icon: String,
92  /// One line about it, at most 1 KiB.
93  pub summary: String,
94  /// The text under that, at most 4 KiB, as plain text: tags an app sent
95  /// are taken out and entities decoded.
96  pub body: String,
97  /// The actions the user can pick, as (key, label), at most 16. The
98  /// app means `default` for a click on the notification itself.
99  pub actions: Vec<(String, String)>,
100  /// How urgent it is.
101  pub urgency: Urgency,
102  /// How long its popup shows.
103  pub timeout: Timeout,
104  /// The picture to show: the app's pixels, an image file, or else its
105  /// icon. `None` when it has none.
106  pub image: Option<IconSource>,
107  /// The app's desktop entry, such as `org.mozilla.firefox`, or empty.
108  pub desktop_entry: String,
109  /// What kind it is, such as `email.arrived`, or empty.
110  pub category: String,
111  /// Whether it stays after one of its actions is picked.
112  pub resident: bool,
113  /// Whether it goes once its popup expires, instead of staying in the
114  /// history.
115  pub transient: bool,
116  /// When it arrived, or was last replaced.
117  pub received: SystemTime,
118}
119
120/// The notifications, from `cx.notifications()`. A clone is cheap and
121/// shares the one server.
122#[derive(Clone)]
123pub struct Notifications {
124  /// `None` when the shell provided no services.
125  service: Option<Rc<Service>>,
126  list: ReadSignal<Vec<Notification>>,
127  popups: ReadSignal<Vec<Notification>>,
128}
129
130impl Notifications {
131  /// Every notification kept, oldest first: the history, from arrival
132  /// until it is dismissed, picked or closed by its app. A replaced one
133  /// keeps its place.
134  pub fn list(&self) -> ReadSignal<Vec<Notification>> {
135    self.list
136  }
137
138  /// The notifications still showing as popups, newest first. Each stays
139  /// for its timeout, and until dismissed when that is
140  /// [`Timeout::Never`] or it is [`Urgency::Critical`]. An expired one
141  /// stays in [`list`](Self::list), unless it is transient.
142  pub fn popups(&self) -> ReadSignal<Vec<Notification>> {
143    self.popups
144  }
145
146  /// Takes notification `id` away, as the user's close button does, and
147  /// tells its app. An unknown `id` is ignored.
148  pub fn dismiss(&self, id: u32) {
149    if let Some(service) = &self.service {
150      service.close(&[id], DISMISSED);
151    }
152  }
153
154  /// Tells notification `id`'s app that the user picked its action
155  /// `key`, then takes it away unless it is resident. An unknown `id` or
156  /// `key` is logged.
157  pub fn invoke(&self, id: u32, key: &str) {
158    if let Some(service) = &self.service {
159      service.invoke(id, key);
160    }
161  }
162
163  /// Dismisses every notification.
164  pub fn clear(&self) {
165    if let Some(service) = &self.service {
166      let state = service.state.borrow();
167      let history = state.as_ref().map(|state| state.history.as_slice());
168      let ids: Vec<u32> = history.unwrap_or_default().iter().map(|n| n.id).collect();
169      drop(state);
170      service.close(&ids, DISMISSED);
171    }
172  }
173}
174
175/// `cx.notifications()`.
176pub(crate) fn notifications(cx: &Cx) -> Notifications {
177  let Some(service) = cx.service::<Service>() else {
178    static LOGGED: Once = Once::new();
179    LOGGED.call_once(|| {
180      tracing::warn!("cx.notifications() needs the services the shell provides; none arrive");
181    });
182    return Notifications {
183      service: None,
184      list: cx.signal(Vec::new()).read_only(),
185      popups: cx.signal(Vec::new()).read_only(),
186    };
187  };
188  let (list, popups) = service.start();
189  Notifications {
190    service: Some(service),
191    list,
192    popups,
193  }
194}
195
196/// The notification server of one shell, started on first use.
197pub(crate) struct Service {
198  shared: Rc<Shared>,
199  state: RefCell<Option<State>>,
200}
201
202struct State {
203  list: Signal<Vec<Notification>>,
204  popups: Signal<Vec<Notification>>,
205  /// The session bus, once connected.
206  connection: Option<Connection>,
207  /// Whether this shell owns the name now, so it answers and signals.
208  serving: bool,
209  /// Every notification kept, oldest first.
210  history: Vec<Notification>,
211  /// The ids showing as popups, newest first, each with when it expires,
212  /// `None` for never.
213  showing: Vec<(u32, Option<Instant>)>,
214  /// The id the next notification gets, unless one kept has it.
215  next_id: u32,
216  /// The expiry timer, while a popup waits to expire.
217  timer: Option<RegistrationToken>,
218}
219
220impl State {
221  /// A new id: the count goes on from the last, skipping 0 when it wraps
222  /// and any id still kept.
223  fn new_id(&mut self) -> u32 {
224    loop {
225      let id = self.next_id;
226      self.next_id = id.checked_add(1).unwrap_or(1);
227      if !self.history.iter().any(|kept| kept.id == id) {
228        return id;
229      }
230    }
231  }
232}
233
234/// A signal for the apps, sent in order.
235enum Event {
236  /// `NotificationClosed`: the id and why.
237  Closed(u32, u32),
238  /// `ActionInvoked`: the id and the action's key.
239  Invoked(u32, String),
240}
241
242impl Service {
243  pub(crate) fn new(shared: Rc<Shared>) -> Service {
244    Service {
245      shared,
246      state: RefCell::new(None),
247    }
248  }
249
250  /// The history and the popups, joining the bus if this is the first
251  /// ask.
252  fn start(self: &Rc<Self>) -> (ReadSignal<Vec<Notification>>, ReadSignal<Vec<Notification>>) {
253    if let Some(state) = &*self.state.borrow() {
254      return (state.list.read_only(), state.popups.read_only());
255    }
256    let list = self.shared.root.signal(Vec::new());
257    let popups = self.shared.root.signal(Vec::new());
258    *self.state.borrow_mut() = Some(State {
259      list,
260      popups,
261      connection: None,
262      serving: false,
263      history: Vec::new(),
264      showing: Vec::new(),
265      next_id: 1,
266      timer: None,
267    });
268    match self.shared.config.session_bus.clone() {
269      Some(address) => self.shared.spawn(run(Rc::clone(self), address)),
270      None => tracing::info!("no session bus, so no notifications arrive"),
271    }
272    (list.read_only(), popups.read_only())
273  }
274
275  /// Whether the history is still there: its owner, the root, isn't
276  /// disposed.
277  fn alive(&self) -> bool {
278    let state = self.state.borrow();
279    let alive = state.as_ref().and_then(|state| state.list.try_with(|_| ()));
280    alive.is_some()
281  }
282
283  fn serving(&self) -> bool {
284    let state = self.state.borrow();
285    state.as_ref().is_some_and(|state| state.serving)
286  }
287
288  /// Keeps `notification`, in place of `replaces` if that one is kept,
289  /// and shows it as a popup. Returns its id.
290  fn notify(self: &Rc<Self>, replaces: u32, mut notification: Notification) -> u32 {
291    let now = Instant::now();
292    let expires = match (notification.urgency, notification.timeout) {
293      (Urgency::Critical, _) | (_, Timeout::Never) => None,
294      (_, Timeout::Default) => Some(now + self.shared.config.popup_timeout),
295      (_, Timeout::After(after)) => Some(now + after),
296    };
297    let (id, dropped) = {
298      let mut state = self.state.borrow_mut();
299      let Some(state) = state.as_mut() else {
300        return 0;
301      };
302      // The spec returns `replaces` even when it is gone, so it names
303      // the new one then.
304      let id = match replaces {
305        0 => state.new_id(),
306        id => id,
307      };
308      notification.id = id;
309      match state.history.iter().position(|kept| kept.id == id) {
310        Some(at) => state.history[at] = notification,
311        None => state.history.push(notification),
312      }
313      match state.showing.iter_mut().find(|(shown, _)| *shown == id) {
314        Some(shown) => shown.1 = expires,
315        None => state.showing.insert(0, (id, expires)),
316      }
317      let excess = state.history.len().saturating_sub(MAX_KEPT);
318      let dropped: Vec<Event> = state
319        .history
320        .drain(..excess)
321        .map(|old| Event::Closed(old.id, EXPIRED))
322        .collect();
323      let history = &state.history;
324      state
325        .showing
326        .retain(|(id, _)| history.iter().any(|kept| kept.id == *id));
327      keep_pixels_within(&mut state.history, MAX_PIXELS_KEPT);
328      (id, dropped)
329    };
330    self.emit(dropped);
331    self.changed();
332    id
333  }
334
335  /// Takes `ids` away and tells their apps `reason`. Returns whether any
336  /// was kept.
337  fn close(self: &Rc<Self>, ids: &[u32], reason: u32) -> bool {
338    let events = self.take(ids, reason);
339    if events.is_empty() {
340      return false;
341    }
342    self.emit(events);
343    self.changed();
344    true
345  }
346
347  /// Picks action `key` of notification `id`, as [`Notifications::invoke`]
348  /// does.
349  fn invoke(self: &Rc<Self>, id: u32, key: &str) {
350    let resident = self.state.borrow().as_ref().and_then(|state| {
351      let kept = state.history.iter().find(|kept| kept.id == id)?;
352      let offered = kept.actions.iter().any(|(action, _)| action == key);
353      offered.then_some(kept.resident)
354    });
355    let Some(resident) = resident else {
356      return tracing::warn!(id, key, "no such notification action");
357    };
358    let mut events = vec![Event::Invoked(id, key.to_owned())];
359    if !resident {
360      events.extend(self.take(&[id], DISMISSED));
361    }
362    self.emit(events);
363    self.changed();
364  }
365
366  /// Takes `ids` out of the history and the popups, and returns the
367  /// signals that say so.
368  fn take(&self, ids: &[u32], reason: u32) -> Vec<Event> {
369    let mut state = self.state.borrow_mut();
370    let Some(state) = state.as_mut() else {
371      return Vec::new();
372    };
373    let taken: Vec<u32> = state
374      .history
375      .iter()
376      .map(|kept| kept.id)
377      .filter(|id| ids.contains(id))
378      .collect();
379    state.history.retain(|kept| !taken.contains(&kept.id));
380    state.showing.retain(|(id, _)| !taken.contains(id));
381    taken
382      .into_iter()
383      .map(|id| Event::Closed(id, reason))
384      .collect()
385  }
386
387  /// The timer's tick: takes the expired popups away, and the transient
388  /// ones out of the history too.
389  fn expire(self: &Rc<Self>) -> TimeoutAction {
390    if let Some(state) = self.state.borrow_mut().as_mut() {
391      // This timer goes once the tick returns.
392      state.timer = None;
393    }
394    if !self.alive() {
395      return TimeoutAction::Drop;
396    }
397    let now = Instant::now();
398    let transient = {
399      let mut state = self.state.borrow_mut();
400      let Some(state) = state.as_mut() else {
401        return TimeoutAction::Drop;
402      };
403      let (expired, showing) = std::mem::take(&mut state.showing)
404        .into_iter()
405        .partition::<Vec<_>, _>(|(_, expires)| expires.is_some_and(|at| at <= now));
406      state.showing = showing;
407      let history = state.history.iter();
408      let transient = history.filter(|kept| kept.transient);
409      transient
410        .map(|kept| kept.id)
411        .filter(|id| expired.iter().any(|(gone, _)| gone == id))
412        .collect::<Vec<_>>()
413    };
414    let events = self.take(&transient, EXPIRED);
415    self.emit(events);
416    self.changed();
417    TimeoutAction::Drop
418  }
419
420  /// Shows the history and popups as they are now, and sets the timer
421  /// for the next popup to expire.
422  fn changed(self: &Rc<Self>) {
423    // Set apart from the borrow, since effects may ask for more.
424    let shown = self.state.borrow().as_ref().map(|state| {
425      let popups = state.showing.iter().filter_map(|(id, _)| {
426        let kept = state.history.iter().find(|kept| kept.id == *id);
427        kept.cloned()
428      });
429      let popups = popups.collect::<Vec<_>>();
430      (state.list, state.history.clone(), state.popups, popups)
431    });
432    if let Some((list_signal, list, popups_signal, popups)) = shown {
433      set_changed(list_signal, list);
434      set_changed(popups_signal, popups);
435    }
436    self.schedule();
437  }
438
439  /// Sets the one timer afresh, at the soonest expiry, or none while no
440  /// popup expires.
441  fn schedule(self: &Rc<Self>) {
442    let Some(event_loop) = self.shared.event_loop() else {
443      return;
444    };
445    let mut state = self.state.borrow_mut();
446    let Some(state) = state.as_mut() else {
447      return;
448    };
449    if let Some(token) = state.timer.take() {
450      event_loop.remove(token);
451    }
452    let Some(next) = state.showing.iter().filter_map(|(_, at)| *at).min() else {
453      return;
454    };
455    let service = Rc::clone(self);
456    let tick = Box::new(move || service.expire());
457    state.timer = event_loop.insert_timer(next, tick);
458  }
459
460  /// Sends `events` in order, while this shell serves.
461  fn emit(&self, events: Vec<Event>) {
462    let connection = {
463      let state = self.state.borrow();
464      let serving = state.as_ref().filter(|state| state.serving);
465      serving.and_then(|state| state.connection.clone())
466    };
467    let Some(connection) = connection.filter(|_| !events.is_empty()) else {
468      return;
469    };
470    self.shared.spawn(async move {
471      for event in events {
472        let (path, interface) = (NOTIFICATIONS_PATH, NOTIFICATIONS);
473        let sent = match &event {
474          Event::Closed(id, reason) => {
475            let body = (*id, *reason);
476            let signal = "NotificationClosed";
477            let emit = connection.emit_signal(None::<&str>, path, interface, signal, &body);
478            emit.await
479          }
480          Event::Invoked(id, key) => {
481            let body = (*id, key.as_str());
482            let signal = "ActionInvoked";
483            let emit = connection.emit_signal(None::<&str>, path, interface, signal, &body);
484            emit.await
485          }
486        };
487        if let Err(error) = sent {
488          tracing::warn!(%error, "a notification signal wasn't sent");
489        }
490      }
491    });
492  }
493
494  /// Acts on one message from the bus.
495  async fn on_message(self: &Rc<Self>, connection: &Connection, message: &Message) {
496    match message.header().message_type() {
497      Kind::MethodCall => server::answer(self, connection, message).await,
498      Kind::Signal => self.on_signal(message),
499      _ => {}
500    }
501  }
502
503  /// The bus handing this shell the name, or taking it away.
504  fn on_signal(&self, message: &Message) {
505    let header = message.header();
506    let from_bus = header.sender().is_some_and(|sender| sender.as_str() == BUS);
507    let ours = message.body().deserialize::<&str>().ok() == Some(NOTIFICATIONS);
508    if !from_bus || !ours {
509      return;
510    }
511    let serving = match header.member().map(|member| member.as_str()) {
512      Some("NameAcquired") => {
513        tracing::info!("serving notifications");
514        true
515      }
516      Some("NameLost") => {
517        tracing::info!("another program took over notifications; waiting for it to leave");
518        false
519      }
520      _ => return,
521    };
522    if let Some(state) = self.state.borrow_mut().as_mut() {
523      state.serving = serving;
524    }
525  }
526}
527
528/// Joins the session bus at `address`, queues for the name, and acts on
529/// what arrives, until the bus closes or the history is disposed.
530async fn run(service: Rc<Service>, address: zbus::Address) {
531  let builder = zbus::connection::Builder::address(address);
532  let connection = match builder.build().await {
533    Ok(connection) => connection,
534    Err(error) => {
535      return tracing::info!(%error, "can't reach the session bus, so no notifications arrive");
536    }
537  };
538  // Made before anything is asked, so no call or NameAcquired is missed.
539  let mut messages = MessageStream::from(&connection);
540  if let Some(state) = service.state.borrow_mut().as_mut() {
541    state.connection = Some(connection.clone());
542  }
543  // Queued rather than refused, so the bus hands the name over when the
544  // daemon that has it leaves. NameAcquired says so, in `on_signal`.
545  let flags = RequestNameFlags::ALLOW_REPLACEMENT;
546  match connection
547    .request_name_with_flags(NOTIFICATIONS, flags)
548    .await
549  {
550    Ok(RequestNameReply::InQueue) => {
551      tracing::info!("another program serves notifications; waiting for it to leave");
552    }
553    Ok(_) => {}
554    Err(error) => return tracing::warn!(%error, "couldn't ask for the notifications name"),
555  }
556  loop {
557    let next = poll_fn(|cx| Pin::new(&mut messages).poll_next(cx)).await;
558    if !service.alive() {
559      return;
560    }
561    match next {
562      Some(Ok(message)) => service.on_message(&connection, &message).await,
563      Some(Err(error)) => tracing::warn!(%error, "a bad message on the session bus"),
564      None => break,
565    }
566  }
567  tracing::warn!("the session bus closed, so no more notifications arrive");
568  if let Some(state) = service.state.borrow_mut().as_mut() {
569    state.serving = false;
570    state.connection = None;
571  }
572}
573
574/// Drops the pixels of the oldest notifications past `budget` bytes in
575/// all, each falling back to its app's icon.
576fn keep_pixels_within(history: &mut [Notification], budget: usize) {
577  let mut total = 0;
578  for kept in history.iter_mut().rev() {
579    let Some(IconSource::Pixels(pixels)) = &kept.image else {
580      continue;
581    };
582    let bytes = pixels.data.len();
583    if total + bytes <= budget {
584      total += bytes;
585    } else {
586      tracing::debug!(
587        id = kept.id,
588        "past the pictures' budget; showing the app icon"
589      );
590      kept.image = server::icon(&kept.app_icon);
591    }
592  }
593}