396 lines
9.9 KiB
Go
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: ¬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, " ")
|
|
}
|