diff --git a/hotkey_notwindows.go b/hotkey_notwindows.go index 5b265f2..822ddf0 100644 --- a/hotkey_notwindows.go +++ b/hotkey_notwindows.go @@ -1,4 +1,4 @@ -//go:build !windows +//go:build !windows && !linux package main diff --git a/keybind_linux.go b/keybind_linux.go index 980297f..2062c6c 100644 --- a/keybind_linux.go +++ b/keybind_linux.go @@ -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 +}