// 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 ( "log" "sync" "time" "github.com/godbus/dbus/v5" ) // Name is the well-known bus name the daemon owns. const Name = "org.freedesktop.Notifications" const ( objPath = "/org/freedesktop/Notifications" iface = "org.freedesktop.Notifications" ctrlPath = "/xyz/danix/Notifyd" ctrlIface = "xyz.danix.Notifyd" ) // Service is the org.freedesktop.Notifications object. type Service struct { conn *dbus.Conn store *Store dir string mu sync.Mutex timers map[uint32]*time.Timer } func NewService(conn *dbus.Conn, dir string) *Service { s := &Service{conn: conn, dir: dir, timers: map[uint32]*time.Timer{}} s.store = NewStore(s.emitClosed, func(live, history []Popup) { if err := Publish(s.dir, live, history); err != nil { log.Printf("notifyd: publish: %v", err) } }) return s } // Start exports the interfaces and empties the state. Nothing from a previous // run is resurrected. func (s *Service) Start() error { if err := s.conn.Export(s, dbus.ObjectPath(objPath), iface); err != nil { return err } if err := s.conn.Export(&control{s}, dbus.ObjectPath(ctrlPath), ctrlIface); err != nil { return err } s.store.Reset() return nil } func (s *Service) emitClosed(id, reason uint32) { s.conn.Emit(dbus.ObjectPath(objPath), iface+".NotificationClosed", id, reason) } // GetCapabilities tells clients what the daemon understands. actions and // body-markup are load-bearing: mail-notify sends actions and escapes its body // because the running dunst advertises markup. func (s *Service) GetCapabilities() ([]string, *dbus.Error) { return []string{"actions", "body-markup", "icon-static", "persistence"}, nil } func (s *Service) GetServerInformation() (string, string, string, string, *dbus.Error) { return "danix", "danix", "0.1", "1.2", nil } // Notify is the spec's entry point. The id is returned to the client; a // replace reuses the id of what it replaced. func (s *Service) Notify(appName string, replacesID uint32, appIcon, summary, body string, actions []string, hints map[string]dbus.Variant, expireTimeout int32) (uint32, *dbus.Error) { u := UrgencyFromHints(hints) tag := StackTagFromHints(hints) now := time.Now().UnixMilli() ms := EffectiveTimeoutMS(expireTimeout, u) n := &Popup{ App: appName, Summary: summary, Body: body, Urgency: u, Icon: appIcon, Actions: ParseActions(actions), Created: now, } if ms > 0 { n.Expires = now + ms } id, _ := s.store.Add(n, tag, replacesID) s.arm(id, ms) return id, nil } // CloseNotification is the spec's programmatic close, reason 3. func (s *Service) CloseNotification(id uint32) *dbus.Error { s.stopTimer(id) s.store.Dismiss(id, 3) return nil } func (s *Service) arm(id uint32, ms int64) { s.mu.Lock() defer s.mu.Unlock() s.stopTimerLocked(id) if ms <= 0 { return } var t *time.Timer t = time.AfterFunc(time.Duration(ms)*time.Millisecond, func() { s.mu.Lock() current := s.timers[id] == t if current { delete(s.timers, id) } s.mu.Unlock() // Only the current timer may expire the id: a timer left over from a // replaced notification must not close its successor. if current { s.store.Expire(id) } }) s.timers[id] = t } func (s *Service) stopTimer(id uint32) { s.mu.Lock() defer s.mu.Unlock() s.stopTimerLocked(id) } func (s *Service) stopTimerLocked(id uint32) { if t, ok := s.timers[id]; ok { t.Stop() delete(s.timers, id) } }