Files
discord-lobby-mute/keybind_linux.go
T
richard 72f3ddde5f Add evdev hotkey backend for Wayland compatibility on Linux
On Wayland, XGrabKey via XWayland is not forwarded by most compositors.
The new Linux implementation tries evdev first (works on Wayland and X11
without compositor-specific support — requires 'input' group or systemd-logind
seat ACLs, which GNOME/KDE grant automatically). Falls back to X11 XGrabKey
for plain X11 sessions or when evdev devices aren't accessible. The log now
records which backend was selected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 19:55:49 +03:00

201 lines
5.2 KiB
Go

//go:build linux
package main
import (
"encoding/binary"
"fmt"
"io"
"log"
"os"
"path/filepath"
"strings"
"sync"
"syscall"
"unsafe"
"golang.design/x/hotkey"
)
// modMap is used by parseKeybind (shared with tests).
var modMap = map[string]hotkey.Modifier{
"ctrl": hotkey.ModCtrl,
"shift": hotkey.ModShift,
"alt": hotkey.Mod1,
"win": hotkey.Mod4,
"super": hotkey.Mod4,
}
// 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}, // KEY_LEFTCTRL, KEY_RIGHTCTRL
"shift": {42, 54}, // KEY_LEFTSHIFT, KEY_RIGHTSHIFT
"alt": {56, 100}, // KEY_LEFTALT, KEY_RIGHTALT
"win": {125, 126}, // KEY_LEFTMETA, KEY_RIGHTMETA
"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 is EVIOCGBIT(EV_KEY=1, 32) — returns a 256-bit mask of supported key codes.
// Computed as _IOC(_IOC_READ=2, 'E', 0x21, 32) on Linux x86-64.
const eviocgbitKey = uintptr(0x80204521)
// hasKey reports whether the evdev device can produce the given key code.
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])))
if errno != 0 {
return false
}
return 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
}
// 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) {
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
}
// Only keep devices that can actually produce the main key.
if hasKey(f, mainKey) {
devices = append(devices, f)
} else {
f.Close()
}
}
if len(devices) == 0 {
return nil, fmt.Errorf("no accessible keyboard devices found 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 type, 2 code, 4 value = 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: // press
held[code] = true
if comboActive(held, modGroups, mainKey) {
select {
case ch <- struct{}{}:
default:
}
}
case 0: // release
delete(held, code)
}
mu.Unlock()
}
}(dev)
}
return ch, nil
}
// startHotkey tries evdev first (Wayland + X11), falls back to X11 XGrabKey.
func startHotkey(keybind string) (<-chan struct{}, error) {
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)
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("X11 hotkey also failed (%v) — on Wayland add user to 'input' group", err2)
}
log.Println("hotkey backend: X11")
out := make(chan struct{})
go func() {
for range hk.Keydown() {
out <- struct{}{}
}
close(out)
}()
return out, nil
}