Files
talk/internal/notify/notify.go
T

114 lines
3.7 KiB
Go
Raw Normal View History

// 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):
//
// Linux: notify-send (libnotify; needs a notification daemon)
// macOS: osascript `display notification`
// Windows: PowerShell toast (Windows.UI.Notifications)
//
// 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"
"fmt"
"os/exec"
"runtime"
"strings"
"time"
"unicode/utf16"
)
// Title is the title of all talk notifications.
const Title = "talk"
// timeout bounds the call of the OS tool.
const timeout = 10 * time.Second
// Available reports whether a notification path exists on this system
// (binary present; whether a daemon listens only Show reveals).
func Available() bool {
_, err := exec.LookPath(tool())
return err == nil
}
// Describe names the path for `talk doctor`.
func Describe() string {
t := tool()
if _, err := exec.LookPath(t); err != nil {
return t + " not found"
}
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 shows a notification with Title and text and waits for the OS call
// to finish (not for the notification to be closed).
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 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 toast (Windows 10/11); the text is passed
// base64-encoded so there is no quoting problem.
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 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)
}