Files
talk/internal/notify/dbus_linux.go
T
m3ta-chironandClaude Fable 5.1 2d9d2a86e9
release / release (push) Canceled after 0s
test / test (push) Canceled after 0s
feat(notify): Windows toasts under talk's own identity, icon on Linux
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>
2026-10-25 12:43:14 +01:00

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
}