Files
talk/internal/notify/dbus_linux.go
T

179 lines
5.0 KiB
Go
Raw Normal View History

//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), "audio-speakers", 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
}