//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 }