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:
m3ta-chironandClaude Fable 5.1 committed 2026-10-24 12:14:38 +02:00
1 parent 83a15af9c5
commit 00eb76128b
16 files changed
+446 -22

No files matched your search

+10
View File
@@ -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"
+18
View File
@@ -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 {
+12
View File
@@ -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"`
}