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

+4 -2
View File
@@ -331,8 +331,10 @@ func daemonEnv(root string, port int, fakeURL string, extra map[string]string) [
"TALK_ELEVENLABS_ENDPOINT": fakeURL,
"TALK_EDGE_ENDPOINT": "ws://127.0.0.1:1/", // the chain must never fall back to real third-party endpoints
"TALK_PORT": strconv.Itoa(port),
"TALK_RATE_LIMIT": "0s", // law off until a test turns it on (see above)
"TALK_AUTOSTART": "false", // no test may leave a daemon behind unnoticed; autostart_test.go turns it on deliberately
"TALK_RATE_LIMIT": "0s", // law off until a test turns it on (see above)
"TALK_AUTOSTART": "false", // no test may leave a daemon behind unnoticed; autostart_test.go turns it on deliberately
"TALK_NOTIFY": "false", // no desktop notifications on the developer's machine; notify_dbus_test.go turns it on against a private bus
"DBUS_SESSION_BUS_ADDRESS": "unix:path=/nonexistent/talk-test-bus", // never the developer's real session bus (toast tests observe notify-send)
}
for k, v := range extra {
set[k] = v
+217
View File
@@ -0,0 +1,217 @@
//go:build linux
package test
// Process-boundary test for per-announcement notifications with a Cancel
// button: a private D-Bus session bus (dbus-daemon) hosts a fake
// org.freedesktop.Notifications service written here in Go; the daemon
// connects to it via DBUS_SESSION_BUS_ADDRESS. Observed: Notify arrives with
// the announcement text and the cancel action, ActionInvoked("cancel")
// aborts the playing synthesis, and CloseNotification follows.
import (
"bufio"
"os/exec"
"strings"
"sync"
"testing"
"time"
"github.com/godbus/dbus/v5"
)
// startPrivateBus starts a dbus-daemon session bus and returns its address.
func startPrivateBus(t *testing.T) string {
t.Helper()
if _, err := exec.LookPath("dbus-daemon"); err != nil {
t.Skip("dbus-daemon not installed")
}
cmd := exec.Command("dbus-daemon", "--session", "--nofork", "--nopidfile", "--print-address=1")
stdout, err := cmd.StdoutPipe()
if err != nil {
t.Fatal(err)
}
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = cmd.Process.Kill(); _ = cmd.Wait() })
line, err := bufio.NewReader(stdout).ReadString('\n')
if err != nil {
t.Fatalf("dbus-daemon printed no address: %v", err)
}
return strings.TrimSpace(line)
}
// notifyCall is one Notify the fake service received.
type notifyCall struct {
id uint32
summary string
body string
actions []string
expire int32
}
// fakeNotifications is a minimal org.freedesktop.Notifications service.
type fakeNotifications struct {
conn *dbus.Conn
mu sync.Mutex
next uint32
calls []notifyCall
closed []uint32
got chan notifyCall
}
func (f *fakeNotifications) Notify(appName string, replaces uint32, icon, summary, body string, actions []string, hints map[string]dbus.Variant, expire int32) (uint32, *dbus.Error) {
f.mu.Lock()
f.next++
c := notifyCall{id: f.next, summary: summary, body: body, actions: actions, expire: expire}
f.calls = append(f.calls, c)
f.mu.Unlock()
f.got <- c
return c.id, nil
}
func (f *fakeNotifications) CloseNotification(id uint32) *dbus.Error {
f.mu.Lock()
f.closed = append(f.closed, id)
f.mu.Unlock()
_ = f.conn.Emit("/org/freedesktop/Notifications", "org.freedesktop.Notifications.NotificationClosed", id, uint32(3))
return nil
}
func (f *fakeNotifications) GetCapabilities() ([]string, *dbus.Error) {
return []string{"body", "actions"}, nil
}
func (f *fakeNotifications) GetServerInformation() (string, string, string, string, *dbus.Error) {
return "fake-notifications", "talk-tests", "1.0", "1.2", nil
}
func (f *fakeNotifications) invoke(id uint32, action string) error {
return f.conn.Emit("/org/freedesktop/Notifications", "org.freedesktop.Notifications.ActionInvoked", id, action)
}
func (f *fakeNotifications) closedIDs() []uint32 {
f.mu.Lock()
defer f.mu.Unlock()
return append([]uint32(nil), f.closed...)
}
// serveFakeNotifications registers the fake service on the bus at addr.
func serveFakeNotifications(t *testing.T, addr string) *fakeNotifications {
t.Helper()
conn, err := dbus.Connect(addr)
if err != nil {
t.Fatalf("connecting to the private bus: %v", err)
}
t.Cleanup(func() { conn.Close() })
f := &fakeNotifications{conn: conn, got: make(chan notifyCall, 8)}
if err := conn.Export(f, "/org/freedesktop/Notifications", "org.freedesktop.Notifications"); err != nil {
t.Fatal(err)
}
reply, err := conn.RequestName("org.freedesktop.Notifications", dbus.NameFlagDoNotQueue)
if err != nil || reply != dbus.RequestNameReplyPrimaryOwner {
t.Fatalf("owning org.freedesktop.Notifications: %v (%v)", err, reply)
}
return f
}
// TestNotificationWithCancelButton: a playing announcement shows a
// notification carrying the text and a Cancel action; clicking it aborts the
// synthesis (the fake provider sees the request cancelled) and the
// notification is closed. A second, uninterrupted announcement is closed
// when it finishes.
func TestNotificationWithCancelButton(t *testing.T) {
addr := startPrivateBus(t)
fakeNotif := serveFakeNotifications(t, addr)
root := t.TempDir()
fake := newFakeProvider(t, 5*time.Second)
d := startDaemon(t, root, fake.srv.URL, map[string]string{
"TALK_NOTIFY": "true",
"DBUS_SESSION_BUS_ADDRESS": addr,
})
code, m := speak(t, d, `{"text":"Der lange Vortrag beginnt jetzt","lang":"de"}`)
if code != 202 || m["id"] == "" {
t.Fatalf("speak: %d %v", code, m)
}
var call notifyCall
select {
case call = <-fakeNotif.got:
case <-time.After(5 * time.Second):
t.Fatalf("no Notify reached the fake service; daemon log:\n%s", d.stderr.String())
}
if call.summary != "talk" || call.body != "Der lange Vortrag beginnt jetzt" {
t.Errorf("Notify = %+v, want summary talk and the announcement text", call)
}
if strings.Join(call.actions, "|") != "cancel|Abbrechen" {
t.Errorf("actions = %v, want [cancel Abbrechen] (German label for lang de)", call.actions)
}
if call.expire != 0 {
t.Errorf("expire_timeout = %d, want 0 (stays until talk closes it)", call.expire)
}
fake.waitArrived(t, 1) // synthesis is in flight (5 s fake delay)
// Click Cancel: the synthesis request is aborted, the daemon goes idle.
if err := fakeNotif.invoke(call.id, "cancel"); err != nil {
t.Fatal(err)
}
hits := fake.waitHits(t, 1)
if hits[0].Completed {
t.Errorf("synthesis completed although Cancel was clicked: %+v", hits[0])
}
waitStatusIdle(t, d, 3*time.Second)
if !strings.Contains(d.stderr.String(), "cancelled from its notification") {
t.Errorf("daemon log lacks the cancel line:\n%s", d.stderr.String())
}
deadline := time.Now().Add(2 * time.Second)
for len(fakeNotif.closedIDs()) == 0 && time.Now().Before(deadline) {
time.Sleep(20 * time.Millisecond)
}
if ids := fakeNotif.closedIDs(); len(ids) != 1 || ids[0] != call.id {
t.Errorf("CloseNotification ids = %v, want [%d]", ids, call.id)
}
// Second announcement runs through; its notification is closed at the end
// and carries the English label for an English text.
fake.setDelay(100 * time.Millisecond)
speak(t, d, `{"text":"The second one finishes on its own"}`)
select {
case call = <-fakeNotif.got:
case <-time.After(5 * time.Second):
t.Fatal("no Notify for the second announcement")
}
if strings.Join(call.actions, "|") != "cancel|Cancel" {
t.Errorf("actions = %v, want [cancel Cancel]", call.actions)
}
fake.waitCompleted(t, 1)
waitStatusIdle(t, d, 3*time.Second)
deadline = time.Now().Add(2 * time.Second)
for len(fakeNotif.closedIDs()) < 2 && time.Now().Before(deadline) {
time.Sleep(20 * time.Millisecond)
}
if ids := fakeNotif.closedIDs(); len(ids) != 2 || ids[1] != call.id {
t.Errorf("CloseNotification ids = %v, want the second id %d appended", ids, call.id)
}
}
// TestNotificationsOffByConfig: TALK_NOTIFY=false shows nothing even with a
// notification service around.
func TestNotificationsOffByConfig(t *testing.T) {
addr := startPrivateBus(t)
fakeNotif := serveFakeNotifications(t, addr)
root := t.TempDir()
fake := newFakeProvider(t, 0)
d := startDaemon(t, root, fake.srv.URL, map[string]string{
"TALK_NOTIFY": "false",
"DBUS_SESSION_BUS_ADDRESS": addr,
})
speak(t, d, `{"text":"silent on the desktop"}`)
fake.waitCompleted(t, 1)
select {
case c := <-fakeNotif.got:
t.Errorf("Notify received although notifications are off: %+v", c)
case <-time.After(300 * time.Millisecond):
}
}