use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use zbus::blocking::{Connection, Proxy};
use zbus::zvariant::Value;
use super::{Answer, Channel, Choice, Question, Report, Weight};
const SERVICE: &str = "org.freedesktop.Notifications";
const OBJECT: &str = "/org/freedesktop/Notifications";
const DESKTOP_ENTRY: &str = "slipcase-open";
const ICON: &str = "document-open";
type Outstanding = Arc<Mutex<HashMap<u32, String>>>;
pub struct Desktop {
connection: Connection,
outstanding: Outstanding,
answers: Arc<Mutex<Vec<Answer>>>,
actions: bool,
markup: bool,
}
impl Desktop {
pub fn connect() -> Result<Self, zbus::Error> {
let connection = Connection::session()?;
let proxy = notifications(&connection)?;
let capabilities: Vec<String> = proxy.call("GetCapabilities", &())?;
let actions = capabilities.iter().any(|c| c == "actions");
let markup = capabilities.iter().any(|c| c == "body-markup");
let desktop = Self {
connection,
outstanding: Outstanding::default(),
answers: Arc::default(),
actions,
markup,
};
desktop.listen()?;
Ok(desktop)
}
fn listen(&self) -> Result<(), zbus::Error> {
let connection = self.connection.clone();
let outstanding = Arc::clone(&self.outstanding);
let answers = Arc::clone(&self.answers);
let proxy = notifications(&connection)?;
std::thread::spawn(move || {
let Ok(signals) = proxy.receive_all_signals() else {
return;
};
for message in signals {
let header = message.header();
match header.member().map(zbus::names::MemberName::as_str) {
Some("ActionInvoked") => {
let Ok((id, key)) = message.body().deserialize::<(u32, String)>() else {
continue;
};
let Some(about) = outstanding.lock().map_or(None, |o| o.get(&id).cloned())
else {
continue;
};
if let Some(choice) = Choice::from_key(&key) {
if let Ok(mut answers) = answers.lock() {
answers.push(Answer { about, choice });
}
}
}
Some("NotificationClosed") => {
if let Ok((id, _reason)) = message.body().deserialize::<(u32, u32)>() {
if let Ok(mut outstanding) = outstanding.lock() {
outstanding.remove(&id);
}
}
}
_ => {}
}
}
});
Ok(())
}
fn notify(
&self,
summary: &str,
body: &str,
actions: &[&str],
weight: Weight,
) -> Result<u32, zbus::Error> {
let proxy = notifications(&self.connection)?;
let body = if self.markup {
escape(body)
} else {
body.to_string()
};
let mut hints: HashMap<&str, Value<'_>> = HashMap::new();
hints.insert("desktop-entry", Value::from(DESKTOP_ENTRY));
hints.insert(
"urgency",
Value::from(match weight {
Weight::Routine => 0u8,
Weight::Ordinary => 1u8,
Weight::Interrupt => 2u8,
}),
);
let timeout: i32 = if actions.is_empty() && weight != Weight::Interrupt {
-1
} else {
0
};
proxy.call(
"Notify",
&(
"slipcase-open",
0u32,
ICON,
summary,
body.as_str(),
actions,
hints,
timeout,
),
)
}
fn identifiers_for(&self, about: &str) -> Vec<u32> {
self.outstanding.lock().map_or_else(
|_| Vec::new(),
|o| {
o.iter()
.filter(|(_, held)| held.as_str() == about)
.map(|(id, _)| *id)
.collect()
},
)
}
}
impl Channel for Desktop {
fn report(&self, report: &Report) {
let _ = self.notify(
&report.summary,
&report.detail.join("\n"),
&[],
report.weight,
);
}
fn ask(&self, question: &Question) {
let mut body = question.detail.clone();
let mut actions: Vec<&str> = Vec::new();
if self.actions {
for choice in &question.choices {
actions.push(choice.key());
actions.push(choice.label());
}
} else {
body.push(String::new());
body.push(format!(
"slipcase-open recover {} --write-back",
question.about
));
body.push(format!(
"slipcase-open recover {} --discard",
question.about
));
}
if let Ok(id) = self.notify(
&question.summary,
&body.join("\n"),
&actions,
Weight::Interrupt,
) {
if let Ok(mut outstanding) = self.outstanding.lock() {
outstanding.insert(id, question.about.clone());
}
}
}
fn withdraw(&self, about: &str) {
let Ok(proxy) = notifications(&self.connection) else {
return;
};
for id in self.identifiers_for(about) {
let _: Result<(), _> = proxy.call("CloseNotification", &(id,));
if let Ok(mut outstanding) = self.outstanding.lock() {
outstanding.remove(&id);
}
}
}
fn answers(&self) -> Vec<Answer> {
self.answers
.lock()
.map_or_else(|_| Vec::new(), |mut a| std::mem::take(&mut *a))
}
}
fn escape(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for c in text.chars() {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
other => out.push(other),
}
}
out
}
fn notifications(connection: &Connection) -> Result<Proxy<'static>, zbus::Error> {
Proxy::new(connection, SERVICE, OBJECT, SERVICE)
}
#[cfg(test)]
mod tests {
use super::{escape, Desktop};
use crate::present::{Channel, Choice, Question, Report};
#[test]
fn a_payload_name_cannot_put_markup_in_the_body() {
assert_eq!(
escape("<b>invoice</b> & <i>co</i>.pdf"),
"<b>invoice</b> & <i>co</i>.pdf"
);
assert_eq!(escape("quarterly report.pdf"), "quarterly report.pdf");
}
#[test]
#[ignore = "needs a session bus and a notification service"]
fn notifications_reach_a_real_service() {
let desktop = Desktop::connect().expect("no notification service");
desktop.report(&Report::ordinary("slipcase-open: a report").and("with a line under it"));
desktop.ask(&Question {
about: "test-0".into(),
summary: "slipcase-open: a question".into(),
detail: vec!["It should carry three buttons.".into()],
choices: vec![Choice::WriteBack, Choice::Discard, Choice::Reveal],
});
assert!(
!desktop.identifiers_for("test-0").is_empty(),
"the question was not given an identifier"
);
desktop.withdraw("test-0");
assert!(desktop.identifiers_for("test-0").is_empty());
}
#[test]
#[ignore = "needs a session bus, and leaves a notification behind"]
fn a_question_persists_in_the_message_list() {
let desktop = Desktop::connect().expect("no notification service");
desktop.ask(&Question {
about: "persistence-0".into(),
summary: "slipcase-open: does this stay?".into(),
detail: vec![
"It should still be in the message list a minute from now.".into(),
"Dismiss it by hand when you have looked.".into(),
],
choices: vec![Choice::WriteBack, Choice::Discard, Choice::Reveal],
});
let held = desktop.identifiers_for("persistence-0");
assert_eq!(held.len(), 1);
println!("notification {} sent. Holding for 60 seconds.", held[0]);
std::thread::sleep(std::time::Duration::from_secs(60));
println!("exiting now; watch whether it goes with me");
}
}