Files
discord-lobby-mute/keybind_linux.go
T
richard fe74d20a3b 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>
2026-06-01 20:40:18 +03:00

355 lines
10 KiB
Go

//go:build linux
package main
import (
"encoding/binary"
"fmt"
"io"
"log"
"os"
"path/filepath"
"strings"
"sync"
"syscall"
"time"
"unsafe"
dbus "github.com/godbus/dbus/v5"
"golang.design/x/hotkey"
)
// modMap is used by parseKeybind (called from main.go and tests).
var modMap = map[string]hotkey.Modifier{
"ctrl": hotkey.ModCtrl,
"shift": hotkey.ModShift,
"alt": hotkey.Mod1,
"win": 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
var evdevKeyMap = map[string]uint16{
"a": 30, "b": 48, "c": 46, "d": 32, "e": 18, "f": 33, "g": 34, "h": 35,
"i": 23, "j": 36, "k": 37, "l": 38, "m": 50, "n": 49, "o": 24, "p": 25,
"q": 16, "r": 19, "s": 31, "t": 20, "u": 22, "v": 47, "w": 17, "x": 45,
"y": 21, "z": 44,
"0": 11, "1": 2, "2": 3, "3": 4, "4": 5, "5": 6, "6": 7, "7": 8, "8": 9, "9": 10,
"f1": 59, "f2": 60, "f3": 61, "f4": 62, "f5": 63, "f6": 64, "f7": 65, "f8": 66,
"f9": 67, "f10": 68, "f11": 87, "f12": 88,
"f13": 183, "f14": 184, "f15": 185, "f16": 186,
"f17": 187, "f18": 188, "f19": 189, "f20": 190,
"space": 57, "return": 28, "enter": 28, "escape": 1, "esc": 1,
"tab": 15, "delete": 111, "left": 105, "right": 106, "up": 103, "down": 108,
}
// evdevModGroups maps each modifier to its [leftKey, rightKey] evdev codes.
var evdevModGroups = map[string][]uint16{
"ctrl": {29, 97},
"shift": {42, 54},
"alt": {56, 100},
"win": {125, 126},
"super": {125, 126},
}
func parseEvdevKeybind(s string) (modGroups [][]uint16, mainKey uint16, err error) {
parts := strings.Split(strings.ToLower(strings.TrimSpace(s)), "+")
if len(parts) == 0 {
return nil, 0, fmt.Errorf("empty keybind")
}
for _, p := range parts[:len(parts)-1] {
p = strings.TrimSpace(p)
g, ok := evdevModGroups[p]
if !ok {
return nil, 0, fmt.Errorf("unknown modifier %q", p)
}
modGroups = append(modGroups, g)
}
keyStr := strings.TrimSpace(parts[len(parts)-1])
k, ok := evdevKeyMap[keyStr]
if !ok {
return nil, 0, fmt.Errorf("unknown key %q", keyStr)
}
return modGroups, k, nil
}
// eviocgbitKey = EVIOCGBIT(EV_KEY=1, 32): 256-bit mask of supported key codes.
// _IOC(_IOC_READ=2, 'E', 0x21, 32) on Linux x86-64.
const eviocgbitKey = uintptr(0x80204521)
func hasKey(f *os.File, code uint16) bool {
if code >= 256 {
return false
}
var bits [32]byte
_, _, errno := syscall.Syscall(syscall.SYS_IOCTL, f.Fd(), eviocgbitKey, uintptr(unsafe.Pointer(&bits[0])))
return errno == 0 && bits[code/8]&(1<<(code%8)) != 0
}
func comboActive(held map[uint16]bool, modGroups [][]uint16, mainKey uint16) bool {
if !held[mainKey] {
return false
}
for _, group := range modGroups {
found := false
for _, code := range group {
if held[code] {
found = true
break
}
}
if !found {
return false
}
}
return true
}
func startHotkeyEvdev(keybind string) (<-chan struct{}, error) {
modGroups, mainKey, err := parseEvdevKeybind(keybind)
if err != nil {
return nil, err
}
paths, _ := filepath.Glob("/dev/input/event*")
var devices []*os.File
for _, p := range paths {
f, err := os.Open(p)
if err != nil {
continue
}
if hasKey(f, mainKey) {
devices = append(devices, f)
} else {
f.Close()
}
}
if len(devices) == 0 {
return nil, fmt.Errorf("no accessible keyboard devices in /dev/input (add user to 'input' group)")
}
ch := make(chan struct{}, 1)
var mu sync.Mutex
held := make(map[uint16]bool)
for _, dev := range devices {
go func(f *os.File) {
defer f.Close()
// input_event on 64-bit Linux: 8+8 bytes timeval + 2+2+4 = 24 bytes
buf := make([]byte, 24)
for {
if _, err := io.ReadFull(f, buf); err != nil {
return
}
if binary.LittleEndian.Uint16(buf[16:18]) != 1 { // EV_KEY
continue
}
code := binary.LittleEndian.Uint16(buf[18:20])
value := int32(binary.LittleEndian.Uint32(buf[20:24]))
mu.Lock()
switch value {
case 1:
held[code] = true
if comboActive(held, modGroups, mainKey) {
select {
case ch <- struct{}{}:
default:
}
}
case 0:
delete(held, code)
}
mu.Unlock()
}
}(dev)
}
return ch, nil
}
// ── startHotkey: portal → evdev → X11 ────────────────────────────────────────
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)
if err == nil {
log.Println("hotkey backend: evdev")
return ch, nil
}
log.Printf("evdev unavailable (%v), falling back to X11", err)
// X11 XGrabKey: works on plain X11 sessions.
mods, key, err2 := parseKeybind(keybind)
if err2 != nil {
return nil, err2
}
hk := hotkey.New(mods, key)
if err2 := hk.Register(); err2 != nil {
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")
out := make(chan struct{})
go func() {
for range hk.Keydown() {
out <- struct{}{}
}
close(out)
}()
return out, nil
}