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