aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/specs/2026-09-15-notification-images-design.md
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-15 17:48:43 +0200
committerDanilo M. <danix@danix.xyz>2026-09-15 17:48:43 +0200
commitbeeb0bc507066c6a57267e82cc35a49d671feb63 (patch)
tree3cb68a15ea41efd54cfa875682c7c1688575a7c2 /docs/superpowers/specs/2026-09-15-notification-images-design.md
parent49e69fb63fe18cf85597655ca3b6b37abddab0cf (diff)
downloadquickshell-beeb0bc507066c6a57267e82cc35a49d671feb63.tar.gz
quickshell-beeb0bc507066c6a57267e82cc35a49d671feb63.zip
docs: add the notification images design
The daemon drops every image hint today, so a screenshot and an application image never arrive. libnotify 0.8.8 sends the content image via -i/--icon (the image-path hint) while -n/--app-icon is app_icon. The spec adds the image contract field, daemon hint handling and theme-name resolution, the balloon preview, and a local-only inline image policy.
Diffstat (limited to 'docs/superpowers/specs/2026-09-15-notification-images-design.md')
-rw-r--r--docs/superpowers/specs/2026-09-15-notification-images-design.md201
1 files changed, 201 insertions, 0 deletions
diff --git a/docs/superpowers/specs/2026-09-15-notification-images-design.md b/docs/superpowers/specs/2026-09-15-notification-images-design.md
new file mode 100644
index 0000000..fd7eabf
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-15-notification-images-design.md
@@ -0,0 +1,201 @@
+# Notification Images
+
+The daemon and the renderers gain content images: a screenshot or an
+application image attached to a notification, shown large in the balloon, plus
+inline images inside the body markup, plus resolution of app icons given as
+theme names.
+
+This extends the shipped daemon design
+(`2026-09-15-notification-daemon-design.md`) and the renderers built from
+`../plans/2026-09-15-notification-renderers.md`.
+
+## Why
+
+The daemon currently reads only the `app_icon` parameter. It ignores every
+image hint, so content images never arrive. Two live consumers show the gap:
+
+ opencode its notifier passes its logo with notify-send --icon
+ grimblast it passes the screenshot with notify-send -i
+
+libnotify 0.8.8 splits two flags that were once one: `-i/--icon` is the content
+image (it lands in the `image-path` hint) and `-n/--app-icon` is the
+`app_icon` parameter. Both consumers use the content image. The daemon drops
+that hint, so opencode shows no logo and a screenshot shows nothing.
+
+Separately, `app_icon` is only usable when it is an absolute path. Apps that
+send a theme name (the spec's other allowed form) render nothing, because the
+renderer builds `file://<name>`.
+
+## What it is not
+
+It is not a change to the daemon's or renderer's suppression, timeout, replace
+or history behaviour. It is not animated images, multiple images, progress
+bars, or body hyperlink handling. It is not a resolver for `desktop-entry`.
+
+## The freedesktop contract
+
+The specification defines one image per notification. An implementation that
+can display both the app icon and the image shows `app_icon` as the icon and
+picks the image in this order:
+
+ 1. image-data (raw pixels, a (iiibiiay) struct)
+ 2. image-path (a URI or a theme icon name)
+ 3. icon_data (deprecated, the same struct as image-data)
+
+An implementation that can show only one image picks from image-data,
+image-path, app_icon, then icon_data. The daemon here shows both, so it uses
+the first order and keeps `app_icon` as the icon.
+
+`image-data` and `icon_data` are a D-Bus structure `(iiibiiay)`:
+
+ width (i) width in pixels
+ height (i) height in pixels
+ rowstride (i) bytes between row starts
+ has_alpha (b) whether there is an alpha channel
+ bits_per_sample (i) always 8
+ channels (i) 4 with alpha, 3 without
+ data (ay) pixels, RGB byte order
+
+`image-path`, and `app_icon`, are each either a `file://` URI or a name in a
+freedesktop icon theme. A name must be resolved against a theme.
+
+## The daemon
+
+### Hints
+
+`Notify` gains hint parsing beside the existing urgency and stack-tag reads:
+
+- `image-data` and the deprecated `icon_data` (struct): decode to an image.
+- `image-path` (string): a `file://` URI, an absolute path, or a theme name.
+
+`image-data` wins when both a data and a path hint are present, per the
+priority above. The underscore spelling `image_data`, which older libnotify
+sent, is accepted as an alias.
+
+### Materialisation and lifecycle
+
+A raw `image-data` is decoded in Go and written as a PNG to
+`$XDG_RUNTIME_DIR/notifyd/img/<id>.png`. A replaced notification reuses its id
+and overwrites the same file. The file is removed when the notification leaves
+the live queue: on dismiss, on eviction, and on expiry. Only the balloon shows
+an image, and the drawer row does not, so nothing needs it once the balloon is
+gone. This bounds the directory to the live balloons.
+
+An `image-path` is the client's file and is published as-is; the daemon never
+deletes it. A theme name is resolved to a file first. A `file://` URI is
+converted to a path.
+
+### Capabilities
+
+`GetCapabilities` adds `body-images`, which is the spec's token for inline
+image support. It keeps `actions`, `body-markup`, `icon-static` and
+`persistence`.
+
+### Icon and image theme-name resolution
+
+`app_icon` and `image-path` values without a `/` are theme names and are
+resolved to a file. A value that is already a path or a `file://` URI is used
+as-is.
+
+The theme is read from qt6ct, `~/.config/qt6ct/qt6ct.conf`, key `icon_theme`.
+On this machine that is `Material-Black-Plum-Suru`, and it is authoritative;
+GTK3, gsettings and qt6ct agree, and the one dissent (GTK4's `breeze-dark`)
+has been corrected to match. If qt6ct has no value, fall back to the GTK3
+setting `gtk-icon-theme-name` (or `gsettings`), then `hicolor`.
+
+Lookup, once a theme name is known:
+
+1. Search `$XDG_DATA_HOME/icons/<theme>`, then each `$XDG_DATA_DIRS/icons/<theme>`
+ in order.
+2. Within the theme, prefer `apps/scalable`, then the largest available size
+ under `apps/`.
+3. Follow the theme's `Inherits` chain from its `index.theme`.
+4. Fall back to `hicolor`.
+5. If nothing matches, leave the value empty, which renders no image. This is
+ today's behaviour and stays the honest outcome.
+
+## The published contract
+
+`Popup` gains one field:
+
+ {
+ "id": 57,
+ "app": "grimblast",
+ "summary": "Screenshot of Area",
+ "body": "...",
+ "urgency": "normal",
+ "icon": "", // app identity, a resolved path
+ "image": "/run/user/1000/notifyd/img/57.png", // content image, empty if none
+ "actions": [],
+ "created": 1758000000000,
+ "expires": 1758000010000
+ }
+
+`icon` keeps its meaning and its consumers. `image` is new and empty when the
+notification has none. History objects carry the same field; history rows do
+not render it, so a stale path there is inert.
+
+## The renderers
+
+### Balloon
+
+When `image` is non-empty, the balloon shows it below the text block,
+`Image.PreserveAspectFit`, the balloon's width, capped at 240px tall. The
+32px `icon` slot is unchanged, and both can appear together. The balloon grows
+to fit, exactly as it does for a long body.
+
+### Inline images in the body
+
+The body is already rendered as `Text.RichText`, and Qt's rich text engine
+loads `<img>` from a local path or a `file://` URI (verified: a 200px
+`<img>` raised the text's `contentHeight` from 52 to 307). No new rendering
+code is needed for the common case.
+
+Policy: only local sources render. Before display, the renderer strips any
+`<img>` whose `src` begins with `http:` or `https:`, so a notification from an
+untrusted sender cannot make the shell fetch a URL. Remote inline images are
+not shown. Sources that are absolute paths or `file://` URIs are left alone.
+
+The existing body line caps still apply: three lines in the balloon, two in a
+drawer row.
+
+### Drawer rows
+
+No image. The reserved space and the history page stay text only, as they are
+today.
+
+## Privacy
+
+A notification is untrusted input. The only new outward action the design can
+cause is a network fetch, and it is closed by the inline policy above: remote
+image sources are stripped, never fetched. The daemon writes a decoded image
+only under its own runtime directory.
+
+## Verification
+
+Daemon (Go, table tests, no bus or clock):
+
+- image hint parsing: `image-data` beats `image-path`; `icon_data` and
+ `image_data` aliases are read; a `file://` URI becomes a path.
+- `image-data` decode to PNG for 3- and 4-channel data with a rowstride.
+- theme-name resolution against a fake icon directory: a plain name, a name
+ found only through `Inherits`, a missing name, and the largest-size
+ preference.
+
+`test-notifyctl.sh` is extended if the contract change reaches it.
+
+End to end, by hand:
+
+- a grimblast screenshot and an opencode event each draw a large preview.
+- `notify-send -n firefox` draws the themed Firefox icon.
+- a `gdbus` call sending `image-data` draws the image.
+- a body containing `<img src="https://example.org/x.png">` draws no image and
+ makes no request.
+- the renderer smoke checks stay clean.
+
+## Out of scope
+
+- Icon caches and theme re-scan; a name is resolved per notification.
+- `desktop-entry` resolution.
+- Animated images, more than one image, an image in a drawer row.
+- Progress bars and body hyperlink handling, unchanged deferrals.