72f3ddde5f
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>
201 lines
5.2 KiB
Go
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
|
|
}
|