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

+28
View File
@@ -614,3 +614,31 @@ func waitFor(t *testing.T, cond func() bool) {
time.Sleep(5 * time.Millisecond)
}
}
// TestCancelItem: cancels only the playing item with that id; a stale id or
// a waiting item is a no-op, the queue goes on with the next item.
func TestCancelItem(t *testing.T) {
g := newSpeaker()
q := New(Laws{}, g.speak, nil)
defer q.Close()
first, _ := q.Enqueue(Item{Text: "playing"})
g.waitUntil(t, 1)
second, _ := q.Enqueue(Item{Text: "waiting"})
if q.CancelItem(second.ID) {
t.Error("CancelItem on a waiting item reported a hit")
}
if q.CancelItem("nosuch") {
t.Error("CancelItem with an unknown id reported a hit")
}
if !q.CancelItem(first.ID) {
t.Fatal("CancelItem on the playing item reported no hit")
}
// The playing item ended via ctx; the waiting one starts next.
started := g.waitUntil(t, 2)
if len(started) != 1 || started[0].ID != second.ID {
t.Errorf("after CancelItem started = %+v, want the waiting item", started)
}
g.step <- struct{}{}
}