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>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
//go:build !windows
|
||||
//go:build !windows && !linux
|
||||
|
||||
package main
|
||||
|
||||
|
||||
+188
-2
@@ -2,9 +2,22 @@
|
||||
|
||||
package main
|
||||
|
||||
import "golang.design/x/hotkey"
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
// Mod1 = Alt, Mod4 = Super/Win on most Linux desktop environments.
|
||||
"golang.design/x/hotkey"
|
||||
)
|
||||
|
||||
// modMap is used by parseKeybind (shared with tests).
|
||||
var modMap = map[string]hotkey.Modifier{
|
||||
"ctrl": hotkey.ModCtrl,
|
||||
"shift": hotkey.ModShift,
|
||||
@@ -12,3 +25,176 @@ var modMap = map[string]hotkey.Modifier{
|
||||
"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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user