mod server;
#[cfg(test)]
mod tests;
use std::cell::RefCell;
use std::future::poll_fn;
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::{ReadSignal, Signal};
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;
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;
const EXPIRED: u32 = 1;
const DISMISSED: u32 = 2;
const CLOSED: u32 = 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>>,
}
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], 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, DISMISSED);
}
}
}
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(),
};
};
let (list, popups) = service.start();
Notifications {
service: Some(service),
list,
popups,
}
}
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>,
}
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, u32),
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,
});
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<Event> = state
.history
.drain(..excess)
.map(|old| Event::Closed(old.id, EXPIRED))
.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.emit(dropped);
self.changed();
id
}
fn close(self: &Rc<Self>, ids: &[u32], reason: u32) -> bool {
let events = self.take(ids, reason);
if events.is_empty() {
return false;
}
self.emit(events);
self.changed();
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 mut events = vec![Event::Invoked(id, key.to_owned())];
if !resident {
events.extend(self.take(&[id], DISMISSED));
}
self.emit(events);
self.changed();
}
fn take(&self, ids: &[u32], reason: u32) -> Vec<Event> {
let mut state = self.state.borrow_mut();
let Some(state) = state.as_mut() else {
return Vec::new();
};
let taken: Vec<u32> = state
.history
.iter()
.map(|kept| kept.id)
.filter(|id| ids.contains(id))
.collect();
state.history.retain(|kept| !taken.contains(&kept.id));
state.showing.retain(|(id, _)| !taken.contains(id));
taken
.into_iter()
.map(|id| Event::Closed(id, reason))
.collect()
}
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 events = self.take(&transient, EXPIRED);
self.emit(events);
self.changed();
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);
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);
}
}
}