Zwei Binaries erzeugten den halben Komplexitätsballast: Degraded Mode mit Lock-Datei und Stale-Lock-Race, „läuft talkd?", Autostart-Konfiguration je OS, doppelte Chain-Konstruktion in cmd/talk und cmd/talkd — und der Degraded Mode kannte weder Rate-Limit noch Ruhezeiten. Jetzt: - internal/speak: der eine Sprech-Kern (Config + Sink + Kette → Ansage); Daemon und Direkt-Pfad benutzen ihn, es gibt keine zweite Zusammensetzung der Kette mehr. - internal/daemon: Run (der bisherige talkd-Kern), Spawn (detached per Setsid bzw. DETACHED_PROCESS, stdout/stderr → daemon.log), PID-Datei daemon.pid, Shutdown-Trigger. - `talk daemon run|start|stop`: Vordergrund (systemd/launchd), expliziter Hintergrund-Start, Stop per POST /shutdown — stop wartet, bis HTTP weg und die PID-Datei gelöscht ist (Prozess wirklich beendet). - Autostart: say/wrap starten den Daemon beim ersten Sprechbefehl selbst (daemon.autostart, Default an; Env TALK_AUTOSTART). Verliert ein paralleler Aufruf das Port-Rennen, erkennt er das am Kind-Ende und nutzt den Daemon des anderen — kein Timeout-Warten. - Direkt-Pfad statt Degraded Mode: ohne erreichbaren oder startbaren Daemon spricht talk direkt (Mute respektiert, kein Lock, Hinweis + Grund + Log-Pfad auf stderr). Steuerbefehle starten den Daemon bewusst nicht; `talk status` zeigt „läuft nicht" (Exit 1). - cmd/talkd und cmd/talk/degraded.go entfernt; README-Daemon-Abschnitt neu. Tests: Harness baut nur noch talk und startet `talk daemon run`; TALK_AUTOSTART=false als Grundausstattung aller Tests (kein Test hinterlässt Daemons), autostart_test.go schaltet es bewusst an: Autostart beim ersten say, Idempotenz, paralleler Start in EINE Queue, expliziter start/stop, Port fremd belegt → Direkt-Pfad ohne Timeout. Cross-Compile aller sechs Targets mit CGO_ENABLED=0 verifiziert. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
144 lines
4.6 KiB
Go
144 lines
4.6 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"code.m3ta.dev/m3tam3re/talk/internal/config"
|
|
"code.m3ta.dev/m3tam3re/talk/internal/queue"
|
|
"code.m3ta.dev/m3tam3re/talk/internal/speak"
|
|
)
|
|
|
|
// getenv ist die Env-Seam von run (Tests injizieren hier TALK_AUDIO_SINK,
|
|
// TALK_PORT etc., ohne den Prozess-Environment anzufassen). Default:
|
|
// os.Getenv.
|
|
var getenv = os.Getenv
|
|
|
|
// sayOptions bündelt die Flags von say (identisch im Default-Pfad
|
|
// `talk "Text"`).
|
|
type sayOptions struct {
|
|
wait bool
|
|
now bool
|
|
priority string
|
|
voice string
|
|
lang string
|
|
provider string
|
|
}
|
|
|
|
// runSay ist der say-Eingangspfad: Text aus Argumenten oder stdin sammeln
|
|
// und validieren, dann über den Daemon sprechen — non-blocking als
|
|
// Default, --wait pollt bis zur Fertigstellung, --now springt vor; Läuft
|
|
// kein Daemon, startet talk ihn selbst (daemon.autostart). Geht auch das
|
|
// nicht, spricht talk direkt im eigenen Prozess (sayDirect) — ohne
|
|
// Warteschlange, aber nie still.
|
|
func runSay(args []string, opts *sayOptions, stdin io.Reader, stdout, stderr io.Writer) int {
|
|
text := strings.Join(args, " ")
|
|
if text == "" {
|
|
if isCharDevice(stdin) {
|
|
fmt.Fprintln(stderr, "talk: kein Text — bitte als Argument übergeben oder über stdin pipen")
|
|
return exitUsage
|
|
}
|
|
b, err := io.ReadAll(stdin)
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "talk: stdin lesen: %v\n", err)
|
|
return exitFailure
|
|
}
|
|
text = strings.TrimSpace(string(b))
|
|
if text == "" {
|
|
fmt.Fprintln(stderr, "talk: kein Text (Argument und stdin sind leer)")
|
|
return exitFailure
|
|
}
|
|
}
|
|
|
|
// Validierung vor jedem Daemon-Kontakt (auch wrap erbt die say-Flags):
|
|
// Benutzungsfehler sollen schnell und ohne Netzwerk-Umwege auffallen.
|
|
if code, bad := validateSayOptions(opts, stderr); bad {
|
|
return code
|
|
}
|
|
|
|
cfg, err := config.Load(config.Options{Getenv: getenv})
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "talk: %v\n", err)
|
|
return exitFailure
|
|
}
|
|
return announce(cfg, text, opts, stdout, stderr)
|
|
}
|
|
|
|
// announce ist der gemeinsame Ansage-Weg von say und wrap: Daemon sicher-
|
|
// stellen (ggf. starten) und einreihen; ohne Daemon direkt sprechen. info
|
|
// ist der Writer für Informations-Token (Item-ID, muted, discarded).
|
|
func announce(cfg config.Config, text string, opts *sayOptions, info, stderr io.Writer) int {
|
|
if client, ok := ensureDaemon(cfg, stderr); ok {
|
|
if code, handled := sayViaDaemon(client, text, opts, info, stderr); handled {
|
|
return code
|
|
}
|
|
}
|
|
fmt.Fprintln(stderr, "talk: kein Daemon erreichbar — spreche direkt (ohne Warteschlange)")
|
|
return sayDirect(cfg, text, opts, info, stderr)
|
|
}
|
|
|
|
// sayDirect ist der Notfallpfad ohne Daemon: Mute respektieren (dieselbe
|
|
// Datei, die der Daemon liest), dann über den Sprech-Kern sprechen —
|
|
// blockierend im CLI-Prozess, unserialisiert (zwei gleichzeitige Aufrufe
|
|
// können überlappen; der Daemon ist der Normalfall). Ohne Config-Dir läuft
|
|
// es ohne Mute weiter, statt zu scheitern.
|
|
func sayDirect(cfg config.Config, text string, opts *sayOptions, info, stderr io.Writer) int {
|
|
if dir, err := config.Dir(getenv); err == nil {
|
|
mute := queue.MuteStore{Path: filepath.Join(dir, queue.MuteFileName)}
|
|
if mute.Muted() {
|
|
fmt.Fprintln(info, "muted")
|
|
fmt.Fprintln(stderr, "talk: stumm geschaltet (mute) — Ansage verworfen")
|
|
return exitOK
|
|
}
|
|
}
|
|
engine, err := speak.New(cfg, getenv, stderr)
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "talk: %v\n", err)
|
|
return exitFailure
|
|
}
|
|
err = engine.Speak(context.Background(), speak.Request{
|
|
Text: text,
|
|
Lang: opts.lang,
|
|
Provider: opts.provider,
|
|
Voice: opts.voice,
|
|
})
|
|
if err != nil {
|
|
fmt.Fprintf(stderr, "talk: %v\n", err)
|
|
return exitFailure
|
|
}
|
|
return exitOK
|
|
}
|
|
|
|
// validateSayOptions prüft die say-Flags (wrap erbt sie): --lang auf de |
|
|
// en, --priority auf low | normal | high. Rückgabe (code, true) heißt:
|
|
// Benutzungsfehler, bereits auf stderr gemeldet.
|
|
func validateSayOptions(opts *sayOptions, stderr io.Writer) (int, bool) {
|
|
if opts.lang != "" && opts.lang != "de" && opts.lang != "en" {
|
|
fmt.Fprintf(stderr, "talk: --lang: ungültiger Wert %q (erlaubt: de, en)\n", opts.lang)
|
|
return exitFailure, true
|
|
}
|
|
if opts.priority != "" && !queue.Priority(opts.priority).Valid() {
|
|
fmt.Fprintf(stderr, "talk: --priority: ungültiger Wert %q (erlaubt: low, normal, high)\n", opts.priority)
|
|
return exitFailure, true
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
// isCharDevice meldet, ob r ein Terminal ist (dann darf talk nicht auf
|
|
// stdin-Input warten, sondern meldet einen Benutzungsfehler).
|
|
func isCharDevice(r io.Reader) bool {
|
|
f, ok := r.(*os.File)
|
|
if !ok {
|
|
return false
|
|
}
|
|
st, err := f.Stat()
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return st.Mode()&os.ModeCharDevice != 0
|
|
}
|