Files

396 lines
9.9 KiB
Go

// 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: &notificationID,
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, &notificationID
}
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, " ")
}