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:
1 parent
6f3e7c9f35
commit
83a15af9c5
22 files changed
+806
-45
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user