use std::collections::HashMap;
use std::fmt;
use std::os::unix::ffi::OsStringExt;
use std::path::PathBuf;
use std::rc::Rc;
use std::time::{Duration, SystemTime};
use crabshell_ui::{IconPixels, IconSource};
use serde::de::{Deserialize, Deserializer, IgnoredAny, SeqAccess, Visitor};
use zbus::message::Flags;
use zbus::{Connection, Message, Signature, Type};
use super::{CLOSED, NOTIFICATIONS_PATH, Notification, Service, Timeout, Urgency};
const MAX_SUMMARY: usize = 1024;
const MAX_BODY: usize = 4 * 1024;
const MAX_TEXT: usize = 256;
const MAX_ACTIONS: usize = 16;
const MAX_IMAGE_SIDE: i32 = 512;
const MAX_IMAGE_BYTES: usize = 1024 * 1024;
const CAPABILITIES: [&str; 4] = ["actions", "body", "icon-static", "persistence"];
const INTROSPECTION: &str = r#"<!DOCTYPE node PUBLIC "-//freedesktop//DTD D-BUS Object Introspection 1.0//EN"
"http://www.freedesktop.org/standards/dbus/1.0/introspect.dtd">
<node>
<interface name="org.freedesktop.Notifications">
<method name="Notify">
<arg name="app_name" type="s" direction="in"/>
<arg name="replaces_id" type="u" direction="in"/>
<arg name="app_icon" type="s" direction="in"/>
<arg name="summary" type="s" direction="in"/>
<arg name="body" type="s" direction="in"/>
<arg name="actions" type="as" direction="in"/>
<arg name="hints" type="a{sv}" direction="in"/>
<arg name="expire_timeout" type="i" direction="in"/>
<arg name="id" type="u" direction="out"/>
</method>
<method name="CloseNotification"><arg name="id" type="u" direction="in"/></method>
<method name="GetCapabilities"><arg name="capabilities" type="as" direction="out"/></method>
<method name="GetServerInformation">
<arg name="name" type="s" direction="out"/>
<arg name="vendor" type="s" direction="out"/>
<arg name="version" type="s" direction="out"/>
<arg name="spec_version" type="s" direction="out"/>
</method>
<signal name="NotificationClosed"><arg name="id" type="u"/><arg name="reason" type="u"/></signal>
<signal name="ActionInvoked"><arg name="id" type="u"/><arg name="action_key" type="s"/></signal>
</interface>
<interface name="org.freedesktop.DBus.Introspectable">
<method name="Introspect"><arg name="xml" type="s" direction="out"/></method>
</interface>
</node>
"#;
const INVALID_ARGS: &str = "org.freedesktop.DBus.Error.InvalidArgs";
const UNKNOWN_METHOD: &str = "org.freedesktop.DBus.Error.UnknownMethod";
pub(super) async fn answer(service: &Rc<Service>, connection: &Connection, call: &Message) {
let header = call.header();
let path = header.path().map(|path| path.as_str());
let member = header.member().map_or("", |member| member.as_str());
let body = call.body();
let reply = Message::method_return(&header);
let refuse = |error: &str, why: &str| Message::error(&header, error).build(&why);
let answer = match (path, member) {
_ if !service.serving() => refuse(UNKNOWN_METHOD, "not serving notifications"),
(Some(NOTIFICATIONS_PATH), "Notify") => match body.deserialize::<NotifyArgs<'_>>() {
Ok(args) => {
let (replaces, notification) = notification(args);
reply.build(&service.notify(replaces, notification))
}
Err(_) => refuse(INVALID_ARGS, "not a notification"),
},
(Some(NOTIFICATIONS_PATH), "CloseNotification") => match body.deserialize::<u32>() {
Ok(id) if service.close(&[id], CLOSED) => reply.build(&()),
_ => refuse(INVALID_ARGS, "no such notification"),
},
(Some(NOTIFICATIONS_PATH), "GetCapabilities") => reply.build(&CAPABILITIES.as_slice()),
(Some(NOTIFICATIONS_PATH), "GetServerInformation") => {
let version = env!("CARGO_PKG_VERSION");
reply.build(&("crabshell", "crabshell", version, "1.2"))
}
(Some(NOTIFICATIONS_PATH), "Introspect") => reply.build(&INTROSPECTION),
_ => refuse(UNKNOWN_METHOD, "no such method"),
};
if header.primary().flags().contains(Flags::NO_REPLY_EXPECTED) {
return;
}
let sent = match answer {
Ok(answer) => connection.send(&answer).await,
Err(error) => Err(error),
};
if let Err(error) = sent {
tracing::debug!(member, %error, "the notification server's answer wasn't sent");
}
}
type NotifyArgs<'a> = (
&'a str,
u32,
&'a str,
&'a str,
&'a str,
Vec<&'a str>,
HashMap<&'a str, Hint<'a>>,
i32,
);
fn notification(args: NotifyArgs<'_>) -> (u32, Notification) {
let (app_name, replaces, app_icon, summary, body, actions, hints, timeout) = args;
let text = |key: &str| match hints.get(key) {
Some(Hint::Text(text)) => cut(text, MAX_TEXT),
_ => String::new(),
};
let flag = |key: &str| matches!(hints.get(key), Some(Hint::Number(n)) if *n != 0);
let pixels = |key: &str| match hints.get(key) {
Some(Hint::Image(raw)) => image_pixels(raw).map(IconSource::Pixels),
_ => None,
};
let file = |key: &str| match hints.get(key) {
Some(Hint::Text(path)) => icon(path),
_ => None,
};
let app_icon = cut(app_icon, MAX_TEXT);
let image = pixels("image-data")
.or_else(|| pixels("image_data"))
.or_else(|| file("image-path"))
.or_else(|| file("image_path"))
.or_else(|| icon(&app_icon))
.or_else(|| pixels("icon_data"));
let notification = Notification {
id: 0,
app_name: cut(app_name, MAX_TEXT),
summary: cut(summary, MAX_SUMMARY),
body: cut(
&plain(&body[..body.floor_char_boundary(MAX_BODY * 4)]),
MAX_BODY,
),
actions: actions
.as_chunks::<2>()
.0
.iter()
.take(MAX_ACTIONS)
.map(|[key, label]| (cut(key, MAX_TEXT), cut(label, MAX_TEXT)))
.collect(),
urgency: match hints.get("urgency") {
Some(Hint::Number(0)) => Urgency::Low,
Some(Hint::Number(2)) => Urgency::Critical,
_ => Urgency::Normal,
},
timeout: match u64::try_from(timeout) {
Ok(0) => Timeout::Never,
Ok(ms) => Timeout::After(Duration::from_millis(ms)),
Err(_) => Timeout::Default,
},
image,
desktop_entry: text("desktop-entry"),
category: text("category"),
resident: flag("resident"),
transient: flag("transient"),
received: SystemTime::now(),
app_icon,
};
(replaces, notification)
}
fn plain(body: &str) -> String {
let mut shown = String::with_capacity(body.len());
let mut rest = body;
while let Some(at) = rest.find(['<', '&']) {
shown.push_str(&rest[..at]);
rest = &rest[at..];
if rest.starts_with('<') {
match rest.find('>') {
Some(end) => rest = &rest[end + 1..],
None => {
shown.push_str(rest);
rest = "";
}
}
continue;
}
let decoded = rest
.find(';')
.filter(|end| *end <= 10)
.and_then(|end| Some((entity(&rest[1..end])?, end)));
match decoded {
Some((character, end)) => {
shown.push(character);
rest = &rest[end + 1..];
}
None => {
shown.push('&');
rest = &rest[1..];
}
}
}
shown.push_str(rest);
shown
}
fn entity(name: &str) -> Option<char> {
match name {
"amp" => Some('&'),
"lt" => Some('<'),
"gt" => Some('>'),
"quot" => Some('"'),
"apos" => Some('\''),
_ => {
let number = name.strip_prefix('#')?;
let code = match number.strip_prefix(['x', 'X']) {
Some(hex) => u32::from_str_radix(hex, 16).ok()?,
None => number.parse().ok()?,
};
char::from_u32(code)
}
}
}
fn cut(text: &str, max: usize) -> String {
text[..text.floor_char_boundary(max)].to_owned()
}
pub(super) fn icon(name: &str) -> Option<IconSource> {
if name.starts_with('/') {
return Some(IconSource::File(name.into()));
}
if name.starts_with("file://") {
return file_path(name).map(IconSource::File);
}
let named = !name.is_empty() && !name.contains('/');
named.then(|| IconSource::Name(name.to_owned()))
}
fn file_path(uri: &str) -> Option<PathBuf> {
let path = uri.strip_prefix("file://")?;
if !path.starts_with('/') {
return None;
}
let mut bytes = Vec::with_capacity(path.len());
let mut rest = path.bytes();
while let Some(byte) = rest.next() {
if byte != b'%' {
bytes.push(byte);
continue;
}
let mut digit = || char::from(rest.next()?).to_digit(16);
let (high, low) = (digit()?, digit()?);
bytes.push((high * 16 + low) as u8);
}
Some(PathBuf::from(std::ffi::OsString::from_vec(bytes)))
}
pub(super) type RawImage<'a> = (i32, i32, i32, bool, i32, i32, &'a [u8]);
pub(super) fn image_pixels(raw: &RawImage<'_>) -> Option<IconPixels> {
let &(width, height, rowstride, alpha, bits, channels, data) = raw;
let refuse = || {
let bytes = data.len();
tracing::debug!(
width,
height,
rowstride,
bits,
channels,
bytes,
"a bad notification picture; skipped"
);
None
};
let side = 1..=MAX_IMAGE_SIDE;
let format = bits == 8 && channels == if alpha { 4 } else { 3 };
let sized = side.contains(&width) && side.contains(&height) && data.len() <= MAX_IMAGE_BYTES;
if !format || !sized {
return refuse();
}
let (width, height, channels) = (width as usize, height as usize, channels as usize);
let row = width * channels;
let stride = usize::try_from(rowstride).ok().filter(|&stride| {
let needed = stride
.checked_mul(height - 1)
.and_then(|n| n.checked_add(row));
stride >= row && needed.is_some_and(|needed| needed <= data.len())
});
let Some(stride) = stride else {
return refuse();
};
let mut argb = Vec::with_capacity(width * height * 4);
for y in 0..height {
for pixel in data[y * stride..][..row].chunks_exact(channels) {
let opacity = pixel.get(3).copied().unwrap_or(255);
argb.extend_from_slice(&[opacity, pixel[0], pixel[1], pixel[2]]);
}
}
Some(IconPixels {
width: width as u32,
height: height as u32,
data: argb.into(),
})
}
enum Hint<'a> {
Number(i64),
Text(&'a str),
Image(RawImage<'a>),
Other,
}
impl Type for Hint<'_> {
const SIGNATURE: &'static Signature = &Signature::Variant;
}
impl<'de: 'a, 'a> Deserialize<'de> for Hint<'a> {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
deserializer.deserialize_any(HintVisitor)
}
}
struct HintVisitor;
impl<'de> Visitor<'de> for HintVisitor {
type Value = Hint<'de>;
fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("a variant")
}
fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<Hint<'de>, A::Error> {
let Some(signature) = seq.next_element::<Signature>()? else {
return Ok(Hint::Other);
};
let number = |n: Option<i64>| n.map(Hint::Number);
let hint = match signature {
Signature::Bool => number(seq.next_element::<bool>()?.map(i64::from)),
Signature::U8 => number(seq.next_element::<u8>()?.map(i64::from)),
Signature::I16 => number(seq.next_element::<i16>()?.map(i64::from)),
Signature::U16 => number(seq.next_element::<u16>()?.map(i64::from)),
Signature::I32 => number(seq.next_element::<i32>()?.map(i64::from)),
Signature::U32 => number(seq.next_element::<u32>()?.map(i64::from)),
Signature::I64 => number(seq.next_element::<i64>()?),
Signature::U64 => number(
seq
.next_element::<u64>()?
.map(|n| i64::try_from(n).unwrap_or(i64::MAX)),
),
Signature::Str => seq.next_element::<&str>()?.map(Hint::Text),
_ if signature == "(iiibiiay)" => seq.next_element::<RawImage<'de>>()?.map(Hint::Image),
_ => seq.next_element::<IgnoredAny>()?.map(|_| Hint::Other),
};
Ok(hint.unwrap_or(Hint::Other))
}
}