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
}
+21
View File
@@ -0,0 +1,21 @@
//go:build !linux
package notify
import (
"context"
"errors"
)
// Without the freedesktop Notifications service there is no bus path; the
// OS tool does the job (no actions).
var errNoDBus = errors.New("notify: no D-Bus notification service on this platform")
func dbusAvailable() bool { return false }
func dbusSupportsActions() bool { return false }
func dbusDescribe() (string, bool) { return "", false }
func dbusClose(uint32) {}
func dbusNotify(context.Context, string, []string, func(string)) (uint32, error) {
return 0, errNoDBus
}
+68 -14
View File
@@ -1,10 +1,12 @@
// Package notify shows desktop notifications: best effort, through the
// operating system's own tools, without a library dependency and without
// CGO (the same line as provider/osbuiltin):
// Package notify shows desktop notifications: best effort, never a reason to
// abort an announcement.
//
// Linux: notify-send (libnotify; needs a notification daemon)
// macOS: osascript `display notification`
// Windows: PowerShell toast (Windows.UI.Notifications)
// Linux: the freedesktop Notifications D-Bus service (org.freedesktop.
// Notifications, pure Go via godbus): notifications with a Cancel
// action, closed again when the announcement ends. Fallback when
// no session bus is reachable: notify-send (no action).
// macOS: osascript `display notification` (no action).
// Windows: PowerShell toast (Windows.UI.Notifications; no action yet).
//
// Missing tools are a clear error for the caller (log), never an abort: a
// notification is an extra, not a contract.
@@ -29,29 +31,46 @@ const Title = "talk"
// timeout bounds the call of the OS tool.
const timeout = 10 * time.Second
// Available reports whether a notification path exists on this system
// (binary present; whether a daemon listens only Show reveals).
// Available reports whether a notification path exists on this system: a
// reachable notification service on the session bus, or the OS tool.
func Available() bool {
if dbusAvailable() {
return true
}
_, err := exec.LookPath(tool())
return err == nil
}
// Describe names the path for `talk doctor`.
// Describe names the path for `talk doctor`: the notification service with
// name and version and whether it supports a Cancel action, or the OS tool.
func Describe() string {
if info, ok := dbusDescribe(); ok {
return info
}
t := tool()
if _, err := exec.LookPath(t); err != nil {
if runtime.GOOS == "linux" {
return "no notification service on the session bus and " + t + " not found"
}
return t + " not found"
}
switch runtime.GOOS {
case "darwin":
return "osascript (Notification Center)"
return "osascript (Notification Center; no cancel button)"
case "windows":
return "powershell (Windows toast)"
return "powershell (Windows toast; no cancel button yet)"
default:
return "notify-send (libnotify)"
return "notify-send (libnotify; no session bus, so no cancel button)"
}
}
// SupportsCancel reports whether notifications on this system can carry a
// Cancel action that reaches talk (Linux with a notification service that
// has the "actions" capability).
func SupportsCancel() bool {
return dbusSupportsActions()
}
func tool() string {
switch runtime.GOOS {
case "darwin":
@@ -63,9 +82,44 @@ func tool() string {
}
}
// Show shows a notification with Title and text and waits for the OS call
// to finish (not for the notification to be closed).
// Show shows a plain notification with Title and text (no action) and waits
// for the handover to finish, not for the notification to be closed. Used
// for the quiet-hours toast.
func Show(ctx context.Context, text string) error {
if dbusAvailable() {
id, err := dbusNotify(ctx, text, nil, nil)
if err == nil {
_ = id
return nil
}
}
return showWithTool(ctx, text)
}
// Announce shows the notification for an announcement that is playing: the
// text plus a Cancel action labelled cancelLabel where the platform supports
// it. onCancel runs when the user clicks it. The returned close function
// dismisses the notification (call it when the announcement ends); it is
// never nil. Platforms without actions show a plain notification and the
// close function does nothing.
func Announce(ctx context.Context, text, cancelLabel string, onCancel func()) (close func(), err error) {
noop := func() {}
if dbusAvailable() {
id, err := dbusNotify(ctx, text, []string{"cancel", cancelLabel}, func(action string) {
if action == "cancel" && onCancel != nil {
onCancel()
}
})
if err == nil {
return func() { dbusClose(id) }, nil
}
// fall through to the tool on a bus error
}
return noop, showWithTool(ctx, text)
}
// showWithTool shows a notification through the OS tool (no action).
func showWithTool(ctx context.Context, text string) error {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
var cmd *exec.Cmd
+7
View File
@@ -9,6 +9,13 @@ import (
"testing"
)
// TestMain cuts the tests off from the developer's real session bus so they
// observe the notify-send fallback instead of popping real notifications.
func TestMain(m *testing.M) {
os.Setenv("DBUS_SESSION_BUS_ADDRESS", "unix:path=/nonexistent/talk-test-bus")
os.Exit(m.Run())
}
// TestShowCallsNotifySend: on Linux, Show goes through notify-send; here a
// fake in PATH that records its arguments (a real process, no mock
// framework). Title and text arrive unchanged.