feat(notify): Desktop-Benachrichtigung für den Ruhezeit-Toast
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
a9ca32bed9
commit
9e2aa506a6
7 files changed
+279
-6
No files matched your search
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user