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

+7 -3
View File
@@ -129,6 +129,10 @@ func checkConfig() (config.Config, check) {
if _, statErr := os.Stat(path); statErr != nil {
return cfg, check{"Config", statusOK, fmt.Sprintf("no file at %s; defaults apply (talk setup creates one)", path)}
}
if len(cfg.Unknown) > 0 {
return cfg, check{"Config", statusWarn, fmt.Sprintf("%s loaded, but it has keys talk does not know: %s. A top-level key written below a [table] header belongs to that table; move it above the first [table]",
path, strings.Join(cfg.Unknown, ", "))}
}
return cfg, check{"Config", statusOK, path + " loaded"}
}
@@ -262,12 +266,12 @@ func probeTier(p provider.Provider) string {
func checkNotify(cfg config.Config) check {
if notify.Available() {
return check{"Toast", statusOK, notify.Describe() + "; used for quiet_mode = toast"}
return check{"Notify", statusOK, notify.Describe() + "; used for quiet_mode = toast"}
}
if cfg.QuietMode == queue.QuietModeToast {
return check{"Toast", statusWarn, notify.Describe() + "; quiet_mode = toast is only logged"}
return check{"Notify", statusWarn, notify.Describe() + "; quiet_mode = toast is only logged"}
}
return check{"Toast", statusOK, notify.Describe() + " (only relevant for quiet_mode = toast)"}
return check{"Notify", statusOK, notify.Describe() + " (only relevant for quiet_mode = toast)"}
}
func checkMute() check {
+21
View File
@@ -114,3 +114,24 @@ func TestDoctorBrokenConfig(t *testing.T) {
}
}
}
// TestDoctorUnknownConfigKeys: a key filed under the wrong table is ⚠ with a
// hint, not an error.
func TestDoctorUnknownConfigKeys(t *testing.T) {
root := t.TempDir()
setGetenv(t, map[string]string{"TALK_AUDIO_SINK": "null", "XDG_CONFIG_HOME": root})
if err := os.MkdirAll(filepath.Join(root, "talk"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "talk", "config.toml"), []byte("[voice]\nlang = \"de\"\n[voice.edge]\nde = \"de-DE-KatjaNeural\"\n"), 0o644); err != nil {
t.Fatal(err)
}
var out, errBuf bytes.Buffer
code := runCLI([]string{"doctor"}, &out, &errBuf)
if code != exitOK {
t.Fatalf("exit %d, want 0 (unknown keys are a warning)\n%s", code, out.String())
}
if !strings.Contains(out.String(), "⚠ Config") || !strings.Contains(out.String(), "voice.lang") || !strings.Contains(out.String(), "[table]") {
t.Errorf("stdout = %q", out.String())
}
}
+10
View File
@@ -10,6 +10,15 @@ import (
"testing"
)
// TestMain cuts the test binary off from the developer's real session bus:
// the notify package would otherwise show real notifications (doctor, test
// --toast) and wait for clicks. With an unreachable address the D-Bus path
// fails fast and the tests observe the notify-send fallback.
func TestMain(m *testing.M) {
os.Setenv("DBUS_SESSION_BUS_ADDRESS", "unix:path=/nonexistent/talk-test-bus")
os.Exit(m.Run())
}
// setGetenv overrides the CLI's environment seam for one test. Base kit of
// every test: TALK_EDGE_ENDPOINT points at a guaranteed dead loopback port
// (connection refused at once); the default chain contains edge, and no
@@ -25,6 +34,7 @@ func setGetenv(t *testing.T, kv map[string]string) {
"TALK_EDGE_ENDPOINT": "ws://127.0.0.1:1/",
"TALK_PORT": "1",
"TALK_AUTOSTART": "false", // in-process tests must never start the test binary as a daemon
"TALK_NOTIFY": "false", // and never pop up notifications on the developer's desktop
}
for k, v := range kv {
base[k] = v
+1
View File
@@ -404,6 +404,7 @@ func printEffective(w io.Writer, cfg config.Config, path string) {
}
fmt.Fprintf(w, " quiet hours: %s\n", orDefault(cfg.QuietHours, "none"))
fmt.Fprintf(w, " autostart: %v\n", cfg.Daemon.Autostart)
fmt.Fprintf(w, " notify: %v (desktop notification per announcement)\n", cfg.Notify.OnSpeak)
}
func audioSummary(cfg config.Config) string {
+34 -6
View File
@@ -88,23 +88,51 @@ func runTest(args []string, opts *sayOptions, _ io.Reader, stdout, stderr io.Wri
return exitOK
}
// runToastTest sends the test notification and reports the tool and the
// result. Exit 1 when no tool exists or the call fails.
// runToastTest shows the test notification the way a playing announcement
// gets one (with a Cancel button where supported), waits a few seconds for a
// click and reports what happened. Exit 1 when no tool exists or the call
// fails.
func runToastTest(lang string, stdout, stderr io.Writer) int {
fmt.Fprintf(stdout, "toast: %s\n", notify.Describe())
if !notify.Available() {
fmt.Fprintln(stderr, "talk test: no notification tool found; install libnotify (notify-send) on Linux, macOS and Windows have it built in")
fmt.Fprintln(stderr, "talk test: no notification path found; on Linux install libnotify (notify-send) or run a notification daemon, macOS and Windows have one built in")
return exitFailure
}
label := "Cancel"
if lang == "de" {
label = "Abbrechen"
}
clicked := make(chan struct{}, 1)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
start := time.Now()
if err := notify.Show(ctx, toastPhrase[lang]); err != nil {
closeFn, err := notify.Announce(ctx, toastPhrase[lang], label, func() {
select {
case clicked <- struct{}{}:
default:
}
})
if err != nil {
fmt.Fprintf(stderr, "talk test: %v\n", err)
fmt.Fprintln(stderr, "hint: on Linux a notification daemon (mako, dunst, GNOME Shell, KDE) must be running for notify-send to show anything.")
fmt.Fprintln(stderr, "hint: on Linux a notification daemon (mako, dunst, GNOME Shell, KDE) must be running.")
return exitFailure
}
fmt.Fprintf(stdout, "sent: %s\n", time.Since(start).Round(time.Millisecond))
fmt.Fprintln(stdout, "If no notification appeared, your notification daemon did not show it (talk only hands it over).")
if !notify.SupportsCancel() {
fmt.Fprintln(stdout, "cancel: not available on this platform (notification without button)")
fmt.Fprintln(stdout, "If no notification appeared, your notification daemon did not show it (talk only hands it over).")
return exitOK
}
fmt.Fprintf(stdout, "cancel: button %q shown; waiting up to %s for a click …\n", label, toastWait)
select {
case <-clicked:
fmt.Fprintln(stdout, "cancel: clicked, works")
case <-time.After(toastWait):
fmt.Fprintln(stdout, "cancel: not clicked (that is fine); closing the notification")
}
closeFn()
return exitOK
}
// toastWait is how long `talk test --toast` waits for a click on Cancel.
const toastWait = 8 * time.Second
+3 -1
View File
@@ -46,6 +46,8 @@ func TestTestToast(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("fake notify-send exists on Linux only")
}
// No session bus in the test binary (TestMain): the fallback through
// notify-send is what we observe here.
dir := t.TempDir()
seen := filepath.Join(dir, "seen.txt")
fakeTTSBin(t, dir, "notify-send", `printf '%s\n' "$@" > "`+seen+`"`)
@@ -71,7 +73,7 @@ func TestTestToast(t *testing.T) {
t.Setenv("PATH", t.TempDir())
out.Reset()
errBuf.Reset()
if code := runCLI([]string{"test", "--toast"}, &out, &errBuf); code != exitFailure || !strings.Contains(errBuf.String(), "no notification tool") {
if code := runCLI([]string{"test", "--toast"}, &out, &errBuf); code != exitFailure || !strings.Contains(errBuf.String(), "no notification path") {
t.Errorf("without tool: exit %d, stderr %q", code, errBuf.String())
}
}