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
+803 -42

No files matched your search

+27 -8
View File
@@ -78,9 +78,9 @@ Keys never go into config.toml.
- **`talk test`** speaks a fixed test sentence directly in this process (not - **`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 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 mentioned) and shows which tier spoke and how long synthesis and playback
took. `talk test --toast` sends a test desktop notification through the took. `talk test --toast` shows a test notification with the Cancel button
same path `quiet_mode = toast` uses, so you can check notifications and waits for a click, so you can check the notification setup without
without a quiet window or a daemon restart. speaking anything.
- **`talk voices`** lists the curated voice catalog (ElevenLabs premade, - **`talk voices`** lists the curated voice catalog (ElevenLabs premade,
OpenAI, Edge neural voices per locale) as a table; `*` marks the voice OpenAI, Edge neural voices per locale) as a table; `*` marks the voice
talk uses without `--voice` (config `voice.<provider>.<lang>` or the talk uses without `--voice` (config `voice.<provider>.<lang>` or the
@@ -95,6 +95,26 @@ Keys never go into config.toml.
very end. Without a terminal (pipe, agent) setup asks nothing and only very end. Without a terminal (pipe, agent) setup asks nothing and only
shows the effective configuration. 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 ### Language
talk detects German and English per announcement (word lists plus umlauts; 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): high (burst is a rate right, not a quiet right):
- `quiet_mode = "discard"` (default): drop silently. - `quiet_mode = "discard"` (default): drop silently.
- `quiet_mode = "toast"`: a desktop notification instead of audio, through - `quiet_mode = "toast"`: a desktop notification instead of audio (the
the system's own tools (Linux `notify-send`, macOS Notification Center, notification service or the system's own tool, see "Notifications"), best
Windows toast), best effort: if the tool is missing, only the log line effort: if nothing is available, only the log line remains and
remains and `talk doctor` shows it under "Toast". Try it with `talk doctor` shows it under "Toast".
`talk test --toast`.
- `now:true` **breaks out of quiet hours too** (a deliberate interruption). - `now:true` **breaks out of quiet hours too** (a deliberate interruption).
**Length and age:** two more laws against what prompts like to do, reading **Length and age:** two more laws against what prompts like to do, reading
+7 -3
View File
@@ -129,6 +129,10 @@ func checkConfig() (config.Config, check) {
if _, statErr := os.Stat(path); statErr != nil { 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)} 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"} return cfg, check{"Config", statusOK, path + " loaded"}
} }
@@ -262,12 +266,12 @@ func probeTier(p provider.Provider) string {
func checkNotify(cfg config.Config) check { func checkNotify(cfg config.Config) check {
if notify.Available() { 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 { 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 { 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" "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 // 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 // every test: TALK_EDGE_ENDPOINT points at a guaranteed dead loopback port
// (connection refused at once); the default chain contains edge, and no // (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_EDGE_ENDPOINT": "ws://127.0.0.1:1/",
"TALK_PORT": "1", "TALK_PORT": "1",
"TALK_AUTOSTART": "false", // in-process tests must never start the test binary as a daemon "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 { for k, v := range kv {
base[k] = v 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, " quiet hours: %s\n", orDefault(cfg.QuietHours, "none"))
fmt.Fprintf(w, " autostart: %v\n", cfg.Daemon.Autostart) 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 { func audioSummary(cfg config.Config) string {
+33 -5
View File
@@ -88,23 +88,51 @@ func runTest(args []string, opts *sayOptions, _ io.Reader, stdout, stderr io.Wri
return exitOK return exitOK
} }
// runToastTest sends the test notification and reports the tool and the // runToastTest shows the test notification the way a playing announcement
// result. Exit 1 when no tool exists or the call fails. // 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 { func runToastTest(lang string, stdout, stderr io.Writer) int {
fmt.Fprintf(stdout, "toast: %s\n", notify.Describe()) fmt.Fprintf(stdout, "toast: %s\n", notify.Describe())
if !notify.Available() { 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 return exitFailure
} }
label := "Cancel"
if lang == "de" {
label = "Abbrechen"
}
clicked := make(chan struct{}, 1)
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel() defer cancel()
start := time.Now() 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.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 return exitFailure
} }
fmt.Fprintf(stdout, "sent: %s\n", time.Since(start).Round(time.Millisecond)) fmt.Fprintf(stdout, "sent: %s\n", time.Since(start).Round(time.Millisecond))
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).") fmt.Fprintln(stdout, "If no notification appeared, your notification daemon did not show it (talk only hands it over).")
return exitOK 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" { if runtime.GOOS != "linux" {
t.Skip("fake notify-send exists on Linux only") 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() dir := t.TempDir()
seen := filepath.Join(dir, "seen.txt") seen := filepath.Join(dir, "seen.txt")
fakeTTSBin(t, dir, "notify-send", `printf '%s\n' "$@" > "`+seen+`"`) fakeTTSBin(t, dir, "notify-send", `printf '%s\n' "$@" > "`+seen+`"`)
@@ -71,7 +73,7 @@ func TestTestToast(t *testing.T) {
t.Setenv("PATH", t.TempDir()) t.Setenv("PATH", t.TempDir())
out.Reset() out.Reset()
errBuf.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()) t.Errorf("without tool: exit %d, stderr %q", code, errBuf.String())
} }
} }
+1 -1
View File
@@ -23,7 +23,7 @@
subPackages = [ "cmd/talk" ]; subPackages = [ "cmd/talk" ];
# Update after changes to go.mod/go.sum: `nix build` prints the # Update after changes to go.mod/go.sum: `nix build` prints the
# expected hash. # expected hash.
vendorHash = "sha256-CbF3juTA7V05hh1ElKKACddsCn4bIPeLZxsz2EZxs9I="; vendorHash = "sha256-Cne8FiMlpsrFfntMJVtV4D3CzEWQ4Jnd+tIEHF9ecEc=";
env.CGO_ENABLED = 0; env.CGO_ENABLED = 0;
ldflags = [ "-s" "-w" "-X" "main.version=${version}" "-X" "main.commit=${self.shortRev or "dirty"}" ]; 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. # The process-boundary tests start daemons and bind ports; not in the sandbox.
+1
View File
@@ -5,6 +5,7 @@ go 1.26.8
require ( require (
github.com/BurntSushi/toml v1.6.0 github.com/BurntSushi/toml v1.6.0
github.com/ebitengine/oto/v3 v3.5.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/gorilla/websocket v1.5.3
github.com/hajimehoshi/go-mp3 v0.3.4 github.com/hajimehoshi/go-mp3 v0.3.4
) )
+2
View File
@@ -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/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 h1:jhp/D+Nyv7UUW8HAcmcjt2N2rYrYi9m3SL21k0Ua/NI=
github.com/ebitengine/purego v0.11.0/go.mod h1:DCHPP08djqhNSoTfImcnHYQRZmd0qhakvrozqaEYhGQ= 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 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68= github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68=
+79 -1
View File
@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"sort"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -53,6 +54,9 @@ type Config struct {
// Daemon configures the background process (autostart). // Daemon configures the background process (autostart).
Daemon Daemon `toml:"daemon"` Daemon Daemon `toml:"daemon"`
// Notify configures desktop notifications.
Notify Notify `toml:"notify"`
// Lang is the fallback language (de | en) when detection cannot decide, // Lang is the fallback language (de | en) when detection cannot decide,
// and the language of talk's own announcements. Default "en"; env // and the language of talk's own announcements. Default "en"; env
// TALK_LANG. // TALK_LANG.
@@ -86,6 +90,13 @@ type Config struct {
// QuietMode is the behaviour during quiet hours: "discard" (default, // QuietMode is the behaviour during quiet hours: "discard" (default,
// drop silently) or "toast" (desktop notification instead of audio). // drop silently) or "toast" (desktop notification instead of audio).
QuietMode string `toml:"quiet_mode"` 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. // Audio configures playback.
@@ -120,6 +131,15 @@ type Daemon struct {
Autostart bool `toml:"autostart"` 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]`). // Wrap holds the `talk wrap` settings (TOML `[wrap]`).
type Wrap struct { type Wrap struct {
// Label replaces the command's base name in the announcement, for // Label replaces the command's base name in the announcement, for
@@ -165,6 +185,7 @@ func Defaults() Config {
Server: Server{Port: DefaultServerPort}, Server: Server{Port: DefaultServerPort},
Queue: Queue{RateLimit: DefaultRateLimit, AllowHighBurst: true, MaxText: DefaultMaxText, MaxAge: DefaultMaxAge}, Queue: Queue{RateLimit: DefaultRateLimit, AllowHighBurst: true, MaxText: DefaultMaxText, MaxAge: DefaultMaxAge},
Daemon: Daemon{Autostart: true}, Daemon: Daemon{Autostart: true},
Notify: Notify{OnSpeak: true},
Lang: DefaultLang, Lang: DefaultLang,
QuietMode: queue.QuietModeDiscard, QuietMode: queue.QuietModeDiscard,
} }
@@ -202,10 +223,29 @@ func Load(opts Options) (Config, error) {
path = filepath.Join(dir, fileName) 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) { if !errors.Is(err, os.ErrNotExist) {
return cfg, fmt.Errorf("config %s: %w", path, err) 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) return applyEnv(cfg, opts.Getenv)
} }
@@ -260,6 +300,13 @@ func applyEnv(cfg Config, getenv func(string) string) (Config, error) {
} }
cfg.Daemon.Autostart = b 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 != "" { if v := getenv("TALK_WRAP_LABEL"); v != "" {
cfg.Wrap.Label = v cfg.Wrap.Label = v
} }
@@ -326,6 +373,37 @@ func applyEnv(cfg Config, getenv func(string) string) (Config, error) {
return cfg, nil return cfg, nil
} }
// strayVoiceKeys finds keys under [voice.<provider>] 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 // splitOrigins splits a comma-separated origin list (the env form of
// server.allowed_origins, for example "https://a.example, https://b.example"). // server.allowed_origins, for example "https://a.example, https://b.example").
// Whitespace around entries is tolerated; empty entries are an error. // Whitespace around entries is tolerated; empty entries are an error.
+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) 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)
}
}
+34 -9
View File
@@ -84,7 +84,34 @@ func Run(ctx context.Context, opts Options) error {
if err != nil { if err != nil {
return err 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, RateLimit: cfg.Queue.RateLimit,
AllowHighBurst: cfg.Queue.AllowHighBurst, AllowHighBurst: cfg.Queue.AllowHighBurst,
Quiet: quiet, Quiet: quiet,
@@ -92,12 +119,10 @@ func Run(ctx context.Context, opts Options) error {
MaxText: cfg.Queue.MaxText, MaxText: cfg.Queue.MaxText,
MaxAge: cfg.Queue.MaxAge, MaxAge: cfg.Queue.MaxAge,
}, func(ctx context.Context, item queue.Item) error { }, func(ctx context.Context, item queue.Item) error {
out, err := engine.Speak(ctx, speak.Request{ req := speak.Request{Text: item.Text, Lang: item.Lang, Provider: item.Provider, Voice: item.Voice}
Text: item.Text, closeNotification := announceNotification(ctx, item, engine.Lang(req))
Lang: item.Lang, defer closeNotification()
Provider: item.Provider, out, err := engine.Speak(ctx, req)
Voice: item.Voice,
})
if err == nil && !out.Discarded { if err == nil && !out.Discarded {
logger.Printf("announcement %s via %s (synthesis %s, playback %s)", item.ID, out.Provider, logger.Printf("announcement %s via %s (synthesis %s, playback %s)", item.ID, out.Provider,
out.Synthesis.Round(time.Millisecond), out.Playback.Round(time.Millisecond)) 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 { if len(cfg.Server.AllowedOrigins) > 0 {
corsLog = fmt.Sprintf("%v", cfg.Server.AllowedOrigins) 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)", 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(), 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) cfg.Queue.RateLimit, cfg.Queue.AllowHighBurst, cfg.Queue.MaxText, cfg.Queue.MaxAge, cfg.QuietHours, cfg.QuietMode, corsLog)
select { select {
+179
View File
@@ -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
}
+21
View File
@@ -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
}
+68 -14
View File
@@ -1,10 +1,12 @@
// Package notify shows desktop notifications: best effort, through the // Package notify shows desktop notifications: best effort, never a reason to
// operating system's own tools, without a library dependency and without // abort an announcement.
// CGO (the same line as provider/osbuiltin):
// //
// Linux: notify-send (libnotify; needs a notification daemon) // Linux: the freedesktop Notifications D-Bus service (org.freedesktop.
// macOS: osascript `display notification` // Notifications, pure Go via godbus): notifications with a Cancel
// Windows: PowerShell toast (Windows.UI.Notifications) // 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 // Missing tools are a clear error for the caller (log), never an abort: a
// notification is an extra, not a contract. // notification is an extra, not a contract.
@@ -29,29 +31,46 @@ const Title = "talk"
// timeout bounds the call of the OS tool. // timeout bounds the call of the OS tool.
const timeout = 10 * time.Second const timeout = 10 * time.Second
// Available reports whether a notification path exists on this system // Available reports whether a notification path exists on this system: a
// (binary present; whether a daemon listens only Show reveals). // reachable notification service on the session bus, or the OS tool.
func Available() bool { func Available() bool {
if dbusAvailable() {
return true
}
_, err := exec.LookPath(tool()) _, err := exec.LookPath(tool())
return err == nil 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 { func Describe() string {
if info, ok := dbusDescribe(); ok {
return info
}
t := tool() t := tool()
if _, err := exec.LookPath(t); err != nil { 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" return t + " not found"
} }
switch runtime.GOOS { switch runtime.GOOS {
case "darwin": case "darwin":
return "osascript (Notification Center)" return "osascript (Notification Center; no cancel button)"
case "windows": case "windows":
return "powershell (Windows toast)" return "powershell (Windows toast; no cancel button yet)"
default: 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 { func tool() string {
switch runtime.GOOS { switch runtime.GOOS {
case "darwin": case "darwin":
@@ -63,9 +82,44 @@ func tool() string {
} }
} }
// Show shows a notification with Title and text and waits for the OS call // Show shows a plain notification with Title and text (no action) and waits
// to finish (not for the notification to be closed). // 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 { 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) ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel() defer cancel()
var cmd *exec.Cmd var cmd *exec.Cmd
+7
View File
@@ -9,6 +9,13 @@ import (
"testing" "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 // TestShowCallsNotifySend: on Linux, Show goes through notify-send; here a
// fake in PATH that records its arguments (a real process, no mock // fake in PATH that records its arguments (a real process, no mock
// framework). Title and text arrive unchanged. // framework). Title and text arrive unchanged.
+13
View File
@@ -259,6 +259,19 @@ func (q *Queue) Cancel() int {
return n 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 // Close shuts the queue down: waiting items are dropped, the playing
// announcement is aborted, the worker loop ends. Close waits until the loop // announcement is aborted, the worker loop ends. Close waits until the loop
// has ended (idempotent). // has ended (idempotent).
+28
View File
@@ -614,3 +614,31 @@ func waitFor(t *testing.T, cond func() bool) {
time.Sleep(5 * time.Millisecond) 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{}{}
}
+9
View File
@@ -82,6 +82,15 @@ func (e *Engine) Speak(ctx context.Context, req Request) (provider.Outcome, erro
return out, err 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 // Chain returns the unpinned base chain (diagnostics: tiers and their
// availability). // availability).
func (e *Engine) Chain() *provider.Chain { return e.base } func (e *Engine) Chain() *provider.Chain { return e.base }
+2
View File
@@ -333,6 +333,8 @@ func daemonEnv(root string, port int, fakeURL string, extra map[string]string) [
"TALK_PORT": strconv.Itoa(port), "TALK_PORT": strconv.Itoa(port),
"TALK_RATE_LIMIT": "0s", // law off until a test turns it on (see above) "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_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 { for k, v := range extra {
set[k] = v set[k] = v
+217
View File
@@ -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):
}
}