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 | |
| 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.
| -rw-r--r-- | docs/superpowers/plans/2026-09-15-notification-image-renderers.md | 218 | ||||
| -rw-r--r-- | docs/superpowers/plans/2026-09-15-notifyd-images.md | 1000 |
2 files changed, 1218 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-09-15-notification-image-renderers.md b/docs/superpowers/plans/2026-09-15-notification-image-renderers.md new file mode 100644 index 0000000..660949e --- /dev/null +++ b/docs/superpowers/plans/2026-09-15-notification-image-renderers.md @@ -0,0 +1,218 @@ +# Notification Image Renderers 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 balloon draws a notification's content image as a large preview below the text, and the RichText body never fetches a remote image. + +**Architecture:** `NotificationBalloon` gains one `Image` bound to the new `notifyd` contract field `image`, sized to the balloon width and capped in height. Inline `<img>` already renders through the existing RichText body; a small sanitizer in the `Notify` singleton strips remote sources before display, so the shell cannot be made to fetch a URL. + +**Tech Stack:** Quickshell 0.3.1, Qt6 QML, `Quickshell.Io.FileView`, `Quickshell.Io.Process`. + +**Spec:** `docs/superpowers/specs/2026-09-15-notification-images-design.md` (read it before starting). The daemon half is a separate plan and must ship for the image path to be exercised; this plan tolerates a daemon without the field. + +## Global Constraints + +- Quickshell 0.3.1, Qt6 QML. Run a config with `qs -p <dir>`. The running process is `qs`: `pkill -x qs`, `pgrep -cx qs`, never `pkill -f`. +- GPLv2 only. Existing headers stay; no new source file in this plan needs one. +- `image` may be absent on an older daemon, so every read guards `!== undefined`. +- Inline images are local only: a `<img>` whose `src` is `http:` or `https:` is stripped before display. Local paths and `file://` are left alone. +- No em dashes. No home paths in committed files. +- Smoke check, harness owns the process: + +```bash +timeout 8 qs -p <dir> 2>&1 | grep -E 'ERROR|TypeError|ReferenceError|is not defined|Cannot assign|Unable to assign' && echo "ERRORS ABOVE" || echo "clean" +``` + +--- + +## File Structure + + notifications/NotificationBalloon.qml the large preview image (modify) + shared/Notify.qml the sanitizer for inline sources (modify) + desktop/NotificationRow.qml use the sanitizer for the row body (modify) + +--- + +### Task 1: The balloon image preview + +**Files:** +- Modify: `notifications/NotificationBalloon.qml` + +**Interfaces:** +- Consumes: the daemon's `image` field (`notification.image`, a path string or undefined). +- Produces: nothing consumed by later tasks. + +- [ ] **Step 1: Make the balloon height account for the image** + +In `notifications/NotificationBalloon.qml`, change: + +```qml + implicitHeight: texts.implicitHeight + 20 +``` + +to: + +```qml + implicitHeight: texts.implicitHeight + 20 + (preview.visible ? preview.height + 8 : 0) +``` + +- [ ] **Step 2: Add the preview image** + +Insert this block immediately after the closing `}` of the `Column { id: texts ... }` +and before the `Text { id: close ... }`: + +```qml + // The content image (a screenshot or an app-provided image), below the + // text. The daemon writes the path; an older daemon without the field + // leaves this hidden. The height matches the scaled width so + // PreserveAspectFit does not letterbox, and a tall screenshot is capped at + // 240px. Asynchronous so a large screenshot does not stall the shell. + Image { + id: preview + visible: b.notification.image !== "" && b.notification.image !== undefined + anchors { + left: parent.left + right: parent.right + top: texts.bottom + leftMargin: 10 + rightMargin: 10 + topMargin: 8 + } + height: visible && implicitWidth > 0 + ? Math.min(width * implicitHeight / implicitWidth, 240) + : 0 + source: visible ? "file://" + b.notification.image : "" + fillMode: Image.PreserveAspectFit + asynchronous: true + } +``` + +- [ ] **Step 3: Smoke check** + +```bash +timeout 8 qs -p ./notifications 2>&1 | grep -E 'ERROR|TypeError|ReferenceError|is not defined|Cannot assign|Unable to assign' && echo "ERRORS ABOVE" || echo "clean" +``` + +Expected: `clean`. The running daemon currently publishes no `image` field, so +this also proves the `undefined` guard holds: nothing new is drawn. + +- [ ] **Step 4: Confirm by hand, once the daemon plan has shipped** + +Ask the user to send: + +```bash +notify-send -u critical -t 30000 -i ~/.cache/opencode/packages/@mohak34/opencode-notifier@latest/node_modules/@mohak34/opencode-notifier/logos/opencode-logo-dark.png "preview" "the logo should fill the balloon width" +``` + +Expected: a balloon with the logo as a large image below the text, undistorted and capped in height. A grimblast screenshot (`notify-send -i <screenshot>`) behaves the same. + +- [ ] **Step 5: Commit** + +```bash +git add notifications/NotificationBalloon.qml +git commit -m "feat(notifications): draw the content image in the balloon + +The daemon now publishes an image path; the balloon shows it below the +text, scaled to the balloon width with a 240px cap. A daemon without the +field leaves it hidden, so the renderer and the daemon can ship in +either order." +``` + +--- + +### Task 2: Strip remote inline image sources + +**Files:** +- Modify: `shared/Notify.qml` +- Modify: `notifications/NotificationBalloon.qml` +- Modify: `desktop/NotificationRow.qml` + +**Interfaces:** +- Consumes: nothing. +- Produces: `Notify.sanitize(body)` returning the body with remote `<img>` tags removed. + +- [ ] **Step 1: Add the sanitizer** + +In `shared/Notify.qml`, add this function beside `run`/`close`: + +```qml + // Inline images are local only. A notification is untrusted input, and a + // remote <img src> would otherwise make the shell fetch a URL, which leaks + // that the notification was shown. This removes such tags before the + // RichText body renders; a local path or file:// source is left alone. + function sanitize(body) { + return (body || "").replace(/<img\b[^>]*\bsrc\s*=\s*["']?\s*https?:\/\/[^>]*>/gi, ""); + } +``` + +- [ ] **Step 2: Use it in both renderers** + +In `notifications/NotificationBalloon.qml`, change the body text: + +```qml + text: b.notification.body || "" +``` + +to: + +```qml + text: Notify.sanitize(b.notification.body) +``` + +In `desktop/NotificationRow.qml`, change: + +```qml + text: row.notification.body || "" +``` + +to: + +```qml + text: Notify.sanitize(row.notification.body) +``` + +The balloon already resolves `Notify`; the row does too, through the +`desktop/Notify.qml` symlink. + +- [ ] **Step 3: Smoke check both configs** + +```bash +timeout 8 qs -p ./notifications 2>&1 | grep -E 'ERROR|TypeError|ReferenceError|is not defined|Cannot assign|Unable to assign' && echo "ERRORS ABOVE" || echo "clean" +timeout 8 qs -p ./desktop 2>&1 | grep -E 'ERROR|TypeError|ReferenceError|is not defined|Cannot assign|Unable to assign' && echo "ERRORS ABOVE" || echo "clean" +``` + +Expected: both `clean`. + +- [ ] **Step 4: Confirm by hand** + +Ask the user to send both, with the logo path from Task 1: + +```bash +notify-send -u critical -t 30000 "inline local" "above<br><img src='file://<logo-path>' width='200'><br>below" +notify-send -u critical -t 30000 "inline remote" "above<br><img src='https://example.org/does-not-exist.png' width='200'><br>below" +``` + +Expected: the first shows the image inline between the lines of text. The +second shows only the text, with no image and no network request. + +- [ ] **Step 5: Commit** + +```bash +git add shared/Notify.qml notifications/NotificationBalloon.qml desktop/NotificationRow.qml +git commit -m "feat(notifications): strip remote inline image sources + +A notification is untrusted input. Inline <img> now renders only for +local sources; an http(s) source is removed before the RichText body is +shown, so a remote sender cannot make the shell fetch a URL. The row and +the balloon share the one sanitizer in the Notify singleton." +``` + +--- + +## Self-Review + +**Spec coverage:** the balloon large preview (Task 1) and the local-only inline policy (Task 2) are the renderer half of the spec. The drawer row correctly gets no image. App-icon theme names are resolved daemon-side, so the renderer is unchanged there. + +**Placeholder scan:** none; every step carries its code. + +**Type consistency:** `Notify.sanitize(body)` is defined in Task 2 and used by both renderers; `notification.image` is read as a string path in Task 1. 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. |
