feat(notify): desktop notification per announcement with a Cancel button
Every playing announcement now shows a desktop notification with its text and, on Linux, a Cancel button (Abbrechen for German) that stops exactly that announcement; the notification is closed when the announcement ends. This brings back what the old talk.sh had with notify-send --action, as a default: a long announcement can be cut short without a terminal. Implementation: the freedesktop Notifications service on the session bus via godbus (pure Go): one connection per process, Notify with the action list, ActionInvoked dispatched to the announcement, CloseNotification at the end, expire_timeout 0 so the button stays for the whole announcement. Without a session bus the notify-send fallback shows a plain notification; macOS and Windows show one without a button for now. queue.CancelItem(id) cancels only the playing item. Config [notify] on_speak (default true, env TALK_NOTIFY). `talk test --toast` now shows the real thing and waits for a click; `talk doctor` names the service and whether it supports the button. Config hygiene found on the way: a key written below a [table] header belongs to that table in TOML (lang under [voice] was silently ignored). config.Load now collects unknown keys (undecoded ones and non-language keys under [voice.<provider>]) and `talk doctor` warns with the hint. Tests: process test on a private dbus-daemon with a fake org.freedesktop.Notifications service (Notify payload, cancel click aborts the synthesis, CloseNotification), queue.CancelItem, config keys. All test binaries point DBUS_SESSION_BUS_ADDRESS at a dead socket so no test ever pops a notification on the developer's desktop. flake vendorHash updated for the new dependency. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
6f3e7c9f35
commit
83a15af9c5
22 files changed
+806
-45
No files matched your search
@@ -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.<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
|
||||
// server.allowed_origins, for example "https://a.example, https://b.example").
|
||||
// Whitespace around entries is tolerated; empty entries are an error.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user