From 03784b2024436827de25515b1d85d9ffdde40745 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Tue, 15 Sep 2026 18:03:29 +0200 Subject: 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. --- internal/notify/service.go | 27 ++++++++++++++++++++++++--- internal/notify/service_test.go | 29 +++++++++++++++++++++++++++++ test-notifyctl.sh | 6 +++++- 3 files changed, 58 insertions(+), 4 deletions(-) diff --git a/internal/notify/service.go b/internal/notify/service.go index 2442127..7bfad6e 100644 --- a/internal/notify/service.go +++ b/internal/notify/service.go @@ -73,6 +73,9 @@ func (s *Service) Start() error { if err := s.conn.Export(&control{s}, dbus.ObjectPath(ctrlPath), ctrlIface); err != nil { return err } + if err := os.RemoveAll(ImagesDir(s.dir)); err != nil { + log.Printf("notifyd: clear images: %v", err) + } s.store.Reset() return nil } @@ -83,9 +86,10 @@ func (s *Service) emitClosed(id, reason uint32) { // GetCapabilities tells clients what the daemon understands. actions and // body-markup are load-bearing: mail-notify sends actions and escapes its body -// because the running dunst advertises markup. +// because the running dunst advertises markup. body-images says the daemon +// decodes image-data and image-path, not just a static app icon. func (s *Service) GetCapabilities() ([]string, *dbus.Error) { - return []string{"actions", "body-markup", "icon-static", "persistence"}, nil + return []string{"actions", "body-markup", "body-images", "icon-static", "persistence"}, nil } func (s *Service) GetServerInformation() (string, string, string, string, *dbus.Error) { @@ -99,19 +103,36 @@ func (s *Service) Notify(appName string, replacesID uint32, appIcon, summary, bo tag := StackTagFromHints(hints) now := time.Now().UnixMilli() ms := EffectiveTimeoutMS(expireTimeout, u) + // image-data wins over image-path, and its blob cannot be written until + // Add has assigned the id the filename is derived from. + var pendingImage []byte n := &Popup{ App: appName, Summary: summary, Body: body, Urgency: u, - Icon: appIcon, + Icon: ResolveIcon(appIcon), Actions: ParseActions(actions), Created: now, } + if raw, ok := ImageDataFromHints(hints); ok { + if data, err := raw.PNG(); err == nil { + pendingImage = data + } + } else if path, ok := ImagePathFromHints(hints); ok { + n.Image = ResolveIcon(path) + } if ms > 0 { n.Expires = now + ms } 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 } diff --git a/internal/notify/service_test.go b/internal/notify/service_test.go index 871a0c7..735d01c 100644 --- a/internal/notify/service_test.go +++ b/internal/notify/service_test.go @@ -67,6 +67,35 @@ func TestIdentityAndCapabilities(t *testing.T) { } } +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) + } +} + +func TestNotifyPublishesImagePath(t *testing.T) { + var live []Popup + s := &Service{dir: t.TempDir(), timers: map[uint32]*time.Timer{}} + 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) + } +} + func TestNotifyReturnsAnIDAndPublishes(t *testing.T) { conn := busOrSkip(t) dir := t.TempDir() diff --git a/test-notifyctl.sh b/test-notifyctl.sh index 7018e2a..e91307a 100755 --- a/test-notifyctl.sh +++ b/test-notifyctl.sh @@ -51,6 +51,9 @@ dbus-run-session -- bash -c ' 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\"")" @@ -58,7 +61,8 @@ dbus-run-session -- bash -c ' ' _ "$tmp" > "$tmp/out" 2>"$tmp/err" check "list shows the notification" "1" "$(sed -n 1p "$tmp/out")" -check "close-all empties the live queue" "0" "$(sed -n 2p "$tmp/out")" +check "image-path is published" "1" "$(sed -n 2p "$tmp/out")" +check "list clears" "0" "$(sed -n 3p "$tmp/out")" check "no errors on stderr" "" "$(cat "$tmp/err")" printf '\n%d passed, %d failed\n' "$pass" "$fail" -- cgit v1.2.3