diff options
Diffstat (limited to 'internal/notify')
| -rw-r--r-- | internal/notify/files.go | 34 | ||||
| -rw-r--r-- | internal/notify/files_test.go | 17 | ||||
| -rw-r--r-- | internal/notify/service.go | 16 | ||||
| -rw-r--r-- | internal/notify/service_test.go | 2 | ||||
| -rw-r--r-- | internal/notify/store.go | 48 | ||||
| -rw-r--r-- | internal/notify/store_test.go | 23 |
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) + } +} |
