// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package notification import ( "context" "fmt" "html" "regexp" "strings" "sync" "github.com/godbus/dbus/v5" ) const ( MakoSource = "mako" makoBusName = "org.freedesktop.Notifications" makoObjectPath = "/fr/emersion/Mako" makoInterface = "fr.emersion.Mako" ) type MakoConnection interface { Signal(chan<- *dbus.Signal) RemoveSignal(subscription chan<- *dbus.Signal) Object(dest string, path dbus.ObjectPath) dbus.BusObject AddMatchSignal(match ...dbus.MatchOption) error RemoveMatchSignal(match ...dbus.MatchOption) error Close() error } type MakoAnnouncer interface { Announce(entry Entry) } type MakoMonitor struct { connection MakoConnection presenter *Presenter announcer MakoAnnouncer mutex sync.Mutex generation int available bool known map[int]struct{} running bool stop chan struct{} stopped chan struct{} subscription <-chan *dbus.Signal } type MakoController interface { IsCurrent(entry Entry) bool Dismiss(notificationID int) bool InvokeAction(notificationID int, actionKey string) bool } func NewMakoMonitor(connection MakoConnection, presenter *Presenter, announcer MakoAnnouncer) *MakoMonitor { return &MakoMonitor{ connection: connection, presenter: presenter, announcer: announcer, known: make(map[int]struct{}), stop: make(chan struct{}), stopped: make(chan struct{}), } } func (monitor *MakoMonitor) Start(ctx context.Context) error { monitor.mutex.Lock() if monitor.running { monitor.mutex.Unlock() return nil } monitor.running = true monitor.stop = make(chan struct{}) monitor.stopped = make(chan struct{}) monitor.mutex.Unlock() go monitor.watch(ctx) return nil } func (monitor *MakoMonitor) Stop(ctx context.Context) error { monitor.mutex.Lock() if !monitor.running { monitor.mutex.Unlock() return nil } monitor.running = false close(monitor.stop) monitor.mutex.Unlock() select { case <-monitor.stopped: case <-ctx.Done(): return ctx.Err() } monitor.mutex.Lock() defer monitor.mutex.Unlock() monitor.unsubscribe() monitor.presenter.MarkSourceUnavailable(MakoSource) clear(monitor.known) monitor.available = false return monitor.connection.Close() } func (monitor *MakoMonitor) Refresh(announceNew bool) bool { monitor.mutex.Lock() available := monitor.available monitor.mutex.Unlock() if !available { return false } notifications, err := monitor.list() if err != nil { monitor.markUnavailable() return false } monitor.sync(notifications, announceNew) return true } func (monitor *MakoMonitor) activate(announceNew bool) bool { notifications, err := monitor.list() if err != nil { monitor.markUnavailable() return false } monitor.mutex.Lock() monitor.generation++ monitor.available = true clear(monitor.known) monitor.mutex.Unlock() monitor.sync(notifications, announceNew) return true } func (monitor *MakoMonitor) Dismiss(notificationID int) bool { options := map[string]dbus.Variant{ "id": dbus.MakeVariant(uint32(notificationID)), "group": dbus.MakeVariant(false), "history": dbus.MakeVariant(true), "all": dbus.MakeVariant(false), } if err := monitor.object().Call("fr.emersion.Mako.DismissNotifications", 0, options).Err; err != nil { return false } monitor.Refresh(false) return true } func (monitor *MakoMonitor) InvokeAction(notificationID int, actionKey string) bool { if err := monitor.object().Call("fr.emersion.Mako.InvokeAction", 0, uint32(notificationID), actionKey).Err; err != nil { return false } monitor.Refresh(false) return true } func (monitor *MakoMonitor) IsCurrent(entry Entry) bool { monitor.mutex.Lock() defer monitor.mutex.Unlock() return monitor.available && entry.Source == MakoSource && entry.SourceGeneration == monitor.generation && entry.Live } func (monitor *MakoMonitor) Generation() int { monitor.mutex.Lock() defer monitor.mutex.Unlock() return monitor.generation } func (monitor *MakoMonitor) watch(ctx context.Context) { defer close(monitor.stopped) signals := make(chan *dbus.Signal, 16) monitor.subscription = signals monitor.connection.Signal(signals) defer monitor.connection.RemoveSignal(signals) if err := monitor.connection.AddMatchSignal( dbus.WithMatchObjectPath(makoObjectPath), dbus.WithMatchInterface("org.freedesktop.DBus.Properties"), ); err != nil { monitor.markUnavailable() return } if err := monitor.connection.AddMatchSignal( dbus.WithMatchSender("org.freedesktop.DBus"), dbus.WithMatchObjectPath("/org/freedesktop/DBus"), dbus.WithMatchInterface("org.freedesktop.DBus"), dbus.WithMatchMember("NameOwnerChanged"), ); err != nil { monitor.markUnavailable() return } monitor.activate(false) for { select { case <-ctx.Done(): return case <-monitor.stop: return case signal := <-signals: monitor.handleSignal(signal) } } } func (monitor *MakoMonitor) handleSignal(signal *dbus.Signal) { if signal == nil { return } switch signal.Name { case "org.freedesktop.DBus.Properties.PropertiesChanged": if signal.Path == makoObjectPath { monitor.Refresh(true) } case "org.freedesktop.DBus.NameOwnerChanged": if signal.Path != "/org/freedesktop/DBus" || len(signal.Body) < 3 { return } name, _ := signal.Body[0].(string) newOwner, _ := signal.Body[2].(string) if name != makoBusName { return } if newOwner == "" { monitor.markUnavailable() } else { monitor.activate(false) } } } func (monitor *MakoMonitor) sync(notifications []map[string]dbus.Variant, announceNew bool) { parsed := make([]LiveMetadata, 0, len(notifications)) ids := make([]int, 0, len(notifications)) live := make(map[int]LiveMetadata) for _, notification := range notifications { metadata, id := parseMako(notification) if id == nil { continue } parsed = append(parsed, metadata) ids = append(ids, *id) live[*id] = metadata } monitor.presenter.SyncLive(MakoSource, monitor.Generation(), live) if announceNew { for index, id := range ids { if monitor.knownID(id) { continue } notificationID := id monitor.announcer.Announce(Entry{ Message: parsed[index].Message, Source: MakoSource, SourceGeneration: monitor.Generation(), NotificationID: ¬ificationID, Live: true, Actions: parsed[index].Actions, AppName: parsed[index].AppName, Summary: parsed[index].Summary, Body: parsed[index].Body, Urgency: parsed[index].Urgency, DesktopEntry: parsed[index].DesktopEntry, }) } } monitor.replaceKnown(live) } func (monitor *MakoMonitor) list() ([]map[string]dbus.Variant, error) { var reply []map[string]dbus.Variant object := monitor.object() if err := object.Call(makoInterface+".ListNotifications", 0).Store(&reply); err != nil { return nil, fmt.Errorf("list mako notifications: %w", err) } return reply, nil } func (monitor *MakoMonitor) object() dbus.BusObject { return monitor.connection.Object(makoBusName, makoObjectPath) } func (monitor *MakoMonitor) knownID(id int) bool { monitor.mutex.Lock() defer monitor.mutex.Unlock() _, known := monitor.known[id] return known } func (monitor *MakoMonitor) replaceKnown(live map[int]LiveMetadata) { monitor.mutex.Lock() defer monitor.mutex.Unlock() monitor.known = make(map[int]struct{}, len(live)) for id := range live { monitor.known[id] = struct{}{} } } func (monitor *MakoMonitor) markUnavailable() { monitor.mutex.Lock() defer monitor.mutex.Unlock() monitor.available = false clear(monitor.known) monitor.presenter.MarkSourceUnavailable(MakoSource) } func (monitor *MakoMonitor) unsubscribe() { if monitor.subscription != nil { _ = monitor.connection.RemoveMatchSignal( dbus.WithMatchObjectPath(makoObjectPath), dbus.WithMatchInterface("org.freedesktop.DBus.Properties"), ) _ = monitor.connection.RemoveMatchSignal( dbus.WithMatchSender("org.freedesktop.DBus"), dbus.WithMatchObjectPath("/org/freedesktop/DBus"), dbus.WithMatchInterface("org.freedesktop.DBus"), dbus.WithMatchMember("NameOwnerChanged"), ) } } var ( makoTagPattern = regexp.MustCompile(`<[^>]+>`) makoWhitespacePattern = regexp.MustCompile(`\s+`) ) func parseMako(notification map[string]dbus.Variant) (LiveMetadata, *int) { id, idValid := notification["id"].Value().(uint32) if !idValid { return LiveMetadata{}, nil } metadata := LiveMetadata{ AppName: normalizeMakoText(notification["app-name"]), Summary: normalizeMakoText(notification["summary"]), Body: normalizeMakoText(notification["body"]), DesktopEntry: normalizeMakoText(notification["desktop-entry"]), } if value, valid := notification["urgency"].Value().(int32); valid { metadata.Urgency = int(value) } else { metadata.Urgency = -1 } if actions, valid := notification["actions"].Value().(map[string]dbus.Variant); valid { metadata.Actions = make(map[string]string, len(actions)) for key, action := range actions { value := normalizeMakoText(action) if value == "" { value = key } metadata.Actions[key] = value } } notificationID := int(id) metadata.Message = buildMakoMessage(metadata) return metadata, ¬ificationID } func normalizeMakoText(variant dbus.Variant) string { text, valid := variant.Value().(string) if !valid { return "" } text = html.UnescapeString(text) text = makoTagPattern.ReplaceAllString(text, " ") text = makoWhitespacePattern.ReplaceAllString(text, " ") return strings.TrimSpace(text) } func buildMakoMessage(metadata LiveMetadata) string { parts := []string{MessageNotification} if metadata.Summary != "" { parts = append(parts, metadata.Summary) } if metadata.Body != "" && metadata.Body != metadata.Summary { parts = append(parts, metadata.Body) } if len(parts) == 1 { fallback := metadata.AppName if fallback == "" { fallback = metadata.DesktopEntry } if fallback != "" { parts = append(parts, fallback) } } return strings.Join(parts, " ") }