git.lucas.co / cce-desktop-portal
Settings and Inhibit portal backends
git clone https://git.lucas.co/cce-desktop-portal.git

src/notification.rs (15.8K)

  1 //! `org.freedesktop.impl.portal.Notification`: portal notifications as
  2 //! cce-notifier cards, and clicks on them back to the app.
  3 //!
  4 //! cce-notifier is the desktop's `org.freedesktop.Notifications` server, so
  5 //! each portal call becomes one call on that interface:
  6 //!
  7 //! - `AddNotification(app_id, id, n)` → `Notify`, with the app's desktop-entry
  8 //!   name as `app_name`, `title`/`body` (or `markup-body`, tags stripped —
  9 //!   the cards draw plain text), the icon as `app_icon` (a theme name, or a
 10 //!   file written under `$XDG_RUNTIME_DIR` for bytes/fd icons), `priority` as
 11 //!   `urgency`, and the actions: `default-action` as the `default` key (the
 12 //!   click on the card), each button as `b<index>`. Re-adding an id replaces
 13 //!   its card (`replaces_id`).
 14 //! - `RemoveNotification` → `CloseNotification`.
 15 //! - The server's `ActionInvoked` is mapped back through the key to the
 16 //!   portal action and its target, and re-emitted as the portal's
 17 //!   `ActionInvoked(app_id, id, action, [target])`; `NotificationClosed`
 18 //!   forgets the card.
 19 //!
 20 //! Before this backend, gtk did the same forwarding; but cce-notifier then
 21 //! ignored actions and themed icons, so a portal notification could be read
 22 //! and never acted on.
 23 
 24 use std::collections::HashMap;
 25 use std::sync::Arc;
 26 
 27 use futures_util::StreamExt;
 28 use tokio::sync::Mutex;
 29 use zbus::object_server::SignalEmitter;
 30 use zbus::zvariant::{OwnedValue, Value};
 31 use zbus::{interface, Connection};
 32 
 33 const FDO_NAME: &str = "org.freedesktop.Notifications";
 34 const FDO_PATH: &str = "/org/freedesktop/Notifications";
 35 
 36 /// What a card's keys mean, kept until the card closes.
 37 struct Entry {
 38     fdo_id: u32,
 39     /// `default-action` and its target.
 40     default: Option<(String, Option<OwnedValue>)>,
 41     /// Each button's action and target, by index (`b<i>`).
 42     buttons: Vec<(String, Option<OwnedValue>)>,
 43 }
 44 
 45 #[derive(Default)]
 46 pub struct Cards {
 47     by_key: HashMap<(String, String), Entry>,
 48     by_fdo: HashMap<u32, (String, String)>,
 49 }
 50 
 51 pub type SharedCards = Arc<Mutex<Cards>>;
 52 
 53 pub struct NotificationPortal {
 54     pub cards: SharedCards,
 55 }
 56 
 57 // ── parsing the portal's vardict ───────────────────────────────────────────
 58 
 59 fn str_of(n: &HashMap<String, OwnedValue>, key: &str) -> Option<String> {
 60     match n.get(key).map(|v| &**v) {
 61         Some(Value::Str(s)) => Some(s.to_string()),
 62         _ => None,
 63     }
 64 }
 65 
 66 /// The text a card can draw of a `markup-body`: tags dropped, the five XML
 67 /// entities decoded. cce-notifier draws plain text.
 68 pub fn strip_markup(markup: &str) -> String {
 69     let mut out = String::with_capacity(markup.len());
 70     let mut in_tag = false;
 71     for c in markup.chars() {
 72         match c {
 73             '<' => in_tag = true,
 74             '>' if in_tag => in_tag = false,
 75             _ if !in_tag => out.push(c),
 76             _ => {}
 77         }
 78     }
 79     out.replace("&lt;", "<").replace("&gt;", ">").replace("&quot;", "\"").replace("&apos;", "'").replace("&amp;", "&")
 80 }
 81 
 82 /// `priority` → the freedesktop `urgency` byte. `high` stays normal: the
 83 /// difference cce-notifier draws is whether a card expires, and only
 84 /// `urgent` should outstay its welcome.
 85 pub fn urgency(priority: Option<&str>) -> u8 {
 86     match priority {
 87         Some("low") => 0,
 88         Some("urgent") => 2,
 89         _ => 1,
 90     }
 91 }
 92 
 93 /// The notification's `icon`, a serialized GIcon `(sv)`: `("themed", as)`,
 94 /// `("bytes", ay)` or `("file-descriptor", h)`. A theme name is passed on as
 95 /// is; image data is written to a file named after its contents (so a
 96 /// repeated icon is written once) and passed as a path.
 97 fn icon_of(n: &HashMap<String, OwnedValue>) -> Option<String> {
 98     let Some(Value::Structure(s)) = n.get("icon").map(|v| &**v) else { return None };
 99     let fields = s.fields();
100     let (Some(Value::Str(kind)), Some(payload)) = (fields.first(), fields.get(1)) else { return None };
101     let payload = match payload {
102         Value::Value(inner) => &**inner,
103         other => other,
104     };
105     match (kind.as_str(), payload) {
106         ("themed", Value::Array(names)) => names.iter().find_map(|v| match v {
107             Value::Str(s) if !s.is_empty() => Some(s.to_string()),
108             _ => None,
109         }),
110         ("bytes", Value::Array(bytes)) => {
111             let data: Vec<u8> = bytes.iter().filter_map(|v| if let Value::U8(b) = v { Some(*b) } else { None }).collect();
112             write_icon(&data)
113         }
114         ("file-descriptor", Value::Fd(fd)) => {
115             use std::io::Read;
116             use std::os::fd::AsFd;
117             let owned = fd.as_fd().try_clone_to_owned().ok()?;
118             let mut data = Vec::new();
119             std::fs::File::from(owned).read_to_end(&mut data).ok()?;
120             write_icon(&data)
121         }
122         _ => None,
123     }
124 }
125 
126 /// An extension `cce_ui::icon` can decode, by the data's own signature.
127 pub fn icon_ext(data: &[u8]) -> Option<&'static str> {
128     if data.starts_with(b"\x89PNG") {
129         Some("png")
130     } else if data.windows(4).take(1024).any(|w| w == b"<svg") {
131         Some("svg")
132     } else {
133         None
134     }
135 }
136 
137 fn write_icon(data: &[u8]) -> Option<String> {
138     use std::hash::{Hash, Hasher};
139     let ext = icon_ext(data)?;
140     let mut h = std::collections::hash_map::DefaultHasher::new();
141     data.hash(&mut h);
142     let dir = std::path::PathBuf::from(std::env::var_os("XDG_RUNTIME_DIR")?).join("cce-desktop-portal");
143     std::fs::create_dir_all(&dir).ok()?;
144     let path = dir.join(format!("icon-{:016x}.{ext}", h.finish()));
145     if !path.exists() {
146         std::fs::write(&path, data).ok()?;
147     }
148     Some(path.to_string_lossy().into_owned())
149 }
150 
151 /// `buttons`: `aa{sv}`, each with `label`, `action` and an optional `target`.
152 fn buttons_of(n: &HashMap<String, OwnedValue>) -> Vec<(String, String, Option<OwnedValue>)> {
153     let Some(Value::Array(list)) = n.get("buttons").map(|v| &**v) else { return Vec::new() };
154     list.iter()
155         .filter_map(|b| {
156             let Value::Dict(d) = b else { return None };
157             // Values in an `a{sv}` arrive wrapped in a variant; unwrap one level.
158             let get = |k: &str| {
159                 d.iter().find_map(|(key, val)| match key {
160                     Value::Str(s) if s.as_str() == k => Some(match val {
161                         Value::Value(inner) => &**inner,
162                         v => v,
163                     }),
164                     _ => None,
165                 })
166             };
167             let label = match get("label") {
168                 Some(Value::Str(s)) => s.to_string(),
169                 _ => return None,
170             };
171             let action = match get("action") {
172                 Some(Value::Str(s)) => s.to_string(),
173                 _ => return None,
174             };
175             let target = get("target").and_then(|v| v.try_to_owned().ok());
176             Some((label, action, target))
177         })
178         .collect()
179 }
180 
181 /// `Name=` and `Icon=` of the app's desktop entry, from the first
182 /// applications dir that has `<app_id>.desktop`.
183 fn desktop_entry(app_id: &str) -> (Option<String>, Option<String>) {
184     if app_id.is_empty() {
185         return (None, None);
186     }
187     let home_data = std::env::var_os("XDG_DATA_HOME")
188         .filter(|v| !v.is_empty())
189         .map(std::path::PathBuf::from)
190         .or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".local/share")));
191     let data_dirs = std::env::var("XDG_DATA_DIRS").ok().filter(|v| !v.is_empty()).unwrap_or_else(|| "/usr/local/share:/usr/share".into());
192     let dirs = home_data.into_iter().chain(std::env::split_paths(&data_dirs));
193     for dir in dirs {
194         let Ok(text) = std::fs::read_to_string(dir.join("applications").join(format!("{app_id}.desktop"))) else { continue };
195         let (mut name, mut icon) = (None, None);
196         let mut in_entry = false;
197         for line in text.lines().map(str::trim) {
198             if line.starts_with('[') {
199                 in_entry = line == "[Desktop Entry]";
200             } else if in_entry {
201                 if let Some(v) = line.strip_prefix("Name=") {
202                     name.get_or_insert_with(|| v.to_string());
203                 } else if let Some(v) = line.strip_prefix("Icon=") {
204                     icon.get_or_insert_with(|| v.to_string());
205                 }
206             }
207         }
208         return (name, icon);
209     }
210     (None, None)
211 }
212 
213 /// The fdo `actions` list for a card: `default` first, then `b<i>` per
214 /// button, each followed by its label.
215 pub fn fdo_actions(has_default: bool, labels: &[String]) -> Vec<String> {
216     let mut out = Vec::new();
217     if has_default {
218         out.extend(["default".to_string(), String::new()]);
219     }
220     for (i, label) in labels.iter().enumerate() {
221         out.extend([format!("b{i}"), label.clone()]);
222     }
223     out
224 }
225 
226 #[interface(name = "org.freedesktop.impl.portal.Notification")]
227 impl NotificationPortal {
228     #[zbus(property, name = "version")]
229     fn version(&self) -> u32 {
230         2
231     }
232 
233     /// No categories or custom sounds: cce-notifier has neither.
234     #[zbus(property, name = "SupportedOptions")]
235     fn supported_options(&self) -> HashMap<String, OwnedValue> {
236         HashMap::new()
237     }
238 
239     async fn add_notification(
240         &self,
241         #[zbus(connection)] conn: &Connection,
242         app_id: String,
243         id: String,
244         notification: HashMap<String, OwnedValue>,
245     ) -> zbus::fdo::Result<()> {
246         let title = str_of(&notification, "title").unwrap_or_default();
247         let body = str_of(&notification, "markup-body")
248             .map(|m| strip_markup(&m))
249             .or_else(|| str_of(&notification, "body"))
250             .unwrap_or_default();
251         let (entry_name, entry_icon) = desktop_entry(&app_id);
252         let app_name = entry_name.unwrap_or_else(|| app_id.clone());
253         let icon = icon_of(&notification).or(entry_icon).unwrap_or_default();
254         let default = str_of(&notification, "default-action")
255             .map(|a| (a, notification.get("default-action-target").and_then(|v| v.try_clone().ok())));
256         let buttons = buttons_of(&notification);
257         let labels: Vec<String> = buttons.iter().map(|(l, _, _)| l.clone()).collect();
258         let actions = fdo_actions(default.is_some(), &labels);
259 
260         let mut hints: HashMap<&str, Value> = HashMap::new();
261         hints.insert("urgency", Value::U8(urgency(str_of(&notification, "priority").as_deref())));
262         if !app_id.is_empty() {
263             hints.insert("desktop-entry", Value::from(app_id.clone()));
264         }
265         if let Some(category) = str_of(&notification, "category") {
266             hints.insert("category", Value::from(category));
267         }
268 
269         let key = (app_id.clone(), id.clone());
270         let replaces = self.cards.lock().await.by_key.get(&key).map_or(0, |e| e.fdo_id);
271         let reply = conn
272             .call_method(
273                 Some(FDO_NAME),
274                 FDO_PATH,
275                 Some(FDO_NAME),
276                 "Notify",
277                 &(&app_name, replaces, &icon, &title, &body, &actions, &hints, -1i32),
278             )
279             .await?;
280         let fdo_id: u32 = reply.body().deserialize()?;
281         log::info!("AddNotification {app_id}/{id} → card {fdo_id} ({} button(s), default {})", labels.len(), default.is_some());
282 
283         let mut cards = self.cards.lock().await;
284         if let Some(old) = cards.by_key.remove(&key) {
285             cards.by_fdo.remove(&old.fdo_id);
286         }
287         cards.by_fdo.insert(fdo_id, key.clone());
288         cards.by_key.insert(key, Entry { fdo_id, default, buttons: buttons.into_iter().map(|(_, a, t)| (a, t)).collect() });
289         Ok(())
290     }
291 
292     async fn remove_notification(&self, #[zbus(connection)] conn: &Connection, app_id: String, id: String) {
293         let entry = {
294             let mut cards = self.cards.lock().await;
295             let entry = cards.by_key.remove(&(app_id.clone(), id.clone()));
296             if let Some(e) = &entry {
297                 cards.by_fdo.remove(&e.fdo_id);
298             }
299             entry
300         };
301         if let Some(e) = entry {
302             log::info!("RemoveNotification {app_id}/{id} (card {})", e.fdo_id);
303             if let Err(err) = conn.call_method(Some(FDO_NAME), FDO_PATH, Some(FDO_NAME), "CloseNotification", &(e.fdo_id,)).await {
304                 log::warn!("closing card {}: {err}", e.fdo_id);
305             }
306         }
307     }
308 
309     #[zbus(signal)]
310     async fn action_invoked(emitter: &SignalEmitter<'_>, app_id: &str, id: &str, action: &str, parameter: Vec<Value<'_>>) -> zbus::Result<()>;
311 }
312 
313 /// Follow cce-notifier's signals: a click on one of our cards becomes the
314 /// portal's `ActionInvoked`; a closed card is forgotten.
315 pub async fn relay(conn: Connection, portal_path: &str, cards: SharedCards) -> zbus::Result<()> {
316     let rule = zbus::MatchRule::builder()
317         .msg_type(zbus::message::Type::Signal)
318         .interface(FDO_NAME)?
319         .path(FDO_PATH)?
320         .build();
321     let mut stream = zbus::MessageStream::for_match_rule(rule, &conn, None).await?;
322     let emitter = SignalEmitter::new(&conn, portal_path.to_string())?;
323     while let Some(msg) = stream.next().await {
324         let Ok(msg) = msg else { continue };
325         let header = msg.header();
326         match header.member().map(|m| m.as_str()) {
327             Some("ActionInvoked") => {
328                 let Ok((fdo_id, key)) = msg.body().deserialize::<(u32, String)>() else { continue };
329                 let cards = cards.lock().await;
330                 let Some((app_id, id)) = cards.by_fdo.get(&fdo_id) else { continue };
331                 let Some(entry) = cards.by_key.get(&(app_id.clone(), id.clone())) else { continue };
332                 let chosen = if key == "default" {
333                     entry.default.as_ref()
334                 } else {
335                     key.strip_prefix('b').and_then(|i| i.parse::<usize>().ok()).and_then(|i| entry.buttons.get(i))
336                 };
337                 let Some((action, target)) = chosen else { continue };
338                 let parameter: Vec<Value> = target.iter().filter_map(|t| t.try_clone().ok()).map(Value::from).collect();
339                 log::info!("card {fdo_id}: {app_id}/{id} action {action:?}");
340                 if let Err(e) = NotificationPortal::action_invoked(&emitter, app_id, id, action, parameter).await {
341                     log::warn!("emitting ActionInvoked: {e}");
342                 }
343             }
344             Some("NotificationClosed") => {
345                 let Ok((fdo_id, _reason)) = msg.body().deserialize::<(u32, u32)>() else { continue };
346                 let mut cards = cards.lock().await;
347                 if let Some(key) = cards.by_fdo.remove(&fdo_id) {
348                     cards.by_key.remove(&key);
349                 }
350             }
351             _ => {}
352         }
353     }
354     Ok(())
355 }
356 
357 #[cfg(test)]
358 mod tests {
359     use super::*;
360 
361     #[test]
362     fn markup_becomes_plain_text() {
363         assert_eq!(strip_markup("<b>Build</b> finished &amp; <i>passed</i> &lt;3"), "Build finished & passed <3");
364         assert_eq!(strip_markup("a <a href=\"x\">link</a>"), "a link");
365     }
366 
367     #[test]
368     fn priority_to_urgency() {
369         assert_eq!(urgency(Some("low")), 0);
370         assert_eq!(urgency(Some("normal")), 1);
371         assert_eq!(urgency(Some("high")), 1);
372         assert_eq!(urgency(Some("urgent")), 2);
373         assert_eq!(urgency(None), 1);
374     }
375 
376     #[test]
377     fn actions_are_default_then_indexed_buttons() {
378         assert_eq!(fdo_actions(true, &["Reply".into(), "Mute".into()]), ["default", "", "b0", "Reply", "b1", "Mute"]);
379         assert_eq!(fdo_actions(false, &[]), Vec::<String>::new());
380     }
381 
382     #[test]
383     fn icon_signatures() {
384         assert_eq!(icon_ext(b"\x89PNG\r\n\x1a\n...."), Some("png"));
385         assert_eq!(icon_ext(b"<?xml version='1.0'?>\n<svg xmlns='...'/>"), Some("svg"));
386         assert_eq!(icon_ext(b"GIF89a"), None);
387     }
388 
389     #[test]
390     fn themed_icon_is_its_first_name() {
391         let icon = Value::from(("themed", Value::from(vec!["org.example.App-symbolic", "org.example.App"])));
392         let n = HashMap::from([("icon".to_string(), icon.try_to_owned().unwrap())]);
393         assert_eq!(icon_of(&n).as_deref(), Some("org.example.App-symbolic"));
394     }
395 }