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:
1 parent
6f3e7c9f35
commit
83a15af9c5
22 files changed
+806
-45
No files matched your search
+7
-3
@@ -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 {
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user