From 79c064efe75aa201584a9d618cbde950574f0ca5 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Tue, 15 Sep 2026 13:47:09 +0200 Subject: feat: add the notification store The store holds the live queue and the history ring as pure state. Expiry tells the client and keeps the entry inert; dismissal and eviction file it in history. Replacing reuses the id and emits nothing. --- internal/notify/store.go | 226 ++++++++++++++++++++++++++++++++++++++++++ internal/notify/store_test.go | 126 +++++++++++++++++++++++ 2 files changed, 352 insertions(+) create mode 100644 internal/notify/store.go create mode 100644 internal/notify/store_test.go (limited to 'internal') diff --git a/internal/notify/store.go b/internal/notify/store.go new file mode 100644 index 0000000..f1e9fe4 --- /dev/null +++ b/internal/notify/store.go @@ -0,0 +1,226 @@ +// Copyright (C) 2026 Danilo M. +// +// 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 "sync" + +// Popup is one notification as the renderers see it. The JSON tags are the +// file contract in the spec. +type Popup struct { + ID uint32 `json:"id"` + App string `json:"app"` + Summary string `json:"summary"` + Body string `json:"body"` + Urgency Urgency `json:"urgency"` + Icon string `json:"icon"` + Actions [][2]string `json:"actions"` + Created int64 `json:"created"` + Expires int64 `json:"expires"` +} + +// live rounds out a Popup with the state the renderers do not need: the stack +// tag it replaces on, and whether the D-Bus client has already been closed. +type live struct { + Popup + Stack string + Closed bool +} + +const ( + liveCap = 20 + historyCap = 20 +) + +// 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) +} + +func NewStore(emit func(id, reason uint32), publish func(live, history []Popup)) *Store { + return &Store{ + nextID: 1, + entries: map[uint32]*live{}, + emit: emit, + publish: publish, + } +} + +// Add inserts n, replacing the entry named by replacesID or stack when one +// matches. A replace reuses the id and emits nothing: the old client is told +// nothing because a new client owns the id now. +func (s *Store) Add(n *Popup, stack string, replacesID uint32) (uint32, bool) { + s.mu.Lock() + defer s.mu.Unlock() + + var old *live + if replacesID != 0 { + old = s.entries[replacesID] + } + if old == nil && stack != "" { + for _, id := range s.order { + if s.entries[id].Stack == stack { + old = s.entries[id] + break + } + } + } + add := &live{Popup: *n, Stack: stack} + if old != nil { + add.ID = old.ID + s.entries[add.ID] = add + s.moveToFrontLocked(add.ID) + s.evictLocked() + s.publishLocked() + return add.ID, true + } + add.ID = s.nextID + s.nextID++ + s.entries[add.ID] = add + s.order = append([]uint32{add.ID}, s.order...) + s.evictLocked() + s.publishLocked() + return add.ID, false +} + +// Expire is the balloon timeout: tell the client, keep the entry as inert. +func (s *Store) Expire(id uint32) { + s.mu.Lock() + defer s.mu.Unlock() + n, ok := s.entries[id] + if !ok || n.Closed { + return + } + n.Closed = true + s.emit(id, 1) +} + +// 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) { + s.mu.Lock() + defer s.mu.Unlock() + n, ok := s.entries[id] + if !ok { + return + } + s.removeLocked(id) + s.fileLocked(n, reason) + s.publishLocked() +} + +// DismissAll dismisses every live entry. +func (s *Store) DismissAll() { + s.mu.Lock() + defer s.mu.Unlock() + for _, id := range append([]uint32(nil), s.order...) { + n := s.entries[id] + if n == nil { + continue + } + s.removeLocked(id) + s.fileLocked(n, 2) + } + s.publishLocked() +} + +// ClearHistory empties the history ring. +func (s *Store) ClearHistory() { + s.mu.Lock() + defer s.mu.Unlock() + s.history = nil + s.publishLocked() +} + +// Actionable reports whether a client is still listening on the id. +func (s *Store) Actionable(id uint32) bool { + s.mu.Lock() + defer s.mu.Unlock() + n, ok := s.entries[id] + return ok && !n.Closed +} + +// Reset makes the state empty, which is what a start needs: nothing from a +// previous run is resurrected. +func (s *Store) Reset() { + s.mu.Lock() + defer s.mu.Unlock() + s.order = nil + s.entries = map[uint32]*live{} + s.history = nil + s.publishLocked() +} + +// historySnapshot is for tests. +func (s *Store) historySnapshot() []*live { + s.mu.Lock() + defer s.mu.Unlock() + return append([]*live(nil), s.history...) +} + +func (s *Store) removeLocked(id uint32) { + delete(s.entries, id) + for i, v := range s.order { + if v == id { + s.order = append(s.order[:i], s.order[i+1:]...) + return + } + } +} + +func (s *Store) moveToFrontLocked(id uint32) { + for i, v := range s.order { + if v == id { + s.order = append(s.order[:i], s.order[i+1:]...) + break + } + } + s.order = append([]uint32{id}, s.order...) +} + +func (s *Store) fileLocked(n *live, reason uint32) { + if !n.Closed { + n.Closed = true + s.emit(n.ID, reason) + } + s.history = append([]*live{n}, s.history...) + if len(s.history) > historyCap { + s.history = s.history[:historyCap] + } +} + +func (s *Store) evictLocked() { + for len(s.order) > liveCap { + id := s.order[len(s.order)-1] + n := s.entries[id] + s.removeLocked(id) + s.fileLocked(n, 1) + } +} + +func (s *Store) publishLocked() { + liveList := make([]Popup, 0, len(s.order)) + for _, id := range s.order { + liveList = append(liveList, s.entries[id].Popup) + } + historyList := make([]Popup, 0, len(s.history)) + for _, n := range s.history { + historyList = append(historyList, n.Popup) + } + s.publish(liveList, historyList) +} diff --git a/internal/notify/store_test.go b/internal/notify/store_test.go new file mode 100644 index 0000000..4e4dd79 --- /dev/null +++ b/internal/notify/store_test.go @@ -0,0 +1,126 @@ +// Copyright (C) 2026 Danilo M. +// +// 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 "testing" + +type event struct { + id uint32 + reason uint32 +} + +func newTestStore() (*Store, *[]event) { + emitted := &[]event{} + s := NewStore( + func(id, reason uint32) { *emitted = append(*emitted, event{id, reason}) }, + func(live, history []Popup) {}, + ) + return s, emitted +} + +func popup(app string) *Popup { + return &Popup{App: app, Urgency: Normal, Created: 1} +} + +func TestAddAssignsIdsFromOne(t *testing.T) { + s, _ := newTestStore() + a, replaced := s.Add(popup("a"), "", 0) + b, _ := s.Add(popup("b"), "", 0) + if replaced { + t.Fatal("first add reported replaced") + } + if a != 1 || b != 2 { + t.Errorf("ids = %d, %d, want 1, 2", a, b) + } +} + +func TestReplaceByIDReusesTheIDAndEmitsNothing(t *testing.T) { + s, emitted := newTestStore() + id, _ := s.Add(popup("a"), "", 0) + *emitted = nil + again, replaced := s.Add(popup("a2"), "", id) + if !replaced { + t.Fatal("replace by id not reported") + } + if again != id { + t.Errorf("replace id = %d, want %d", again, id) + } + if len(*emitted) != 0 { + t.Errorf("replace emitted %v, want none", *emitted) + } +} + +func TestReplaceByStackTag(t *testing.T) { + s, _ := newTestStore() + first, _ := s.Add(popup("mail"), "mail-account", 0) + second, replaced := s.Add(popup("mail"), "mail-account", 0) + if !replaced || second != first { + t.Errorf("stack replace = id %d replaced %v, want id %d replaced true", second, replaced, first) + } +} + +func TestExpireClosesOnceAndKeepsTheEntry(t *testing.T) { + s, emitted := newTestStore() + id, _ := s.Add(popup("a"), "", 0) + s.Expire(id) + s.Expire(id) + if len(*emitted) != 1 || (*emitted)[0].reason != 1 { + t.Fatalf("emitted = %v, want one reason 1", *emitted) + } + if s.Actionable(id) { + t.Error("expired entry still actionable") + } +} + +func TestDismissFilesHistoryAndEmitsDismissed(t *testing.T) { + s, emitted := newTestStore() + id, _ := s.Add(popup("a"), "", 0) + s.Dismiss(id, 2) + if len(*emitted) != 1 || (*emitted)[0].reason != 2 { + t.Fatalf("emitted = %v, want one reason 2", *emitted) + } + if s.Actionable(id) { + t.Error("dismissed entry still live") + } +} + +func TestDismissAfterExpireEmitsNothingMore(t *testing.T) { + s, emitted := newTestStore() + id, _ := s.Add(popup("a"), "", 0) + s.Expire(id) + *emitted = nil + s.Dismiss(id, 2) + if len(*emitted) != 0 { + t.Errorf("dismiss after expire emitted %v, want none", *emitted) + } +} + +func TestQueueCapEvictsToHistory(t *testing.T) { + s, emitted := newTestStore() + for i := 0; i < 21; i++ { + s.Add(popup("a"), "", 0) + } + if len(*emitted) == 0 { + t.Fatal("eviction emitted nothing") + } +} + +func TestHistoryRingCapsAtTwenty(t *testing.T) { + s, _ := newTestStore() + for i := 0; i < 30; i++ { + id, _ := s.Add(popup("a"), "", 0) + s.Dismiss(id, 2) + } + if got := len(s.historySnapshot()); got != 20 { + t.Errorf("history length = %d, want 20", got) + } +} -- cgit v1.2.3