feat(notify): desktop notification per announcement with a Cancel button

Every playing announcement now shows a desktop notification with its text
and, on Linux, a Cancel button (Abbrechen for German) that stops exactly
that announcement; the notification is closed when the announcement ends.
This brings back what the old talk.sh had with notify-send --action, as a
default: a long announcement can be cut short without a terminal.

Implementation: the freedesktop Notifications service on the session bus
via godbus (pure Go): one connection per process, Notify with the action
list, ActionInvoked dispatched to the announcement, CloseNotification at
the end, expire_timeout 0 so the button stays for the whole announcement.
Without a session bus the notify-send fallback shows a plain notification;
macOS and Windows show one without a button for now. queue.CancelItem(id)
cancels only the playing item. Config [notify] on_speak (default true, env
TALK_NOTIFY). `talk test --toast` now shows the real thing and waits for a
click; `talk doctor` names the service and whether it supports the button.

Config hygiene found on the way: a key written below a [table] header
belongs to that table in TOML (lang under [voice] was silently ignored).
config.Load now collects unknown keys (undecoded ones and non-language
keys under [voice.<provider>]) and `talk doctor` warns with the hint.

Tests: process test on a private dbus-daemon with a fake
org.freedesktop.Notifications service (Notify payload, cancel click aborts
the synthesis, CloseNotification), queue.CancelItem, config keys. All test
binaries point DBUS_SESSION_BUS_ADDRESS at a dead socket so no test ever
pops a notification on the developer's desktop. flake vendorHash updated
for the new dependency.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
m3ta-chironandClaude Fable 5.1 committed 2026-10-24 12:10:49 +02:00
1 parent 6f3e7c9f35
commit 83a15af9c5
22 files changed
+806 -45

No files matched your search

+179
View File
@@ -0,0 +1,179 @@
//go:build linux
package notify
import (
"context"
"fmt"
"slices"
"sync"
"time"
"github.com/godbus/dbus/v5"
)
// The freedesktop Notifications service on the session bus. One connection
// per process, made lazily on first use and kept: the daemon notifies on
// every announcement and must see the ActionInvoked signals for the Cancel
// button, which only works on a connection that stays open.
const (
notifDest = "org.freedesktop.Notifications"
notifPath = "/org/freedesktop/Notifications"
notifIface = "org.freedesktop.Notifications"
// dbusCallTimeout bounds every call: a hanging notification service
// must never hold up an announcement.
dbusCallTimeout = 2 * time.Second
)
var bus struct {
once sync.Once
conn *dbus.Conn
err error
caps []string
server string
mu sync.Mutex
handlers map[uint32]func(action string) // by notification id
}
// connect opens the session bus once, subscribes to the service's signals
// and starts the dispatcher. A missing bus (DBUS_SESSION_BUS_ADDRESS unset:
// servers, SSH, WinRM-style sessions) is a normal, remembered failure.
func connect() (*dbus.Conn, error) {
bus.once.Do(func() {
conn, err := dbus.SessionBus()
if err != nil {
bus.err = fmt.Errorf("session bus: %w", err)
return
}
ctx, cancel := context.WithTimeout(context.Background(), dbusCallTimeout)
defer cancel()
obj := conn.Object(notifDest, notifPath)
var name, vendor, version, spec string
if err := obj.CallWithContext(ctx, notifIface+".GetServerInformation", 0).Store(&name, &vendor, &version, &spec); err != nil {
bus.err = fmt.Errorf("no notification service on the session bus: %w", err)
return
}
bus.server = name
if version != "" {
bus.server += " " + version
}
if err := obj.CallWithContext(ctx, notifIface+".GetCapabilities", 0).Store(&bus.caps); err != nil {
bus.caps = nil
}
if err := conn.AddMatchSignal(dbus.WithMatchObjectPath(notifPath), dbus.WithMatchInterface(notifIface)); err != nil {
bus.err = fmt.Errorf("subscribing to notification signals: %w", err)
return
}
bus.handlers = map[uint32]func(string){}
ch := make(chan *dbus.Signal, 32)
conn.Signal(ch)
go dispatch(ch)
bus.conn = conn
})
return bus.conn, bus.err
}
// dispatch routes ActionInvoked to the notification's handler and drops the
// handler on NotificationClosed.
func dispatch(ch <-chan *dbus.Signal) {
for sig := range ch {
if len(sig.Body) == 0 {
continue
}
id, ok := sig.Body[0].(uint32)
if !ok {
continue
}
switch sig.Name {
case notifIface + ".ActionInvoked":
action, _ := sig.Body[1].(string)
bus.mu.Lock()
h := bus.handlers[id]
bus.mu.Unlock()
if h != nil {
h(action)
}
case notifIface + ".NotificationClosed":
bus.mu.Lock()
delete(bus.handlers, id)
bus.mu.Unlock()
}
}
}
// dbusAvailable reports whether a notification service answers on the bus.
func dbusAvailable() bool {
_, err := connect()
return err == nil
}
// dbusSupportsActions reports the "actions" capability of the service.
func dbusSupportsActions() bool {
if _, err := connect(); err != nil {
return false
}
return slices.Contains(bus.caps, "actions")
}
// dbusDescribe names the service for `talk doctor`.
func dbusDescribe() (string, bool) {
if _, err := connect(); err != nil {
return "", false
}
s := "notification service " + bus.server + " via D-Bus"
if slices.Contains(bus.caps, "actions") {
return s + " (cancel button supported)", true
}
return s + " (no actions: no cancel button)", true
}
// dbusNotify shows a notification and returns its id. actions is the flat
// freedesktop list [key, label, …]; onAction receives the key when the user
// clicks. expire_timeout 0 keeps the notification until talk closes it, so
// the Cancel button stays for the whole announcement.
func dbusNotify(ctx context.Context, text string, actions []string, onAction func(action string)) (uint32, error) {
conn, err := connect()
if err != nil {
return 0, err
}
if actions == nil {
actions = []string{}
}
expire := int32(-1) // server default for plain toasts
if len(actions) > 0 {
expire = 0
}
ctx, cancel := context.WithTimeout(ctx, dbusCallTimeout)
defer cancel()
hints := map[string]dbus.Variant{
"urgency": dbus.MakeVariant(byte(1)),
"desktop-entry": dbus.MakeVariant(Title),
}
var id uint32
err = conn.Object(notifDest, notifPath).CallWithContext(ctx, notifIface+".Notify", 0,
Title, uint32(0), "audio-speakers", Title, text, actions, hints, expire).Store(&id)
if err != nil {
return 0, fmt.Errorf("notify: %w", err)
}
if onAction != nil {
bus.mu.Lock()
bus.handlers[id] = onAction
bus.mu.Unlock()
}
return id, nil
}
// dbusClose dismisses a notification (best effort).
func dbusClose(id uint32) {
conn, err := connect()
if err != nil {
return
}
bus.mu.Lock()
delete(bus.handlers, id)
bus.mu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), dbusCallTimeout)
defer cancel()
_ = conn.Object(notifDest, notifPath).CallWithContext(ctx, notifIface+".CloseNotification", 0, id).Err
}