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
@@ -20,6 +20,7 @@ dependencies.
|
|||||||
| `talk mute` / `talk unmute` | mute all announcements persistently / turn them back on |
|
| `talk mute` / `talk unmute` | mute all announcements persistently / turn them back on |
|
||||||
| `talk status` | daemon status (queue, mute, provider, laws, version) |
|
| `talk status` | daemon status (queue, mute, provider, laws, version) |
|
||||||
| `talk cancel` | cancel the playing and all queued announcements |
|
| `talk cancel` | cancel the playing and all queued announcements |
|
||||||
|
| `talk uri <talk:…>` | 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 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 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 |
|
| `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
|
### Notifications with a Cancel button
|
||||||
|
|
||||||
Every announcement that plays also shows a desktop notification with its
|
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 without a terminal. The notification disappears when the
|
||||||
announcement ends. talk talks to the freedesktop notification service on the
|
announcement ends.
|
||||||
session bus directly (any daemon with the "actions" capability: mako, dunst,
|
|
||||||
GNOME Shell, KDE, quickshell); without a session bus it falls back to
|
- **Linux:** talk talks to the freedesktop notification service on the
|
||||||
notify-send, on macOS to Notification Center and on Windows to a toast, all
|
session bus directly (any daemon with the "actions" capability: mako,
|
||||||
without a button. Turn it off with:
|
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
|
```toml
|
||||||
[notify]
|
[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 |
|
| `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]}` |
|
| `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` | 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 /mute` / `POST /unmute` | persistent mute state → `200 {"muted":bool}` |
|
||||||
| `POST /shutdown` | shuts the daemon down (`talk daemon stop`) → `200 {"ok":true}` |
|
| `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) |
|
| `POST /mcp` | MCP (Streamable HTTP): JSON-RPC `initialize`, `notifications/initialized`, `ping`, `tools/list`, `tools/call`; one tool `speak` (see below) |
|
||||||
|
|||||||
@@ -78,6 +78,13 @@ var commands = []command{
|
|||||||
detail: "Aborts the playing announcement and empties the daemon's queue; prints the number of cancelled announcements.",
|
detail: "Aborts the playing announcement and empties the daemon's queue; prints the number of cancelled announcements.",
|
||||||
handler: runCancel,
|
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",
|
name: "voices",
|
||||||
short: "list known voices",
|
short: "list known voices",
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
// list (not derived from the command table) so the tests fire when table and
|
// list (not derived from the command table) so the tests fire when table and
|
||||||
// documentation drift apart.
|
// documentation drift apart.
|
||||||
var wantSubcommands = []string{
|
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
|
// 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)
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
start := time.Now()
|
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 {
|
select {
|
||||||
case clicked <- struct{}{}:
|
case clicked <- struct{}{}:
|
||||||
default:
|
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
|
||||||
|
}
|
||||||
@@ -71,6 +71,16 @@ func (c *Client) Cancel(ctx context.Context) (int, error) {
|
|||||||
return resp.Cancelled, nil
|
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).
|
// SetMute calls POST /mute (muted=true) or POST /unmute (muted=false).
|
||||||
func (c *Client) SetMute(ctx context.Context, muted bool) error {
|
func (c *Client) SetMute(ctx context.Context, muted bool) error {
|
||||||
path := "/mute"
|
path := "/mute"
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ func NewHandler(cfg HandlerConfig) http.Handler {
|
|||||||
mux.HandleFunc("OPTIONS /speak", h.speakPreflight) // CORS preflight
|
mux.HandleFunc("OPTIONS /speak", h.speakPreflight) // CORS preflight
|
||||||
mux.HandleFunc("GET /status", h.status)
|
mux.HandleFunc("GET /status", h.status)
|
||||||
mux.HandleFunc("POST /cancel", h.cancel)
|
mux.HandleFunc("POST /cancel", h.cancel)
|
||||||
|
mux.HandleFunc("POST /cancel-item", h.cancelItem)
|
||||||
mux.HandleFunc("POST /mute", h.muteEndpoint(true))
|
mux.HandleFunc("POST /mute", h.muteEndpoint(true))
|
||||||
mux.HandleFunc("POST /unmute", h.muteEndpoint(false))
|
mux.HandleFunc("POST /unmute", h.muteEndpoint(false))
|
||||||
mux.HandleFunc("POST /shutdown", h.shutdownEndpoint)
|
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()})
|
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
|
// muteEndpoint handles POST /mute and POST /unmute: persistent mute state
|
||||||
// (file in the config dir, survives daemon restarts).
|
// (file in the config dir, survives daemon restarts).
|
||||||
func (h *handler) muteEndpoint(muted bool) http.HandlerFunc {
|
func (h *handler) muteEndpoint(muted bool) http.HandlerFunc {
|
||||||
|
|||||||
@@ -152,3 +152,15 @@ type MuteResponse struct {
|
|||||||
type ShutdownResponse struct {
|
type ShutdownResponse struct {
|
||||||
OK bool `json:"ok"`
|
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"`
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -84,6 +85,16 @@ func Run(ctx context.Context, opts Options) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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
|
// Desktop notification per announcement (notify.on_speak): shown when the
|
||||||
// announcement starts, with a Cancel button where the platform has one,
|
// announcement starts, with a Cancel button where the platform has one,
|
||||||
// closed when it ends. Best effort: the first failure is logged, later
|
// 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" {
|
if lang == "de" {
|
||||||
label = "Abbrechen"
|
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) {
|
if q.CancelItem(item.ID) {
|
||||||
logger.Printf("announcement %s cancelled from its notification", item.ID)
|
logger.Printf("announcement %s cancelled from its notification", item.ID)
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-13
@@ -6,7 +6,9 @@
|
|||||||
// action, closed again when the announcement ends. Fallback when
|
// action, closed again when the announcement ends. Fallback when
|
||||||
// no session bus is reachable: notify-send (no action).
|
// no session bus is reachable: notify-send (no action).
|
||||||
// macOS: osascript `display notification` (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
|
// Missing tools are a clear error for the caller (log), never an abort: a
|
||||||
// notification is an extra, not a contract.
|
// notification is an extra, not a contract.
|
||||||
@@ -58,17 +60,18 @@ func Describe() string {
|
|||||||
case "darwin":
|
case "darwin":
|
||||||
return "osascript (Notification Center; no cancel button)"
|
return "osascript (Notification Center; no cancel button)"
|
||||||
case "windows":
|
case "windows":
|
||||||
return "powershell (Windows toast; no cancel button yet)"
|
return "powershell (Windows toast with cancel button via the talk: link)"
|
||||||
default:
|
default:
|
||||||
return "notify-send (libnotify; no session bus, so no cancel button)"
|
return "notify-send (libnotify; no session bus, so no cancel button)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SupportsCancel reports whether notifications on this system can carry a
|
// SupportsCancel reports whether notifications on this system can carry a
|
||||||
// Cancel action that reaches talk (Linux with a notification service that
|
// Cancel button that reaches talk: Linux with a notification service that
|
||||||
// has the "actions" capability).
|
// has the "actions" capability, or Windows (toast button via the talk:
|
||||||
|
// protocol handler).
|
||||||
func SupportsCancel() bool {
|
func SupportsCancel() bool {
|
||||||
return dbusSupportsActions()
|
return dbusSupportsActions() || runtime.GOOS == "windows"
|
||||||
}
|
}
|
||||||
|
|
||||||
func tool() string {
|
func tool() string {
|
||||||
@@ -96,16 +99,35 @@ func Show(ctx context.Context, text string) error {
|
|||||||
return showWithTool(ctx, text)
|
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
|
// Announce shows the notification for an announcement that is playing: the
|
||||||
// text plus a Cancel action labelled cancelLabel where the platform supports
|
// text plus a Cancel button where the platform supports it. onCancel runs
|
||||||
// it. onCancel runs when the user clicks it. The returned close function
|
// when the user clicks it on platforms that report the click back (Linux
|
||||||
// dismisses the notification (call it when the announcement ends); it is
|
// D-Bus); on Windows the click opens CancelURI instead. The returned close
|
||||||
// never nil. Platforms without actions show a plain notification and the
|
// function dismisses the notification (call it when the announcement
|
||||||
// close function does nothing.
|
// ends); it is never nil. Platforms without actions show a plain
|
||||||
func Announce(ctx context.Context, text, cancelLabel string, onCancel func()) (close func(), err error) {
|
// notification and the close function does nothing.
|
||||||
|
func Announce(ctx context.Context, a Announcement, onCancel func()) (close func(), err error) {
|
||||||
noop := func() {}
|
noop := func() {}
|
||||||
if dbusAvailable() {
|
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 {
|
if action == "cancel" && onCancel != nil {
|
||||||
onCancel()
|
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
|
// 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).
|
// showWithTool shows a notification through the OS tool (no action).
|
||||||
|
|||||||
@@ -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 }
|
||||||
@@ -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 `<exe> uri <link>`. 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
|
||||||
|
}
|
||||||
@@ -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 <link>`,
|
||||||
|
// 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 = "<toast><visual><binding template='ToastGeneric'><text>talk</text><text>$body</text></binding></visual><actions><action content='$label' arguments='$uri' activationType='protocol'/></actions></toast>"
|
||||||
|
$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)) }
|
||||||
@@ -535,3 +535,18 @@ func mcpDecode(t *testing.T, raw rawResponse) map[string]any {
|
|||||||
}
|
}
|
||||||
return m
|
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
|
||||||
|
}
|
||||||
@@ -271,3 +271,39 @@ func TestStatusContract(t *testing.T) {
|
|||||||
t.Errorf("provider = %q, want elevenlabs", st.Provider)
|
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)
|
||||||
|
}
|
||||||
Reference in new issue
Block a user