aboutsummaryrefslogtreecommitdiffstats
path: root/internal
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-15 17:59:35 +0200
committerDanilo M. <danix@danix.xyz>2026-09-15 17:59:35 +0200
commit3b8a0e0f4a5c28394ac578e8559bf57ae82d3fd2 (patch)
treefafa293256cca4615e4725553a391bf8dfdee99b /internal
parenta248608721e813d5e6d19fc79d2505a2a8c2716a (diff)
downloadnotifyd-3b8a0e0f4a5c28394ac578e8559bf57ae82d3fd2.tar.gz
notifyd-3b8a0e0f4a5c28394ac578e8559bf57ae82d3fd2.zip
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. NewStore now takes the callback as a third parameter, so the service wires its removeImage in (unlinking only under ImagesDir) and the existing call sites pass nil.
Diffstat (limited to 'internal')
-rw-r--r--internal/notify/files.go34
-rw-r--r--internal/notify/files_test.go17
-rw-r--r--internal/notify/service.go16
-rw-r--r--internal/notify/service_test.go2
-rw-r--r--internal/notify/store.go48
-rw-r--r--internal/notify/store_test.go23
6 files changed, 126 insertions, 14 deletions
diff --git a/internal/notify/files.go b/internal/notify/files.go
index 53e7f3f..e4da336 100644
--- a/internal/notify/files.go
+++ b/internal/notify/files.go
@@ -15,6 +15,7 @@ import (
"encoding/json"
"os"
"path/filepath"
+ "strconv"
)
// RuntimeDir is where the daemon publishes. It is tmpfs, so a reboot clears
@@ -45,6 +46,39 @@ func Publish(dir string, live, history []Popup) error {
return writeJSON(filepath.Join(dir, "history.json"), history)
}
+// 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
+}
+
func writeJSON(path string, v any) error {
data, err := json.Marshal(v)
if err != nil {
diff --git a/internal/notify/files_test.go b/internal/notify/files_test.go
index b17a4f6..606b131 100644
--- a/internal/notify/files_test.go
+++ b/internal/notify/files_test.go
@@ -55,3 +55,20 @@ func TestPublishEmptyIsAnEmptyArray(t *testing.T) {
t.Errorf("empty queue encoded as %q, want []", data)
}
}
+
+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)
+ }
+}
diff --git a/internal/notify/service.go b/internal/notify/service.go
index 22d7830..d46293f 100644
--- a/internal/notify/service.go
+++ b/internal/notify/service.go
@@ -13,6 +13,8 @@ package notify
import (
"log"
+ "os"
+ "strings"
"sync"
"time"
@@ -45,10 +47,22 @@ func NewService(conn *dbus.Conn, dir string) *Service {
if err := Publish(s.dir, live, history); err != nil {
log.Printf("notifyd: publish: %v", err)
}
- })
+ }, s.removeImage)
return s
}
+// 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)
+}
+
// Start exports the interfaces and empties the state. Nothing from a previous
// run is resurrected.
func (s *Service) Start() error {
diff --git a/internal/notify/service_test.go b/internal/notify/service_test.go
index 1e9f771..fb47b98 100644
--- a/internal/notify/service_test.go
+++ b/internal/notify/service_test.go
@@ -149,7 +149,7 @@ func TestCloseNotificationEmitsReasonThree(t *testing.T) {
// so the test exercises arm's closure directly.
func TestNaturalExpiryDeletesTheTimerEntry(t *testing.T) {
s := &Service{dir: t.TempDir(), timers: map[uint32]*time.Timer{}}
- s.store = NewStore(func(id, reason uint32) {}, func(live, history []Popup) {})
+ s.store = NewStore(func(id, reason uint32) {}, func(live, history []Popup) {}, nil)
id, err := s.Notify("app", 0, "", "s", "b", nil, nil, 50)
if err != nil {
t.Fatalf("Notify: %v", err)
diff --git a/internal/notify/store.go b/internal/notify/store.go
index f1e9fe4..7a1a745 100644
--- a/internal/notify/store.go
+++ b/internal/notify/store.go
@@ -22,6 +22,7 @@ type Popup struct {
Body string `json:"body"`
Urgency Urgency `json:"urgency"`
Icon string `json:"icon"`
+ Image string `json:"image"`
Actions [][2]string `json:"actions"`
Created int64 `json:"created"`
Expires int64 `json:"expires"`
@@ -43,21 +44,23 @@ const (
// Store holds the live queue and the history ring. Time, signals and file
// writes are injected, so the whole thing is tested without a bus or a clock.
type Store struct {
- mu sync.Mutex
- nextID uint32
- order []uint32
- entries map[uint32]*live
- history []*live
- emit func(id, reason uint32)
- publish func(live, history []Popup)
+ mu sync.Mutex
+ nextID uint32
+ order []uint32
+ entries map[uint32]*live
+ history []*live
+ emit func(id, reason uint32)
+ publish func(live, history []Popup)
+ removeImage func(string)
}
-func NewStore(emit func(id, reason uint32), publish func(live, history []Popup)) *Store {
+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,
+ nextID: 1,
+ entries: map[uint32]*live{},
+ emit: emit,
+ publish: publish,
+ removeImage: removeImage,
}
}
@@ -82,6 +85,9 @@ func (s *Store) Add(n *Popup, stack string, replacesID uint32) (uint32, bool) {
}
add := &live{Popup: *n, Stack: stack}
if old != nil {
+ if s.removeImage != nil {
+ s.removeImage(old.Image)
+ }
add.ID = old.ID
s.entries[add.ID] = add
s.moveToFrontLocked(add.ID)
@@ -107,9 +113,24 @@ func (s *Store) Expire(id uint32) {
return
}
n.Closed = true
+ if s.removeImage != nil {
+ s.removeImage(n.Image)
+ }
s.emit(id, 1)
}
+// 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()
+}
+
// Dismiss is an explicit close from either renderer. The client is told only
// if expiry has not already told it, then the entry is filed.
func (s *Store) Dismiss(id, reason uint32) {
@@ -174,6 +195,9 @@ func (s *Store) historySnapshot() []*live {
}
func (s *Store) removeLocked(id uint32) {
+ if n, ok := s.entries[id]; ok && s.removeImage != nil {
+ s.removeImage(n.Image)
+ }
delete(s.entries, id)
for i, v := range s.order {
if v == id {
diff --git a/internal/notify/store_test.go b/internal/notify/store_test.go
index 1fb8bdc..a0ab7be 100644
--- a/internal/notify/store_test.go
+++ b/internal/notify/store_test.go
@@ -23,6 +23,7 @@ func newTestStore() (*Store, *[]event) {
s := NewStore(
func(id, reason uint32) { *emitted = append(*emitted, event{id, reason}) },
func(live, history []Popup) {},
+ nil,
)
return s, emitted
}
@@ -131,3 +132,25 @@ func TestHistoryRingCapsAtTwenty(t *testing.T) {
t.Errorf("history length = %d, want 20", got)
}
}
+
+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)
+ }
+}