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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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{}{}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
Reference in new issue
Block a user