From 83a15af9c5a41a56c14930b3635a5f86c528e869 Mon Sep 17 00:00:00 2001 From: m3ta-chiron Date: Sat, 24 Oct 2026 12:10:49 +0200 Subject: [PATCH] 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.]) 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 --- README.md | 35 ++++-- cmd/talk/doctor.go | 10 +- cmd/talk/doctor_test.go | 21 ++++ cmd/talk/say_test.go | 10 ++ cmd/talk/setup.go | 1 + cmd/talk/testcmd.go | 40 +++++- cmd/talk/testcmd_test.go | 4 +- flake.nix | 2 +- go.mod | 1 + go.sum | 2 + internal/config/config.go | 80 +++++++++++- internal/config/config_test.go | 40 ++++++ internal/daemon/daemon.go | 43 +++++-- internal/notify/dbus_linux.go | 179 +++++++++++++++++++++++++++ internal/notify/dbus_other.go | 21 ++++ internal/notify/notify.go | 82 ++++++++++--- internal/notify/notify_test.go | 7 ++ internal/queue/queue.go | 13 ++ internal/queue/queue_test.go | 28 +++++ internal/speak/engine.go | 9 ++ test/harness_test.go | 6 +- test/notify_dbus_test.go | 217 +++++++++++++++++++++++++++++++++ 22 files changed, 806 insertions(+), 45 deletions(-) create mode 100644 internal/notify/dbus_linux.go create mode 100644 internal/notify/dbus_other.go create mode 100644 test/notify_dbus_test.go diff --git a/README.md b/README.md index da7c0d2..9fd8102 100644 --- a/README.md +++ b/README.md @@ -78,9 +78,9 @@ Keys never go into config.toml. - **`talk test`** speaks a fixed test sentence directly in this process (not via the daemon; mute and quiet hours do not apply here, an active mute is mentioned) and shows which tier spoke and how long synthesis and playback - took. `talk test --toast` sends a test desktop notification through the - same path `quiet_mode = toast` uses, so you can check notifications - without a quiet window or a daemon restart. + took. `talk test --toast` shows a test notification with the Cancel button + and waits for a click, so you can check the notification setup without + speaking anything. - **`talk voices`** lists the curated voice catalog (ElevenLabs premade, OpenAI, Edge neural voices per locale) as a table; `*` marks the voice talk uses without `--voice` (config `voice..` or the @@ -95,6 +95,26 @@ Keys never go into config.toml. very end. Without a terminal (pipe, agent) setup asks nothing and only shows the effective configuration. +### Notifications with a Cancel button + +Every announcement that plays also shows a desktop notification with its +text and, on Linux, a **Cancel** button (German: Abbrechen) that stops that +announcement without a terminal. The notification disappears when the +announcement ends. talk talks to the freedesktop notification service on the +session bus directly (any daemon with the "actions" capability: mako, dunst, +GNOME Shell, KDE, quickshell); without a session bus it falls back to +notify-send, on macOS to Notification Center and on Windows to a toast, all +without a button. Turn it off with: + +```toml +[notify] +on_speak = false # env TALK_NOTIFY=false +``` + +`talk test --toast` shows one such notification, waits a few seconds for a +click on Cancel and reports what happened; `talk doctor` names the service +and whether it supports the button. + ### Language talk detects German and English per announcement (word lists plus umlauts; @@ -183,11 +203,10 @@ hours are opt-in like mute). During quiet hours audio is dropped, also for high (burst is a rate right, not a quiet right): - `quiet_mode = "discard"` (default): drop silently. -- `quiet_mode = "toast"`: a desktop notification instead of audio, through - the system's own tools (Linux `notify-send`, macOS Notification Center, - Windows toast), best effort: if the tool is missing, only the log line - remains and `talk doctor` shows it under "Toast". Try it with - `talk test --toast`. +- `quiet_mode = "toast"`: a desktop notification instead of audio (the + notification service or the system's own tool, see "Notifications"), best + effort: if nothing is available, only the log line remains and + `talk doctor` shows it under "Toast". - `now:true` **breaks out of quiet hours too** (a deliberate interruption). **Length and age:** two more laws against what prompts like to do, reading diff --git a/cmd/talk/doctor.go b/cmd/talk/doctor.go index 587a79f..865bc2f 100644 --- a/cmd/talk/doctor.go +++ b/cmd/talk/doctor.go @@ -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 { diff --git a/cmd/talk/doctor_test.go b/cmd/talk/doctor_test.go index 4d44704..bb3e050 100644 --- a/cmd/talk/doctor_test.go +++ b/cmd/talk/doctor_test.go @@ -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()) + } +} diff --git a/cmd/talk/say_test.go b/cmd/talk/say_test.go index dbac0d4..3f18337 100644 --- a/cmd/talk/say_test.go +++ b/cmd/talk/say_test.go @@ -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 diff --git a/cmd/talk/setup.go b/cmd/talk/setup.go index 06c6dcd..e7e5017 100644 --- a/cmd/talk/setup.go +++ b/cmd/talk/setup.go @@ -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 { diff --git a/cmd/talk/testcmd.go b/cmd/talk/testcmd.go index a0cdaea..6ba2f22 100644 --- a/cmd/talk/testcmd.go +++ b/cmd/talk/testcmd.go @@ -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 diff --git a/cmd/talk/testcmd_test.go b/cmd/talk/testcmd_test.go index 1d0db41..481c0ee 100644 --- a/cmd/talk/testcmd_test.go +++ b/cmd/talk/testcmd_test.go @@ -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()) } } diff --git a/flake.nix b/flake.nix index 7d672d1..b03c749 100644 --- a/flake.nix +++ b/flake.nix @@ -23,7 +23,7 @@ subPackages = [ "cmd/talk" ]; # Update after changes to go.mod/go.sum: `nix build` prints the # expected hash. - vendorHash = "sha256-CbF3juTA7V05hh1ElKKACddsCn4bIPeLZxsz2EZxs9I="; + vendorHash = "sha256-Cne8FiMlpsrFfntMJVtV4D3CzEWQ4Jnd+tIEHF9ecEc="; env.CGO_ENABLED = 0; ldflags = [ "-s" "-w" "-X" "main.version=${version}" "-X" "main.commit=${self.shortRev or "dirty"}" ]; # The process-boundary tests start daemons and bind ports; not in the sandbox. diff --git a/go.mod b/go.mod index 6159ab6..25862ad 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.26.8 require ( github.com/BurntSushi/toml v1.6.0 github.com/ebitengine/oto/v3 v3.5.0 + github.com/godbus/dbus/v5 v5.2.4 github.com/gorilla/websocket v1.5.3 github.com/hajimehoshi/go-mp3 v0.3.4 ) diff --git a/go.sum b/go.sum index b405b7f..06adbf2 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/ebitengine/oto/v3 v3.5.0 h1:8ZDhKP3Hhslyb9NL3g4HWvhRQqwI+hKOHAXAqD6GP github.com/ebitengine/oto/v3 v3.5.0/go.mod h1:Elkm7yzTRns3w2efvibzVOoQ65YOwmec9a76dCiK10o= github.com/ebitengine/purego v0.11.0 h1:jhp/D+Nyv7UUW8HAcmcjt2N2rYrYi9m3SL21k0Ua/NI= github.com/ebitengine/purego v0.11.0/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ= +github.com/godbus/dbus/v5 v5.2.4 h1:F2gUCy++NMIxGmo9J+z5le/+ZC8W1OgeR51P4i9ZB/U= +github.com/godbus/dbus/v5 v5.2.4/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68= diff --git a/internal/config/config.go b/internal/config/config.go index 00c1947..9cda156 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "sort" "strconv" "strings" "time" @@ -53,6 +54,9 @@ type Config struct { // Daemon configures the background process (autostart). Daemon Daemon `toml:"daemon"` + // Notify configures desktop notifications. + Notify Notify `toml:"notify"` + // Lang is the fallback language (de | en) when detection cannot decide, // and the language of talk's own announcements. Default "en"; env // TALK_LANG. @@ -86,6 +90,13 @@ type Config struct { // QuietMode is the behaviour during quiet hours: "discard" (default, // drop silently) or "toast" (desktop notification instead of audio). QuietMode string `toml:"quiet_mode"` + + // Unknown lists keys in config.toml that talk does not know (dotted, + // for example "voice.lang"). Not an error, because an older talk must + // still read a newer file, but `talk doctor` reports them: the classic + // trap is a top-level key written below a [table] header, which TOML + // then files under that table. + Unknown []string `toml:"-"` } // Audio configures playback. @@ -120,6 +131,15 @@ type Daemon struct { Autostart bool `toml:"autostart"` } +// Notify holds the desktop notification settings (TOML `[notify]`). +type Notify struct { + // OnSpeak shows a desktop notification for every announcement that is + // playing, with a Cancel button where the platform supports it (Linux + // with a freedesktop notification service), so a long announcement can + // be stopped without a terminal. Default true; env TALK_NOTIFY. + OnSpeak bool `toml:"on_speak"` +} + // Wrap holds the `talk wrap` settings (TOML `[wrap]`). type Wrap struct { // Label replaces the command's base name in the announcement, for @@ -165,6 +185,7 @@ func Defaults() Config { Server: Server{Port: DefaultServerPort}, Queue: Queue{RateLimit: DefaultRateLimit, AllowHighBurst: true, MaxText: DefaultMaxText, MaxAge: DefaultMaxAge}, Daemon: Daemon{Autostart: true}, + Notify: Notify{OnSpeak: true}, Lang: DefaultLang, QuietMode: queue.QuietModeDiscard, } @@ -202,10 +223,29 @@ func Load(opts Options) (Config, error) { path = filepath.Join(dir, fileName) } - if _, err := toml.DecodeFile(path, &cfg); err != nil { + md, err := toml.DecodeFile(path, &cfg) + if err != nil { if !errors.Is(err, os.ErrNotExist) { return cfg, fmt.Errorf("config %s: %w", path, err) } + } else { + // The decoder lists an unknown table and its keys; keep the keys, + // which say more than the table. + undecoded := md.Undecoded() + for i, key := range undecoded { + prefix := key.String() + "." + isTable := false + for j, other := range undecoded { + if i != j && strings.HasPrefix(other.String(), prefix) { + isTable = true + break + } + } + if !isTable { + cfg.Unknown = append(cfg.Unknown, key.String()) + } + } + cfg.Unknown = append(cfg.Unknown, strayVoiceKeys(cfg.Voice)...) } return applyEnv(cfg, opts.Getenv) } @@ -260,6 +300,13 @@ func applyEnv(cfg Config, getenv func(string) string) (Config, error) { } cfg.Daemon.Autostart = b } + if v := getenv("TALK_NOTIFY"); v != "" { + b, err := strconv.ParseBool(v) + if err != nil { + return cfg, fmt.Errorf("TALK_NOTIFY: invalid value %q (true/false)", v) + } + cfg.Notify.OnSpeak = b + } if v := getenv("TALK_WRAP_LABEL"); v != "" { cfg.Wrap.Label = v } @@ -326,6 +373,37 @@ func applyEnv(cfg Config, getenv func(string) string) (Config, error) { return cfg, nil } +// strayVoiceKeys finds keys under [voice.] that are not languages +// (de | en) and voice entries that are not a table of languages at all. The +// decoder accepts anything there because the mapping is open; a top-level +// key such as `lang` written below [voice] lands here by accident. +func strayVoiceKeys(voice map[string]map[string]string) []string { + var out []string + providers := make([]string, 0, len(voice)) + for p := range voice { + providers = append(providers, p) + } + sort.Strings(providers) + for _, p := range providers { + langs := voice[p] + if len(langs) == 0 { + out = append(out, "voice."+p) + continue + } + keys := make([]string, 0, len(langs)) + for k := range langs { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + if k != "de" && k != "en" { + out = append(out, "voice."+p+"."+k) + } + } + } + return out +} + // splitOrigins splits a comma-separated origin list (the env form of // server.allowed_origins, for example "https://a.example, https://b.example"). // Whitespace around entries is tolerated; empty entries are an error. diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 73c8896..70db5fb 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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) + } +} diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 7d29182..0afcc55 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -84,7 +84,34 @@ func Run(ctx context.Context, opts Options) error { if err != nil { return err } - q := queue.New(queue.Laws{ + // Desktop notification per announcement (notify.on_speak): shown when the + // announcement starts, with a Cancel button where the platform has one, + // closed when it ends. Best effort: the first failure is logged, later + // ones are silent, speech never waits for a notification problem. + var q *queue.Queue + var notifyFailed sync.Once + announceNotification := func(ctx context.Context, item queue.Item, lang string) func() { + if !cfg.Notify.OnSpeak { + return func() {} + } + label := "Cancel" + if lang == "de" { + label = "Abbrechen" + } + closeFn, err := notify.Announce(ctx, item.Text, label, func() { + if q.CancelItem(item.ID) { + logger.Printf("announcement %s cancelled from its notification", item.ID) + } + }) + if err != nil { + notifyFailed.Do(func() { + logger.Printf("warning: notifications unavailable (%v); announcements play without one. Set [notify] on_speak = false to silence this", err) + }) + } + return closeFn + } + + q = queue.New(queue.Laws{ RateLimit: cfg.Queue.RateLimit, AllowHighBurst: cfg.Queue.AllowHighBurst, Quiet: quiet, @@ -92,12 +119,10 @@ func Run(ctx context.Context, opts Options) error { MaxText: cfg.Queue.MaxText, MaxAge: cfg.Queue.MaxAge, }, func(ctx context.Context, item queue.Item) error { - out, err := engine.Speak(ctx, speak.Request{ - Text: item.Text, - Lang: item.Lang, - Provider: item.Provider, - Voice: item.Voice, - }) + req := speak.Request{Text: item.Text, Lang: item.Lang, Provider: item.Provider, Voice: item.Voice} + closeNotification := announceNotification(ctx, item, engine.Lang(req)) + defer closeNotification() + out, err := engine.Speak(ctx, req) if err == nil && !out.Discarded { logger.Printf("announcement %s via %s (synthesis %s, playback %s)", item.ID, out.Provider, out.Synthesis.Round(time.Millisecond), out.Playback.Round(time.Millisecond)) @@ -175,8 +200,8 @@ func Run(ctx context.Context, opts Options) error { if len(cfg.Server.AllowedOrigins) > 0 { corsLog = fmt.Sprintf("%v", cfg.Server.AllowedOrigins) } - logger.Printf("listening on http://%s (version %s, pid %d, audio sink %s, mute=%v, rate-limit %s, high-burst=%v, max-text %d, max-age %s, quiet-hours=%q mode %s, cors=%s, mcp=/mcp)", - addr, opts.Version, os.Getpid(), engine.SinkName(), mute.Muted(), + logger.Printf("listening on http://%s (version %s, pid %d, audio sink %s, mute=%v, notify=%v, rate-limit %s, high-burst=%v, max-text %d, max-age %s, quiet-hours=%q mode %s, cors=%s, mcp=/mcp)", + addr, opts.Version, os.Getpid(), engine.SinkName(), mute.Muted(), cfg.Notify.OnSpeak, cfg.Queue.RateLimit, cfg.Queue.AllowHighBurst, cfg.Queue.MaxText, cfg.Queue.MaxAge, cfg.QuietHours, cfg.QuietMode, corsLog) select { diff --git a/internal/notify/dbus_linux.go b/internal/notify/dbus_linux.go new file mode 100644 index 0000000..c2d4a53 --- /dev/null +++ b/internal/notify/dbus_linux.go @@ -0,0 +1,179 @@ +//go:build linux + +package notify + +import ( + "context" + "fmt" + "slices" + "sync" + "time" + + "github.com/godbus/dbus/v5" +) + +// The freedesktop Notifications service on the session bus. One connection +// per process, made lazily on first use and kept: the daemon notifies on +// every announcement and must see the ActionInvoked signals for the Cancel +// button, which only works on a connection that stays open. +const ( + notifDest = "org.freedesktop.Notifications" + notifPath = "/org/freedesktop/Notifications" + notifIface = "org.freedesktop.Notifications" + + // dbusCallTimeout bounds every call: a hanging notification service + // must never hold up an announcement. + dbusCallTimeout = 2 * time.Second +) + +var bus struct { + once sync.Once + conn *dbus.Conn + err error + caps []string + server string + mu sync.Mutex + handlers map[uint32]func(action string) // by notification id +} + +// connect opens the session bus once, subscribes to the service's signals +// and starts the dispatcher. A missing bus (DBUS_SESSION_BUS_ADDRESS unset: +// servers, SSH, WinRM-style sessions) is a normal, remembered failure. +func connect() (*dbus.Conn, error) { + bus.once.Do(func() { + conn, err := dbus.SessionBus() + if err != nil { + bus.err = fmt.Errorf("session bus: %w", err) + return + } + ctx, cancel := context.WithTimeout(context.Background(), dbusCallTimeout) + defer cancel() + obj := conn.Object(notifDest, notifPath) + var name, vendor, version, spec string + if err := obj.CallWithContext(ctx, notifIface+".GetServerInformation", 0).Store(&name, &vendor, &version, &spec); err != nil { + bus.err = fmt.Errorf("no notification service on the session bus: %w", err) + return + } + bus.server = name + if version != "" { + bus.server += " " + version + } + if err := obj.CallWithContext(ctx, notifIface+".GetCapabilities", 0).Store(&bus.caps); err != nil { + bus.caps = nil + } + if err := conn.AddMatchSignal(dbus.WithMatchObjectPath(notifPath), dbus.WithMatchInterface(notifIface)); err != nil { + bus.err = fmt.Errorf("subscribing to notification signals: %w", err) + return + } + bus.handlers = map[uint32]func(string){} + ch := make(chan *dbus.Signal, 32) + conn.Signal(ch) + go dispatch(ch) + bus.conn = conn + }) + return bus.conn, bus.err +} + +// dispatch routes ActionInvoked to the notification's handler and drops the +// handler on NotificationClosed. +func dispatch(ch <-chan *dbus.Signal) { + for sig := range ch { + if len(sig.Body) == 0 { + continue + } + id, ok := sig.Body[0].(uint32) + if !ok { + continue + } + switch sig.Name { + case notifIface + ".ActionInvoked": + action, _ := sig.Body[1].(string) + bus.mu.Lock() + h := bus.handlers[id] + bus.mu.Unlock() + if h != nil { + h(action) + } + case notifIface + ".NotificationClosed": + bus.mu.Lock() + delete(bus.handlers, id) + bus.mu.Unlock() + } + } +} + +// dbusAvailable reports whether a notification service answers on the bus. +func dbusAvailable() bool { + _, err := connect() + return err == nil +} + +// dbusSupportsActions reports the "actions" capability of the service. +func dbusSupportsActions() bool { + if _, err := connect(); err != nil { + return false + } + return slices.Contains(bus.caps, "actions") +} + +// dbusDescribe names the service for `talk doctor`. +func dbusDescribe() (string, bool) { + if _, err := connect(); err != nil { + return "", false + } + s := "notification service " + bus.server + " via D-Bus" + if slices.Contains(bus.caps, "actions") { + return s + " (cancel button supported)", true + } + return s + " (no actions: no cancel button)", true +} + +// dbusNotify shows a notification and returns its id. actions is the flat +// freedesktop list [key, label, …]; onAction receives the key when the user +// clicks. expire_timeout 0 keeps the notification until talk closes it, so +// the Cancel button stays for the whole announcement. +func dbusNotify(ctx context.Context, text string, actions []string, onAction func(action string)) (uint32, error) { + conn, err := connect() + if err != nil { + return 0, err + } + if actions == nil { + actions = []string{} + } + expire := int32(-1) // server default for plain toasts + if len(actions) > 0 { + expire = 0 + } + ctx, cancel := context.WithTimeout(ctx, dbusCallTimeout) + defer cancel() + hints := map[string]dbus.Variant{ + "urgency": dbus.MakeVariant(byte(1)), + "desktop-entry": dbus.MakeVariant(Title), + } + var id uint32 + err = conn.Object(notifDest, notifPath).CallWithContext(ctx, notifIface+".Notify", 0, + Title, uint32(0), "audio-speakers", Title, text, actions, hints, expire).Store(&id) + if err != nil { + return 0, fmt.Errorf("notify: %w", err) + } + if onAction != nil { + bus.mu.Lock() + bus.handlers[id] = onAction + bus.mu.Unlock() + } + return id, nil +} + +// dbusClose dismisses a notification (best effort). +func dbusClose(id uint32) { + conn, err := connect() + if err != nil { + return + } + bus.mu.Lock() + delete(bus.handlers, id) + bus.mu.Unlock() + ctx, cancel := context.WithTimeout(context.Background(), dbusCallTimeout) + defer cancel() + _ = conn.Object(notifDest, notifPath).CallWithContext(ctx, notifIface+".CloseNotification", 0, id).Err +} diff --git a/internal/notify/dbus_other.go b/internal/notify/dbus_other.go new file mode 100644 index 0000000..2011437 --- /dev/null +++ b/internal/notify/dbus_other.go @@ -0,0 +1,21 @@ +//go:build !linux + +package notify + +import ( + "context" + "errors" +) + +// Without the freedesktop Notifications service there is no bus path; the +// OS tool does the job (no actions). + +var errNoDBus = errors.New("notify: no D-Bus notification service on this platform") + +func dbusAvailable() bool { return false } +func dbusSupportsActions() bool { return false } +func dbusDescribe() (string, bool) { return "", false } +func dbusClose(uint32) {} +func dbusNotify(context.Context, string, []string, func(string)) (uint32, error) { + return 0, errNoDBus +} diff --git a/internal/notify/notify.go b/internal/notify/notify.go index 7713122..5546771 100644 --- a/internal/notify/notify.go +++ b/internal/notify/notify.go @@ -1,10 +1,12 @@ -// Package notify shows desktop notifications: best effort, through the -// operating system's own tools, without a library dependency and without -// CGO (the same line as provider/osbuiltin): +// Package notify shows desktop notifications: best effort, never a reason to +// abort an announcement. // -// Linux: notify-send (libnotify; needs a notification daemon) -// macOS: osascript `display notification` -// Windows: PowerShell toast (Windows.UI.Notifications) +// Linux: the freedesktop Notifications D-Bus service (org.freedesktop. +// Notifications, pure Go via godbus): notifications with a Cancel +// action, closed again when the announcement ends. Fallback when +// no session bus is reachable: notify-send (no action). +// macOS: osascript `display notification` (no action). +// Windows: PowerShell toast (Windows.UI.Notifications; no action yet). // // Missing tools are a clear error for the caller (log), never an abort: a // notification is an extra, not a contract. @@ -29,29 +31,46 @@ const Title = "talk" // timeout bounds the call of the OS tool. const timeout = 10 * time.Second -// Available reports whether a notification path exists on this system -// (binary present; whether a daemon listens only Show reveals). +// Available reports whether a notification path exists on this system: a +// reachable notification service on the session bus, or the OS tool. func Available() bool { + if dbusAvailable() { + return true + } _, err := exec.LookPath(tool()) return err == nil } -// Describe names the path for `talk doctor`. +// Describe names the path for `talk doctor`: the notification service with +// name and version and whether it supports a Cancel action, or the OS tool. func Describe() string { + if info, ok := dbusDescribe(); ok { + return info + } t := tool() if _, err := exec.LookPath(t); err != nil { + if runtime.GOOS == "linux" { + return "no notification service on the session bus and " + t + " not found" + } return t + " not found" } switch runtime.GOOS { case "darwin": - return "osascript (Notification Center)" + return "osascript (Notification Center; no cancel button)" case "windows": - return "powershell (Windows toast)" + return "powershell (Windows toast; no cancel button yet)" default: - return "notify-send (libnotify)" + return "notify-send (libnotify; no session bus, so no cancel button)" } } +// SupportsCancel reports whether notifications on this system can carry a +// Cancel action that reaches talk (Linux with a notification service that +// has the "actions" capability). +func SupportsCancel() bool { + return dbusSupportsActions() +} + func tool() string { switch runtime.GOOS { case "darwin": @@ -63,9 +82,44 @@ func tool() string { } } -// Show shows a notification with Title and text and waits for the OS call -// to finish (not for the notification to be closed). +// Show shows a plain notification with Title and text (no action) and waits +// for the handover to finish, not for the notification to be closed. Used +// for the quiet-hours toast. func Show(ctx context.Context, text string) error { + if dbusAvailable() { + id, err := dbusNotify(ctx, text, nil, nil) + if err == nil { + _ = id + return nil + } + } + return showWithTool(ctx, text) +} + +// Announce shows the notification for an announcement that is playing: the +// text plus a Cancel action labelled cancelLabel where the platform supports +// it. onCancel runs when the user clicks it. The returned close function +// dismisses the notification (call it when the announcement ends); it is +// never nil. Platforms without actions show a plain notification and the +// close function does nothing. +func Announce(ctx context.Context, text, cancelLabel string, onCancel func()) (close func(), err error) { + noop := func() {} + if dbusAvailable() { + id, err := dbusNotify(ctx, text, []string{"cancel", cancelLabel}, func(action string) { + if action == "cancel" && onCancel != nil { + onCancel() + } + }) + if err == nil { + return func() { dbusClose(id) }, nil + } + // fall through to the tool on a bus error + } + return noop, showWithTool(ctx, text) +} + +// showWithTool shows a notification through the OS tool (no action). +func showWithTool(ctx context.Context, text string) error { ctx, cancel := context.WithTimeout(ctx, timeout) defer cancel() var cmd *exec.Cmd diff --git a/internal/notify/notify_test.go b/internal/notify/notify_test.go index 6e270c1..fda3222 100644 --- a/internal/notify/notify_test.go +++ b/internal/notify/notify_test.go @@ -9,6 +9,13 @@ import ( "testing" ) +// TestMain cuts the tests off from the developer's real session bus so they +// observe the notify-send fallback instead of popping real notifications. +func TestMain(m *testing.M) { + os.Setenv("DBUS_SESSION_BUS_ADDRESS", "unix:path=/nonexistent/talk-test-bus") + os.Exit(m.Run()) +} + // TestShowCallsNotifySend: on Linux, Show goes through notify-send; here a // fake in PATH that records its arguments (a real process, no mock // framework). Title and text arrive unchanged. diff --git a/internal/queue/queue.go b/internal/queue/queue.go index 9c783c0..d3aaf80 100644 --- a/internal/queue/queue.go +++ b/internal/queue/queue.go @@ -259,6 +259,19 @@ func (q *Queue) Cancel() int { return n } +// CancelItem aborts the announcement with the given id if it is the one +// playing right now (the Cancel button of its notification). Waiting items +// and the rest of the queue are untouched. Reports whether it hit. +func (q *Queue) CancelItem(id string) bool { + q.mu.Lock() + defer q.mu.Unlock() + if q.current == nil || q.current.ID != id || q.cancelCur == nil { + return false + } + q.cancelCur() + return true +} + // Close shuts the queue down: waiting items are dropped, the playing // announcement is aborted, the worker loop ends. Close waits until the loop // has ended (idempotent). diff --git a/internal/queue/queue_test.go b/internal/queue/queue_test.go index 3c29a0a..92bdd4c 100644 --- a/internal/queue/queue_test.go +++ b/internal/queue/queue_test.go @@ -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{}{} +} diff --git a/internal/speak/engine.go b/internal/speak/engine.go index ee9e4fa..e084551 100644 --- a/internal/speak/engine.go +++ b/internal/speak/engine.go @@ -82,6 +82,15 @@ func (e *Engine) Speak(ctx context.Context, req Request) (provider.Outcome, erro return out, err } +// Lang resolves the language of a request: the pin, or detection with the +// configured fallback. +func (e *Engine) Lang(req Request) string { + if req.Lang != "" { + return req.Lang + } + return langdetect.Detect(req.Text, e.cfg.Lang) +} + // Chain returns the unpinned base chain (diagnostics: tiers and their // availability). func (e *Engine) Chain() *provider.Chain { return e.base } diff --git a/test/harness_test.go b/test/harness_test.go index ee4d3ce..c9ab58d 100644 --- a/test/harness_test.go +++ b/test/harness_test.go @@ -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 diff --git a/test/notify_dbus_test.go b/test/notify_dbus_test.go new file mode 100644 index 0000000..fbefac2 --- /dev/null +++ b/test/notify_dbus_test.go @@ -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): + } +}