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

+40
View File
@@ -629,3 +629,43 @@ func TestLoadLang(t *testing.T) {
t.Errorf("TALK_LANG=fr: error = %v, want a clear lang error", err)
}
}
// TestLoadNotify covers [notify] on_speak: default on, TOML off, env
// TALK_NOTIFY beats the file, garbage is an error.
func TestLoadNotify(t *testing.T) {
cfg, err := Load(Options{File: missingFile(t), Getenv: env(nil)})
if err != nil || !cfg.Notify.OnSpeak {
t.Fatalf("default = %+v (%v), want on_speak true", cfg.Notify, err)
}
cfg, err = Load(Options{File: writeTOML(t, "[notify]\non_speak = false\n"), Getenv: env(nil)})
if err != nil || cfg.Notify.OnSpeak {
t.Errorf("TOML = %+v (%v), want on_speak false", cfg.Notify, err)
}
cfg, err = Load(Options{File: writeTOML(t, "[notify]\non_speak = false\n"), Getenv: env(map[string]string{"TALK_NOTIFY": "true"})})
if err != nil || !cfg.Notify.OnSpeak {
t.Errorf("env = %+v (%v), want on_speak true (env beats file)", cfg.Notify, err)
}
if _, err := Load(Options{File: missingFile(t), Getenv: env(map[string]string{"TALK_NOTIFY": "maybe"})}); err == nil {
t.Error("TALK_NOTIFY=maybe: no error, want a clear error")
}
}
// TestLoadReportsUnknownKeys: keys talk does not know are collected (not an
// error), including the classic trap of a top-level key written below a
// [table] header.
func TestLoadReportsUnknownKeys(t *testing.T) {
cfg, err := Load(Options{File: writeTOML(t, "[voice]\nlang = \"de\"\n[voice.edge]\nde = \"de-DE-KatjaNeural\"\nvolume = \"loud\"\n\n[typo]\nvalue = 1\n"), Getenv: env(nil)})
if err != nil {
t.Fatalf("Load: %v", err)
}
if strings.Join(cfg.Unknown, ",") != "typo.value,voice.edge.volume,voice.lang" {
t.Errorf("Unknown = %v, want [typo.value voice.edge.volume voice.lang]", cfg.Unknown)
}
if cfg.Lang != "en" {
t.Errorf("Lang = %q; the misplaced key must not leak into the top level", cfg.Lang)
}
cfg, _ = Load(Options{File: writeTOML(t, "lang = \"de\"\n"), Getenv: env(nil)})
if len(cfg.Unknown) != 0 {
t.Errorf("Unknown = %v for a clean file, want none", cfg.Unknown)
}
}