aboutsummaryrefslogtreecommitdiffstats
path: root/internal/notify
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-15 18:31:30 +0200
committerDanilo M. <danix@danix.xyz>2026-09-15 18:31:30 +0200
commit8087086bc0a996e7dc020ba8bcf1332dd843f034 (patch)
treef0fade4044cd29681670e54f7b9e80af8fce2f23 /internal/notify
parentd5812ffa717cad99e12752b719a024f06b72b6af (diff)
downloadnotifyd-8087086bc0a996e7dc020ba8bcf1332dd843f034.tar.gz
notifyd-8087086bc0a996e7dc020ba8bcf1332dd843f034.zip
fix(notify): route icon-like content images to the app-icon slot
Clients like kitty, mail and opencode send their icon through the content-image hint, so it drew as a large balloon preview. A theme-name source, an SVG, or a raster small on both sides now fills the app-icon slot instead; a larger raster (a screenshot) stays the preview. An app_icon already present keeps the slot.
Diffstat (limited to 'internal/notify')
-rw-r--r--internal/notify/classify.go58
-rw-r--r--internal/notify/classify_test.go95
-rw-r--r--internal/notify/service.go11
3 files changed, 163 insertions, 1 deletions
diff --git a/internal/notify/classify.go b/internal/notify/classify.go
new file mode 100644
index 0000000..c7e0dc8
--- /dev/null
+++ b/internal/notify/classify.go
@@ -0,0 +1,58 @@
+// 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 (
+ "image"
+ _ "image/gif"
+ _ "image/jpeg"
+ _ "image/png"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+// IconMaxPixels is the long-side bound below which a content image is treated
+// as an icon rather than a preview. A screenshot is far larger; a themed icon
+// or an application logo is not.
+const IconMaxPixels = 128
+
+// IsIconImage reports whether a content image should fill the app-icon slot
+// instead of the large preview. Clients disagree about where the icon goes:
+// kitty, mail and opencode send it through the content-image hint, while a
+// screenshot uses the same hint. The hint is one of the icon forms when the
+// client sent a theme name, when the resolved file is an SVG, or when a raster
+// is small on both sides. raw is the value before ResolveIcon; resolved is the
+// path it resolved to.
+func IsIconImage(raw, resolved string) bool {
+ if raw != "" && !strings.Contains(raw, "/") && !strings.HasPrefix(raw, "file:") {
+ return true
+ }
+ if resolved == "" {
+ return false
+ }
+ if strings.EqualFold(filepath.Ext(resolved), ".svg") {
+ return true
+ }
+ f, err := os.Open(resolved)
+ if err != nil {
+ return false
+ }
+ defer f.Close()
+ cfg, _, err := image.DecodeConfig(f)
+ if err != nil {
+ // A format without a registered decoder is not something we can size,
+ // so it stays a preview rather than being silently dropped.
+ return false
+ }
+ return cfg.Width <= IconMaxPixels && cfg.Height <= IconMaxPixels
+}
diff --git a/internal/notify/classify_test.go b/internal/notify/classify_test.go
new file mode 100644
index 0000000..6234008
--- /dev/null
+++ b/internal/notify/classify_test.go
@@ -0,0 +1,95 @@
+// 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 (
+ "image"
+ "image/png"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/godbus/dbus/v5"
+)
+
+func writeTestPNG(t *testing.T, path string, w, h int) {
+ t.Helper()
+ f, err := os.Create(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer f.Close()
+ if err := png.Encode(f, image.NewRGBA(image.Rect(0, 0, w, h))); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestIsIconImage(t *testing.T) {
+ dir := t.TempDir()
+ small := filepath.Join(dir, "logo.png")
+ large := filepath.Join(dir, "shot.png")
+ svg := filepath.Join(dir, "icon.svg")
+ writeTestPNG(t, small, 32, 32)
+ writeTestPNG(t, large, 800, 600)
+ if err := os.WriteFile(svg, []byte("<svg/>"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ cases := []struct {
+ name string
+ raw, resolved string
+ want bool
+ }{
+ {"theme name", "utilities-terminal", "/x/apps/16/utilities-terminal.svg", true},
+ {"svg", svg, svg, true},
+ {"small raster", small, small, true},
+ {"large raster", large, large, false},
+ {"missing file", "/no/such.png", "/no/such.png", false},
+ {"empty", "", "", false},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ if got := IsIconImage(c.raw, c.resolved); got != c.want {
+ t.Fatalf("IsIconImage(%q, %q) = %v, want %v", c.raw, c.resolved, got, c.want)
+ }
+ })
+ }
+}
+
+func TestNotifyRoutesContentImageByKind(t *testing.T) {
+ dir := t.TempDir()
+ small := filepath.Join(dir, "logo.png")
+ large := filepath.Join(dir, "shot.png")
+ writeTestPNG(t, small, 32, 32)
+ writeTestPNG(t, large, 800, 600)
+
+ run := func(path string) Popup {
+ 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(path)}
+ if _, err := s.Notify("app", 0, "", "s", "b", nil, hints, -1); err != nil {
+ t.Fatal(err)
+ }
+ if len(live) != 1 {
+ t.Fatalf("published %d popups", len(live))
+ }
+ return live[0]
+ }
+
+ if p := run(small); p.Icon != small || p.Image != "" {
+ t.Fatalf("small image: icon=%q image=%q, want icon only", p.Icon, p.Image)
+ }
+ if p := run(large); p.Icon != "" || p.Image != large {
+ t.Fatalf("large image: icon=%q image=%q, want image only", p.Icon, p.Image)
+ }
+}
diff --git a/internal/notify/service.go b/internal/notify/service.go
index 86918d4..a2bf103 100644
--- a/internal/notify/service.go
+++ b/internal/notify/service.go
@@ -120,7 +120,16 @@ func (s *Service) Notify(appName string, replacesID uint32, appIcon, summary, bo
if r, ok := ImageDataFromHints(hints); ok {
raw = r
} else if path, ok := ImagePathFromHints(hints); ok {
- n.Image = ResolveIcon(path)
+ resolved := ResolveIcon(path)
+ // An icon-like content image (a theme name, an SVG, a small raster) is
+ // the app icon clients like kitty send through this hint; a larger
+ // raster is a screenshot and stays the balloon preview. An app_icon
+ // already provided keeps the slot.
+ if IsIconImage(path, resolved) && n.Icon == "" {
+ n.Icon = resolved
+ } else {
+ n.Image = resolved
+ }
} else if r, ok := IconDataFromHints(hints); ok {
raw = r
}