179 lines
5.0 KiB
Go
179 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), "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
|
||
|
|
}
|