// 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"` Image string `json:"image"` 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) removeImage func(string) } 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, removeImage: removeImage, } } // 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 { if s.removeImage != nil { s.removeImage(old.Image) } 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 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) { 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) { 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 { 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) }