//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" → "m". func toPortalTrigger(keybind string) string { parts := strings.Split(strings.ToLower(strings.TrimSpace(keybind)), "+") mods := map[string]string{ "ctrl": "", "shift": "", "alt": "", "win": "", "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 }