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>
138 lines
3.8 KiB
Go
138 lines
3.8 KiB
Go
package api
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Client is the CLI's HTTP client for the daemon on 127.0.0.1; the same
|
|
// contract that server.go serves.
|
|
type Client struct {
|
|
// BaseURL is the daemon address (http://127.0.0.1:<port>).
|
|
BaseURL string
|
|
|
|
// HTTP is the client in use; NewClient sets it when nil.
|
|
HTTP *http.Client
|
|
}
|
|
|
|
// NewClient builds the client for a daemon port. The caller controls time
|
|
// budgets per call via the context (daemon detection short, /speak more
|
|
// generous); the client timeout is only the hard ceiling.
|
|
func NewClient(port int) *Client {
|
|
return &Client{
|
|
BaseURL: fmt.Sprintf("http://127.0.0.1:%d", port),
|
|
HTTP: &http.Client{Timeout: 10 * time.Second},
|
|
}
|
|
}
|
|
|
|
// HTTPError is a daemon response with an unexpected status code (e.g. 400
|
|
// validation). Network errors (daemon dead) are regular *url.Error values
|
|
// instead; the CLI distinguishes by that: show HTTP errors, fall into
|
|
// degraded mode on network errors.
|
|
type HTTPError struct {
|
|
Status int
|
|
Body string
|
|
}
|
|
|
|
func (e *HTTPError) Error() string {
|
|
return fmt.Sprintf("HTTP %d: %s", e.Status, e.Body)
|
|
}
|
|
|
|
// Status calls GET /status (daemon detection, --wait polling).
|
|
func (c *Client) Status(ctx context.Context) (*StatusResponse, error) {
|
|
var st StatusResponse
|
|
if err := c.do(ctx, http.MethodGet, "/status", nil, http.StatusOK, &st); err != nil {
|
|
return nil, err
|
|
}
|
|
return &st, nil
|
|
}
|
|
|
|
// Speak calls POST /speak (enqueue).
|
|
func (c *Client) Speak(ctx context.Context, req SpeakRequest) (*SpeakResponse, error) {
|
|
var resp SpeakResponse
|
|
if err := c.do(ctx, http.MethodPost, "/speak", req, http.StatusAccepted, &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
return &resp, nil
|
|
}
|
|
|
|
// Cancel calls POST /cancel; returns the number of cancelled announcements.
|
|
func (c *Client) Cancel(ctx context.Context) (int, error) {
|
|
var resp CancelResponse
|
|
if err := c.do(ctx, http.MethodPost, "/cancel", nil, http.StatusOK, &resp); err != nil {
|
|
return 0, err
|
|
}
|
|
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"
|
|
if !muted {
|
|
path = "/unmute"
|
|
}
|
|
return c.do(ctx, http.MethodPost, path, nil, http.StatusOK, nil)
|
|
}
|
|
|
|
// Shutdown calls POST /shutdown (`talk daemon stop`).
|
|
func (c *Client) Shutdown(ctx context.Context) error {
|
|
return c.do(ctx, http.MethodPost, "/shutdown", nil, http.StatusOK, nil)
|
|
}
|
|
|
|
// do is the shared core: build the request, send it, check the status,
|
|
// decode. Network errors are returned unfiltered (the caller distinguishes
|
|
// via errors.As(&HTTPError)).
|
|
func (c *Client) do(ctx context.Context, method, path string, body any, wantStatus int, out any) error {
|
|
var rd io.Reader
|
|
if body != nil {
|
|
b, err := json.Marshal(body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rd = bytes.NewReader(b)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, rd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
client := c.HTTP
|
|
if client == nil {
|
|
client = http.DefaultClient
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
data, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyBytes))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if resp.StatusCode != wantStatus {
|
|
return &HTTPError{Status: resp.StatusCode, Body: strings.TrimSpace(string(data))}
|
|
}
|
|
if out == nil {
|
|
return nil
|
|
}
|
|
return json.Unmarshal(data, out)
|
|
}
|