mod server;
#[cfg(test)]
mod tests;
use std::cell::RefCell;
use std::future::poll_fn;
use std::panic::{self, AssertUnwindSafe, Location};
use std::pin::Pin;
use std::rc::Rc;
use std::sync::Once;
use std::time::{Duration, Instant, SystemTime};
use calloop::RegistrationToken;
use calloop::timer::TimeoutAction;
use crabshell_reactive::{Owner, ReadSignal, Signal, untrack};
use crabshell_ui::{Cx, IconSource};
use zbus::export::futures_core::Stream;
use zbus::fdo::{RequestNameFlags, RequestNameReply};
use zbus::message::Type as Kind;
use zbus::{Connection, Message, MessageStream};
use crate::Shared;
use crate::dbus::BUS;
use crate::dbus::set_changed;
use crate::lines::panic_message;
const NOTIFICATIONS: &str = "org.freedesktop.Notifications";
const NOTIFICATIONS_PATH: &str = "/org/freedesktop/Notifications";
pub(crate) const POPUP_TIMEOUT: Duration = Duration::from_secs(5);
const MAX_KEPT: usize = 256;
const MAX_PIXELS_KEPT: usize = 8 * 1024 * 1024;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CloseReason {
Expired,
Dismissed,
CloseRequested,
}
impl CloseReason {
fn code(self) -> u32 {
match self {
CloseReason::Expired => 1,
CloseReason::Dismissed => 2,
CloseReason::CloseRequested => 3,
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Urgency {
Low,
#[default]
Normal,
Critical,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Timeout {
#[default]
Default,
Never,
After(Duration),
}
#[derive(Clone, Debug, PartialEq)]
pub struct Notification {
pub id: u32,
pub app_name: String,
pub app_icon: String,
pub summary: String,
pub body: String,
pub actions: Vec<(String, String)>,
pub urgency: Urgency,
pub timeout: Timeout,
pub image: Option<IconSource>,
pub desktop_entry: String,
pub category: String,
pub resident: bool,
pub transient: bool,
pub received: SystemTime,
}
#[derive(Clone)]
pub struct Notifications {
service: Option<Rc<Service>>,
list: ReadSignal<Vec<Notification>>,
popups: ReadSignal<Vec<Notification>>,
owner: Owner,
}
impl Notifications {
pub fn list(&self) -> ReadSignal<Vec<Notification>> {
self.list
}
pub fn popups(&self) -> ReadSignal<Vec<Notification>> {
self.popups
}
pub fn dismiss(&self, id: u32) {
if let Some(service) = &self.service {
service.close(&[id], CloseReason::Dismissed);
}
}
pub fn invoke(&self, id: u32, key: &str) {
if let Some(service) = &self.service {
service.invoke(id, key);
}
}
pub fn clear(&self) {
if let Some(service) = &self.service {
let state = service.state.borrow();
let history = state.as_ref().map(|state| state.history.as_slice());
let ids: Vec<u32> = history.unwrap_or_default().iter().map(|n| n.id).collect();
drop(state);
service.close(&ids, CloseReason::Dismissed);
}
}
#[track_caller]
pub fn on_closed(&self, handler: impl Fn(&Notification, CloseReason) + 'static) {
let at = Location::caller();
if self.owner.is_disposed() {
return tracing::warn!(
"notifications on_closed (called at {at}) on a context that is gone does nothing"
);
}
if let Some(service) = &self.service {
let id = service.listen(Rc::new(handler), at);
self.owner.signal(Unlisten(Rc::clone(service), id));
}
}
}
type CloseHandler = Rc<dyn Fn(&Notification, CloseReason)>;
#[derive(Clone)]
struct Listener {
id: u64,
handler: CloseHandler,
at: &'static Location<'static>,
}
struct Unlisten(Rc<Service>, u64);
impl Drop for Unlisten {
fn drop(&mut self) {
drop(self.0.unlisten(self.1));
}
}
pub(crate) fn notifications(cx: &Cx) -> Notifications {
let Some(service) = cx.service::<Service>() else {
static LOGGED: Once = Once::new();
LOGGED.call_once(|| {
tracing::warn!("cx.notifications() needs the services the shell provides; none arrive");
});
return Notifications {
service: None,
list: cx.signal(Vec::new()).read_only(),
popups: cx.signal(Vec::new()).read_only(),
owner: cx.owner(),
};
};
let (list, popups) = service.start();
Notifications {
service: Some(service),
list,
popups,
owner: cx.owner(),
}
}
pub(crate) struct Service {
shared: Rc<Shared>,
state: RefCell<Option<State>>,
}
struct State {
list: Signal<Vec<Notification>>,
popups: Signal<Vec<Notification>>,
connection: Option<Connection>,
serving: bool,
history: Vec<Notification>,
showing: Vec<(u32, Option<Instant>)>,
next_id: u32,
timer: Option<RegistrationToken>,
listeners: Vec<Listener>,
next_listener: u64,
}
impl State {
fn new_id(&mut self) -> u32 {
loop {
let id = self.next_id;
self.next_id = id.checked_add(1).unwrap_or(1);
if !self.history.iter().any(|kept| kept.id == id) {
return id;
}
}
}
}
enum Event {
Closed(u32, CloseReason),
Invoked(u32, String),
}
impl Service {
pub(crate) fn new(shared: Rc<Shared>) -> Service {
Service {
shared,
state: RefCell::new(None),
}
}
fn start(self: &Rc<Self>) -> (ReadSignal<Vec<Notification>>, ReadSignal<Vec<Notification>>) {
if let Some(state) = &*self.state.borrow() {
return (state.list.read_only(), state.popups.read_only());
}
let list = self.shared.root.signal(Vec::new());
let popups = self.shared.root.signal(Vec::new());
*self.state.borrow_mut() = Some(State {
list,
popups,
connection: None,
serving: false,
history: Vec::new(),
showing: Vec::new(),
next_id: 1,
timer: None,
listeners: Vec::new(),
next_listener: 0,
});
match self.shared.config.session_bus.clone() {
Some(address) => self.shared.spawn(run(Rc::clone(self), address)),
None => tracing::info!("no session bus, so no notifications arrive"),
}
(list.read_only(), popups.read_only())
}
fn alive(&self) -> bool {
let state = self.state.borrow();
let alive = state.as_ref().and_then(|state| state.list.try_with(|_| ()));
alive.is_some()
}
fn serving(&self) -> bool {
let state = self.state.borrow();
state.as_ref().is_some_and(|state| state.serving)
}
fn notify(self: &Rc<Self>, replaces: u32, mut notification: Notification) -> u32 {
let now = Instant::now();
let expires = match (notification.urgency, notification.timeout) {
(Urgency::Critical, _) | (_, Timeout::Never) => None,
(_, Timeout::Default) => Some(now + self.shared.config.popup_timeout),
(_, Timeout::After(after)) => Some(now + after),
};
let (id, dropped) = {
let mut state = self.state.borrow_mut();
let Some(state) = state.as_mut() else {
return 0;
};
let id = match replaces {
0 => state.new_id(),
id => id,
};
notification.id = id;
match state.history.iter().position(|kept| kept.id == id) {
Some(at) => state.history[at] = notification,
None => state.history.push(notification),
}
match state.showing.iter_mut().find(|(shown, _)| *shown == id) {
Some(shown) => shown.1 = expires,
None => state.showing.insert(0, (id, expires)),
}
let excess = state.history.len().saturating_sub(MAX_KEPT);
let dropped: Vec<Notification> = state.history.drain(..excess).collect();
let history = &state.history;
state
.showing
.retain(|(id, _)| history.iter().any(|kept| kept.id == *id));
keep_pixels_within(&mut state.history, MAX_PIXELS_KEPT);
(id, dropped)
};
self.closed(Vec::new(), dropped, CloseReason::Expired);
id
}
fn close(self: &Rc<Self>, ids: &[u32], reason: CloseReason) -> bool {
let taken = self.take(ids);
if taken.is_empty() {
return false;
}
self.closed(Vec::new(), taken, reason);
true
}
fn invoke(self: &Rc<Self>, id: u32, key: &str) {
let resident = self.state.borrow().as_ref().and_then(|state| {
let kept = state.history.iter().find(|kept| kept.id == id)?;
let offered = kept.actions.iter().any(|(action, _)| action == key);
offered.then_some(kept.resident)
});
let Some(resident) = resident else {
return tracing::warn!(id, key, "no such notification action");
};
let events = vec![Event::Invoked(id, key.to_owned())];
let taken = if resident {
Vec::new()
} else {
self.take(&[id])
};
self.closed(events, taken, CloseReason::Dismissed);
}
fn take(&self, ids: &[u32]) -> Vec<Notification> {
let mut state = self.state.borrow_mut();
let Some(state) = state.as_mut() else {
return Vec::new();
};
let (taken, kept): (Vec<_>, Vec<_>) = std::mem::take(&mut state.history)
.into_iter()
.partition(|kept| ids.contains(&kept.id));
state.history = kept;
state.showing.retain(|(id, _)| !ids.contains(id));
taken
}
fn closed(
self: &Rc<Self>,
mut events: Vec<Event>,
closed: Vec<Notification>,
reason: CloseReason,
) {
events.extend(closed.iter().map(|gone| Event::Closed(gone.id, reason)));
self.emit(events);
self.changed();
self.deliver(&closed, reason);
}
fn listen(&self, handler: CloseHandler, at: &'static Location<'static>) -> u64 {
let mut state = self.state.borrow_mut();
let state = state
.as_mut()
.expect("invariant: started by cx.notifications()");
state.next_listener += 1;
let id = state.next_listener;
state.listeners.push(Listener { id, handler, at });
id
}
fn unlisten(&self, id: u64) -> Option<Listener> {
let mut state = self.state.borrow_mut();
let listeners = &mut state.as_mut()?.listeners;
let index = listeners.iter().position(|listener| listener.id == id)?;
Some(listeners.remove(index))
}
fn listening(&self, id: u64) -> bool {
let state = self.state.borrow();
let mut listeners = state.iter().flat_map(|state| &state.listeners);
listeners.any(|listener| listener.id == id)
}
fn deliver(&self, closed: &[Notification], reason: CloseReason) {
let listeners = match self.state.borrow().as_ref() {
Some(state) if !closed.is_empty() => state.listeners.clone(),
_ => return,
};
for gone in closed {
for Listener { id, handler, at } in &listeners {
if !self.listening(*id) {
continue;
}
let run = AssertUnwindSafe(|| untrack(|| handler(gone, reason)));
if let Err(payload) = panic::catch_unwind(run) {
let message = panic_message(payload.as_ref());
tracing::error!("notifications on_closed (called at {at}) panicked: {message}");
drop(self.unlisten(*id));
}
}
}
}
fn expire(self: &Rc<Self>) -> TimeoutAction {
if let Some(state) = self.state.borrow_mut().as_mut() {
state.timer = None;
}
if !self.alive() {
return TimeoutAction::Drop;
}
let now = Instant::now();
let transient = {
let mut state = self.state.borrow_mut();
let Some(state) = state.as_mut() else {
return TimeoutAction::Drop;
};
let (expired, showing) = std::mem::take(&mut state.showing)
.into_iter()
.partition::<Vec<_>, _>(|(_, expires)| expires.is_some_and(|at| at <= now));
state.showing = showing;
let history = state.history.iter();
let transient = history.filter(|kept| kept.transient);
transient
.map(|kept| kept.id)
.filter(|id| expired.iter().any(|(gone, _)| gone == id))
.collect::<Vec<_>>()
};
let taken = self.take(&transient);
self.closed(Vec::new(), taken, CloseReason::Expired);
TimeoutAction::Drop
}
fn changed(self: &Rc<Self>) {
let shown = self.state.borrow().as_ref().map(|state| {
let popups = state.showing.iter().filter_map(|(id, _)| {
let kept = state.history.iter().find(|kept| kept.id == *id);
kept.cloned()
});
let popups = popups.collect::<Vec<_>>();
(state.list, state.history.clone(), state.popups, popups)
});
if let Some((list_signal, list, popups_signal, popups)) = shown {
set_changed(list_signal, list);
set_changed(popups_signal, popups);
}
self.schedule();
}
fn schedule(self: &Rc<Self>) {
let Some(event_loop) = self.shared.event_loop() else {
return;
};
let mut state = self.state.borrow_mut();
let Some(state) = state.as_mut() else {
return;
};
if let Some(token) = state.timer.take() {
event_loop.remove(token);
}
let Some(next) = state.showing.iter().filter_map(|(_, at)| *at).min() else {
return;
};
let service = Rc::clone(self);
let tick = Box::new(move || service.expire());
state.timer = event_loop.insert_timer(next, tick);
}
fn emit(&self, events: Vec<Event>) {
let connection = {
let state = self.state.borrow();
let serving = state.as_ref().filter(|state| state.serving);
serving.and_then(|state| state.connection.clone())
};
let Some(connection) = connection.filter(|_| !events.is_empty()) else {
return;
};
self.shared.spawn(async move {
for event in events {
let (path, interface) = (NOTIFICATIONS_PATH, NOTIFICATIONS);
let sent = match &event {
Event::Closed(id, reason) => {
let body = (*id, reason.code());
let signal = "NotificationClosed";
let emit = connection.emit_signal(None::<&str>, path, interface, signal, &body);
emit.await
}
Event::Invoked(id, key) => {
let body = (*id, key.as_str());
let signal = "ActionInvoked";
let emit = connection.emit_signal(None::<&str>, path, interface, signal, &body);
emit.await
}
};
if let Err(error) = sent {
tracing::warn!(%error, "a notification signal wasn't sent");
}
}
});
}
async fn on_message(self: &Rc<Self>, connection: &Connection, message: &Message) {
match message.header().message_type() {
Kind::MethodCall => server::answer(self, connection, message).await,
Kind::Signal => self.on_signal(message),
_ => {}
}
}
fn on_signal(&self, message: &Message) {
let header = message.header();
let from_bus = header.sender().is_some_and(|sender| sender.as_str() == BUS);
let ours = message.body().deserialize::<&str>().ok() == Some(NOTIFICATIONS);
if !from_bus || !ours {
return;
}
let serving = match header.member().map(|member| member.as_str()) {
Some("NameAcquired") => {
tracing::info!("serving notifications");
true
}
Some("NameLost") => {
tracing::info!("another program took over notifications; waiting for it to leave");
false
}
_ => return,
};
if let Some(state) = self.state.borrow_mut().as_mut() {
state.serving = serving;
}
}
}
async fn run(service: Rc<Service>, address: zbus::Address) {
let builder = zbus::connection::Builder::address(address);
let connection = match builder.build().await {
Ok(connection) => connection,
Err(error) => {
return tracing::info!(%error, "can't reach the session bus, so no notifications arrive");
}
};
let mut messages = MessageStream::from(&connection);
if let Some(state) = service.state.borrow_mut().as_mut() {
state.connection = Some(connection.clone());
}
let flags = RequestNameFlags::ALLOW_REPLACEMENT;
match connection
.request_name_with_flags(NOTIFICATIONS, flags)
.await
{
Ok(RequestNameReply::InQueue) => {
tracing::info!("another program serves notifications; waiting for it to leave");
}
Ok(_) => {}
Err(error) => return tracing::warn!(%error, "couldn't ask for the notifications name"),
}
loop {
let next = poll_fn(|cx| Pin::new(&mut messages).poll_next(cx)).await;
if !service.alive() {
return;
}
match next {
Some(Ok(message)) => service.on_message(&connection, &message).await,
Some(Err(error)) => tracing::warn!(%error, "a bad message on the session bus"),
None => break,
}
}
tracing::warn!("the session bus closed, so no more notifications arrive");
if let Some(state) = service.state.borrow_mut().as_mut() {
state.serving = false;
state.connection = None;
}
}
fn keep_pixels_within(history: &mut [Notification], budget: usize) {
let mut total = 0;
for kept in history.iter_mut().rev() {
let Some(IconSource::Pixels(pixels)) = &kept.image else {
continue;
};
let bytes = pixels.data.len();
if total + bytes <= budget {
total += bytes;
} else {
tracing::debug!(
id = kept.id,
"past the pictures' budget; showing the app icon"
);
kept.image = server::icon(&kept.app_icon);
}
}
}