Files
m3ta-chironandClaude Fable 5.1 2d9d2a86e9
release / release (push) Canceled after 0s
test / test (push) Canceled after 0s
feat(notify): Windows toasts under talk's own identity, icon on Linux
The daemon (and talk test --toast) now register the AppUserModelID
m3ta.talk under HKCU with display name and the installed icon, so
toasts appear as "talk" with the icon instead of Windows PowerShell;
the PowerShell identity stays as fallback until the registration has
run once. The redundant "talk" title line is dropped under the own
identity. notify.Setup bundles icon install, identity and the talk:
protocol handler for both callers. Linux notifications carry the
installed icon as app_icon. doctor names the active identity.

Verified on the Windows 11 VM: registry key, icon file, toast with
working Cancel button, exe icon; Linux via quickshell.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-25 12:43:14 +01:00

281 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"
"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
}
// Desktop side: the icon next to the config, Windows additionally the
// toast identity and the talk: protocol handler for the Cancel button.
if dir, err := config.Dir(getenv); err == nil {
exe, _ := os.Executable()
if err := notify.Setup(dir, exe); err != nil {
logger.Printf("warning: %v; notifications fall back to the plain identity or lose the Cancel button", 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
}