# 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. // // 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. // // 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. // // 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(""), 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. // // 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 .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.