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>
218 lines
7.2 KiB
Go
218 lines
7.2 KiB
Go
//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):
|
|
}
|
|
}
|