feat(notify): Cancel button on Windows toasts via the talk: protocol handler
Windows toasts cannot call back into the daemon, so the Cancel button opens a talk: link instead: `talk:cancel?id=<id>&port=<port>`. The daemon registers the talk: URL protocol for the current user (HKCU, no admin) and points it at `talk uri "%1"`; Windows starts that on the click, and `talk uri` posts to the new endpoint POST /cancel-item, which cancels only the announcement with that id (queue.CancelItem). The toast is tagged and removed again when the announcement ends. For now the toast is shown under Windows PowerShell's app identity, which unpackaged programs can use without a Start Menu shortcut. Verified on the Windows 11 VM: registry entry written by the daemon, Start-Process "talk:cancel?id=…&port=7800" (what the button does) stops the playing announcement within two seconds, no toast errors in the log. Tests: `talk uri` against a real handler on a loopback port (playing vs waiting id, dead daemon, talk:test, garbage links), POST /cancel-item process test (only the playing item, waiting one plays next, 400 without id). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
83a15af9c5
commit
00eb76128b
16 files changed
+446
-22
No files matched your search
@@ -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: "<talk:…>",
|
||||
detail: "The handler behind the talk: URL protocol: a toast's Cancel button opens talk:cancel?id=<id>&port=<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",
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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:
|
||||
|
||||
@@ -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 <talk:…>`: 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=<announcement id>&port=<daemon 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user