Files
m3ta-chironandClaude Fable 5.1 2d9d2a86e9
release / release (push) Canceled after 0s
test / test (push) Canceled after 0s
feat(notify): Windows toasts under talk's own identity, icon on Linux
The daemon (and talk test --toast) now register the AppUserModelID
m3ta.talk under HKCU with display name and the installed icon, so
toasts appear as "talk" with the icon instead of Windows PowerShell;
the PowerShell identity stays as fallback until the registration has
run once. The redundant "talk" title line is dropped under the own
identity. notify.Setup bundles icon install, identity and the talk:
protocol handler for both callers. Linux notifications carry the
installed icon as app_icon. doctor names the active identity.

Verified on the Windows 11 VM: registry key, icon file, toast with
working Cancel button, exe icon; Linux via quickshell.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-25 12:43:14 +01:00

248 lines
8.2 KiB
Go

// 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("<toast><visual><binding template='ToastGeneric'>%s<text>$msg</text></binding></visual></toast>")
[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)
}