// 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 }