# 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://`. ## 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/.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/`, then each `$XDG_DATA_DIRS/icons/` 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 `` from a local path or a `file://` URI (verified: a 200px `` 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 `` 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 `` 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.