aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--internal/notify/image.go134
-rw-r--r--internal/notify/image_test.go101
2 files changed, 235 insertions, 0 deletions
diff --git a/internal/notify/image.go b/internal/notify/image.go
new file mode 100644
index 0000000..4a0f6df
--- /dev/null
+++ b/internal/notify/image.go
@@ -0,0 +1,134 @@
+// 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
+}
diff --git a/internal/notify/image_test.go b/internal/notify/image_test.go
new file mode 100644
index 0000000..2db4c75
--- /dev/null
+++ b/internal/notify/image_test.go
@@ -0,0 +1,101 @@
+// 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")
+ }
+}