diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-15 17:48:43 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-15 17:48:43 +0200 |
| commit | beeb0bc507066c6a57267e82cc35a49d671feb63 (patch) | |
| tree | 3cb68a15ea41efd54cfa875682c7c1688575a7c2 /docs/superpowers/specs/2026-09-15-notification-images-design.md | |
| parent | 49e69fb63fe18cf85597655ca3b6b37abddab0cf (diff) | |
| download | quickshell-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.md | 201 |
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. |
