diff options
| author | Danilo M. <danix@danix.xyz> | 2026-09-15 17:48:54 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-09-15 17:48:54 +0200 |
| commit | a94dfc49961db651b9659c6670125285fd7fc553 (patch) | |
| tree | a1a19234e367649c640d8bbf9c5f977607a9785a /docs/superpowers/plans/2026-09-15-notifyd-images.md | |
| parent | beeb0bc507066c6a57267e82cc35a49d671feb63 (diff) | |
| download | quickshell-a94dfc49961db651b9659c6670125285fd7fc553.tar.gz quickshell-a94dfc49961db651b9659c6670125285fd7fc553.zip | |
docs: add the notification images implementation plans
Two plans, one per subsystem: the notifyd daemon (hint parsing, PNG encode, theme-name resolution, the image field and its cleanup) and the quickshell renderers (the balloon preview and remote inline source stripping). Either can ship first; the renderer tolerates a daemon without the field.
Diffstat (limited to 'docs/superpowers/plans/2026-09-15-notifyd-images.md')
| -rw-r--r-- | docs/superpowers/plans/2026-09-15-notifyd-images.md | 1000 |
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. |
