Add xdg-desktop-portal GlobalShortcuts backend for Wayland (Linux)
Implements a three-tier hotkey backend chain on Linux: 1. xdg-desktop-portal GlobalShortcuts — works on Hyprland, GNOME, and KDE without any privilege setup; requires one bind line in hyprland.conf for Hyprland 2. evdev — fallback for users in the input group or with systemd-logind seat ACLs 3. X11 XGrabKey — last resort for plain X11 sessions Fixes double-fire on Hyprland by filtering signals by sig.Name to guard against Deactivated events leaking through the Activated match rule. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+178
-24
@@ -12,12 +12,14 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
"time"
|
||||||
"unsafe"
|
"unsafe"
|
||||||
|
|
||||||
|
dbus "github.com/godbus/dbus/v5"
|
||||||
"golang.design/x/hotkey"
|
"golang.design/x/hotkey"
|
||||||
)
|
)
|
||||||
|
|
||||||
// modMap is used by parseKeybind (shared with tests).
|
// modMap is used by parseKeybind (called from main.go and tests).
|
||||||
var modMap = map[string]hotkey.Modifier{
|
var modMap = map[string]hotkey.Modifier{
|
||||||
"ctrl": hotkey.ModCtrl,
|
"ctrl": hotkey.ModCtrl,
|
||||||
"shift": hotkey.ModShift,
|
"shift": hotkey.ModShift,
|
||||||
@@ -26,6 +28,152 @@ var modMap = map[string]hotkey.Modifier{
|
|||||||
"super": hotkey.Mod4,
|
"super": hotkey.Mod4,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Portal (xdg-desktop-portal GlobalShortcuts) ──────────────────────────────
|
||||||
|
|
||||||
|
// toPortalTrigger converts "ctrl+alt+m" → "<Control><Alt>m".
|
||||||
|
func toPortalTrigger(keybind string) string {
|
||||||
|
parts := strings.Split(strings.ToLower(strings.TrimSpace(keybind)), "+")
|
||||||
|
mods := map[string]string{
|
||||||
|
"ctrl": "<Control>", "shift": "<Shift>",
|
||||||
|
"alt": "<Alt>", "win": "<Super>", "super": "<Super>",
|
||||||
|
}
|
||||||
|
var sb strings.Builder
|
||||||
|
for _, p := range parts[:len(parts)-1] {
|
||||||
|
if m, ok := mods[strings.TrimSpace(p)]; ok {
|
||||||
|
sb.WriteString(m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.WriteString(strings.TrimSpace(parts[len(parts)-1]))
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func startHotkeyPortal(keybind string) (<-chan struct{}, error) {
|
||||||
|
if os.Getenv("WAYLAND_DISPLAY") == "" {
|
||||||
|
return nil, fmt.Errorf("not a Wayland session")
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := dbus.SessionBus()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("D-Bus session bus: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ":1.123" → "1_123"
|
||||||
|
sender := strings.ReplaceAll(strings.TrimPrefix(conn.Names()[0], ":"), ".", "_")
|
||||||
|
portal := conn.Object("org.freedesktop.portal.Desktop", "/org/freedesktop/portal/desktop")
|
||||||
|
pid := os.Getpid()
|
||||||
|
|
||||||
|
awaitResponse := func(token string) (map[string]dbus.Variant, error) {
|
||||||
|
reqPath := dbus.ObjectPath(fmt.Sprintf(
|
||||||
|
"/org/freedesktop/portal/desktop/request/%s/%s", sender, token))
|
||||||
|
ch := make(chan *dbus.Signal, 8)
|
||||||
|
conn.Signal(ch)
|
||||||
|
defer conn.RemoveSignal(ch)
|
||||||
|
opts := []dbus.MatchOption{
|
||||||
|
dbus.WithMatchInterface("org.freedesktop.portal.Request"),
|
||||||
|
dbus.WithMatchMember("Response"),
|
||||||
|
dbus.WithMatchObjectPath(reqPath),
|
||||||
|
}
|
||||||
|
if err := conn.AddMatchSignal(opts...); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer conn.RemoveMatchSignal(opts...) //nolint:errcheck
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case sig := <-ch:
|
||||||
|
if sig == nil || sig.Path != reqPath {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if code, _ := sig.Body[0].(uint32); code != 0 {
|
||||||
|
return nil, fmt.Errorf("portal declined (code %d)", code)
|
||||||
|
}
|
||||||
|
if results, ok := sig.Body[1].(map[string]dbus.Variant); ok {
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
return map[string]dbus.Variant{}, nil
|
||||||
|
case <-time.After(30 * time.Second):
|
||||||
|
return nil, fmt.Errorf("portal response timed out")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateSession
|
||||||
|
reqToken1 := fmt.Sprintf("dlm_r1_%d", pid)
|
||||||
|
sessToken := fmt.Sprintf("dlm_s_%d", pid)
|
||||||
|
if err := portal.Call("org.freedesktop.portal.GlobalShortcuts.CreateSession", 0,
|
||||||
|
map[string]dbus.Variant{
|
||||||
|
"handle_token": dbus.MakeVariant(reqToken1),
|
||||||
|
"session_handle_token": dbus.MakeVariant(sessToken),
|
||||||
|
}).Err; err != nil {
|
||||||
|
return nil, fmt.Errorf("GlobalShortcuts portal unavailable: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := awaitResponse(reqToken1); err != nil {
|
||||||
|
return nil, fmt.Errorf("CreateSession: %w", err)
|
||||||
|
}
|
||||||
|
// xdg-desktop-portal-hyprland doesn't populate session_handle in the response
|
||||||
|
// (spec bug). The path is always predictable from sender + session token.
|
||||||
|
sessionHandle := dbus.ObjectPath(fmt.Sprintf(
|
||||||
|
"/org/freedesktop/portal/desktop/session/%s/%s", sender, sessToken))
|
||||||
|
|
||||||
|
// BindShortcuts — portal expects a(sa{sv}): array of (id, props) structs.
|
||||||
|
type shortcutEntry struct {
|
||||||
|
ID string
|
||||||
|
Props map[string]dbus.Variant
|
||||||
|
}
|
||||||
|
reqToken2 := fmt.Sprintf("dlm_r2_%d", pid)
|
||||||
|
shortcuts := []shortcutEntry{{
|
||||||
|
ID: "mute-toggle",
|
||||||
|
Props: map[string]dbus.Variant{
|
||||||
|
"description": dbus.MakeVariant("Toggle Discord Lobby Mute"),
|
||||||
|
"preferred_trigger": dbus.MakeVariant(toPortalTrigger(keybind)),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
if err := portal.Call("org.freedesktop.portal.GlobalShortcuts.BindShortcuts", 0,
|
||||||
|
sessionHandle, shortcuts, "", map[string]dbus.Variant{
|
||||||
|
"handle_token": dbus.MakeVariant(reqToken2),
|
||||||
|
}).Err; err != nil {
|
||||||
|
return nil, fmt.Errorf("BindShortcuts: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := awaitResponse(reqToken2); err != nil {
|
||||||
|
return nil, fmt.Errorf("BindShortcuts: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activated signals are emitted from /org/freedesktop/portal/desktop,
|
||||||
|
// not from the session handle path.
|
||||||
|
const portalDesktopPath = dbus.ObjectPath("/org/freedesktop/portal/desktop")
|
||||||
|
out := make(chan struct{}, 1)
|
||||||
|
sigCh := make(chan *dbus.Signal, 8)
|
||||||
|
conn.Signal(sigCh)
|
||||||
|
conn.AddMatchSignal( //nolint:errcheck
|
||||||
|
dbus.WithMatchInterface("org.freedesktop.portal.GlobalShortcuts"),
|
||||||
|
dbus.WithMatchMember("Activated"),
|
||||||
|
dbus.WithMatchObjectPath(portalDesktopPath),
|
||||||
|
)
|
||||||
|
go func() {
|
||||||
|
for sig := range sigCh {
|
||||||
|
if sig.Name != "org.freedesktop.portal.GlobalShortcuts.Activated" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if sig.Path != portalDesktopPath || len(sig.Body) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Body: (o session_handle, s shortcut_id, t timestamp, a{sv} options)
|
||||||
|
if sh, _ := sig.Body[0].(dbus.ObjectPath); sh != sessionHandle {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if id, _ := sig.Body[1].(string); id == "mute-toggle" {
|
||||||
|
select {
|
||||||
|
case out <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── evdev ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// evdev key codes from linux/input-event-codes.h
|
// evdev key codes from linux/input-event-codes.h
|
||||||
var evdevKeyMap = map[string]uint16{
|
var evdevKeyMap = map[string]uint16{
|
||||||
"a": 30, "b": 48, "c": 46, "d": 32, "e": 18, "f": 33, "g": 34, "h": 35,
|
"a": 30, "b": 48, "c": 46, "d": 32, "e": 18, "f": 33, "g": 34, "h": 35,
|
||||||
@@ -43,10 +191,10 @@ var evdevKeyMap = map[string]uint16{
|
|||||||
|
|
||||||
// evdevModGroups maps each modifier to its [leftKey, rightKey] evdev codes.
|
// evdevModGroups maps each modifier to its [leftKey, rightKey] evdev codes.
|
||||||
var evdevModGroups = map[string][]uint16{
|
var evdevModGroups = map[string][]uint16{
|
||||||
"ctrl": {29, 97}, // KEY_LEFTCTRL, KEY_RIGHTCTRL
|
"ctrl": {29, 97},
|
||||||
"shift": {42, 54}, // KEY_LEFTSHIFT, KEY_RIGHTSHIFT
|
"shift": {42, 54},
|
||||||
"alt": {56, 100}, // KEY_LEFTALT, KEY_RIGHTALT
|
"alt": {56, 100},
|
||||||
"win": {125, 126}, // KEY_LEFTMETA, KEY_RIGHTMETA
|
"win": {125, 126},
|
||||||
"super": {125, 126},
|
"super": {125, 126},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,21 +219,17 @@ func parseEvdevKeybind(s string) (modGroups [][]uint16, mainKey uint16, err erro
|
|||||||
return modGroups, k, nil
|
return modGroups, k, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// eviocgbitKey is EVIOCGBIT(EV_KEY=1, 32) — returns a 256-bit mask of supported key codes.
|
// eviocgbitKey = EVIOCGBIT(EV_KEY=1, 32): 256-bit mask of supported key codes.
|
||||||
// Computed as _IOC(_IOC_READ=2, 'E', 0x21, 32) on Linux x86-64.
|
// _IOC(_IOC_READ=2, 'E', 0x21, 32) on Linux x86-64.
|
||||||
const eviocgbitKey = uintptr(0x80204521)
|
const eviocgbitKey = uintptr(0x80204521)
|
||||||
|
|
||||||
// hasKey reports whether the evdev device can produce the given key code.
|
|
||||||
func hasKey(f *os.File, code uint16) bool {
|
func hasKey(f *os.File, code uint16) bool {
|
||||||
if code >= 256 {
|
if code >= 256 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
var bits [32]byte
|
var bits [32]byte
|
||||||
_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, f.Fd(), eviocgbitKey, uintptr(unsafe.Pointer(&bits[0])))
|
_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, f.Fd(), eviocgbitKey, uintptr(unsafe.Pointer(&bits[0])))
|
||||||
if errno != 0 {
|
return errno == 0 && bits[code/8]&(1<<(code%8)) != 0
|
||||||
return false
|
|
||||||
}
|
|
||||||
return bits[code/8]&(1<<(code%8)) != 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func comboActive(held map[uint16]bool, modGroups [][]uint16, mainKey uint16) bool {
|
func comboActive(held map[uint16]bool, modGroups [][]uint16, mainKey uint16) bool {
|
||||||
@@ -107,14 +251,11 @@ func comboActive(held map[uint16]bool, modGroups [][]uint16, mainKey uint16) boo
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// startHotkeyEvdev registers the hotkey via raw evdev. Works on both X11 and Wayland.
|
|
||||||
// Requires the user to be in the 'input' group or have systemd-logind seat ACLs.
|
|
||||||
func startHotkeyEvdev(keybind string) (<-chan struct{}, error) {
|
func startHotkeyEvdev(keybind string) (<-chan struct{}, error) {
|
||||||
modGroups, mainKey, err := parseEvdevKeybind(keybind)
|
modGroups, mainKey, err := parseEvdevKeybind(keybind)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
paths, _ := filepath.Glob("/dev/input/event*")
|
paths, _ := filepath.Glob("/dev/input/event*")
|
||||||
var devices []*os.File
|
var devices []*os.File
|
||||||
for _, p := range paths {
|
for _, p := range paths {
|
||||||
@@ -122,7 +263,6 @@ func startHotkeyEvdev(keybind string) (<-chan struct{}, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Only keep devices that can actually produce the main key.
|
|
||||||
if hasKey(f, mainKey) {
|
if hasKey(f, mainKey) {
|
||||||
devices = append(devices, f)
|
devices = append(devices, f)
|
||||||
} else {
|
} else {
|
||||||
@@ -130,17 +270,15 @@ func startHotkeyEvdev(keybind string) (<-chan struct{}, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(devices) == 0 {
|
if len(devices) == 0 {
|
||||||
return nil, fmt.Errorf("no accessible keyboard devices found in /dev/input — add user to 'input' group")
|
return nil, fmt.Errorf("no accessible keyboard devices in /dev/input (add user to 'input' group)")
|
||||||
}
|
}
|
||||||
|
|
||||||
ch := make(chan struct{}, 1)
|
ch := make(chan struct{}, 1)
|
||||||
var mu sync.Mutex
|
var mu sync.Mutex
|
||||||
held := make(map[uint16]bool)
|
held := make(map[uint16]bool)
|
||||||
|
|
||||||
for _, dev := range devices {
|
for _, dev := range devices {
|
||||||
go func(f *os.File) {
|
go func(f *os.File) {
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
// input_event on 64-bit Linux: 8+8 bytes timeval, 2 type, 2 code, 4 value = 24 bytes
|
// input_event on 64-bit Linux: 8+8 bytes timeval + 2+2+4 = 24 bytes
|
||||||
buf := make([]byte, 24)
|
buf := make([]byte, 24)
|
||||||
for {
|
for {
|
||||||
if _, err := io.ReadFull(f, buf); err != nil {
|
if _, err := io.ReadFull(f, buf); err != nil {
|
||||||
@@ -153,7 +291,7 @@ func startHotkeyEvdev(keybind string) (<-chan struct{}, error) {
|
|||||||
value := int32(binary.LittleEndian.Uint32(buf[20:24]))
|
value := int32(binary.LittleEndian.Uint32(buf[20:24]))
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
switch value {
|
switch value {
|
||||||
case 1: // press
|
case 1:
|
||||||
held[code] = true
|
held[code] = true
|
||||||
if comboActive(held, modGroups, mainKey) {
|
if comboActive(held, modGroups, mainKey) {
|
||||||
select {
|
select {
|
||||||
@@ -161,7 +299,7 @@ func startHotkeyEvdev(keybind string) (<-chan struct{}, error) {
|
|||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case 0: // release
|
case 0:
|
||||||
delete(held, code)
|
delete(held, code)
|
||||||
}
|
}
|
||||||
mu.Unlock()
|
mu.Unlock()
|
||||||
@@ -171,8 +309,23 @@ func startHotkeyEvdev(keybind string) (<-chan struct{}, error) {
|
|||||||
return ch, nil
|
return ch, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// startHotkey tries evdev first (Wayland + X11), falls back to X11 XGrabKey.
|
// ── startHotkey: portal → evdev → X11 ────────────────────────────────────────
|
||||||
|
|
||||||
func startHotkey(keybind string) (<-chan struct{}, error) {
|
func startHotkey(keybind string) (<-chan struct{}, error) {
|
||||||
|
// On Wayland, try xdg-desktop-portal GlobalShortcuts first.
|
||||||
|
// Works on GNOME, KDE, and Hyprland (via xdg-desktop-portal-hyprland)
|
||||||
|
// without any privilege setup.
|
||||||
|
if os.Getenv("WAYLAND_DISPLAY") != "" {
|
||||||
|
ch, err := startHotkeyPortal(keybind)
|
||||||
|
if err == nil {
|
||||||
|
log.Println("hotkey backend: xdg-desktop-portal")
|
||||||
|
return ch, nil
|
||||||
|
}
|
||||||
|
log.Printf("portal unavailable (%v), trying evdev", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// evdev: works on both X11 and Wayland if the user is in the 'input'
|
||||||
|
// group or systemd-logind grants seat ACLs (default on GNOME/KDE).
|
||||||
ch, err := startHotkeyEvdev(keybind)
|
ch, err := startHotkeyEvdev(keybind)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
log.Println("hotkey backend: evdev")
|
log.Println("hotkey backend: evdev")
|
||||||
@@ -180,13 +333,14 @@ func startHotkey(keybind string) (<-chan struct{}, error) {
|
|||||||
}
|
}
|
||||||
log.Printf("evdev unavailable (%v), falling back to X11", err)
|
log.Printf("evdev unavailable (%v), falling back to X11", err)
|
||||||
|
|
||||||
|
// X11 XGrabKey: works on plain X11 sessions.
|
||||||
mods, key, err2 := parseKeybind(keybind)
|
mods, key, err2 := parseKeybind(keybind)
|
||||||
if err2 != nil {
|
if err2 != nil {
|
||||||
return nil, err2
|
return nil, err2
|
||||||
}
|
}
|
||||||
hk := hotkey.New(mods, key)
|
hk := hotkey.New(mods, key)
|
||||||
if err2 := hk.Register(); err2 != nil {
|
if err2 := hk.Register(); err2 != nil {
|
||||||
return nil, fmt.Errorf("X11 hotkey also failed (%v) — on Wayland add user to 'input' group", err2)
|
return nil, fmt.Errorf("all hotkey backends failed — on Wayland without xdg-desktop-portal support, add user to 'input' group: %v", err2)
|
||||||
}
|
}
|
||||||
log.Println("hotkey backend: X11")
|
log.Println("hotkey backend: X11")
|
||||||
out := make(chan struct{})
|
out := make(chan struct{})
|
||||||
|
|||||||
Reference in New Issue
Block a user