Settings and Inhibit portal backends
git clone https://git.lucas.co/cce-desktop-portal.git
Add the Notification backend: portal notifications as cce-notifier cards
AddNotification maps onto org.freedesktop.Notifications.Notify (desktop
entry name, plain text from markup-body, themed or written-out icon,
priority as urgency, default-action as `default`, buttons as `b<i>`);
RemoveNotification onto CloseNotification. cce-notifier's ActionInvoked
comes back through those keys as the portal's ActionInvoked with the
action's target; NotificationClosed forgets the card.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 26 ++-
portals/cce-desktop.portal | 2 +-
src/main.rs | 22 ++-
src/notification.rs | 395 +++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 437 insertions(+), 8 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index bee673f..f4f27f2 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -6,7 +6,7 @@ rules.
## What this is
-`cce-desktop-portal` is cce's own backend for three portal interfaces that
+`cce-desktop-portal` is cce's own backend for four portal interfaces that
used to fall to xdg-desktop-portal-gtk through `default=gtk`, plus the
`org.freedesktop.ScreenSaver` service apps call directly:
@@ -43,6 +43,16 @@ used to fall to xdg-desktop-portal-gtk through `default=gtk`, plus the
cancelled on Escape. The request's `Close` kills the cce-cloud client; the
cce-cloud daemon closes a popup whose client hung up. `UpdateChoices` is
only logged. `CCE_CHOOSER_BIN` points a run at another cce-cloud build.
+- **`org.freedesktop.impl.portal.Notification`** (`src/notification.rs`) —
+ portal notifications as cce-notifier cards: `AddNotification` becomes
+ `Notify` on `org.freedesktop.Notifications` (desktop-entry name as
+ app_name, `markup-body` stripped, the icon as a theme name or a file under
+ `$XDG_RUNTIME_DIR/cce-desktop-portal/` for bytes/fd icons, `priority` →
+ urgency, `default-action` as the `default` key and buttons as `b<i>`);
+ the server's `ActionInvoked` comes back through those keys as the
+ portal's `ActionInvoked(app_id, id, action, [target])`. Needs cce-notifier
+ with actions and signals (its `feat: clickable cards…` commit) — before
+ that, cards dropped every action.
`idle status` names holders as `portal:<who>`.
@@ -66,8 +76,9 @@ does, so the bus activation environment must carry it (startcce imports it).
- `dbus/*.service` → `~/.local/share/dbus-1/services/` (absolute `Exec`)
- **`~/.config/xdg-desktop-portal/cce-portals.conf`** (user config, not
versioned) must say `org.freedesktop.impl.portal.Settings=cce-desktop;gtk`,
- `org.freedesktop.impl.portal.Inhibit=cce-desktop` and
- `org.freedesktop.impl.portal.AppChooser=cce-desktop`.
+ `org.freedesktop.impl.portal.Inhibit=cce-desktop`,
+ `org.freedesktop.impl.portal.AppChooser=cce-desktop` and
+ `org.freedesktop.impl.portal.Notification=cce-desktop`.
The frontend reads portal files and the conf at startup. Restarting it
(`systemctl --user restart xdg-desktop-portal`) drops every app's portal
@@ -98,3 +109,12 @@ a private `dbus-run-session` running this backend with
keypress` (108 Down, 28 Enter, 1 Escape) and read gdbus's reply. cce-cloud's
client socket is keyed by `WAYLAND_DISPLAY`, so a shadow's chooser runs
standalone (or under a shadow daemon) and never reaches the live one.
+
+Notifications the same way: the private bus also runs a tree
+`cce-notifier` (it owns `org.freedesktop.Notifications` there, so the live
+one is never asked), `gdbus monitor --dest` each name into a file, post
+with `AddNotification`, and click the cards with `cce-shadow ctl
+pointer-move-to` / `pointer-click [right]`. The notifier reads the real
+config, so its bell may sound. When cleaning up, match the private bus by
+its socket path in `/proc/<pid>/environ` — a `pkill -f` on that path also
+matches the shell running it.
diff --git a/portals/cce-desktop.portal b/portals/cce-desktop.portal
index e9c3f9f..a3a3228 100644
--- a/portals/cce-desktop.portal
+++ b/portals/cce-desktop.portal
@@ -1,4 +1,4 @@
[portal]
DBusName=org.freedesktop.impl.portal.desktop.cce-desktop
-Interfaces=org.freedesktop.impl.portal.Settings;org.freedesktop.impl.portal.Inhibit;org.freedesktop.impl.portal.AppChooser;
+Interfaces=org.freedesktop.impl.portal.Settings;org.freedesktop.impl.portal.Inhibit;org.freedesktop.impl.portal.AppChooser;org.freedesktop.impl.portal.Notification;
UseIn=cce;
diff --git a/src/main.rs b/src/main.rs
index f1981a1..d7cd64a 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,12 +1,15 @@
-//! cce-desktop-portal — the cce desktop's own `Settings`, `Inhibit` and
-//! `AppChooser` portal backends, and `org.freedesktop.ScreenSaver`.
+//! cce-desktop-portal — the cce desktop's own `Settings`, `Inhibit`,
+//! `AppChooser` and `Notification` portal backends, and
+//! `org.freedesktop.ScreenSaver`.
//!
//! Settings and Inhibit used to fall to xdg-desktop-portal-gtk (`default=gtk` in
//! `cce-portals.conf`): Settings published GNOME's GSettings rather than cce's
//! config, and Inhibit forwarded to session services no cce session runs, so
//! it silently did nothing. AppChooser ("Open with…") was a GTK window; it is
-//! now cce-cloud's chooser mode. See `settings.rs`, `inhibit.rs` and
-//! `app_chooser.rs`.
+//! now cce-cloud's chooser mode. Notification forwarded to cce-notifier
+//! through gtk, which kept the text and lost the actions; it now maps them
+//! both ways. See `settings.rs`, `inhibit.rs`, `app_chooser.rs` and
+//! `notification.rs`.
//!
//! One bus-activated process owns both names; whichever is asked for first
//! starts it. It lives for the session — Settings has to be there to signal
@@ -15,6 +18,7 @@
mod app_chooser;
mod inhibit;
+mod notification;
mod settings;
use std::sync::Arc;
@@ -41,6 +45,7 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
let appearance = Arc::new(RwLock::new(settings::load(&settings::config_path())));
log::info!("appearance: {:?}", *appearance.read().await);
let inhibitor = inhibit::Inhibitor::new();
+ let cards = notification::SharedCards::default();
// Leases a predecessor held die with it here rather than after their
// ttl. An older compositor answers an error; external inhibitors then
@@ -56,6 +61,7 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
.serve_at(PORTAL_PATH, settings::SettingsPortal { state: appearance.clone() })?
.serve_at(PORTAL_PATH, inhibit::InhibitPortal { inhibitor: inhibitor.clone() })?
.serve_at(PORTAL_PATH, app_chooser::AppChooser::default())?
+ .serve_at(PORTAL_PATH, notification::NotificationPortal { cards: cards.clone() })?
// Both paths are in use in the wild (KDE answered at /ScreenSaver).
.serve_at("/org/freedesktop/ScreenSaver", screensaver(&inhibitor))?
.serve_at("/ScreenSaver", screensaver(&inhibitor))?
@@ -80,6 +86,14 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
log::info!("serving {PORTAL_NAME} and {SCREENSAVER_NAME}");
tokio::spawn(inhibitor.clone().renew_loop());
+ {
+ let conn = conn.clone();
+ tokio::spawn(async move {
+ if let Err(e) = notification::relay(conn, PORTAL_PATH, cards).await {
+ log::error!("notification relay stopped: {e}");
+ }
+ });
+ }
{
let (conn, appearance) = (conn.clone(), appearance.clone());
tokio::spawn(async move {
diff --git a/src/notification.rs b/src/notification.rs
new file mode 100644
index 0000000..de65db7
--- /dev/null
+++ b/src/notification.rs
@@ -0,0 +1,395 @@
+//! `org.freedesktop.impl.portal.Notification`: portal notifications as
+//! cce-notifier cards, and clicks on them back to the app.
+//!
+//! cce-notifier is the desktop's `org.freedesktop.Notifications` server, so
+//! each portal call becomes one call on that interface:
+//!
+//! - `AddNotification(app_id, id, n)` → `Notify`, with the app's desktop-entry
+//! name as `app_name`, `title`/`body` (or `markup-body`, tags stripped —
+//! the cards draw plain text), the icon as `app_icon` (a theme name, or a
+//! file written under `$XDG_RUNTIME_DIR` for bytes/fd icons), `priority` as
+//! `urgency`, and the actions: `default-action` as the `default` key (the
+//! click on the card), each button as `b<index>`. Re-adding an id replaces
+//! its card (`replaces_id`).
+//! - `RemoveNotification` → `CloseNotification`.
+//! - The server's `ActionInvoked` is mapped back through the key to the
+//! portal action and its target, and re-emitted as the portal's
+//! `ActionInvoked(app_id, id, action, [target])`; `NotificationClosed`
+//! forgets the card.
+//!
+//! Before this backend, gtk did the same forwarding; but cce-notifier then
+//! ignored actions and themed icons, so a portal notification could be read
+//! and never acted on.
+
+use std::collections::HashMap;
+use std::sync::Arc;
+
+use futures_util::StreamExt;
+use tokio::sync::Mutex;
+use zbus::object_server::SignalEmitter;
+use zbus::zvariant::{OwnedValue, Value};
+use zbus::{interface, Connection};
+
+const FDO_NAME: &str = "org.freedesktop.Notifications";
+const FDO_PATH: &str = "/org/freedesktop/Notifications";
+
+/// What a card's keys mean, kept until the card closes.
+struct Entry {
+ fdo_id: u32,
+ /// `default-action` and its target.
+ default: Option<(String, Option<OwnedValue>)>,
+ /// Each button's action and target, by index (`b<i>`).
+ buttons: Vec<(String, Option<OwnedValue>)>,
+}
+
+#[derive(Default)]
+pub struct Cards {
+ by_key: HashMap<(String, String), Entry>,
+ by_fdo: HashMap<u32, (String, String)>,
+}
+
+pub type SharedCards = Arc<Mutex<Cards>>;
+
+pub struct NotificationPortal {
+ pub cards: SharedCards,
+}
+
+// ── parsing the portal's vardict ───────────────────────────────────────────
+
+fn str_of(n: &HashMap<String, OwnedValue>, key: &str) -> Option<String> {
+ match n.get(key).map(|v| &**v) {
+ Some(Value::Str(s)) => Some(s.to_string()),
+ _ => None,
+ }
+}
+
+/// The text a card can draw of a `markup-body`: tags dropped, the five XML
+/// entities decoded. cce-notifier draws plain text.
+pub fn strip_markup(markup: &str) -> String {
+ let mut out = String::with_capacity(markup.len());
+ let mut in_tag = false;
+ for c in markup.chars() {
+ match c {
+ '<' => in_tag = true,
+ '>' if in_tag => in_tag = false,
+ _ if !in_tag => out.push(c),
+ _ => {}
+ }
+ }
+ out.replace("<", "<").replace(">", ">").replace(""", "\"").replace("'", "'").replace("&", "&")
+}
+
+/// `priority` → the freedesktop `urgency` byte. `high` stays normal: the
+/// difference cce-notifier draws is whether a card expires, and only
+/// `urgent` should outstay its welcome.
+pub fn urgency(priority: Option<&str>) -> u8 {
+ match priority {
+ Some("low") => 0,
+ Some("urgent") => 2,
+ _ => 1,
+ }
+}
+
+/// The notification's `icon`, a serialized GIcon `(sv)`: `("themed", as)`,
+/// `("bytes", ay)` or `("file-descriptor", h)`. A theme name is passed on as
+/// is; image data is written to a file named after its contents (so a
+/// repeated icon is written once) and passed as a path.
+fn icon_of(n: &HashMap<String, OwnedValue>) -> Option<String> {
+ let Some(Value::Structure(s)) = n.get("icon").map(|v| &**v) else { return None };
+ let fields = s.fields();
+ let (Some(Value::Str(kind)), Some(payload)) = (fields.first(), fields.get(1)) else { return None };
+ let payload = match payload {
+ Value::Value(inner) => &**inner,
+ other => other,
+ };
+ match (kind.as_str(), payload) {
+ ("themed", Value::Array(names)) => names.iter().find_map(|v| match v {
+ Value::Str(s) if !s.is_empty() => Some(s.to_string()),
+ _ => None,
+ }),
+ ("bytes", Value::Array(bytes)) => {
+ let data: Vec<u8> = bytes.iter().filter_map(|v| if let Value::U8(b) = v { Some(*b) } else { None }).collect();
+ write_icon(&data)
+ }
+ ("file-descriptor", Value::Fd(fd)) => {
+ use std::io::Read;
+ use std::os::fd::AsFd;
+ let owned = fd.as_fd().try_clone_to_owned().ok()?;
+ let mut data = Vec::new();
+ std::fs::File::from(owned).read_to_end(&mut data).ok()?;
+ write_icon(&data)
+ }
+ _ => None,
+ }
+}
+
+/// An extension `cce_ui::icon` can decode, by the data's own signature.
+pub fn icon_ext(data: &[u8]) -> Option<&'static str> {
+ if data.starts_with(b"\x89PNG") {
+ Some("png")
+ } else if data.windows(4).take(1024).any(|w| w == b"<svg") {
+ Some("svg")
+ } else {
+ None
+ }
+}
+
+fn write_icon(data: &[u8]) -> Option<String> {
+ use std::hash::{Hash, Hasher};
+ let ext = icon_ext(data)?;
+ let mut h = std::collections::hash_map::DefaultHasher::new();
+ data.hash(&mut h);
+ let dir = std::path::PathBuf::from(std::env::var_os("XDG_RUNTIME_DIR")?).join("cce-desktop-portal");
+ std::fs::create_dir_all(&dir).ok()?;
+ let path = dir.join(format!("icon-{:016x}.{ext}", h.finish()));
+ if !path.exists() {
+ std::fs::write(&path, data).ok()?;
+ }
+ Some(path.to_string_lossy().into_owned())
+}
+
+/// `buttons`: `aa{sv}`, each with `label`, `action` and an optional `target`.
+fn buttons_of(n: &HashMap<String, OwnedValue>) -> Vec<(String, String, Option<OwnedValue>)> {
+ let Some(Value::Array(list)) = n.get("buttons").map(|v| &**v) else { return Vec::new() };
+ list.iter()
+ .filter_map(|b| {
+ let Value::Dict(d) = b else { return None };
+ // Values in an `a{sv}` arrive wrapped in a variant; unwrap one level.
+ let get = |k: &str| {
+ d.iter().find_map(|(key, val)| match key {
+ Value::Str(s) if s.as_str() == k => Some(match val {
+ Value::Value(inner) => &**inner,
+ v => v,
+ }),
+ _ => None,
+ })
+ };
+ let label = match get("label") {
+ Some(Value::Str(s)) => s.to_string(),
+ _ => return None,
+ };
+ let action = match get("action") {
+ Some(Value::Str(s)) => s.to_string(),
+ _ => return None,
+ };
+ let target = get("target").and_then(|v| v.try_to_owned().ok());
+ Some((label, action, target))
+ })
+ .collect()
+}
+
+/// `Name=` and `Icon=` of the app's desktop entry, from the first
+/// applications dir that has `<app_id>.desktop`.
+fn desktop_entry(app_id: &str) -> (Option<String>, Option<String>) {
+ if app_id.is_empty() {
+ return (None, None);
+ }
+ let home_data = std::env::var_os("XDG_DATA_HOME")
+ .filter(|v| !v.is_empty())
+ .map(std::path::PathBuf::from)
+ .or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".local/share")));
+ let data_dirs = std::env::var("XDG_DATA_DIRS").ok().filter(|v| !v.is_empty()).unwrap_or_else(|| "/usr/local/share:/usr/share".into());
+ let dirs = home_data.into_iter().chain(std::env::split_paths(&data_dirs));
+ for dir in dirs {
+ let Ok(text) = std::fs::read_to_string(dir.join("applications").join(format!("{app_id}.desktop"))) else { continue };
+ let (mut name, mut icon) = (None, None);
+ let mut in_entry = false;
+ for line in text.lines().map(str::trim) {
+ if line.starts_with('[') {
+ in_entry = line == "[Desktop Entry]";
+ } else if in_entry {
+ if let Some(v) = line.strip_prefix("Name=") {
+ name.get_or_insert_with(|| v.to_string());
+ } else if let Some(v) = line.strip_prefix("Icon=") {
+ icon.get_or_insert_with(|| v.to_string());
+ }
+ }
+ }
+ return (name, icon);
+ }
+ (None, None)
+}
+
+/// The fdo `actions` list for a card: `default` first, then `b<i>` per
+/// button, each followed by its label.
+pub fn fdo_actions(has_default: bool, labels: &[String]) -> Vec<String> {
+ let mut out = Vec::new();
+ if has_default {
+ out.extend(["default".to_string(), String::new()]);
+ }
+ for (i, label) in labels.iter().enumerate() {
+ out.extend([format!("b{i}"), label.clone()]);
+ }
+ out
+}
+
+#[interface(name = "org.freedesktop.impl.portal.Notification")]
+impl NotificationPortal {
+ #[zbus(property, name = "version")]
+ fn version(&self) -> u32 {
+ 2
+ }
+
+ /// No categories or custom sounds: cce-notifier has neither.
+ #[zbus(property, name = "SupportedOptions")]
+ fn supported_options(&self) -> HashMap<String, OwnedValue> {
+ HashMap::new()
+ }
+
+ async fn add_notification(
+ &self,
+ #[zbus(connection)] conn: &Connection,
+ app_id: String,
+ id: String,
+ notification: HashMap<String, OwnedValue>,
+ ) -> zbus::fdo::Result<()> {
+ let title = str_of(¬ification, "title").unwrap_or_default();
+ let body = str_of(¬ification, "markup-body")
+ .map(|m| strip_markup(&m))
+ .or_else(|| str_of(¬ification, "body"))
+ .unwrap_or_default();
+ let (entry_name, entry_icon) = desktop_entry(&app_id);
+ let app_name = entry_name.unwrap_or_else(|| app_id.clone());
+ let icon = icon_of(¬ification).or(entry_icon).unwrap_or_default();
+ let default = str_of(¬ification, "default-action")
+ .map(|a| (a, notification.get("default-action-target").and_then(|v| v.try_clone().ok())));
+ let buttons = buttons_of(¬ification);
+ let labels: Vec<String> = buttons.iter().map(|(l, _, _)| l.clone()).collect();
+ let actions = fdo_actions(default.is_some(), &labels);
+
+ let mut hints: HashMap<&str, Value> = HashMap::new();
+ hints.insert("urgency", Value::U8(urgency(str_of(¬ification, "priority").as_deref())));
+ if !app_id.is_empty() {
+ hints.insert("desktop-entry", Value::from(app_id.clone()));
+ }
+ if let Some(category) = str_of(¬ification, "category") {
+ hints.insert("category", Value::from(category));
+ }
+
+ let key = (app_id.clone(), id.clone());
+ let replaces = self.cards.lock().await.by_key.get(&key).map_or(0, |e| e.fdo_id);
+ let reply = conn
+ .call_method(
+ Some(FDO_NAME),
+ FDO_PATH,
+ Some(FDO_NAME),
+ "Notify",
+ &(&app_name, replaces, &icon, &title, &body, &actions, &hints, -1i32),
+ )
+ .await?;
+ let fdo_id: u32 = reply.body().deserialize()?;
+ log::info!("AddNotification {app_id}/{id} → card {fdo_id} ({} button(s), default {})", labels.len(), default.is_some());
+
+ let mut cards = self.cards.lock().await;
+ if let Some(old) = cards.by_key.remove(&key) {
+ cards.by_fdo.remove(&old.fdo_id);
+ }
+ cards.by_fdo.insert(fdo_id, key.clone());
+ cards.by_key.insert(key, Entry { fdo_id, default, buttons: buttons.into_iter().map(|(_, a, t)| (a, t)).collect() });
+ Ok(())
+ }
+
+ async fn remove_notification(&self, #[zbus(connection)] conn: &Connection, app_id: String, id: String) {
+ let entry = {
+ let mut cards = self.cards.lock().await;
+ let entry = cards.by_key.remove(&(app_id.clone(), id.clone()));
+ if let Some(e) = &entry {
+ cards.by_fdo.remove(&e.fdo_id);
+ }
+ entry
+ };
+ if let Some(e) = entry {
+ log::info!("RemoveNotification {app_id}/{id} (card {})", e.fdo_id);
+ if let Err(err) = conn.call_method(Some(FDO_NAME), FDO_PATH, Some(FDO_NAME), "CloseNotification", &(e.fdo_id,)).await {
+ log::warn!("closing card {}: {err}", e.fdo_id);
+ }
+ }
+ }
+
+ #[zbus(signal)]
+ async fn action_invoked(emitter: &SignalEmitter<'_>, app_id: &str, id: &str, action: &str, parameter: Vec<Value<'_>>) -> zbus::Result<()>;
+}
+
+/// Follow cce-notifier's signals: a click on one of our cards becomes the
+/// portal's `ActionInvoked`; a closed card is forgotten.
+pub async fn relay(conn: Connection, portal_path: &str, cards: SharedCards) -> zbus::Result<()> {
+ let rule = zbus::MatchRule::builder()
+ .msg_type(zbus::message::Type::Signal)
+ .interface(FDO_NAME)?
+ .path(FDO_PATH)?
+ .build();
+ let mut stream = zbus::MessageStream::for_match_rule(rule, &conn, None).await?;
+ let emitter = SignalEmitter::new(&conn, portal_path.to_string())?;
+ while let Some(msg) = stream.next().await {
+ let Ok(msg) = msg else { continue };
+ let header = msg.header();
+ match header.member().map(|m| m.as_str()) {
+ Some("ActionInvoked") => {
+ let Ok((fdo_id, key)) = msg.body().deserialize::<(u32, String)>() else { continue };
+ let cards = cards.lock().await;
+ let Some((app_id, id)) = cards.by_fdo.get(&fdo_id) else { continue };
+ let Some(entry) = cards.by_key.get(&(app_id.clone(), id.clone())) else { continue };
+ let chosen = if key == "default" {
+ entry.default.as_ref()
+ } else {
+ key.strip_prefix('b').and_then(|i| i.parse::<usize>().ok()).and_then(|i| entry.buttons.get(i))
+ };
+ let Some((action, target)) = chosen else { continue };
+ let parameter: Vec<Value> = target.iter().filter_map(|t| t.try_clone().ok()).map(Value::from).collect();
+ log::info!("card {fdo_id}: {app_id}/{id} action {action:?}");
+ if let Err(e) = NotificationPortal::action_invoked(&emitter, app_id, id, action, parameter).await {
+ log::warn!("emitting ActionInvoked: {e}");
+ }
+ }
+ Some("NotificationClosed") => {
+ let Ok((fdo_id, _reason)) = msg.body().deserialize::<(u32, u32)>() else { continue };
+ let mut cards = cards.lock().await;
+ if let Some(key) = cards.by_fdo.remove(&fdo_id) {
+ cards.by_key.remove(&key);
+ }
+ }
+ _ => {}
+ }
+ }
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn markup_becomes_plain_text() {
+ assert_eq!(strip_markup("<b>Build</b> finished & <i>passed</i> <3"), "Build finished & passed <3");
+ assert_eq!(strip_markup("a <a href=\"x\">link</a>"), "a link");
+ }
+
+ #[test]
+ fn priority_to_urgency() {
+ assert_eq!(urgency(Some("low")), 0);
+ assert_eq!(urgency(Some("normal")), 1);
+ assert_eq!(urgency(Some("high")), 1);
+ assert_eq!(urgency(Some("urgent")), 2);
+ assert_eq!(urgency(None), 1);
+ }
+
+ #[test]
+ fn actions_are_default_then_indexed_buttons() {
+ assert_eq!(fdo_actions(true, &["Reply".into(), "Mute".into()]), ["default", "", "b0", "Reply", "b1", "Mute"]);
+ assert_eq!(fdo_actions(false, &[]), Vec::<String>::new());
+ }
+
+ #[test]
+ fn icon_signatures() {
+ assert_eq!(icon_ext(b"\x89PNG\r\n\x1a\n...."), Some("png"));
+ assert_eq!(icon_ext(b"<?xml version='1.0'?>\n<svg xmlns='...'/>"), Some("svg"));
+ assert_eq!(icon_ext(b"GIF89a"), None);
+ }
+
+ #[test]
+ fn themed_icon_is_its_first_name() {
+ let icon = Value::from(("themed", Value::from(vec!["org.example.App-symbolic", "org.example.App"])));
+ let n = HashMap::from([("icon".to_string(), icon.try_to_owned().unwrap())]);
+ assert_eq!(icon_of(&n).as_deref(), Some("org.example.App-symbolic"));
+ }
+}