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("<", "<").replace(">", ">").replace(""", "\"").replace("'", "'").replace("&", "&")
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(¬ification, "title").unwrap_or_default();
247 let body = str_of(¬ification, "markup-body")
248 .map(|m| strip_markup(&m))
249 .or_else(|| str_of(¬ification, "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(¬ification).or(entry_icon).unwrap_or_default();
254 let default = str_of(¬ification, "default-action")
255 .map(|a| (a, notification.get("default-action-target").and_then(|v| v.try_clone().ok())));
256 let buttons = buttons_of(¬ification);
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(¬ification, "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(¬ification, "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 & <i>passed</i> <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 }