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>
180 lines
5.0 KiB
Go
180 lines
5.0 KiB
Go
//go:build linux
|
|
|
|
package notify
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"slices"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/godbus/dbus/v5"
|
|
)
|
|
|
|
// The freedesktop Notifications service on the session bus. One connection
|
|
// per process, made lazily on first use and kept: the daemon notifies on
|
|
// every announcement and must see the ActionInvoked signals for the Cancel
|
|
// button, which only works on a connection that stays open.
|
|
const (
|
|
notifDest = "org.freedesktop.Notifications"
|
|
notifPath = "/org/freedesktop/Notifications"
|
|
notifIface = "org.freedesktop.Notifications"
|
|
|
|
// dbusCallTimeout bounds every call: a hanging notification service
|
|
// must never hold up an announcement.
|
|
dbusCallTimeout = 2 * time.Second
|
|
)
|
|
|
|
var bus struct {
|
|
once sync.Once
|
|
conn *dbus.Conn
|
|
err error
|
|
caps []string
|
|
server string
|
|
mu sync.Mutex
|
|
handlers map[uint32]func(action string) // by notification id
|
|
}
|
|
|
|
// connect opens the session bus once, subscribes to the service's signals
|
|
// and starts the dispatcher. A missing bus (DBUS_SESSION_BUS_ADDRESS unset:
|
|
// servers, SSH, WinRM-style sessions) is a normal, remembered failure.
|
|
func connect() (*dbus.Conn, error) {
|
|
bus.once.Do(func() {
|
|
conn, err := dbus.SessionBus()
|
|
if err != nil {
|
|
bus.err = fmt.Errorf("session bus: %w", err)
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), dbusCallTimeout)
|
|
defer cancel()
|
|
obj := conn.Object(notifDest, notifPath)
|
|
var name, vendor, version, spec string
|
|
if err := obj.CallWithContext(ctx, notifIface+".GetServerInformation", 0).Store(&name, &vendor, &version, &spec); err != nil {
|
|
bus.err = fmt.Errorf("no notification service on the session bus: %w", err)
|
|
return
|
|
}
|
|
bus.server = name
|
|
if version != "" {
|
|
bus.server += " " + version
|
|
}
|
|
if err := obj.CallWithContext(ctx, notifIface+".GetCapabilities", 0).Store(&bus.caps); err != nil {
|
|
bus.caps = nil
|
|
}
|
|
if err := conn.AddMatchSignal(dbus.WithMatchObjectPath(notifPath), dbus.WithMatchInterface(notifIface)); err != nil {
|
|
bus.err = fmt.Errorf("subscribing to notification signals: %w", err)
|
|
return
|
|
}
|
|
bus.handlers = map[uint32]func(string){}
|
|
ch := make(chan *dbus.Signal, 32)
|
|
conn.Signal(ch)
|
|
go dispatch(ch)
|
|
bus.conn = conn
|
|
})
|
|
return bus.conn, bus.err
|
|
}
|
|
|
|
// dispatch routes ActionInvoked to the notification's handler and drops the
|
|
// handler on NotificationClosed.
|
|
func dispatch(ch <-chan *dbus.Signal) {
|
|
for sig := range ch {
|
|
if len(sig.Body) == 0 {
|
|
continue
|
|
}
|
|
id, ok := sig.Body[0].(uint32)
|
|
if !ok {
|
|
continue
|
|
}
|
|
switch sig.Name {
|
|
case notifIface + ".ActionInvoked":
|
|
action, _ := sig.Body[1].(string)
|
|
bus.mu.Lock()
|
|
h := bus.handlers[id]
|
|
bus.mu.Unlock()
|
|
if h != nil {
|
|
h(action)
|
|
}
|
|
case notifIface + ".NotificationClosed":
|
|
bus.mu.Lock()
|
|
delete(bus.handlers, id)
|
|
bus.mu.Unlock()
|
|
}
|
|
}
|
|
}
|
|
|
|
// dbusAvailable reports whether a notification service answers on the bus.
|
|
func dbusAvailable() bool {
|
|
_, err := connect()
|
|
return err == nil
|
|
}
|
|
|
|
// dbusSupportsActions reports the "actions" capability of the service.
|
|
func dbusSupportsActions() bool {
|
|
if _, err := connect(); err != nil {
|
|
return false
|
|
}
|
|
return slices.Contains(bus.caps, "actions")
|
|
}
|
|
|
|
// dbusDescribe names the service for `talk doctor`.
|
|
func dbusDescribe() (string, bool) {
|
|
if _, err := connect(); err != nil {
|
|
return "", false
|
|
}
|
|
s := "notification service " + bus.server + " via D-Bus"
|
|
if slices.Contains(bus.caps, "actions") {
|
|
return s + " (cancel button supported)", true
|
|
}
|
|
return s + " (no actions: no cancel button)", true
|
|
}
|
|
|
|
// dbusNotify shows a notification and returns its id. actions is the flat
|
|
// freedesktop list [key, label, …]; onAction receives the key when the user
|
|
// clicks. expire_timeout 0 keeps the notification until talk closes it, so
|
|
// the Cancel button stays for the whole announcement.
|
|
func dbusNotify(ctx context.Context, text string, actions []string, onAction func(action string)) (uint32, error) {
|
|
conn, err := connect()
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if actions == nil {
|
|
actions = []string{}
|
|
}
|
|
expire := int32(-1) // server default for plain toasts
|
|
if len(actions) > 0 {
|
|
expire = 0
|
|
}
|
|
ctx, cancel := context.WithTimeout(ctx, dbusCallTimeout)
|
|
defer cancel()
|
|
hints := map[string]dbus.Variant{
|
|
"urgency": dbus.MakeVariant(byte(1)),
|
|
"desktop-entry": dbus.MakeVariant(Title),
|
|
}
|
|
var id uint32
|
|
err = conn.Object(notifDest, notifPath).CallWithContext(ctx, notifIface+".Notify", 0,
|
|
Title, uint32(0), appIcon(), Title, text, actions, hints, expire).Store(&id)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("notify: %w", err)
|
|
}
|
|
if onAction != nil {
|
|
bus.mu.Lock()
|
|
bus.handlers[id] = onAction
|
|
bus.mu.Unlock()
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
// dbusClose dismisses a notification (best effort).
|
|
func dbusClose(id uint32) {
|
|
conn, err := connect()
|
|
if err != nil {
|
|
return
|
|
}
|
|
bus.mu.Lock()
|
|
delete(bus.handlers, id)
|
|
bus.mu.Unlock()
|
|
ctx, cancel := context.WithTimeout(context.Background(), dbusCallTimeout)
|
|
defer cancel()
|
|
_ = conn.Object(notifDest, notifPath).CallWithContext(ctx, notifIface+".CloseNotification", 0, id).Err
|
|
}
|