aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/plans/2026-09-15-notifyd-images.md
diff options
context:
space:
mode:
Diffstat (limited to 'docs/superpowers/plans/2026-09-15-notifyd-images.md')
-rw-r--r--docs/superpowers/plans/2026-09-15-notifyd-images.md1000
1 files changed, 1000 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-09-15-notifyd-images.md b/docs/superpowers/plans/2026-09-15-notifyd-images.md
new file mode 100644
index 0000000..104e566
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-15-notifyd-images.md
@@ -0,0 +1,1000 @@
+# notifyd Image Support Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** The daemon accepts the freedesktop image hints, publishes a content image path for every notification, resolves app_icon / image-path theme names to files, and cleans up what it owns.
+
+**Architecture:** Image hints are parsed as pure functions in `internal/notify`, decoded and encoded to PNG with the standard library, and written under `$XDG_RUNTIME_DIR/notifyd/img/`. `Popup` gains an `image` field that the renderers read. A theme-name resolver reads qt6ct (then GTK3, then hicolor) and searches the XDG icon directories. The store gains an injected cleanup callback so a daemon-owned image is unlinked when its notification leaves the live queue.
+
+**Tech Stack:** Go, `github.com/godbus/dbus/v5`, the standard library (`image`, `image/png`), `dbus-run-session` for the integration test.
+
+**Spec:** `docs/superpowers/specs/2026-09-15-notification-images-design.md` (in the quickshell repo; read it before starting). The renderer half is a separate plan.
+
+## Global Constraints
+
+- Go module path `danix.xyz/notifyd`. The only third-party dependency is `github.com/godbus/dbus/v5`; everything else is the standard library.
+- GPLv2 only. Every new `.go` file begins with the standard per-file header notice (copy it from `policy.go`).
+- The published contract is exact: `Popup` gains `"image"` (a path, empty when none); `created` and `expires` stay epoch milliseconds.
+- `GetCapabilities` becomes `actions`, `body-markup`, `body-images`, `icon-static`, `persistence`.
+- The spec's image priority is `image-data`, then `image-path`, then the deprecated `icon_data`; `app_icon` stays the icon, not a fallback image.
+- `image-data` / `icon_data` are a D-Bus `(iiibiiay)` struct: width, height, rowstride, has_alpha, bits_per_sample, channels, data (RGB byte order).
+- Theme source is qt6ct `icon_theme`, then GTK3 `gtk-icon-theme-name`, then `hicolor`. Search `$XDG_DATA_HOME/icons` then `$XDG_DATA_DIRS/icons`.
+- No home paths in committed files. `gofmt` clean. `go vet ./...` clean.
+- Test commands: `go test ./...` for pure logic; `dbus-run-session -- go test ./internal/notify` for the bus test; `bash test-notifyctl.sh` for the end to end check.
+- Work in the `notifyd` repo (`~/Programming/GIT/notifyd`), not the quickshell repo.
+
+---
+
+## File Structure
+
+ internal/notify/image.go image hint parsing and PNG encoding (create)
+ internal/notify/image_test.go
+ internal/notify/icons.go theme-name resolution to an icon file (create)
+ internal/notify/icons_test.go
+ internal/notify/policy.go add image hint entry points (modify)
+ internal/notify/store.go Popup.Image and the removal callback (modify)
+ internal/notify/store_test.go
+ internal/notify/files.go image directory and PNG write (modify)
+ internal/notify/files_test.go
+ internal/notify/service.go capabilities, materialisation, wiring (modify)
+ internal/notify/service_test.go
+ test-notifyctl.sh assert the image field survives publish (modify)
+
+---
+
+### Task 1: Image hint parsing and PNG encoding
+
+**Files:**
+- Create: `internal/notify/image.go`
+- Test: `internal/notify/image_test.go`
+
+**Interfaces:**
+- Consumes: `github.com/godbus/dbus/v5`.
+- Produces: `type RawImage struct { Width, Height, RowStride int; HasAlpha bool; BitsPerSample, Channels int; Data []byte }`; `ImageDataFromHints(hints map[string]dbus.Variant) (*RawImage, bool)`; `ImagePathFromHints(hints map[string]dbus.Variant) (string, bool)`; `(*RawImage) PNG() ([]byte, error)`. Tasks 3 and 4 use these.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `internal/notify/image_test.go`:
+
+```go
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+package notify
+
+import (
+ "bytes"
+ "image/png"
+ "testing"
+
+ "github.com/godbus/dbus/v5"
+)
+
+// rawVariant builds the (iiibiiay) struct the bus delivers for image-data.
+func rawVariant(w, h, stride int, alpha bool, ch int, data []byte) dbus.Variant {
+ return dbus.MakeVariant([]interface{}{
+ int32(w), int32(h), int32(stride), alpha, int32(8), int32(ch), data,
+ })
+}
+
+func TestImageDataFromHints(t *testing.T) {
+ // 2x1 RGBA: red, green.
+ rgba := []byte{255, 0, 0, 255, 0, 255, 0, 255}
+ cases := []struct {
+ name string
+ hints map[string]dbus.Variant
+ wantW int
+ want bool
+ }{
+ {"image-data wins", map[string]dbus.Variant{
+ "image-data": rawVariant(2, 1, 8, true, 4, rgba),
+ "image-path": dbus.MakeVariant("/tmp/x.png"),
+ }, 2, true},
+ {"icon_data fallback", map[string]dbus.Variant{
+ "icon_data": rawVariant(2, 1, 8, true, 4, rgba),
+ }, 2, true},
+ {"image_data alias", map[string]dbus.Variant{
+ "image_data": rawVariant(2, 1, 8, true, 4, rgba),
+ }, 2, true},
+ {"absent", map[string]dbus.Variant{}, 0, false},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ got, ok := ImageDataFromHints(c.hints)
+ if ok != c.want {
+ t.Fatalf("ok = %v, want %v", ok, c.want)
+ }
+ if ok && got.Width != c.wantW {
+ t.Fatalf("width = %d, want %d", got.Width, c.wantW)
+ }
+ })
+ }
+}
+
+func TestImagePathFromHints(t *testing.T) {
+ got, ok := ImagePathFromHints(map[string]dbus.Variant{"image-path": dbus.MakeVariant("/tmp/shot.png")})
+ if !ok || got != "/tmp/shot.png" {
+ t.Fatalf("got %q ok=%v", got, ok)
+ }
+ if _, ok := ImagePathFromHints(map[string]dbus.Variant{}); ok {
+ t.Fatal("empty hints must not report a path")
+ }
+}
+
+func TestRawImagePNG(t *testing.T) {
+ // 2x1 RGBA on a rowstride wider than the data, to prove stride is honoured.
+ r := &RawImage{Width: 2, Height: 1, RowStride: 12, HasAlpha: true, BitsPerSample: 8, Channels: 4,
+ Data: []byte{255, 0, 0, 255, 0, 255, 0, 255, 9, 9, 9, 9}}
+ data, err := r.PNG()
+ if err != nil {
+ t.Fatalf("PNG: %v", err)
+ }
+ img, err := png.Decode(bytes.NewReader(data))
+ if err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if img.Bounds().Dx() != 2 || img.Bounds().Dy() != 1 {
+ t.Fatalf("bounds = %v", img.Bounds())
+ }
+ r0, g0, b0, a0 := img.At(0, 0).RGBA()
+ if r0>>8 != 255 || g0>>8 != 0 || b0>>8 != 0 || a0>>8 != 255 {
+ t.Fatalf("pixel 0 = %d %d %d %d", r0>>8, g0>>8, b0>>8, a0>>8)
+ }
+}
+
+func TestRawImagePNGRejectsBadData(t *testing.T) {
+ if _, err := (&RawImage{Width: 0, Height: 1, Channels: 4, BitsPerSample: 8}).PNG(); err == nil {
+ t.Fatal("zero width must error")
+ }
+ if _, err := (&RawImage{Width: 1, Height: 1, Channels: 2, BitsPerSample: 8}).PNG(); err == nil {
+ t.Fatal("channels 2 must error")
+ }
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `go test ./internal/notify -run 'TestImage|TestRaw' -v`
+Expected: FAIL with `undefined: RawImage` and the hint functions.
+
+- [ ] **Step 3: Write the implementation**
+
+Create `internal/notify/image.go`:
+
+```go
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+package notify
+
+import (
+ "bytes"
+ "fmt"
+ "image"
+ "image/color"
+ "image/png"
+
+ "github.com/godbus/dbus/v5"
+)
+
+// RawImage is the spec's image-data structure (iiibiiay). Data is RGB byte
+// order: 4 bytes per pixel with alpha, 3 without, and rows are RowStride
+// bytes apart, which may exceed Width*Channels.
+type RawImage struct {
+ Width int
+ Height int
+ RowStride int
+ HasAlpha bool
+ BitsPerSample int
+ Channels int
+ Data []byte
+}
+
+// ImageDataFromHints reads the raw image struct, preferring the spec key then
+// the deprecated icon_data, then the underscore alias older libnotify sent.
+func ImageDataFromHints(hints map[string]dbus.Variant) (*RawImage, bool) {
+ for _, key := range []string{"image-data", "icon_data", "image_data"} {
+ v, ok := hints[key]
+ if !ok {
+ continue
+ }
+ if r, ok := rawImageFromVariant(v); ok {
+ return r, true
+ }
+ }
+ return nil, false
+}
+
+// ImagePathFromHints reads image-path, a URI, a path, or a theme icon name.
+func ImagePathFromHints(hints map[string]dbus.Variant) (string, bool) {
+ v, ok := hints["image-path"]
+ if !ok {
+ return "", false
+ }
+ s, ok := v.Value().(string)
+ if !ok || s == "" {
+ return "", false
+ }
+ return s, true
+}
+
+// rawImageFromVariant accepts the []interface{} godbus yields for a struct.
+// Each numeric field may arrive as int32 or int depending on the encoder.
+func rawImageFromVariant(v dbus.Variant) (*RawImage, bool) {
+ f, ok := v.Value().([]interface{})
+ if !ok || len(f) != 7 {
+ return nil, false
+ }
+ r := &RawImage{}
+ var okW, okH, okS, okC, okD bool
+ r.Width, okW = asInt(f[0])
+ r.Height, okH = asInt(f[1])
+ r.RowStride, okS = asInt(f[2])
+ r.HasAlpha, _ = f[3].(bool)
+ r.BitsPerSample, _ = asInt(f[4])
+ r.Channels, okC = asInt(f[5])
+ r.Data, okD = f[6].([]byte)
+ if !okW || !okH || !okS || !okC || !okD {
+ return nil, false
+ }
+ return r, true
+}
+
+func asInt(v any) (int, bool) {
+ switch n := v.(type) {
+ case int:
+ return n, true
+ case int32:
+ return int(n), true
+ case int64:
+ return int(n), true
+ case uint32:
+ return int(n), true
+ }
+ return 0, false
+}
+
+// PNG encodes the raw pixels as a PNG the renderer can load.
+func (r *RawImage) PNG() ([]byte, error) {
+ if r.Width <= 0 || r.Height <= 0 {
+ return nil, fmt.Errorf("notifyd: image %dx%d", r.Width, r.Height)
+ }
+ if r.BitsPerSample != 8 || (r.Channels != 3 && r.Channels != 4) {
+ return nil, fmt.Errorf("notifyd: image bits=%d channels=%d", r.BitsPerSample, r.Channels)
+ }
+ stride := r.RowStride
+ if stride < r.Width*r.Channels {
+ stride = r.Width * r.Channels
+ }
+ if len(r.Data) < stride*(r.Height-1)+r.Width*r.Channels {
+ return nil, fmt.Errorf("notifyd: image data short: %d bytes", len(r.Data))
+ }
+ img := image.NewRGBA(image.Rect(0, 0, r.Width, r.Height))
+ for y := 0; y < r.Height; y++ {
+ row := r.Data[y*stride:]
+ for x := 0; x < r.Width; x++ {
+ if r.Channels == 4 {
+ i := x * 4
+ img.SetRGBA(x, y, color.RGBA{row[i], row[i+1], row[i+2], row[i+3]})
+ } else {
+ i := x * 3
+ img.SetRGBA(x, y, color.RGBA{row[i], row[i+1], row[i+2], 255})
+ }
+ }
+ }
+ var buf bytes.Buffer
+ if err := png.Encode(&buf, img); err != nil {
+ return nil, err
+ }
+ return buf.Bytes(), nil
+}
+```
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `go test ./internal/notify -run 'TestImage|TestRaw' -v`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/notify/image.go internal/notify/image_test.go
+git commit -m "feat(notify): parse the image hints and encode them to PNG
+
+image-data (and the deprecated icon_data) is the (iiibiiay) struct; the
+PNG encoder honours rowstride and both 3- and 4-channel data. The path
+and data readers are pure, so the service can apply the spec's priority
+and the store stays free of image handling."
+```
+
+---
+
+### Task 2: Theme-name resolution
+
+**Files:**
+- Create: `internal/notify/icons.go`
+- Test: `internal/notify/icons_test.go`
+
+**Interfaces:**
+- Consumes: nothing but the standard library and `os`.
+- Produces: `IconThemeName() string`; `ResolveIcon(value string) string`; `XDGIconDirs() []string`. Task 4 uses both entry points.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `internal/notify/icons_test.go`:
+
+```go
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+package notify
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestResolveIconPathAndURI(t *testing.T) {
+ if got := ResolveIcon("/usr/share/icons/x/apps/48/firefox.png"); got != "/usr/share/icons/x/apps/48/firefox.png" {
+ t.Fatalf("path passthrough: %q", got)
+ }
+ if got := ResolveIcon("file:///tmp/shot.png"); got != "/tmp/shot.png" {
+ t.Fatalf("uri: %q", got)
+ }
+ if got := ResolveIcon(""); got != "" {
+ t.Fatalf("empty: %q", got)
+ }
+}
+
+func TestResolveIconThemeName(t *testing.T) {
+ root := t.TempDir()
+ // Theme "Plum" inherits "Base"; the icon is only in Base.
+ theme := filepath.Join(root, "icons", "Plum")
+ base := filepath.Join(root, "icons", "Base")
+ plumIndex := filepath.Join(theme, "index.theme")
+ baseApp := filepath.Join(base, "apps", "48")
+ if err := os.MkdirAll(filepath.Join(theme, "apps", "scalable"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.MkdirAll(baseApp, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(plumIndex, []byte("[Icon Theme]\nInherits=Base\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(baseApp, "firefox.svg"), []byte("<svg/>"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ t.Setenv("XDG_DATA_HOME", root)
+ t.Setenv("XDG_DATA_DIRS", "")
+ t.Setenv("HOME", filepath.Join(root, "home"))
+ if err := os.MkdirAll(filepath.Join(root, "home", ".config", "qt6ct"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(root, "home", ".config", "qt6ct", "qt6ct.conf"), []byte("icon_theme=Plum\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ got := ResolveIcon("firefox")
+ want := filepath.Join(root, "icons", "Base", "apps", "48", "firefox.svg")
+ if got != want {
+ t.Fatalf("resolved %q, want %q", got, want)
+ }
+ if got := ResolveIcon("no-such-icon-xyz"); got != "" {
+ t.Fatalf("missing name must be empty, got %q", got)
+ }
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `go test ./internal/notify -run TestResolveIcon -v`
+Expected: FAIL with `undefined: ResolveIcon`.
+
+- [ ] **Step 3: Write the implementation**
+
+Create `internal/notify/icons.go`:
+
+```go
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+package notify
+
+import (
+ "os"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "strings"
+)
+
+// IconThemeName reads the desktop's current icon theme: qt6ct is the truth on
+// this desktop, then the GTK3 setting, then hicolor. A missing or empty value
+// falls through.
+func IconThemeName() string {
+ if v := iniValue(filepath.Join(homeDir(), ".config", "qt6ct", "qt6ct.conf"), "icon_theme"); v != "" {
+ return v
+ }
+ if v := iniValue(filepath.Join(homeDir(), ".config", "gtk-3.0", "settings.ini"), "gtk-icon-theme-name"); v != "" {
+ return v
+ }
+ return "hicolor"
+}
+
+// ResolveIcon turns an app_icon or image-path value into a file path. A URI is
+// trimmed, a path is returned unchanged, and a bare name is looked up in the
+// icon theme. Nothing found is an empty string, which renders no image.
+func ResolveIcon(value string) string {
+ if value == "" {
+ return ""
+ }
+ if strings.HasPrefix(value, "file://") {
+ return strings.TrimPrefix(value, "file://")
+ }
+ if strings.Contains(value, "/") {
+ return value
+ }
+ return lookupThemeIcon(value, IconThemeName())
+}
+
+// XDGIconDirs is the icon search path: the user's dir then each data dir.
+func XDGIconDirs() []string {
+ home := os.Getenv("XDG_DATA_HOME")
+ if home == "" {
+ home = filepath.Join(homeDir(), ".local", "share")
+ }
+ dirs := []string{filepath.Join(home, "icons")}
+ for _, d := range filepath.SplitList(os.Getenv("XDG_DATA_DIRS")) {
+ if d != "" {
+ dirs = append(dirs, filepath.Join(d, "icons"))
+ }
+ }
+ if len(dirs) == 1 {
+ dirs = append(dirs, "/usr/local/share/icons", "/usr/share/icons")
+ }
+ return dirs
+}
+
+// lookupThemeIcon searches the theme, then its Inherits chain, then hicolor.
+func lookupThemeIcon(name, theme string) string {
+ seen := map[string]bool{}
+ for theme != "" && !seen[theme] {
+ seen[theme] = true
+ found, next := "", ""
+ for _, root := range XDGIconDirs() {
+ base := filepath.Join(root, theme)
+ if p := findInTheme(base, name); p != "" {
+ found = p
+ break
+ }
+ if next == "" {
+ next = inheritsOf(base)
+ }
+ }
+ if found != "" {
+ return found
+ }
+ theme = next
+ }
+ for _, root := range XDGIconDirs() {
+ if p := findInTheme(filepath.Join(root, "hicolor"), name); p != "" {
+ return p
+ }
+ }
+ return ""
+}
+
+// findInTheme prefers scalable then the largest raster under apps/.
+func findInTheme(base, name string) string {
+ sizes := []string{"scalable"}
+ matches, _ := filepath.Glob(filepath.Join(base, "apps", "[0-9]*"))
+ for _, m := range matches {
+ sizes = append(sizes, filepath.Base(m))
+ }
+ numeric := sizes[1:]
+ sort.Slice(numeric, func(i, j int) bool {
+ a, _ := strconv.Atoi(strings.TrimSuffix(numeric[i], "@2x"))
+ b, _ := strconv.Atoi(strings.TrimSuffix(numeric[j], "@2x"))
+ return a > b
+ })
+ for _, size := range sizes {
+ for _, ext := range []string{"svg", "png", "xpm"} {
+ p := filepath.Join(base, "apps", size, name+"."+ext)
+ if fileExists(p) {
+ return p
+ }
+ }
+ }
+ return ""
+}
+
+func inheritsOf(base string) string {
+ v := iniValue(filepath.Join(base, "index.theme"), "Inherits")
+ if i := strings.IndexByte(v, ','); i >= 0 {
+ v = v[:i]
+ }
+ return strings.TrimSpace(v)
+}
+
+func iniValue(path, key string) string {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return ""
+ }
+ for _, line := range strings.Split(string(data), "\n") {
+ line = strings.TrimSpace(line)
+ if strings.HasPrefix(line, "#") || !strings.Contains(line, "=") {
+ continue
+ }
+ k, v, _ := strings.Cut(line, "=")
+ if strings.TrimSpace(k) == key {
+ return strings.TrimSpace(v)
+ }
+ }
+ return ""
+}
+
+func homeDir() string {
+ if h := os.Getenv("HOME"); h != "" {
+ return h
+ }
+ return os.TempDir()
+}
+
+func fileExists(path string) bool {
+ info, err := os.Stat(path)
+ return err == nil && !info.IsDir()
+}
+```
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `go test ./internal/notify -run TestResolveIcon -v`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/notify/icons.go internal/notify/icons_test.go
+git commit -m "feat(notify): resolve theme icon names to files
+
+qt6ct's icon_theme is authoritative on this desktop, with the GTK3
+setting and hicolor as fallbacks. The lookup prefers scalable, then the
+largest raster, and follows the theme's Inherits chain, so an app that
+passes a name instead of a path gets an icon."
+```
+
+---
+
+### Task 3: The image field and its lifecycle
+
+**Files:**
+- Modify: `internal/notify/store.go`
+- Modify: `internal/notify/files.go`
+- Test: `internal/notify/store_test.go`
+- Test: `internal/notify/files_test.go`
+
+**Interfaces:**
+- Consumes: `Popup` from `store.go`.
+- Produces: `Popup.Image string`; `NewStore(emit, publish, removeImage)` with a third parameter `removeImage func(string)`; `(*Store) SetImage(id uint32, path string)`; `WriteImage(dir string, id uint32, data []byte) (string, error)`; `ImagesDir(dir string) string`. Task 4 wires them.
+
+- [ ] **Step 1: Write the failing test**
+
+Append to `internal/notify/store_test.go`:
+
+```go
+func TestStoreRemovesImageOnDismissAndExpire(t *testing.T) {
+ var removed []string
+ s := NewStore(func(uint32, uint32) {}, func(_, _ []Popup) {}, func(p string) { removed = append(removed, p) })
+ id, _ := s.Add(&Popup{Image: "/run/img/1.png"}, "", 0)
+ s.SetImage(id, "/run/img/other.png")
+ s.Dismiss(id, 2)
+ if len(removed) != 1 || removed[0] != "/run/img/other.png" {
+ t.Fatalf("dismiss removed %v", removed)
+ }
+}
+
+func TestStoreRemovesImageOnReplace(t *testing.T) {
+ var removed []string
+ s := NewStore(func(uint32, uint32) {}, func(_, _ []Popup) {}, func(p string) { removed = append(removed, p) })
+ s.Add(&Popup{Image: "/run/img/old.png"}, "tag", 0)
+ id, _ := s.Add(&Popup{Image: "/run/img/new.png"}, "tag", 0)
+ _ = id
+ if len(removed) != 1 || removed[0] != "/run/img/old.png" {
+ t.Fatalf("replace removed %v", removed)
+ }
+}
+```
+
+Create `internal/notify/files_test.go` if it does not exist, else append:
+
+```go
+func TestWriteImage(t *testing.T) {
+ dir := t.TempDir()
+ p, err := WriteImage(dir, 7, []byte("png-bytes"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if filepath.Base(p) != "7.png" {
+ t.Fatalf("path %q", p)
+ }
+ if b, _ := os.ReadFile(p); string(b) != "png-bytes" {
+ t.Fatalf("contents %q", b)
+ }
+ if got := ImagesDir(dir); got != filepath.Join(dir, "img") {
+ t.Fatalf("ImagesDir %q", got)
+ }
+}
+```
+
+Add the needed imports (`os`, `path/filepath`) to the test files.
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `go test ./internal/notify -run 'TestStoreRemovesImage|TestWriteImage' -v`
+Expected: FAIL with `undefined: SetImage` / `WriteImage` and a NewStore arity error.
+
+- [ ] **Step 3: Write the implementation**
+
+In `internal/notify/store.go`, add the field to `Popup`:
+
+```go
+ Image string `json:"image"`
+```
+
+Add the callback to `Store` and `NewStore`:
+
+```go
+type Store struct {
+ // ...existing fields...
+ removeImage func(string)
+}
+
+func NewStore(emit func(id, reason uint32), publish func(live, history []Popup), removeImage func(string)) *Store {
+ return &Store{
+ nextID: 1,
+ entries: map[uint32]*live{},
+ emit: emit,
+ publish: publish,
+ removeImage: removeImage,
+ }
+}
+```
+
+In `Add`, before overwriting a replaced entry, remove its daemon-owned image:
+
+```go
+ add := &live{Popup: *n, Stack: stack}
+ if old != nil {
+ if s.removeImage != nil {
+ s.removeImage(old.Image)
+ }
+ add.ID = old.ID
+ // ...unchanged...
+```
+
+Add `SetImage` and clean up in `removeLocked` and `Expire`:
+
+```go
+// SetImage attaches a materialised image path to a live entry and republishes.
+func (s *Store) SetImage(id uint32, path string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ n, ok := s.entries[id]
+ if !ok {
+ return
+ }
+ n.Image = path
+ s.publishLocked()
+}
+```
+
+In `Expire`, after `n.Closed = true`, remove the image (the balloon is gone):
+
+```go
+ if s.removeImage != nil {
+ s.removeImage(n.Image)
+ }
+```
+
+In `removeLocked`, remove the image before deleting:
+
+```go
+func (s *Store) removeLocked(id uint32) {
+ if n, ok := s.entries[id]; ok && s.removeImage != nil {
+ s.removeImage(n.Image)
+ }
+ delete(s.entries, id)
+ // ...unchanged...
+```
+
+Update the two existing `NewStore(...)` call sites in `store_test.go` to pass `nil` or a no-op as the third argument.
+
+In `internal/notify/files.go`, add:
+
+```go
+// ImagesDir is where the daemon writes decoded image-data.
+func ImagesDir(dir string) string {
+ return filepath.Join(dir, "img")
+}
+
+// WriteImage writes a decoded image as <id>.png under the image directory.
+func WriteImage(dir string, id uint32, data []byte) (string, error) {
+ imgDir := ImagesDir(dir)
+ if err := os.MkdirAll(imgDir, 0o700); err != nil {
+ return "", err
+ }
+ path := filepath.Join(imgDir, strconv.FormatUint(uint64(id), 10)+".png")
+ tmp, err := os.CreateTemp(imgDir, ".img-*")
+ if err != nil {
+ return "", err
+ }
+ tmpName := tmp.Name()
+ if _, err := tmp.Write(data); err != nil {
+ tmp.Close()
+ os.Remove(tmpName)
+ return "", err
+ }
+ if err := tmp.Close(); err != nil {
+ os.Remove(tmpName)
+ return "", err
+ }
+ if err := os.Rename(tmpName, path); err != nil {
+ os.Remove(tmpName)
+ return "", err
+ }
+ return path, nil
+}
+```
+
+Add `strconv` to the `files.go` imports.
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run: `go test ./internal/notify -run 'TestStoreRemovesImage|TestWriteImage' -v`
+Expected: PASS. Then run `go test ./...` and `go vet ./...`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/notify/store.go internal/notify/store_test.go internal/notify/files.go internal/notify/files_test.go
+git commit -m "feat(notify): add the image field and its cleanup
+
+Popup gains image, and the store calls an injected removeImage when an
+entry is dismissed, evicted, replaced or expired, so a daemon-written
+PNG does not outlive its balloon. The service decides what is
+daemon-owned; the store only names the path."
+```
+
+---
+
+### Task 4: Service materialisation and capabilities
+
+**Files:**
+- Modify: `internal/notify/service.go`
+- Test: `internal/notify/service_test.go`
+- Modify: `test-notifyctl.sh`
+
+**Interfaces:**
+- Consumes: `ImageDataFromHints`, `ImagePathFromHints`, `(*RawImage).PNG`, `ResolveIcon`, `WriteImage`, `ImagesDir`, `Store.SetImage`.
+- Produces: a `Popup.Image` populated for every notification and `body-images` advertised.
+
+- [ ] **Step 1: Write the failing test**
+
+In `internal/notify/service_test.go`, add a capability assertion and an image-path assertion. The existing bus test builds a `NewService(...)`; keep its shape and add:
+
+```go
+func TestCapabilitiesIncludeBodyImages(t *testing.T) {
+ caps, err := NewService(nil, t.TempDir()).GetCapabilities()
+ if err != nil {
+ t.Fatal(err)
+ }
+ found := false
+ for _, c := range caps {
+ if c == "body-images" {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatalf("body-images missing from %v", caps)
+ }
+}
+```
+
+Add a pure test that an image-path hint becomes `Popup.Image`. The test file is
+`package notify`, so it builds a Service directly and captures the publish:
+
+```go
+func TestNotifyPublishesImagePath(t *testing.T) {
+ var live []Popup
+ s := &Service{dir: t.TempDir()}
+ s.store = NewStore(s.emitClosed, func(l, _ []Popup) { live = l }, s.removeImage)
+ hints := map[string]dbus.Variant{"image-path": dbus.MakeVariant("/tmp/shot.png")}
+ if _, err := s.Notify("t", 0, "", "s", "b", nil, hints, -1); err != nil {
+ t.Fatal(err)
+ }
+ if len(live) != 1 || live[0].Image != "/tmp/shot.png" {
+ t.Fatalf("published %+v", live)
+ }
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `go test ./...`
+Expected: FAIL with the missing capability and the missing `newServiceWithDir` if used.
+
+- [ ] **Step 3: Write the implementation**
+
+In `internal/notify/service.go`:
+
+```go
+func (s *Service) GetCapabilities() ([]string, *dbus.Error) {
+ return []string{"actions", "body-markup", "body-images", "icon-static", "persistence"}, nil
+}
+```
+
+Add the image removal hook and wire it in `NewService`:
+
+```go
+// removeImage unlinks only what the daemon wrote, so a client's own image-path
+// is never touched.
+func (s *Service) removeImage(path string) {
+ if path == "" {
+ return
+ }
+ if !strings.HasPrefix(path, ImagesDir(s.dir)+string(os.PathSeparator)) {
+ return
+ }
+ os.Remove(path)
+}
+```
+
+Update `NewService` to pass it:
+
+```go
+ s.store = NewStore(s.emitClosed, func(live, history []Popup) {
+ if err := Publish(s.dir, live, history); err != nil {
+ log.Printf("notifyd: publish: %v", err)
+ }
+ }, s.removeImage)
+```
+
+In `Start`, clear leftovers from a previous run:
+
+```go
+ if err := os.RemoveAll(ImagesDir(s.dir)); err != nil {
+ log.Printf("notifyd: clear images: %v", err)
+ }
+```
+
+In `Notify`, resolve the icon and the image:
+
+```go
+ n := &Popup{
+ App: appName,
+ Summary: summary,
+ Body: body,
+ Urgency: u,
+ Icon: ResolveIcon(appIcon),
+ Actions: ParseActions(actions),
+ Created: now,
+ }
+ if raw, ok := ImageDataFromHints(hints); ok {
+ if data, err := raw.PNG(); err == nil {
+ // The id is assigned by Add; remember the blob and write it after.
+ pendingImage = data
+ }
+ } else if path, ok := ImagePathFromHints(hints); ok {
+ n.Image = ResolveIcon(path)
+ }
+```
+
+Then after `Add`:
+
+```go
+ id, _ := s.store.Add(n, tag, replacesID)
+ if pendingImage != nil {
+ if path, err := WriteImage(s.dir, id, pendingImage); err == nil {
+ s.store.SetImage(id, path)
+ } else {
+ log.Printf("notifyd: write image: %v", err)
+ }
+ }
+ s.arm(id, ms)
+ return id, nil
+```
+
+Declare `var pendingImage []byte` before the `Popup` literal. Add `os` and `strings` to the imports.
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run: `go test ./... && go vet ./...`
+Expected: PASS and clean. Then `dbus-run-session -- go test ./internal/notify` and `bash test-notifyctl.sh`.
+
+Extend `test-notifyctl.sh`. The file is structured around a `dbus-run-session`
+that prints one line per observation into `$tmp/out`, then a `check label want
+got` per line. Add the image-path notification and its emitted line inside the
+session, after the first `notifyctl list` echo, then add the matching check and
+shift the existing "list cleared" check to the third line:
+
+Inside the `dbus-run-session` script, change the block to:
+
+```bash
+ notify-send -a test -u normal "t1" "b1" || exit 3
+ sleep 0.3
+ echo "$(notifyctl list | grep -c "\"summary\": \"t1\"")"
+ notify-send -a test -u normal --hint=string:image-path:/tmp/x.png "t2" "b2" || exit 3
+ sleep 0.3
+ echo "$(notifyctl list | grep -c "\"image\": \"/tmp/x.png\"")"
+ notifyctl close-all
+ sleep 0.3
+ echo "$(notifyctl list | grep -c "\"summary\": \"t1\"")"
+ kill $daemon
+```
+
+Then add and adjust the checks at the bottom of the script:
+
+```bash
+check "list shows the notification" "1" "$(sed -n 1p "$tmp/out")"
+check "image-path is published" "1" "$(sed -n 2p "$tmp/out")"
+check "list clears" "0" "$(sed -n 3p "$tmp/out")"
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/notify/service.go internal/notify/service_test.go test-notifyctl.sh
+git commit -m "feat(notify): materialise notification images
+
+Notify resolves app_icon and image-path theme names, decodes image-data to
+a PNG under the image directory, and re-publishes the entry with its
+image path. The daemon advertises body-images, and removeImage refuses to
+touch a path outside its own directory so a client's screenshot file is
+never deleted."
+```
+
+---
+
+## Self-Review
+
+**Spec coverage:** hints and priority (Task 1), theme resolution including qt6ct authority and Inherits (Task 2), the `image` contract field and cleanup lifecycle (Task 3), capabilities and materialisation (Task 4). The inline-image and renderer sections belong to the renderer plan.
+
+**Placeholder scan:** none; every code step carries the code.
+
+**Type consistency:** `RawImage`, `ImageDataFromHints`, `ImagePathFromHints`, `PNG`, `ResolveIcon`, `WriteImage`, `ImagesDir`, `SetImage`, and the `NewStore` third parameter are used with the same signatures across tasks.