From 9e2aa506a670a639bbb9d522890a8c8d43a24b97 Mon Sep 17 00:00:00 2001 From: m3ta-chiron Date: Sat, 24 Oct 2026 10:37:46 +0200 Subject: [PATCH] =?UTF-8?q?feat(notify):=20Desktop-Benachrichtigung=20f?= =?UTF-8?q?=C3=BCr=20den=20Ruhezeit-Toast?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit quiet_mode = "toast" war bisher nur eine Protokollzeile. Jetzt zeigt der Daemon eine Desktop-Benachrichtigung — über die Bordmittel des Systems, exec-basiert wie osbuiltin, ohne Bibliothek und ohne CGO: notify-send (Linux), osascript display notification (macOS), PowerShell Toast (Windows; Text base64, kein Quoting-Problem). Best effort: fehlt das Werkzeug, bleibt es bei der Protokollzeile plus Start-Warnung; `talk doctor` zeigt den Weg unter „Toast". Der Aufruf läuft asynchron, ein hängender Notification-Daemon blockiert die Queue nie. Queue: OnToast-Hook, gefeuert bei Toast-Verwurf beim Eintritt und im Worker (nie unter dem Mutex). Tests: notify-Paket mit fake notify-send im PATH, Prozessgrenze: Ruhezeit + toast → fake notify-send bekommt Titel und Text, der Provider keinen Treffer. Co-Authored-By: Claude Fable 5.1 --- cmd/talk/doctor.go | 18 +++++- internal/daemon/daemon.go | 18 ++++++ internal/notify/doc.go | 4 -- internal/notify/notify.go | 114 +++++++++++++++++++++++++++++++++ internal/notify/notify_test.go | 61 ++++++++++++++++++ internal/queue/queue.go | 15 +++++ test/notify_test.go | 55 ++++++++++++++++ 7 files changed, 279 insertions(+), 6 deletions(-) delete mode 100644 internal/notify/doc.go create mode 100644 internal/notify/notify.go create mode 100644 internal/notify/notify_test.go create mode 100644 test/notify_test.go diff --git a/cmd/talk/doctor.go b/cmd/talk/doctor.go index 3673232..94c47bb 100644 --- a/cmd/talk/doctor.go +++ b/cmd/talk/doctor.go @@ -16,6 +16,7 @@ import ( "code.m3ta.dev/m3tam3re/talk/internal/audio" "code.m3ta.dev/m3tam3re/talk/internal/config" "code.m3ta.dev/m3tam3re/talk/internal/daemon" + "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" @@ -117,10 +118,13 @@ func doctorChecks(probe bool) []check { // 4. Provider-Stufen (+ 5. Gesamt-Verfügbarkeit) out = append(out, checkProviders(cfg, probe)...) - // 6. Mute + // 6. Benachrichtigungen (Ruhezeit-Toast) + out = append(out, checkNotify(cfg)) + + // 7. Mute out = append(out, checkMute()) - // 7. Gesetze + // 8. Gesetze out = append(out, checkLaws(cfg)) return out @@ -271,6 +275,16 @@ func probeTier(p provider.Provider) string { } } +func checkNotify(cfg config.Config) check { + if notify.Available() { + return check{"Toast", statusOK, notify.Describe() + " — für quiet_mode = toast"} + } + if cfg.QuietMode == queue.QuietModeToast { + return check{"Toast", statusWarn, notify.Describe() + " — quiet_mode = toast wird nur protokolliert"} + } + return check{"Toast", statusOK, notify.Describe() + " (nur für quiet_mode = toast relevant)"} +} + func checkMute() check { dir, err := config.Dir(getenv) if err != nil { diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index f718d4c..3ba51a9 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -22,6 +22,7 @@ import ( "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" @@ -103,6 +104,23 @@ func Run(ctx context.Context, opts Options) error { return err }, lw) + // Ruhezeit-Toast: Desktop-Benachrichtigung statt Audio (quiet_mode = + // "toast"); best effort, Fehler nur im Protokoll. Läuft asynchron, damit + // ein hängender Notification-Daemon nie die Queue blockiert. + 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("warnung: quiet_mode = toast, aber %s — Ruhezeit-Ansagen werden nur protokolliert", notify.Describe()) + } + } + // Persistenter Mute-State und PID-Datei im Config-Dir. Ohne Config-Dir // läuft der Daemon ohne beides weiter (Warnung) statt zu scheitern. var mutePath, pidPath string diff --git a/internal/notify/doc.go b/internal/notify/doc.go deleted file mode 100644 index 5c993e0..0000000 --- a/internal/notify/doc.go +++ /dev/null @@ -1,4 +0,0 @@ -// Package notify implementiert Best-effort-Desktop-Notifications parallel -// zum Audio (beeep: notify-send / macOS Notification Center / Windows Toast), -// mit Abbrechen-Button, wo das OS ihn hergibt. Implementierung folgt. -package notify diff --git a/internal/notify/notify.go b/internal/notify/notify.go new file mode 100644 index 0000000..50c9f67 --- /dev/null +++ b/internal/notify/notify.go @@ -0,0 +1,114 @@ +// Package notify zeigt Desktop-Benachrichtigungen — best effort, über die +// Bordmittel des Betriebssystems, ohne Bibliotheks-Abhängigkeit und ohne +// CGO (dieselbe Linie wie provider/osbuiltin): +// +// Linux: notify-send (libnotify; braucht einen Notification-Daemon) +// macOS: osascript `display notification` +// Windows: PowerShell Toast (Windows.UI.Notifications) +// +// Fehlende Werkzeuge sind ein klarer Fehler für den Aufrufer (Protokoll), +// nie ein Abbruch: Eine Benachrichtigung ist Zusatz, kein Vertrag. +package notify + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/binary" + "fmt" + "os/exec" + "runtime" + "strings" + "time" + "unicode/utf16" +) + +// Title ist der Titel aller talk-Benachrichtigungen. +const Title = "talk" + +// timeout begrenzt den Aufruf des OS-Werkzeugs. +const timeout = 10 * time.Second + +// Available meldet, ob auf diesem System ein Benachrichtigungs-Weg +// existiert (Binary vorhanden; ob ein Daemon lauscht, zeigt erst Show). +func Available() bool { + _, err := exec.LookPath(tool()) + return err == nil +} + +// Describe nennt den Weg für `talk doctor`. +func Describe() string { + t := tool() + if _, err := exec.LookPath(t); err != nil { + return t + " nicht gefunden" + } + switch runtime.GOOS { + case "darwin": + return "osascript (Notification Center)" + case "windows": + return "powershell (Windows Toast)" + default: + return "notify-send (libnotify)" + } +} + +func tool() string { + switch runtime.GOOS { + case "darwin": + return "osascript" + case "windows": + return "powershell" + default: + return "notify-send" + } +} + +// Show zeigt eine Benachrichtigung mit Title und text und wartet auf das +// Ende des OS-Aufrufs (nicht auf das Schließen der Benachrichtigung). +func Show(ctx context.Context, text string) error { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + script := fmt.Sprintf("display notification %s with title %s", appleString(text), appleString(Title)) + cmd = exec.CommandContext(ctx, "osascript", "-e", script) + case "windows": + cmd = exec.CommandContext(ctx, "powershell", "-NoProfile", "-NonInteractive", "-EncodedCommand", psEncode(toastScript(text))) + default: + cmd = exec.CommandContext(ctx, "notify-send", "--app-name="+Title, "--urgency=normal", Title, text) + } + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("notify: %s fehlgeschlagen: %w: %s", tool(), err, bytes.TrimSpace(out)) + } + return nil +} + +// appleString macht aus s ein AppleScript-Stringliteral. +func appleString(s string) string { + return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s) + `"` +} + +// toastScript ist der PowerShell-Toast (Windows 10/11); der Text wird +// base64 übergeben, damit es kein Quoting-Problem gibt. +func toastScript(text string) string { + return fmt.Sprintf(`$ErrorActionPreference = 'Stop' +[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null +$msg = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('%s')) +$xml = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02) +$nodes = $xml.GetElementsByTagName('text') +$nodes.Item(0).AppendChild($xml.CreateTextNode('%s')) | Out-Null +$nodes.Item(1).AppendChild($xml.CreateTextNode($msg)) | Out-Null +[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('%s').Show([Windows.UI.Notifications.ToastNotification]::new($xml)) +`, base64.StdEncoding.EncodeToString([]byte(text)), Title, Title) +} + +// psEncode kodiert ein Skript für -EncodedCommand (UTF-16LE + Base64). +func psEncode(script string) string { + runes := utf16.Encode([]rune(script)) + buf := make([]byte, len(runes)*2) + for i, r := range runes { + binary.LittleEndian.PutUint16(buf[i*2:], r) + } + return base64.StdEncoding.EncodeToString(buf) +} diff --git a/internal/notify/notify_test.go b/internal/notify/notify_test.go new file mode 100644 index 0000000..9ed530e --- /dev/null +++ b/internal/notify/notify_test.go @@ -0,0 +1,61 @@ +package notify + +import ( + "context" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// TestShowRuftNotifySend: Auf Linux geht Show über notify-send — hier ein +// Fake im PATH, der seine Argumente protokolliert (echter Prozess, kein +// Mock-Framework). Titel und Text kommen unverändert an. +func TestShowRuftNotifySend(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("notify-send-Weg gibt es nur auf Linux") + } + dir := t.TempDir() + seen := filepath.Join(dir, "seen.txt") + script := "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"" + seen + "\"\n" + if err := os.WriteFile(filepath.Join(dir, "notify-send"), []byte(script), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir) + + if !Available() { + t.Fatal("Available = false mit Fake im PATH") + } + if err := Show(context.Background(), "Ruhezeit: Build fertig"); err != nil { + t.Fatalf("Show: %v", err) + } + b, err := os.ReadFile(seen) + if err != nil { + t.Fatal(err) + } + args := strings.Split(strings.TrimSpace(string(b)), "\n") + if args[len(args)-1] != "Ruhezeit: Build fertig" || args[len(args)-2] != Title { + t.Errorf("notify-send-Argumente = %q, want … %q %q", args, Title, "Ruhezeit: Build fertig") + } +} + +// TestShowOhneWerkzeug: fehlt das Werkzeug, ist das ein klarer Fehler. +func TestShowOhneWerkzeug(t *testing.T) { + t.Setenv("PATH", t.TempDir()) + if Available() { + t.Fatal("Available = true ohne Werkzeug") + } + if err := Show(context.Background(), "x"); err == nil { + t.Error("Show ohne Werkzeug: kein Fehler") + } + if !strings.Contains(Describe(), "nicht gefunden") { + t.Errorf("Describe = %q", Describe()) + } +} + +func TestAppleString(t *testing.T) { + if got := appleString(`sag "hallo" \ tschüss`); got != `"sag \"hallo\" \\ tschüss"` { + t.Errorf("appleString = %s", got) + } +} diff --git a/internal/queue/queue.go b/internal/queue/queue.go index 75728f6..6ef12d7 100644 --- a/internal/queue/queue.go +++ b/internal/queue/queue.go @@ -115,6 +115,7 @@ type Queue struct { limit *RateLimiter speak Speaker log io.Writer + toast func(Item) // Ruhezeit-Toast (optional; gesetzt vor dem ersten Enqueue) mu sync.Mutex notEmpty *sync.Cond // Signal: neues Item; Broadcast: Close/Slot frei @@ -146,6 +147,18 @@ func New(laws Laws, speak Speaker, log io.Writer) *Queue { return q } +// OnToast setzt die Benachrichtigung, die in der Ruhezeit mit quiet_mode = +// "toast" statt Audio ausgelöst wird (Daemon: Desktop-Notification). Ohne +// Setzen bleibt es bei der Protokollzeile. Vor dem ersten Enqueue setzen. +func (q *Queue) OnToast(fn func(Item)) { q.toast = fn } + +// fireToast ruft die Toast-Benachrichtigung auf (nie unter dem Mutex). +func (q *Queue) fireToast(item Item) { + if q.toast != nil { + q.toast(item) + } +} + // Enqueue stellt item in die Warteschlange und liefert die Aufnahme- // Entscheidung: Aufgenommene tragen ihre ID (bei leerer ID neu vergeben: // kurz und zufällig). Ruhezeit-Verwurf/Toast und low über dem Fenster-Budget @@ -176,6 +189,7 @@ func (q *Queue) Enqueue(item Item) (Admission, error) { q.mu.Unlock() q.logf("queue: ansage (%q, priorität %s) durch toast ersetzt — ruhezeit %s aktiv (modus toast)", item.Text, item.Priority, q.laws.Quiet) + q.fireToast(item) return Admission{Discarded: DiscardQuietHours, Toast: true}, nil case verdictDiscardRate: q.mu.Unlock() @@ -365,6 +379,7 @@ func (q *Queue) loop() { case verdictToast: q.logf("queue: ansage %s (%q) durch toast ersetzt — ruhezeit %s aktiv (modus toast)", item.ID, item.Text, q.laws.Quiet) + q.fireToast(item) case verdictDiscardExpired: q.logf("queue: ansage %s (%q) verworfen — %s gewartet, älter als max_age %s", item.ID, item.Text, time.Since(item.enqueued).Round(time.Second), q.laws.MaxAge) diff --git a/test/notify_test.go b/test/notify_test.go new file mode 100644 index 0000000..e80f8ec --- /dev/null +++ b/test/notify_test.go @@ -0,0 +1,55 @@ +package test + +// Prozessgrenzen-Test für den Ruhezeit-Toast: quiet_mode = toast in aktiver +// Ruhezeit löst eine Desktop-Benachrichtigung aus — hier ein fake +// notify-send im PATH des Daemons, das seine Argumente protokolliert. + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" +) + +func TestRuhezeitToastZeigtNotification(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("fake notify-send gibt es nur auf Linux") + } + root := t.TempDir() + bin := filepath.Join(root, "bin") + if err := os.MkdirAll(bin, 0o755); err != nil { + t.Fatal(err) + } + seen := filepath.Join(root, "seen.txt") + script := "#!/bin/sh\nprintf '%s\\n' \"$@\" > \"" + seen + "\"\n" + if err := os.WriteFile(filepath.Join(bin, "notify-send"), []byte(script), 0o755); err != nil { + t.Fatal(err) + } + fake := newFakeProvider(t, 0) + d := startDaemon(t, root, fake.srv.URL, map[string]string{ + "TALK_QUIET_HOURS": fensterUm(), + "TALK_QUIET_MODE": "toast", + "PATH": bin + string(os.PathListSeparator) + os.Getenv("PATH"), + }) + + code, m := speak(t, d, `{"text":"Build fertig, bitte Review"}`) + if code != 202 || m["discarded"] != "quiet_hours" || m["toast"] != true { + t.Fatalf("speak in Ruhezeit (toast): %d %v", code, m) + } + deadline := time.Now().Add(3 * time.Second) + for { + if b, err := os.ReadFile(seen); err == nil { + if !strings.Contains(string(b), "Build fertig, bitte Review") || !strings.Contains(string(b), "talk") { + t.Errorf("notify-send-Argumente = %q", b) + } + break + } + if time.Now().After(deadline) { + t.Fatalf("notify-send wurde nicht gerufen — Daemon-Log:\n%s", d.stderr.String()) + } + time.Sleep(25 * time.Millisecond) + } + fake.assertNoHits(t, 200*time.Millisecond) +}