Files
talk/internal/daemon/daemon.go
T
m3ta-chironandClaude Fable 5.1 00eb76128b 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>
2026-10-24 12:14:38 +02:00

283 lines
9.2 KiB
Go

// Package daemon is talk's background process: HTTP API on 127.0.0.1, a
// serialized announcement queue with priorities and the now jump,
// persistent mute state, the anti-spam laws (rate limit, quiet hours), the
// CORS door and the MCP endpoint. There is exactly one binary: `talk daemon
// run` runs the daemon in the foreground, and the CLI starts it in the
// background itself via Spawn when needed (daemon.autostart).
package daemon
import (
"context"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"time"
"code.m3ta.dev/m3tam3re/talk/internal/api"
"code.m3ta.dev/m3tam3re/talk/internal/config"
"code.m3ta.dev/m3tam3re/talk/internal/notify"
"code.m3ta.dev/m3tam3re/talk/internal/provider"
"code.m3ta.dev/m3tam3re/talk/internal/queue"
"code.m3ta.dev/m3tam3re/talk/internal/speak"
)
// Files of the daemon in the config directory.
const (
// LogFileName is the log of a daemon started in the background (Spawn
// redirects stdout/stderr there; fresh per start).
LogFileName = "daemon.log"
// PIDFileName holds the process ID of the running daemon (for
// diagnostics and as an emergency brake when the HTTP API does not
// answer).
PIDFileName = "daemon.pid"
)
// Options are the dependencies of Run.
type Options struct {
// Config is the loaded configuration (port, laws, sink, ...).
Config config.Config
// Getenv supplies keys and endpoint overrides (nil = os.Getenv).
Getenv func(string) string
// Version is the daemon version for /status and MCP serverInfo.
Version string
// Log receives the log output (foreground: stderr; background: the log
// file). nil = os.Stderr.
Log io.Writer
}
// Run runs the daemon until ctx ends (signal), POST /shutdown arrives or
// the HTTP server fails. A playing announcement is cancelled on shutdown;
// the daemon should react promptly. A port in use is a clear error ("is
// another daemon running?").
func Run(ctx context.Context, opts Options) error {
getenv := opts.Getenv
if getenv == nil {
getenv = os.Getenv
}
out := opts.Log
if out == nil {
out = os.Stderr
}
logger := log.New(out, "talk daemon: ", log.LstdFlags)
lw := lineWriter{logger}
cfg := opts.Config
engine, err := speak.New(cfg, getenv, lw)
if err != nil {
return err
}
// Anti-spam law: config.Load has validated quiet_hours; an error here
// is practically impossible but must never be silent.
quiet, err := queue.ParseQuietHours(cfg.QuietHours)
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
// ones are silent, speech never waits for a notification problem.
var q *queue.Queue
var notifyFailed sync.Once
announceNotification := func(ctx context.Context, item queue.Item, lang string) func() {
if !cfg.Notify.OnSpeak {
return func() {}
}
label := "Cancel"
if lang == "de" {
label = "Abbrechen"
}
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)
}
})
if err != nil {
notifyFailed.Do(func() {
logger.Printf("warning: notifications unavailable (%v); announcements play without one. Set [notify] on_speak = false to silence this", err)
})
}
return closeFn
}
q = queue.New(queue.Laws{
RateLimit: cfg.Queue.RateLimit,
AllowHighBurst: cfg.Queue.AllowHighBurst,
Quiet: quiet,
QuietMode: cfg.QuietMode,
MaxText: cfg.Queue.MaxText,
MaxAge: cfg.Queue.MaxAge,
}, func(ctx context.Context, item queue.Item) error {
req := speak.Request{Text: item.Text, Lang: item.Lang, Provider: item.Provider, Voice: item.Voice}
closeNotification := announceNotification(ctx, item, engine.Lang(req))
defer closeNotification()
out, err := engine.Speak(ctx, req)
if err == nil && !out.Discarded {
logger.Printf("announcement %s via %s (synthesis %s, playback %s)", item.ID, out.Provider,
out.Synthesis.Round(time.Millisecond), out.Playback.Round(time.Millisecond))
}
return err
}, lw)
// Quiet-hours toast: desktop notification instead of audio (quiet_mode
// = "toast"); best effort, errors only in the log. Runs asynchronously
// so a hanging notification daemon never blocks the queue.
if cfg.QuietMode == queue.QuietModeToast {
if notify.Available() {
q.OnToast(func(item queue.Item) {
go func() {
if err := notify.Show(context.Background(), item.Text); err != nil {
logger.Printf("toast: %v", err)
}
}()
})
} else {
logger.Printf("warning: quiet_mode = toast, but %s; quiet-hours announcements are only logged", notify.Describe())
}
}
// Persistent mute state and PID file in the config dir. Without a
// config dir the daemon keeps running without both (warning) instead
// of failing.
var mutePath, pidPath string
if dir, err := config.Dir(getenv); err == nil {
mutePath = filepath.Join(dir, queue.MuteFileName)
pidPath = filepath.Join(dir, PIDFileName)
} else {
logger.Printf("warning: config directory unknown; mute state and PID file disabled (%v)", err)
}
mute := queue.MuteStore{Path: mutePath}
shutdown := make(chan struct{})
var once sync.Once
handler := api.NewHandler(api.HandlerConfig{
Queue: q,
Mute: mute,
Version: opts.Version,
KnownProviders: provider.Names(),
AllowedOrigins: cfg.Server.AllowedOrigins,
Provider: func() string {
if cur, ok := q.Current(); ok {
return engine.ActiveProvider(cur.Provider)
}
if last := engine.LastProvider(); last != "" {
return last
}
return engine.ActiveProvider("")
},
Shutdown: func() { once.Do(func() { close(shutdown) }) },
})
addr := fmt.Sprintf("127.0.0.1:%d", cfg.Server.Port)
ln, err := net.Listen("tcp", addr)
if err != nil {
q.Close()
return fmt.Errorf("bind %s: %w; is another talk daemon running?", addr, err)
}
if pidPath != "" {
writePID(pidPath, logger)
// The PID file disappears last: `talk daemon stop` waits for that
// and then knows the process is done, not just HTTP.
defer os.Remove(pidPath)
}
srv := &http.Server{Handler: handler, ReadHeaderTimeout: 5 * time.Second}
errCh := make(chan error, 1)
go func() { errCh <- srv.Serve(ln) }()
corsLog := "closed (no CORS)"
if len(cfg.Server.AllowedOrigins) > 0 {
corsLog = fmt.Sprintf("%v", cfg.Server.AllowedOrigins)
}
logger.Printf("listening on http://%s (version %s, pid %d, audio sink %s, mute=%v, notify=%v, rate-limit %s, high-burst=%v, max-text %d, max-age %s, quiet-hours=%q mode %s, cors=%s, mcp=/mcp)",
addr, opts.Version, os.Getpid(), engine.SinkName(), mute.Muted(), cfg.Notify.OnSpeak,
cfg.Queue.RateLimit, cfg.Queue.AllowHighBurst, cfg.Queue.MaxText, cfg.Queue.MaxAge, cfg.QuietHours, cfg.QuietMode, corsLog)
select {
case err := <-errCh:
q.Close()
return fmt.Errorf("http server: %w", err)
case <-ctx.Done():
logger.Printf("signal received; shutting down (the playing announcement is cancelled)")
case <-shutdown:
logger.Printf("shutdown requested (talk daemon stop); shutting down")
}
// Order: close HTTP first (no new requests), then close the queue
// (cancel the playing announcement, stop the worker and wait for it).
// That way no announcement ever keeps playing after the daemon ends.
shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
logger.Printf("http shutdown: %v", err)
}
q.Close()
return nil
}
// writePID writes our own process ID (best effort; an error is only a log
// line, the daemon works without a PID file too).
func writePID(path string, logger *log.Logger) {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
logger.Printf("warning: PID file: %v", err)
return
}
if err := os.WriteFile(path, []byte(strconv.Itoa(os.Getpid())+"\n"), 0o644); err != nil {
logger.Printf("warning: PID file: %v", err)
}
}
// ReadPID reads the PID file; 0 if no (readable) file exists.
func ReadPID(dir string) int {
b, err := os.ReadFile(filepath.Join(dir, PIDFileName))
if err != nil {
return 0
}
pid, err := strconv.Atoi(strings.TrimSpace(string(b)))
if err != nil || pid <= 0 {
return 0
}
return pid
}
// lineWriter forwards lines to the logger (timestamp + prefix) instead of
// writing them raw: provider chain, queue and null sink write line by line,
// which yields one uniform log.
type lineWriter struct{ l *log.Logger }
func (w lineWriter) Write(p []byte) (int, error) {
for _, line := range strings.Split(strings.TrimRight(string(p), "\n"), "\n") {
if line != "" {
w.l.Print(line)
}
}
return len(p), nil
}