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

+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