Cthulhu is no longer a fork. Now it is a screen reader written in Golang.

This commit is contained in:
Storm Dragon committed 2026-10-14 00:16:34 -04:00
1 parent a998fe67de
commit 069fc674dc
708 files changed
+112482 -532

No files matched your search

+395
View File
@@ -0,0 +1,395 @@
// 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, " ")
}
+228
View File
@@ -0,0 +1,228 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package notification
import (
"context"
"errors"
"testing"
"github.com/godbus/dbus/v5"
)
func TestMakoSyncAnnouncesOnlyNewNotifications(t *testing.T) {
presenter := NewPresenter()
announcer := &fakeAnnouncer{}
monitor := NewMakoMonitor(nil, presenter, announcer)
monitor.available = true
notifications := []map[string]dbus.Variant{makoNotification(7)}
monitor.sync(notifications, true)
if len(announcer.entries) != 1 {
t.Fatalf("announced %d notifications, want 1", len(announcer.entries))
}
if announcer.entries[0].Message != "Notification Mail You have one message" {
t.Fatalf("message = %q", announcer.entries[0].Message)
}
monitor.sync(notifications, true)
if len(announcer.entries) != 1 {
t.Fatalf("announced %d notifications after refresh, want 1", len(announcer.entries))
}
}
func TestMakoParseNormalizesMetadata(t *testing.T) {
metadata, id := parseMako(makoNotification(9))
if id == nil || *id != 9 {
t.Fatalf("id = %v, want 9", id)
}
if metadata.AppName != "Application" || metadata.Summary != "Mail" ||
metadata.Body != "You have one message" || metadata.DesktopEntry != "application" ||
metadata.Urgency != 2 || metadata.Actions["default"] != "View" {
t.Fatalf("metadata = %+v", metadata)
}
}
func TestMakoStopMarksLiveHistoryUnavailable(t *testing.T) {
presenter := NewPresenter()
notificationID := 12
presenter.Save("Notification Mail", SaveOptions{
Source: MakoSource, NotificationID: &notificationID, Live: true,
})
monitor := NewMakoMonitor(&fakeMakoConnection{}, presenter, &fakeAnnouncer{})
monitor.available = true
monitor.running = true
monitor.stop = make(chan struct{})
monitor.stopped = make(chan struct{})
close(monitor.stopped)
if err := monitor.Stop(context.Background()); err != nil {
t.Fatalf("stop mako: %v", err)
}
entries := presenter.Entries()
if len(entries) != 1 || entries[0].Live {
t.Fatalf("entries = %+v, want one unavailable entry", entries)
}
}
func TestMakoOwnerLossMarksLiveHistoryUnavailable(t *testing.T) {
presenter := NewPresenter()
notificationID := 12
presenter.Save("Notification Mail", SaveOptions{
Source: MakoSource, NotificationID: &notificationID, Live: true,
})
monitor := NewMakoMonitor(&fakeMakoConnection{}, presenter, &fakeAnnouncer{})
monitor.available = true
monitor.handleSignal(&dbus.Signal{
Name: "org.freedesktop.DBus.NameOwnerChanged",
Path: "/org/freedesktop/DBus",
Body: []interface{}{makoBusName, ":1.10", ""},
})
entries := presenter.Entries()
if monitor.available || len(entries) != 1 || entries[0].Live {
t.Fatalf("available = %v, entries = %+v", monitor.available, entries)
}
}
func TestMakoUnsupportedDaemonMarksHistoryUnavailable(t *testing.T) {
presenter := NewPresenter()
notificationID := 12
presenter.Save("Notification Mail", SaveOptions{
Source: MakoSource, NotificationID: &notificationID, Live: true,
})
monitor := NewMakoMonitor(&fakeMakoConnection{
object: &fakeMakoObject{err: errors.New("object does not exist")},
}, presenter, &fakeAnnouncer{})
monitor.available = true
if monitor.Refresh(false) {
t.Fatal("unsupported notification daemon reported as available")
}
entries := presenter.Entries()
if len(entries) != 1 || entries[0].Live {
t.Fatalf("entries = %+v, want one unavailable entry", entries)
}
if len(presenter.ActionsForEntry(entries[0])) != 0 {
t.Fatal("unsupported notification daemon retained live actions")
}
}
func TestPresenterRoutesLiveMakoActions(t *testing.T) {
presenter := NewPresenter()
controller := &fakeMakoController{}
presenter.SetMakoController(controller)
notificationID := 6
entry := presenter.Save("Notification Mail New message", SaveOptions{
Source: MakoSource, SourceGeneration: 1,
NotificationID: &notificationID, Live: true,
Actions: map[string]string{"default": "View"},
})
if !presenter.CanControlEntry(entry) {
t.Fatal("live mako entry was not controllable")
}
if actions := presenter.ActionsForEntry(entry); actions["default"] != "View" {
t.Fatalf("actions = %q, want View", actions["default"])
}
messages, dismissed := presenter.DismissEntry(entry)
if !dismissed || messages[0] != MessageDismissed {
t.Fatalf("dismiss = %v %q", dismissed, messages)
}
if len(presenter.Entries()) != 0 || controller.dismissed[0] != 6 {
t.Fatalf("entries = %+v dismissed = %v", presenter.Entries(), controller.dismissed)
}
actionMessages, invoked := presenter.InvokeActionEntry(entry, "default")
if !invoked || actionMessages[0] != MessageActionInvoked || controller.invoked[0] != (makoAction{6, "default"}) {
t.Fatalf("invoke = %v %q", invoked, actionMessages)
}
}
func makoNotification(id uint32) map[string]dbus.Variant {
return map[string]dbus.Variant{
"id": dbus.MakeVariant(id),
"app-name": dbus.MakeVariant("Application"),
"summary": dbus.MakeVariant("<b>Mail</b>"),
"body": dbus.MakeVariant("You have one message"),
"desktop-entry": dbus.MakeVariant("application"),
"urgency": dbus.MakeVariant(int32(2)),
"actions": dbus.MakeVariant(map[string]dbus.Variant{"default": dbus.MakeVariant("View")}),
}
}
type fakeAnnouncer struct{ entries []Entry }
func (announcer *fakeAnnouncer) Announce(entry Entry) {
announcer.entries = append(announcer.entries, entry)
}
type fakeMakoConnection struct {
object *fakeMakoObject
}
func (connection *fakeMakoConnection) Signal(chan<- *dbus.Signal) {}
func (connection *fakeMakoConnection) RemoveSignal(chan<- *dbus.Signal) {}
func (connection *fakeMakoConnection) Object(string, dbus.ObjectPath) dbus.BusObject {
return connection.object
}
func (connection *fakeMakoConnection) AddMatchSignal(...dbus.MatchOption) error { return nil }
func (connection *fakeMakoConnection) RemoveMatchSignal(...dbus.MatchOption) error { return nil }
func (connection *fakeMakoConnection) Close() error { return nil }
type fakeMakoObject struct {
err error
}
func (object *fakeMakoObject) Call(string, dbus.Flags, ...interface{}) *dbus.Call {
return &dbus.Call{Err: object.err}
}
func (object *fakeMakoObject) CallWithContext(context.Context, string, dbus.Flags, ...interface{}) *dbus.Call {
return &dbus.Call{Err: object.err}
}
func (object *fakeMakoObject) Go(string, dbus.Flags, chan *dbus.Call, ...interface{}) *dbus.Call {
return &dbus.Call{Err: object.err}
}
func (object *fakeMakoObject) GoWithContext(context.Context, string, dbus.Flags, chan *dbus.Call, ...interface{}) *dbus.Call {
return &dbus.Call{Err: object.err}
}
func (object *fakeMakoObject) AddMatchSignal(string, string, ...dbus.MatchOption) *dbus.Call {
return &dbus.Call{Err: object.err}
}
func (object *fakeMakoObject) RemoveMatchSignal(string, string, ...dbus.MatchOption) *dbus.Call {
return &dbus.Call{Err: object.err}
}
func (object *fakeMakoObject) GetProperty(string) (dbus.Variant, error) {
return dbus.Variant{}, object.err
}
func (object *fakeMakoObject) StoreProperty(string, interface{}) error {
return object.err
}
func (object *fakeMakoObject) SetProperty(string, interface{}) error {
return object.err
}
func (object *fakeMakoObject) Destination() string { return makoBusName }
func (object *fakeMakoObject) Path() dbus.ObjectPath { return makoObjectPath }
type makoAction struct {
id int
key string
}
type fakeMakoController struct {
dismissed []int
invoked []makoAction
}
func (controller *fakeMakoController) IsCurrent(Entry) bool { return true }
func (controller *fakeMakoController) Dismiss(id int) bool {
controller.dismissed = append(controller.dismissed, id)
return true
}
func (controller *fakeMakoController) InvokeAction(id int, key string) bool {
controller.invoked = append(controller.invoked, makoAction{id, key})
return true
}
+442
View File
@@ -0,0 +1,442 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package notification
import (
"fmt"
"math"
"sync"
"time"
)
var noMessages []string
const historySize = 55
const (
MessageNotification = "Notification"
MessageListBottom = "Bottom"
MessageListTop = "Top"
MessageNoMessages = "No notification messages"
MessageUnavailable = "Notification is no longer available"
MessageNoActions = "No notification actions available"
MessageDismissed = "Notification dismissed"
MessageActionInvoked = "Notification action invoked"
SourceGeneric = "generic"
ListTitle = "Notifications"
ListColumnMessages = "Notifications"
ListColumnReceived = "Received"
ListDismissButton = "Dismiss Notification"
ListActionsButton = "Notification Actions"
ListActionsTitle = "Notification Actions"
ListInvokeActionButton = "Invoke Action"
ListClearButton = "Clear"
ListCopyButton = "Copy"
ListCloseButton = "Close"
)
type Entry struct {
ID string
Message string
Timestamp time.Time
Source string
SourceGeneration int
NotificationID *int
Live bool
Actions map[string]string
AppName string
Summary string
Body string
Urgency int
DesktopEntry string
}
type SaveOptions struct {
Source string
SourceGeneration int
NotificationID *int
Live bool
Actions map[string]string
AppName string
Summary string
Body string
Urgency int
DesktopEntry string
}
type LiveMetadata struct {
Message string
Actions map[string]string
AppName string
Summary string
Body string
Urgency int
DesktopEntry string
}
type Presenter struct {
mutex sync.Mutex
now func() time.Time
mako MakoController
entries []Entry
currentIndex int
}
func NewPresenter() *Presenter {
return &Presenter{now: time.Now, currentIndex: -1}
}
func NewPresenterAt(now func() time.Time) *Presenter {
return &Presenter{now: now, currentIndex: -1}
}
func (presenter *Presenter) SetMakoController(controller MakoController) {
presenter.mutex.Lock()
defer presenter.mutex.Unlock()
presenter.mako = controller
}
func (presenter *Presenter) Save(message string, options ...SaveOptions) Entry {
option := SaveOptions{}
if len(options) != 0 {
option = options[0]
}
if option.Source == "" {
option.Source = SourceGeneric
}
presenter.mutex.Lock()
defer presenter.mutex.Unlock()
if excess := len(presenter.entries) - historySize + 1; excess > 0 {
presenter.entries = presenter.entries[excess:]
}
entry := Entry{
Message: message,
Timestamp: presenter.now(),
Source: option.Source,
SourceGeneration: option.SourceGeneration,
NotificationID: option.NotificationID,
Live: option.Live,
Actions: cloneActions(option.Actions),
AppName: option.AppName,
Summary: option.Summary,
Body: option.Body,
Urgency: option.Urgency,
DesktopEntry: option.DesktopEntry,
}
presenter.entries = append(presenter.entries, entry)
return entry
}
func (presenter *Presenter) Clear() {
presenter.mutex.Lock()
defer presenter.mutex.Unlock()
presenter.entries = nil
presenter.currentIndex = -1
}
func (presenter *Presenter) Entries() []Entry {
presenter.mutex.Lock()
defer presenter.mutex.Unlock()
return append([]Entry(nil), presenter.entries...)
}
func (presenter *Presenter) EntriesForList() []Entry {
presenter.mutex.Lock()
defer presenter.mutex.Unlock()
return presenter.entriesForList()
}
func (presenter *Presenter) ListTitle() string {
presenter.mutex.Lock()
defer presenter.mutex.Unlock()
return presenter.listTitle()
}
func (presenter *Presenter) EntryText(entry Entry) string {
presenter.mutex.Lock()
now := presenter.now()
presenter.mutex.Unlock()
return entryText(entry, now)
}
func (presenter *Presenter) ReceivedTime(timestamp time.Time) string {
presenter.mutex.Lock()
now := presenter.now()
presenter.mutex.Unlock()
return relativeTime(timestamp, now)
}
func (presenter *Presenter) RefreshLive() bool {
presenter.mutex.Lock()
controller := presenter.mako
presenter.mutex.Unlock()
refresher, ok := controller.(interface{ Refresh(bool) bool })
if !ok {
return false
}
return refresher.Refresh(false)
}
func (presenter *Presenter) Remove(entry Entry) bool {
presenter.mutex.Lock()
defer presenter.mutex.Unlock()
for index, candidate := range presenter.entries {
if entriesEqual(candidate, entry) {
presenter.entries = append(presenter.entries[:index], presenter.entries[index+1:]...)
presenter.removeIndex(index)
return true
}
}
return false
}
func (presenter *Presenter) SyncLive(source string, generation int, live map[int]LiveMetadata) {
presenter.mutex.Lock()
defer presenter.mutex.Unlock()
for index := range presenter.entries {
entry := &presenter.entries[index]
if entry.Source != source || entry.SourceGeneration != generation {
continue
}
if entry.NotificationID == nil {
continue
}
if metadata, exists := live[*entry.NotificationID]; exists {
entry.Live = true
if metadata.Message != "" {
entry.Message = metadata.Message
}
entry.Actions = cloneActions(metadata.Actions)
entry.AppName = metadata.AppName
entry.Summary = metadata.Summary
entry.Body = metadata.Body
entry.Urgency = metadata.Urgency
entry.DesktopEntry = metadata.DesktopEntry
} else if entry.Live {
entry.Live = false
}
}
}
func (presenter *Presenter) MarkSourceUnavailable(source string) {
presenter.mutex.Lock()
defer presenter.mutex.Unlock()
for index := range presenter.entries {
if presenter.entries[index].Source == source && presenter.entries[index].Live {
presenter.entries[index].Live = false
}
}
}
func (presenter *Presenter) CanControlEntry(entry Entry) bool {
presenter.mutex.Lock()
controller := presenter.mako
presenter.mutex.Unlock()
return entry.Live && entry.NotificationID != nil && controller != nil && controller.IsCurrent(entry)
}
func (presenter *Presenter) ActionsForEntry(entry Entry) map[string]string {
if !presenter.CanControlEntry(entry) {
return map[string]string{}
}
presenter.mutex.Lock()
defer presenter.mutex.Unlock()
return cloneActions(entry.Actions)
}
func (presenter *Presenter) DismissEntry(entry Entry) ([]string, bool) {
if !presenter.CanControlEntry(entry) {
return []string{MessageUnavailable}, false
}
presenter.mutex.Lock()
controller := presenter.mako
presenter.mutex.Unlock()
if controller == nil || !controller.Dismiss(*entry.NotificationID) {
return []string{MessageUnavailable}, false
}
entry.Live = false
presenter.Remove(entry)
return []string{MessageDismissed}, true
}
func (presenter *Presenter) InvokeActionEntry(entry Entry, actionKey string) ([]string, bool) {
if !presenter.CanControlEntry(entry) {
return []string{MessageUnavailable}, false
}
presenter.mutex.Lock()
controller := presenter.mako
presenter.mutex.Unlock()
if controller == nil || !controller.InvokeAction(*entry.NotificationID, actionKey) {
return []string{MessageUnavailable}, false
}
return []string{MessageActionInvoked}, true
}
func (presenter *Presenter) PresentLast() []string {
presenter.mutex.Lock()
defer presenter.mutex.Unlock()
if len(presenter.entries) == 0 {
return []string{MessageNoMessages}
}
presenter.currentIndex = -1
return []string{entryText(presenter.entries[len(presenter.entries)-1], presenter.now())}
}
func (presenter *Presenter) PresentPrevious() []string {
presenter.mutex.Lock()
defer presenter.mutex.Unlock()
if len(presenter.entries) == 0 {
return []string{MessageNoMessages}
}
messages := noMessages
next := presenter.currentIndex - 1
if next < -len(presenter.entries) {
messages = append(messages, MessageListTop)
} else {
presenter.currentIndex = next
}
current := len(presenter.entries) + presenter.currentIndex
return append(messages, entryText(presenter.entries[current], presenter.now()))
}
func (presenter *Presenter) PresentNext() []string {
presenter.mutex.Lock()
defer presenter.mutex.Unlock()
if len(presenter.entries) == 0 {
return []string{MessageNoMessages}
}
messages := noMessages
if presenter.currentIndex == -1 {
messages = append(messages, MessageListBottom)
} else {
next := presenter.currentIndex + 1
if next > -1 {
messages = append(messages, MessageListBottom)
} else {
presenter.currentIndex = next
}
}
current := len(presenter.entries) + presenter.currentIndex
return append(messages, entryText(presenter.entries[current], presenter.now()))
}
func (presenter *Presenter) removeIndex(index int) {
if len(presenter.entries) == 0 {
presenter.currentIndex = -1
return
}
if presenter.currentIndex == -1 {
return
}
// Navigation uses negative offsets from the newest entry; removal receives
// a forward slice index. Preserve the selected entry when it survives, or
// select its successor when removed, without retaining an out-of-range offset.
current := len(presenter.entries) + 1 + presenter.currentIndex
if index < current {
current--
}
current = min(current, len(presenter.entries)-1)
presenter.currentIndex = current - len(presenter.entries)
}
func (presenter *Presenter) entriesForList() []Entry {
entries := append([]Entry(nil), presenter.entries...)
for left, right := 0, len(entries)-1; left < right; left, right = left+1, right-1 {
entries[left], entries[right] = entries[right], entries[left]
}
return entries
}
func (presenter *Presenter) listTitle() string {
return plural(len(presenter.entries), "%d notification", "%d notifications")
}
func entryText(entry Entry, now time.Time) string {
return fmt.Sprintf("%s %s", entry.Message, relativeTime(entry.Timestamp, now))
}
func relativeTime(timestamp, now time.Time) string {
elapsed := now.Sub(timestamp)
switch {
case elapsed < time.Minute:
count := int(elapsed.Seconds())
return plural(count, "%d second ago", "%d seconds ago")
case elapsed < time.Hour:
count := roundLikePython(elapsed.Minutes())
return plural(count, "%d minute ago", "%d minutes ago")
case elapsed < 24*time.Hour:
count := roundLikePython(elapsed.Hours())
return plural(count, "%d hour ago", "%d hours ago")
default:
count := roundLikePython(elapsed.Hours() / 24)
return plural(count, "%d day ago", "%d days ago")
}
}
func plural(count int, singular, pluralFormat string) string {
if count == 1 {
return fmt.Sprintf(singular, count)
}
return fmt.Sprintf(pluralFormat, count)
}
func roundLikePython(value float64) int {
lower := math.Floor(value)
fraction := value - lower
switch {
case fraction > 0.5:
return int(lower) + 1
case fraction == 0.5 && int(lower)%2 != 0:
return int(lower) + 1
default:
return int(lower)
}
}
func cloneActions(actions map[string]string) map[string]string {
if len(actions) == 0 {
return map[string]string{}
}
clone := make(map[string]string, len(actions))
for key, value := range actions {
clone[key] = value
}
return clone
}
func entriesEqual(left, right Entry) bool {
return left.Message == right.Message &&
left.Timestamp.Equal(right.Timestamp) &&
left.Source == right.Source &&
left.SourceGeneration == right.SourceGeneration &&
notificationIDsEqual(left.NotificationID, right.NotificationID) &&
actionsEqual(left.Actions, right.Actions) &&
left.AppName == right.AppName &&
left.Summary == right.Summary &&
left.Body == right.Body &&
left.Urgency == right.Urgency &&
left.DesktopEntry == right.DesktopEntry
}
func notificationIDsEqual(left, right *int) bool {
if left == nil || right == nil {
return left == right
}
return *left == *right
}
func actionsEqual(left, right map[string]string) bool {
if len(left) != len(right) {
return false
}
for key, value := range left {
if right[key] != value {
return false
}
}
return true
}
+123
View File
@@ -0,0 +1,123 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package notification
import (
"testing"
"time"
)
func TestPresenterSavesHistoryAndEnforcesMaximumSize(t *testing.T) {
now := time.Unix(1_800_000_000, 0)
presenter := NewPresenterAt(func() time.Time { return now })
for number := 0; number < historySize+2; number++ {
presenter.Save(time.Unix(int64(number), 0).Format("2006"))
now = now.Add(time.Second)
}
entries := presenter.Entries()
if len(entries) != historySize {
t.Fatalf("history length = %d, want %d", len(entries), historySize)
}
if entries[0].Source != SourceGeneric {
t.Fatalf("first source = %q, want %q", entries[0].Source, SourceGeneric)
}
}
func TestPresenterNavigationMatchesReferenceBoundaries(t *testing.T) {
now := time.Unix(1_800_000_000, 0)
presenter := NewPresenterAt(func() time.Time { return now })
presenter.Save("first")
presenter.Save("second")
assertMessages := func(t *testing.T, got []string, want ...string) {
t.Helper()
if len(got) != len(want) {
t.Fatalf("message count = %d, want %d: %q", len(got), len(want), got)
}
for index := range want {
if got[index] != want[index] {
t.Fatalf("message %d = %q, want %q", index, got[index], want[index])
}
}
}
t.Run("last", func(t *testing.T) {
assertMessages(t, presenter.PresentLast(), "second 0 seconds ago")
})
t.Run("previous then top", func(t *testing.T) {
assertMessages(t, presenter.PresentPrevious(), "first 0 seconds ago")
assertMessages(t, presenter.PresentPrevious(), MessageListTop, "first 0 seconds ago")
})
t.Run("next then bottom", func(t *testing.T) {
assertMessages(t, presenter.PresentNext(), "second 0 seconds ago")
assertMessages(t, presenter.PresentNext(), MessageListBottom, "second 0 seconds ago")
assertMessages(t, presenter.PresentNext(), MessageListBottom, "second 0 seconds ago")
})
t.Run("empty", func(t *testing.T) {
empty := NewPresenterAt(func() time.Time { return now })
assertMessages(t, empty.PresentLast(), MessageNoMessages)
assertMessages(t, empty.PresentPrevious(), MessageNoMessages)
assertMessages(t, empty.PresentNext(), MessageNoMessages)
})
}
func TestPresenterStartsAtLatestNotification(t *testing.T) {
now := time.Unix(1_800_000_000, 0)
presenter := NewPresenterAt(func() time.Time { return now })
presenter.Save("first")
presenter.Save("second")
assertMessages(t, presenter.PresentPrevious(), "first 0 seconds ago")
}
func TestPresenterRemovalKeepsHistoryNavigationValid(t *testing.T) {
for _, removeAt := range []int{0, 1, 2} {
t.Run(time.Duration(removeAt).String(), func(t *testing.T) {
now := time.Unix(1_800_000_000, 0)
presenter := NewPresenterAt(func() time.Time { return now })
entries := []Entry{presenter.Save("first"), presenter.Save("second"), presenter.Save("third")}
presenter.PresentPrevious()
presenter.PresentPrevious()
if !presenter.Remove(entries[removeAt]) {
t.Fatal("notification was not removed")
}
want := "first 0 seconds ago"
if removeAt == 0 {
want = "second 0 seconds ago"
}
assertMessages(t, presenter.PresentPrevious(), MessageListTop, want)
presenter.PresentNext()
})
}
}
func assertMessages(t *testing.T, got []string, want ...string) {
t.Helper()
if len(got) != len(want) {
t.Fatalf("message count = %d, want %d: %q", len(got), len(want), got)
}
for index := range want {
if got[index] != want[index] {
t.Fatalf("message %d = %q, want %q", index, got[index], want[index])
}
}
}
func TestRelativeTimeUsesReferenceRounding(t *testing.T) {
now := time.Unix(1_800_000_000, 0)
for _, test := range []struct {
elapsed time.Duration
want string
}{
{90 * time.Second, "2 minutes ago"},
{150 * time.Second, "2 minutes ago"},
{89 * time.Second, "1 minute ago"},
{90 * time.Minute, "2 hours ago"},
{90 * time.Hour, "4 days ago"},
} {
if got := relativeTime(now.Add(-test.elapsed), now); got != test.want {
t.Fatalf("relativeTime(%s) = %q, want %q", test.elapsed, got, test.want)
}
}
}