2026-10-24 12:10:49 +02:00
|
|
|
// Package notify shows desktop notifications: best effort, never a reason to
|
|
|
|
|
// abort an announcement.
|
2026-10-24 10:37:46 +02:00
|
|
|
//
|
2026-10-24 12:10:49 +02:00
|
|
|
// 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).
|
2026-10-24 12:14:38 +02:00
|
|
|
// 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.
|
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"
|
2026-10-25 12:43:14 +01:00
|
|
|
"errors"
|
2026-10-24 10:37:46 +02:00
|
|
|
"fmt"
|
|
|
|
|
"os/exec"
|
|
|
|
|
"runtime"
|
|
|
|
|
"strings"
|
2026-10-25 12:43:14 +01:00
|
|
|
"sync"
|
2026-10-24 10:37:46 +02:00
|
|
|
"time"
|
|
|
|
|
"unicode/utf16"
|
2026-10-25 12:43:14 +01:00
|
|
|
|
|
|
|
|
"code.m3ta.dev/m3tam3re/talk/internal/icon"
|
2026-10-24 10:37:46 +02:00
|
|
|
)
|
|
|
|
|
|
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-25 12:43:14 +01:00
|
|
|
// 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...)
|
|
|
|
|
}
|
|
|
|
|
|
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 12:10:49 +02:00
|
|
|
// Available reports whether a notification path exists on this system: a
|
|
|
|
|
// reachable notification service on the session bus, or the OS tool.
|
2026-10-24 10:37:46 +02:00
|
|
|
func Available() bool {
|
2026-10-24 12:10:49 +02:00
|
|
|
if dbusAvailable() {
|
|
|
|
|
return true
|
|
|
|
|
}
|
2026-10-24 10:37:46 +02:00
|
|
|
_, err := exec.LookPath(tool())
|
|
|
|
|
return err == nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 12:10:49 +02:00
|
|
|
// 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.
|
2026-10-24 10:37:46 +02:00
|
|
|
func Describe() string {
|
2026-10-24 12:10:49 +02:00
|
|
|
if info, ok := dbusDescribe(); ok {
|
|
|
|
|
return info
|
|
|
|
|
}
|
2026-10-24 10:37:46 +02:00
|
|
|
t := tool()
|
|
|
|
|
if _, err := exec.LookPath(t); err != nil {
|
2026-10-24 12:10:49 +02:00
|
|
|
if runtime.GOOS == "linux" {
|
|
|
|
|
return "no notification service on the session bus and " + t + " not found"
|
|
|
|
|
}
|
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":
|
2026-10-24 12:10:49 +02:00
|
|
|
return "osascript (Notification Center; no cancel button)"
|
2026-10-24 10:37:46 +02:00
|
|
|
case "windows":
|
2026-10-25 12:43:14 +01:00
|
|
|
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)"
|
2026-10-24 10:37:46 +02:00
|
|
|
default:
|
2026-10-24 12:10:49 +02:00
|
|
|
return "notify-send (libnotify; no session bus, so no cancel button)"
|
2026-10-24 10:37:46 +02:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 12:10:49 +02:00
|
|
|
// SupportsCancel reports whether notifications on this system can carry a
|
2026-10-24 12:14:38 +02:00
|
|
|
// Cancel button that reaches talk: Linux with a notification service that
|
|
|
|
|
// has the "actions" capability, or Windows (toast button via the talk:
|
|
|
|
|
// protocol handler).
|
2026-10-24 12:10:49 +02:00
|
|
|
func SupportsCancel() bool {
|
2026-10-24 12:14:38 +02:00
|
|
|
return dbusSupportsActions() || runtime.GOOS == "windows"
|
2026-10-24 12:10:49 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-24 10:37:46 +02:00
|
|
|
func tool() string {
|
|
|
|
|
switch runtime.GOOS {
|
|
|
|
|
case "darwin":
|
|
|
|
|
return "osascript"
|
|
|
|
|
case "windows":
|
|
|
|
|
return "powershell"
|
|
|
|
|
default:
|
|
|
|
|
return "notify-send"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 12:10:49 +02:00
|
|
|
// 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.
|
2026-10-24 10:37:46 +02:00
|
|
|
func Show(ctx context.Context, text string) error {
|
2026-10-24 12:10:49 +02:00
|
|
|
if dbusAvailable() {
|
|
|
|
|
id, err := dbusNotify(ctx, text, nil, nil)
|
|
|
|
|
if err == nil {
|
|
|
|
|
_ = id
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return showWithTool(ctx, text)
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 12:14:38 +02:00
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-24 12:10:49 +02:00
|
|
|
// Announce shows the notification for an announcement that is playing: the
|
2026-10-24 12:14:38 +02:00
|
|
|
// 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) {
|
2026-10-24 12:10:49 +02:00
|
|
|
noop := func() {}
|
|
|
|
|
if dbusAvailable() {
|
2026-10-24 12:14:38 +02:00
|
|
|
id, err := dbusNotify(ctx, a.Text, []string{"cancel", a.CancelLabel}, func(action string) {
|
2026-10-24 12:10:49 +02:00
|
|
|
if action == "cancel" && onCancel != nil {
|
|
|
|
|
onCancel()
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
if err == nil {
|
|
|
|
|
return func() { dbusClose(id) }, nil
|
|
|
|
|
}
|
|
|
|
|
// fall through to the tool on a bus error
|
|
|
|
|
}
|
2026-10-24 12:14:38 +02:00
|
|
|
if runtime.GOOS == "windows" && a.CancelURI != "" {
|
|
|
|
|
return announceWindows(ctx, a)
|
|
|
|
|
}
|
|
|
|
|
return noop, showWithTool(ctx, a.Text)
|
2026-10-24 12:10:49 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// showWithTool shows a notification through the OS tool (no action).
|
|
|
|
|
func showWithTool(ctx context.Context, text string) error {
|
2026-10-24 10:37:46 +02:00
|
|
|
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":
|
2026-10-25 12:43:14 +01:00
|
|
|
cmd = exec.CommandContext(ctx, "powershell", "-NoProfile", "-NonInteractive", "-EncodedCommand", psEncode(toastScript(text, toastAppID())))
|
2026-10-24 10:37:46 +02:00
|
|
|
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-25 12:43:14 +01:00
|
|
|
// 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 {
|
2026-10-24 10:37:46 +02:00
|
|
|
return fmt.Sprintf(`$ErrorActionPreference = 'Stop'
|
|
|
|
|
[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
|
2026-10-25 12:43:14 +01:00
|
|
|
[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>")
|
2026-10-24 10:37:46 +02:00
|
|
|
[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('%s').Show([Windows.UI.Notifications.ToastNotification]::new($xml))
|
2026-10-25 12:43:14 +01:00
|
|
|
`, base64.StdEncoding.EncodeToString([]byte(text)), titleLine(appID), appID)
|
2026-10-24 10:37:46 +02:00
|
|
|
}
|
|
|
|
|
|
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)
|
|
|
|
|
}
|