// Package notify shows desktop notifications: best effort, never a reason to // abort an announcement. // // Linux: the freedesktop Notifications D-Bus service (org.freedesktop. // Notifications, pure Go via godbus): notifications with a Cancel // action, closed again when the announcement ends. Fallback when // no session bus is reachable: notify-send (no action). // macOS: osascript `display notification` (no action). // Windows: PowerShell toast (Windows.UI.Notifications) with a Cancel // button that opens a talk: link; the talk: protocol handler // (registered in HKCU, `talk uri`) relays the click to the daemon. // // Missing tools are a clear error for the caller (log), never an abort: a // notification is an extra, not a contract. package notify import ( "bytes" "context" "encoding/base64" "encoding/binary" "errors" "fmt" "os/exec" "runtime" "strings" "sync" "time" "unicode/utf16" "code.m3ta.dev/m3tam3re/talk/internal/icon" ) // Title is the title of all talk notifications. const Title = "talk" // AppID is talk's AppUserModelID on Windows: the identity (name, icon) its // toasts are shown under once UseIcon has registered it. const AppID = "m3ta.talk" var ( iconMu sync.Mutex iconFile string ) func setIconPath(p string) { iconMu.Lock() iconFile = p iconMu.Unlock() } // appIcon is the icon a freedesktop notification carries: the installed // talk icon, or a theme icon while none is installed. func appIcon() string { iconMu.Lock() defer iconMu.Unlock() if iconFile != "" { return iconFile } return "audio-speakers" } // Setup prepares the desktop side once per process (daemon start, talk // test --toast): installs the icon into dir and registers it (Windows: the // toast identity and, with exe, the talk: protocol handler the Cancel // button needs). Best effort: the error is for the log, notifications still // work under the fallback identity. func Setup(dir, exe string) error { path, err := icon.Install(dir) if err != nil { return err } var errs []error if err := UseIcon(path); err != nil { errs = append(errs, err) } if exe != "" { if err := RegisterProtocol(exe); err != nil { errs = append(errs, err) } } return errors.Join(errs...) } // timeout bounds the call of the OS tool. const timeout = 10 * time.Second // Available reports whether a notification path exists on this system: a // reachable notification service on the session bus, or the OS tool. func Available() bool { if dbusAvailable() { return true } _, err := exec.LookPath(tool()) return err == nil } // Describe names the path for `talk doctor`: the notification service with // name and version and whether it supports a Cancel action, or the OS tool. func Describe() string { if info, ok := dbusDescribe(); ok { return info } t := tool() if _, err := exec.LookPath(t); err != nil { if runtime.GOOS == "linux" { return "no notification service on the session bus and " + t + " not found" } return t + " not found" } switch runtime.GOOS { case "darwin": return "osascript (Notification Center; no cancel button)" case "windows": if ownIdentity() { return "powershell (Windows toast as talk, cancel button via the talk: link)" } return "powershell (Windows toast under the Windows PowerShell identity until the daemon has run once; cancel button via the talk: link)" default: return "notify-send (libnotify; no session bus, so no cancel button)" } } // SupportsCancel reports whether notifications on this system can carry a // Cancel button that reaches talk: Linux with a notification service that // has the "actions" capability, or Windows (toast button via the talk: // protocol handler). func SupportsCancel() bool { return dbusSupportsActions() || runtime.GOOS == "windows" } func tool() string { switch runtime.GOOS { case "darwin": return "osascript" case "windows": return "powershell" default: return "notify-send" } } // Show shows a plain notification with Title and text (no action) and waits // for the handover to finish, not for the notification to be closed. Used // for the quiet-hours toast. func Show(ctx context.Context, text string) error { if dbusAvailable() { id, err := dbusNotify(ctx, text, nil, nil) if err == nil { _ = id return nil } } return showWithTool(ctx, text) } // Announcement is what Announce shows for a playing announcement. type Announcement struct { // Text is the announcement text (the notification body). Text string // CancelLabel is the button label ("Cancel", "Abbrechen"). CancelLabel string // CancelURI is the talk: link the button opens where the click cannot // reach the daemon directly (Windows toast): talk:cancel?id=…&port=…. // The talk: protocol handler (talk uri) turns it into POST /cancel-item. CancelURI string // Tag identifies the notification for closing it again where the // platform needs a tag (Windows); typically the announcement id. Tag string } // Announce shows the notification for an announcement that is playing: the // text plus a Cancel button where the platform supports it. onCancel runs // when the user clicks it on platforms that report the click back (Linux // D-Bus); on Windows the click opens CancelURI instead. The returned close // function dismisses the notification (call it when the announcement // ends); it is never nil. Platforms without actions show a plain // notification and the close function does nothing. func Announce(ctx context.Context, a Announcement, onCancel func()) (close func(), err error) { noop := func() {} if dbusAvailable() { id, err := dbusNotify(ctx, a.Text, []string{"cancel", a.CancelLabel}, func(action string) { if action == "cancel" && onCancel != nil { onCancel() } }) if err == nil { return func() { dbusClose(id) }, nil } // fall through to the tool on a bus error } if runtime.GOOS == "windows" && a.CancelURI != "" { return announceWindows(ctx, a) } return noop, showWithTool(ctx, a.Text) } // showWithTool shows a notification through the OS tool (no action). func showWithTool(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, toastAppID()))) 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 failed: %w: %s", tool(), err, bytes.TrimSpace(out)) } return nil } // appleString turns s into an AppleScript string literal. func appleString(s string) string { return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s) + `"` } // toastScript is the PowerShell for a plain toast (Windows 10/11) under // appID; the text is passed base64-encoded so there is no quoting problem. func toastScript(text, appID string) string { return fmt.Sprintf(`$ErrorActionPreference = 'Stop' [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null $msg = [System.Security.SecurityElement]::Escape([System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('%s'))) $xml = New-Object Windows.Data.Xml.Dom.XmlDocument $xml.LoadXml("%s$msg") [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('%s').Show([Windows.UI.Notifications.ToastNotification]::new($xml)) `, base64.StdEncoding.EncodeToString([]byte(text)), titleLine(appID), appID) } // psEncode encodes a script for -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) }