2026-10-24 11:15:35 +02:00
|
|
|
// Package notify shows desktop notifications: best effort, through the
|
|
|
|
|
// operating system's own tools, without a library dependency and without
|
|
|
|
|
// CGO (the same line as provider/osbuiltin):
|
2026-10-24 10:37:46 +02:00
|
|
|
//
|
2026-10-24 11:15:35 +02:00
|
|
|
// Linux: notify-send (libnotify; needs a notification daemon)
|
2026-10-24 10:37:46 +02:00
|
|
|
// macOS: osascript `display notification`
|
2026-10-24 11:15:35 +02:00
|
|
|
// Windows: PowerShell toast (Windows.UI.Notifications)
|
2026-10-24 10:37:46 +02:00
|
|
|
//
|
2026-10-24 11:15:35 +02:00
|
|
|
// Missing tools are a clear error for the caller (log), never an abort: a
|
|
|
|
|
// notification is an extra, not a contract.
|
2026-10-24 10:37:46 +02:00
|
|
|
package notify
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bytes"
|
|
|
|
|
"context"
|
|
|
|
|
"encoding/base64"
|
|
|
|
|
"encoding/binary"
|
|
|
|
|
"fmt"
|
|
|
|
|
"os/exec"
|
|
|
|
|
"runtime"
|
|
|
|
|
"strings"
|
|
|
|
|
"time"
|
|
|
|
|
"unicode/utf16"
|
|
|
|
|
)
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Title is the title of all talk notifications.
|
2026-10-24 10:37:46 +02:00
|
|
|
const Title = "talk"
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// timeout bounds the call of the OS tool.
|
2026-10-24 10:37:46 +02:00
|
|
|
const timeout = 10 * time.Second
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Available reports whether a notification path exists on this system
|
|
|
|
|
// (binary present; whether a daemon listens only Show reveals).
|
2026-10-24 10:37:46 +02:00
|
|
|
func Available() bool {
|
|
|
|
|
_, err := exec.LookPath(tool())
|
|
|
|
|
return err == nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Describe names the path for `talk doctor`.
|
2026-10-24 10:37:46 +02:00
|
|
|
func Describe() string {
|
|
|
|
|
t := tool()
|
|
|
|
|
if _, err := exec.LookPath(t); err != nil {
|
2026-10-24 11:15:35 +02:00
|
|
|
return t + " not found"
|
2026-10-24 10:37:46 +02:00
|
|
|
}
|
|
|
|
|
switch runtime.GOOS {
|
|
|
|
|
case "darwin":
|
|
|
|
|
return "osascript (Notification Center)"
|
|
|
|
|
case "windows":
|
2026-10-24 11:15:35 +02:00
|
|
|
return "powershell (Windows toast)"
|
2026-10-24 10:37:46 +02:00
|
|
|
default:
|
|
|
|
|
return "notify-send (libnotify)"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func tool() string {
|
|
|
|
|
switch runtime.GOOS {
|
|
|
|
|
case "darwin":
|
|
|
|
|
return "osascript"
|
|
|
|
|
case "windows":
|
|
|
|
|
return "powershell"
|
|
|
|
|
default:
|
|
|
|
|
return "notify-send"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// Show shows a notification with Title and text and waits for the OS call
|
|
|
|
|
// to finish (not for the notification to be closed).
|
2026-10-24 10:37:46 +02:00
|
|
|
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 {
|
2026-10-24 11:15:35 +02:00
|
|
|
return fmt.Errorf("notify: %s failed: %w: %s", tool(), err, bytes.TrimSpace(out))
|
2026-10-24 10:37:46 +02:00
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// appleString turns s into an AppleScript string literal.
|
2026-10-24 10:37:46 +02:00
|
|
|
func appleString(s string) string {
|
|
|
|
|
return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s) + `"`
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// toastScript is the PowerShell toast (Windows 10/11); the text is passed
|
|
|
|
|
// base64-encoded so there is no quoting problem.
|
2026-10-24 10:37:46 +02:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 11:15:35 +02:00
|
|
|
// psEncode encodes a script for -EncodedCommand (UTF-16LE + base64).
|
2026-10-24 10:37:46 +02:00
|
|
|
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)
|
|
|
|
|
}
|