diff --git a/README.md b/README.md index 9fd8102..860c4f3 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ dependencies. | `talk mute` / `talk unmute` | mute all announcements persistently / turn them back on | | `talk status` | daemon status (queue, mute, provider, laws, version) | | `talk cancel` | cancel the playing and all queued announcements | +| `talk uri ` | handler behind the `talk:` links of notification buttons (Windows); not called by hand | | `talk daemon run\|start\|stop` | run the daemon in the foreground / start it in the background / stop it (normally not needed, see below) | | `talk voices` | known voices per provider and language, default marked (`--provider`, `--lang`, `--json`) | | `talk test` | speak a test sentence; shows provider and timings (`--lang`, `--provider`, `--voice`); `--toast` sends a test notification instead | @@ -98,13 +99,22 @@ Keys never go into config.toml. ### 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 +text and 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: +announcement ends. + +- **Linux:** talk talks to the freedesktop notification service on the + session bus directly (any daemon with the "actions" capability: mako, + dunst, GNOME Shell, KDE, quickshell) and receives the click over D-Bus. + Without a session bus it falls back to notify-send without a button. +- **Windows:** a toast with a Cancel button. The button opens a `talk:` + link; the daemon registers that URL protocol for the current user (HKCU, + no admin rights) so Windows starts `talk uri talk:cancel?id=…`, which + tells the daemon to stop the announcement. The toast appears under + Windows PowerShell's identity for now. +- **macOS:** Notification Center, no button yet. + +Turn it off with: ```toml [notify] @@ -159,6 +169,7 @@ mute` writes the mute file directly. | `OPTIONS /speak` | CORS preflight → `204` with allow headers when the `Origin` is listed in `server.allowed_origins` (see below); otherwise `204` without CORS headers | | `GET /status` | `{"version", "queue", "queue_ids", "current", "muted", "provider", "rate_limit": {"interval", "allow_high_burst", "next_slot"}, "quiet_hours": {"window", "active", "mode", "until"}, "limits": {"max_text", "max_age"}, "mcp": true, "cors": [origins]}` | | `POST /cancel` | aborts the playing announcement and empties the queue → `200 {"cancelled":n}` | +| `POST /cancel-item` | `{"id":"…"}` cancels only that announcement if it is playing (notification Cancel button) → `200 {"cancelled":bool}` | | `POST /mute` / `POST /unmute` | persistent mute state → `200 {"muted":bool}` | | `POST /shutdown` | shuts the daemon down (`talk daemon stop`) → `200 {"ok":true}` | | `POST /mcp` | MCP (Streamable HTTP): JSON-RPC `initialize`, `notifications/initialized`, `ping`, `tools/list`, `tools/call`; one tool `speak` (see below) | diff --git a/cmd/talk/cli.go b/cmd/talk/cli.go index 3646057..dca823b 100644 --- a/cmd/talk/cli.go +++ b/cmd/talk/cli.go @@ -78,6 +78,13 @@ var commands = []command{ detail: "Aborts the playing announcement and empties the daemon's queue; prints the number of cancelled announcements.", handler: runCancel, }, + { + name: "uri", + short: "handle a talk: link (notification buttons)", + args: "", + detail: "The handler behind the talk: URL protocol: a toast's Cancel button opens talk:cancel?id=&port=, Windows starts `talk uri` with it, and talk tells the daemon to cancel that announcement. Registered for the current user by the daemon on Windows; not needed elsewhere.", + handler: runURI, + }, { name: "voices", short: "list known voices", diff --git a/cmd/talk/cli_test.go b/cmd/talk/cli_test.go index a5694fa..32d7875 100644 --- a/cmd/talk/cli_test.go +++ b/cmd/talk/cli_test.go @@ -11,7 +11,7 @@ import ( // list (not derived from the command table) so the tests fire when table and // documentation drift apart. var wantSubcommands = []string{ - "say", "wrap", "mute", "unmute", "status", "daemon", "cancel", "voices", "test", "doctor", "setup", + "say", "wrap", "mute", "unmute", "status", "daemon", "cancel", "uri", "voices", "test", "doctor", "setup", } // runCLI is the test entry into run with empty stdin (no text via pipe), the diff --git a/cmd/talk/testcmd.go b/cmd/talk/testcmd.go index 6ba2f22..bf1fbe1 100644 --- a/cmd/talk/testcmd.go +++ b/cmd/talk/testcmd.go @@ -106,7 +106,7 @@ func runToastTest(lang string, stdout, stderr io.Writer) int { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() start := time.Now() - closeFn, err := notify.Announce(ctx, toastPhrase[lang], label, func() { + closeFn, err := notify.Announce(ctx, notify.Announcement{Text: toastPhrase[lang], CancelLabel: label, CancelURI: "talk:test", Tag: "test"}, func() { select { case clicked <- struct{}{}: default: diff --git a/cmd/talk/uri.go b/cmd/talk/uri.go new file mode 100644 index 0000000..a062879 --- /dev/null +++ b/cmd/talk/uri.go @@ -0,0 +1,83 @@ +package main + +import ( + "context" + "fmt" + "io" + "net/url" + "strconv" + + "code.m3ta.dev/m3tam3re/talk/internal/api" + "code.m3ta.dev/m3tam3re/talk/internal/config" +) + +// runURI implements `talk uri `: the handler behind the talk: URL +// protocol that Windows opens when the Cancel button of a toast is clicked +// (registered by the daemon in HKCU). Links: +// +// talk:cancel?id=&port= cancel that announcement +// talk:test the test toast's button; nothing to do +// +// It never fails loudly: a stale id or a gone daemon is simply nothing to +// cancel. Output is one line so a console flash, if any, says what happened. +func runURI(args []string, _ *sayOptions, _ io.Reader, stdout, stderr io.Writer) int { + if len(args) != 1 { + fmt.Fprintln(stderr, "talk uri: expects exactly one talk: link") + return exitUsage + } + u, err := url.Parse(args[0]) + if err != nil || u.Scheme != "talk" { + fmt.Fprintf(stderr, "talk uri: not a talk: link: %q\n", args[0]) + return exitUsage + } + action := u.Opaque + if action == "" { + action = u.Host + u.Path + } + if i := indexByte(action, '?'); i >= 0 { + action = action[:i] + } + switch action { + case "cancel": + id := u.Query().Get("id") + if id == "" { + fmt.Fprintln(stderr, "talk uri: cancel link without id") + return exitUsage + } + port := config.DefaultServerPort + if p := u.Query().Get("port"); p != "" { + if n, err := strconv.Atoi(p); err == nil && n > 0 && n < 65536 { + port = n + } + } else if cfg, err := config.Load(config.Options{Getenv: getenv}); err == nil { + port = cfg.Server.Port + } + ctx, cancel := context.WithTimeout(context.Background(), daemonCallTimeout) + defer cancel() + hit, err := api.NewClient(port).CancelItem(ctx, id) + switch { + case err != nil: + fmt.Fprintf(stdout, "nothing to cancel (no daemon at port %d)\n", port) + case hit: + fmt.Fprintf(stdout, "cancelled %s\n", id) + default: + fmt.Fprintf(stdout, "%s is not playing any more\n", id) + } + return exitOK + case "test": + fmt.Fprintln(stdout, "talk: the Cancel button works") + return exitOK + default: + fmt.Fprintf(stderr, "talk uri: unknown action %q\n", action) + return exitUsage + } +} + +func indexByte(s string, c byte) int { + for i := 0; i < len(s); i++ { + if s[i] == c { + return i + } + } + return -1 +} diff --git a/cmd/talk/uri_test.go b/cmd/talk/uri_test.go new file mode 100644 index 0000000..b9b14d7 --- /dev/null +++ b/cmd/talk/uri_test.go @@ -0,0 +1,82 @@ +package main + +import ( + "bytes" + "context" + "net" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + "time" + + "code.m3ta.dev/m3tam3re/talk/internal/api" + "code.m3ta.dev/m3tam3re/talk/internal/queue" +) + +// TestURICancelsPlayingItem: `talk uri talk:cancel?id=…&port=…` tells the +// daemon at that port to cancel exactly that announcement; a stale id and a +// dead daemon are plain outcomes, not errors; garbage links are usage +// errors. +func TestURICancelsPlayingItem(t *testing.T) { + setGetenv(t, nil) + started := make(chan queue.Item, 4) + release := make(chan struct{}) + q := queue.New(queue.Laws{}, func(ctx context.Context, item queue.Item) error { + started <- item + select { + case <-release: + return nil + case <-ctx.Done(): + return ctx.Err() + } + }, nil) + defer q.Close() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + srv := httptest.NewUnstartedServer(api.NewHandler(api.HandlerConfig{Queue: q, KnownProviders: []string{"edge"}})) + srv.Listener = ln + srv.Start() + defer srv.Close() + port := strconv.Itoa(ln.Addr().(*net.TCPAddr).Port) + + adm, _ := q.Enqueue(queue.Item{Text: "playing"}) + waiting, _ := q.Enqueue(queue.Item{Text: "waiting"}) + <-started + + var out, errBuf bytes.Buffer + if code := runCLI([]string{"uri", "talk:cancel?id=" + waiting.ID + "&port=" + port}, &out, &errBuf); code != exitOK || !strings.Contains(out.String(), "not playing") { + t.Errorf("waiting id: exit %d, stdout %q", code, out.String()) + } + out.Reset() + if code := runCLI([]string{"uri", "talk:cancel?id=" + adm.ID + "&port=" + port}, &out, &errBuf); code != exitOK || !strings.Contains(out.String(), "cancelled "+adm.ID) { + t.Errorf("playing id: exit %d, stdout %q, stderr %q", code, out.String(), errBuf.String()) + } + select { + case it := <-started: + if it.ID != waiting.ID { + t.Errorf("after cancel the next started item = %+v, want the waiting one", it) + } + case <-time.After(2 * time.Second): + t.Fatal("waiting item did not start after the playing one was cancelled") + } + close(release) + + out.Reset() + if code := runCLI([]string{"uri", "talk:cancel?id=abc&port=1"}, &out, &errBuf); code != exitOK || !strings.Contains(out.String(), "nothing to cancel") { + t.Errorf("dead daemon: exit %d, stdout %q", code, out.String()) + } + out.Reset() + if code := runCLI([]string{"uri", "talk:test"}, &out, &errBuf); code != exitOK || !strings.Contains(out.String(), "works") { + t.Errorf("talk:test: exit %d, stdout %q", code, out.String()) + } + for _, bad := range []string{"http://example.com", "talk:cancel", "talk:fly"} { + if code := runCLI([]string{"uri", bad}, &out, &errBuf); code != exitUsage { + t.Errorf("%q: exit %d, want usage error", bad, code) + } + } + _ = http.StatusOK +} diff --git a/internal/api/client.go b/internal/api/client.go index cc24785..c4d0e6b 100644 --- a/internal/api/client.go +++ b/internal/api/client.go @@ -71,6 +71,16 @@ func (c *Client) Cancel(ctx context.Context) (int, error) { return resp.Cancelled, nil } +// CancelItem calls POST /cancel-item; it reports whether the id was the +// playing announcement. +func (c *Client) CancelItem(ctx context.Context, id string) (bool, error) { + var resp CancelItemResponse + if err := c.do(ctx, http.MethodPost, "/cancel-item", CancelItemRequest{ID: id}, http.StatusOK, &resp); err != nil { + return false, err + } + return resp.Cancelled, nil +} + // SetMute calls POST /mute (muted=true) or POST /unmute (muted=false). func (c *Client) SetMute(ctx context.Context, muted bool) error { path := "/mute" diff --git a/internal/api/server.go b/internal/api/server.go index 7e3aa69..8a031aa 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -67,6 +67,7 @@ func NewHandler(cfg HandlerConfig) http.Handler { mux.HandleFunc("OPTIONS /speak", h.speakPreflight) // CORS preflight mux.HandleFunc("GET /status", h.status) mux.HandleFunc("POST /cancel", h.cancel) + mux.HandleFunc("POST /cancel-item", h.cancelItem) mux.HandleFunc("POST /mute", h.muteEndpoint(true)) mux.HandleFunc("POST /unmute", h.muteEndpoint(false)) mux.HandleFunc("POST /shutdown", h.shutdownEndpoint) @@ -240,6 +241,23 @@ func (h *handler) cancel(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, CancelResponse{Cancelled: h.queue.Cancel()}) } +// cancelItem handles POST /cancel-item: cancels the announcement with the +// given id if it is the one playing (the Cancel button of its notification; +// on Windows it arrives through the talk: protocol handler). Waiting items +// stay. A stale id is not an error: {"cancelled": false}. +func (h *handler) cancelItem(w http.ResponseWriter, r *http.Request) { + var req CancelItemRequest + if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxBodyBytes)).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error()) + return + } + if strings.TrimSpace(req.ID) == "" { + writeError(w, http.StatusBadRequest, "id is missing or empty") + return + } + writeJSON(w, http.StatusOK, CancelItemResponse{Cancelled: h.queue.CancelItem(req.ID)}) +} + // muteEndpoint handles POST /mute and POST /unmute: persistent mute state // (file in the config dir, survives daemon restarts). func (h *handler) muteEndpoint(muted bool) http.HandlerFunc { diff --git a/internal/api/types.go b/internal/api/types.go index adb9d46..f488086 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -152,3 +152,15 @@ type MuteResponse struct { type ShutdownResponse struct { OK bool `json:"ok"` } + +// CancelItemRequest is the body of POST /cancel-item: the id of the +// announcement to cancel, if it is the one playing. +type CancelItemRequest struct { + ID string `json:"id"` +} + +// CancelItemResponse is the answer of POST /cancel-item: whether the id was +// the playing announcement and has been cancelled. +type CancelItemResponse struct { + Cancelled bool `json:"cancelled"` +} diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 0afcc55..e7bd55f 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -15,6 +15,7 @@ import ( "net/http" "os" "path/filepath" + "runtime" "strconv" "strings" "sync" @@ -84,6 +85,16 @@ func Run(ctx context.Context, opts Options) error { if err != nil { return err } + // Windows: the toast's Cancel button opens a talk: link; register the + // protocol handler for this user so the click starts `talk uri`. + if cfg.Notify.OnSpeak && runtime.GOOS == "windows" { + if exe, err := os.Executable(); err == nil { + if err := notify.RegisterProtocol(exe); err != nil { + logger.Printf("warning: %v; the toast's Cancel button will not work", err) + } + } + } + // 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 @@ -98,7 +109,12 @@ func Run(ctx context.Context, opts Options) error { if lang == "de" { label = "Abbrechen" } - closeFn, err := notify.Announce(ctx, item.Text, label, func() { + closeFn, err := notify.Announce(ctx, notify.Announcement{ + Text: item.Text, + CancelLabel: label, + CancelURI: fmt.Sprintf("talk:cancel?id=%s&port=%d", item.ID, cfg.Server.Port), + Tag: item.ID, + }, func() { if q.CancelItem(item.ID) { logger.Printf("announcement %s cancelled from its notification", item.ID) } diff --git a/internal/notify/notify.go b/internal/notify/notify.go index 5546771..68b387b 100644 --- a/internal/notify/notify.go +++ b/internal/notify/notify.go @@ -6,7 +6,9 @@ // 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). +// Windows: PowerShell toast (Windows.UI.Notifications) with a Cancel +// button that opens a talk: link; the talk: protocol handler +// (registered in HKCU, `talk uri`) relays the click to the daemon. // // Missing tools are a clear error for the caller (log), never an abort: a // notification is an extra, not a contract. @@ -58,17 +60,18 @@ func Describe() string { case "darwin": return "osascript (Notification Center; no cancel button)" case "windows": - return "powershell (Windows toast; no cancel button yet)" + return "powershell (Windows toast with cancel button via the talk: link)" default: 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). +// Cancel button that reaches talk: Linux with a notification service that +// has the "actions" capability, or Windows (toast button via the talk: +// protocol handler). func SupportsCancel() bool { - return dbusSupportsActions() + return dbusSupportsActions() || runtime.GOOS == "windows" } func tool() string { @@ -96,16 +99,35 @@ func Show(ctx context.Context, text string) error { return showWithTool(ctx, text) } +// Announcement is what Announce shows for a playing announcement. +type Announcement struct { + // Text is the announcement text (the notification body). + Text string + + // CancelLabel is the button label ("Cancel", "Abbrechen"). + CancelLabel string + + // CancelURI is the talk: link the button opens where the click cannot + // reach the daemon directly (Windows toast): talk:cancel?id=…&port=…. + // The talk: protocol handler (talk uri) turns it into POST /cancel-item. + CancelURI string + + // Tag identifies the notification for closing it again where the + // platform needs a tag (Windows); typically the announcement id. + Tag string +} + // 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) { +// text plus a Cancel button where the platform supports it. onCancel runs +// when the user clicks it on platforms that report the click back (Linux +// D-Bus); on Windows the click opens CancelURI instead. 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, a Announcement, onCancel func()) (close func(), err error) { noop := func() {} if dbusAvailable() { - id, err := dbusNotify(ctx, text, []string{"cancel", cancelLabel}, func(action string) { + id, err := dbusNotify(ctx, a.Text, []string{"cancel", a.CancelLabel}, func(action string) { if action == "cancel" && onCancel != nil { onCancel() } @@ -115,7 +137,10 @@ func Announce(ctx context.Context, text, cancelLabel string, onCancel func()) (c } // fall through to the tool on a bus error } - return noop, showWithTool(ctx, text) + if runtime.GOOS == "windows" && a.CancelURI != "" { + return announceWindows(ctx, a) + } + return noop, showWithTool(ctx, a.Text) } // showWithTool shows a notification through the OS tool (no action). diff --git a/internal/notify/protocol_other.go b/internal/notify/protocol_other.go new file mode 100644 index 0000000..1fabbd3 --- /dev/null +++ b/internal/notify/protocol_other.go @@ -0,0 +1,7 @@ +//go:build !windows + +package notify + +// RegisterProtocol is a no-op outside Windows: Linux reports clicks over +// D-Bus, macOS has no button. +func RegisterProtocol(string) error { return nil } diff --git a/internal/notify/protocol_windows.go b/internal/notify/protocol_windows.go new file mode 100644 index 0000000..a18a7a7 --- /dev/null +++ b/internal/notify/protocol_windows.go @@ -0,0 +1,36 @@ +//go:build windows + +package notify + +import ( + "fmt" + + "golang.org/x/sys/windows/registry" +) + +// RegisterProtocol registers the talk: URL protocol for the current user +// (HKCU\Software\Classes\talk, no admin rights needed) so that a toast +// button with a talk: link starts ` uri `. Idempotent; the +// command is rewritten on every call so it follows the binary's location. +func RegisterProtocol(exe string) error { + root, _, err := registry.CreateKey(registry.CURRENT_USER, `Software\Classes\talk`, registry.SET_VALUE) + if err != nil { + return fmt.Errorf("registering talk: protocol: %w", err) + } + defer root.Close() + if err := root.SetStringValue("", "URL:talk protocol"); err != nil { + return fmt.Errorf("registering talk: protocol: %w", err) + } + if err := root.SetStringValue("URL Protocol", ""); err != nil { + return fmt.Errorf("registering talk: protocol: %w", err) + } + cmdKey, _, err := registry.CreateKey(registry.CURRENT_USER, `Software\Classes\talk\shell\open\command`, registry.SET_VALUE) + if err != nil { + return fmt.Errorf("registering talk: protocol: %w", err) + } + defer cmdKey.Close() + if err := cmdKey.SetStringValue("", fmt.Sprintf(`"%s" uri "%%1"`, exe)); err != nil { + return fmt.Errorf("registering talk: protocol: %w", err) + } + return nil +} diff --git a/internal/notify/toast_windows_impl.go b/internal/notify/toast_windows_impl.go new file mode 100644 index 0000000..a5f61a9 --- /dev/null +++ b/internal/notify/toast_windows_impl.go @@ -0,0 +1,66 @@ +package notify + +import ( + "context" + "encoding/base64" + "fmt" + "os/exec" +) + +// toastAppID is the AppUserModelID the toasts are shown under. Unpackaged +// programs need a registered one; PowerShell's own is present on every +// Windows and lets the toast show reliably (it then appears as coming from +// Windows PowerShell). A talk-specific identity would need a Start Menu +// shortcut; left for later. +const toastAppID = `{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}\WindowsPowerShell\v1.0\powershell.exe` + +// toastGroup groups all talk toasts so they can be removed by tag. +const toastGroup = "talk" + +// announceWindows shows a toast with the text and a Cancel button that opens +// a.CancelURI (protocol activation; Windows then starts `talk uri `, +// which tells the daemon to cancel). The returned close removes the toast by +// its tag when the announcement ends. +func announceWindows(ctx context.Context, a Announcement) (func(), error) { + tag := "talk-" + a.Tag + script := fmt.Sprintf(`$ErrorActionPreference = 'Stop' +[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null +[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null +$dec = { param($b) [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($b)) } +$esc = { param($s) [System.Security.SecurityElement]::Escape($s) } +$body = & $esc (& $dec '%s') +$label = & $esc (& $dec '%s') +$uri = & $esc (& $dec '%s') +$xmlText = "talk$body" +$xml = New-Object Windows.Data.Xml.Dom.XmlDocument +$xml.LoadXml($xmlText) +$toast = [Windows.UI.Notifications.ToastNotification]::new($xml) +$toast.Tag = '%s' +$toast.Group = '%s' +[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('%s').Show($toast) +`, b64(a.Text), b64(a.CancelLabel), b64(a.CancelURI), tag, toastGroup, toastAppID) + if err := runPowerShell(ctx, script); err != nil { + return func() {}, err + } + return func() { + remove := fmt.Sprintf(`[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null +[Windows.UI.Notifications.ToastNotificationManager]::History.Remove('%s', '%s', '%s') +`, tag, toastGroup, toastAppID) + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + _ = runPowerShell(ctx, remove) + }, nil +} + +// runPowerShell runs a script via -EncodedCommand. +func runPowerShell(ctx context.Context, script string) error { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + cmd := exec.CommandContext(ctx, "powershell", "-NoProfile", "-NonInteractive", "-EncodedCommand", psEncode(script)) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("notify: powershell failed: %w: %s", err, string(out)) + } + return nil +} + +func b64(s string) string { return base64.StdEncoding.EncodeToString([]byte(s)) } diff --git a/test/harness_test.go b/test/harness_test.go index c9ab58d..79130e0 100644 --- a/test/harness_test.go +++ b/test/harness_test.go @@ -535,3 +535,18 @@ func mcpDecode(t *testing.T, raw rawResponse) map[string]any { } return m } + +// postJSON POSTs a JSON body to a daemon endpoint and decodes the reply. +func postJSON(t *testing.T, d *daemonProc, path, body string) (int, map[string]any) { + t.Helper() + resp, err := http.Post(d.baseURL+path, "application/json", strings.NewReader(body)) + if err != nil { + t.Fatalf("POST %s: %v", path, err) + } + defer resp.Body.Close() + var m map[string]any + if err := json.NewDecoder(resp.Body).Decode(&m); err != nil { + t.Fatalf("decoding POST %s reply: %v", path, err) + } + return resp.StatusCode, m +} diff --git a/test/queue_test.go b/test/queue_test.go index 9b3e467..c88f617 100644 --- a/test/queue_test.go +++ b/test/queue_test.go @@ -271,3 +271,39 @@ func TestStatusContract(t *testing.T) { t.Errorf("provider = %q, want elevenlabs", st.Provider) } } + +// TestCancelItemEndpoint: POST /cancel-item cancels only the playing +// announcement with that id; waiting items stay and play next; a stale id +// yields cancelled:false, a missing id 400. +func TestCancelItemEndpoint(t *testing.T) { + root := t.TempDir() + fake := newFakeProvider(t, 5*time.Second) + d := startDaemon(t, root, fake.srv.URL, nil) + + _, first := speak(t, d, `{"text":"blocker"}`) + fake.waitArrived(t, 1) + _, second := speak(t, d, `{"text":"waiting"}`) + + code, m := postJSON(t, d, "/cancel-item", `{"id":"`+second["id"].(string)+`"}`) + if code != 200 || m["cancelled"] != false { + t.Fatalf("cancel-item on the waiting id: %d %v, want cancelled:false", code, m) + } + code, m = postJSON(t, d, "/cancel-item", `{"id":"`+first["id"].(string)+`"}`) + if code != 200 || m["cancelled"] != true { + t.Fatalf("cancel-item on the playing id: %d %v, want cancelled:true", code, m) + } + hits := fake.waitHits(t, 1) + if hits[0].Completed { + t.Errorf("blocker synthesis completed although cancelled: %+v", hits[0]) + } + // The waiting item plays next (the queue was not emptied). + fake.setDelay(50 * time.Millisecond) + done := fake.waitCompleted(t, 1) + if done[0].Text != "waiting" { + t.Errorf("next completed = %q, want the waiting announcement", done[0].Text) + } + if code, m := postJSON(t, d, "/cancel-item", `{}`); code != 400 || m["error"] == nil { + t.Errorf("cancel-item without id: %d %v, want 400 with error", code, m) + } + waitStatusIdle(t, d, 3*time.Second) +}