diff --git a/keybind_linux.go b/keybind_linux.go index 2062c6c..e42613f 100644 --- a/keybind_linux.go +++ b/keybind_linux.go @@ -12,12 +12,14 @@ import ( "strings" "sync" "syscall" + "time" "unsafe" + dbus "github.com/godbus/dbus/v5" "golang.design/x/hotkey" ) -// modMap is used by parseKeybind (shared with tests). +// modMap is used by parseKeybind (called from main.go and tests). var modMap = map[string]hotkey.Modifier{ "ctrl": hotkey.ModCtrl, "shift": hotkey.ModShift, @@ -26,6 +28,152 @@ var modMap = map[string]hotkey.Modifier{ "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, @@ -43,10 +191,10 @@ var evdevKeyMap = map[string]uint16{ // 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 + "ctrl": {29, 97}, + "shift": {42, 54}, + "alt": {56, 100}, + "win": {125, 126}, "super": {125, 126}, } @@ -71,21 +219,17 @@ func parseEvdevKeybind(s string) (modGroups [][]uint16, mainKey uint16, err erro 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. +// 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) -// 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 + return errno == 0 && bits[code/8]&(1<<(code%8)) != 0 } func comboActive(held map[uint16]bool, modGroups [][]uint16, mainKey uint16) bool { @@ -107,14 +251,11 @@ func comboActive(held map[uint16]bool, modGroups [][]uint16, mainKey uint16) boo 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 { @@ -122,7 +263,6 @@ func startHotkeyEvdev(keybind string) (<-chan struct{}, error) { if err != nil { continue } - // Only keep devices that can actually produce the main key. if hasKey(f, mainKey) { devices = append(devices, f) } else { @@ -130,17 +270,15 @@ func startHotkeyEvdev(keybind string) (<-chan struct{}, error) { } } if len(devices) == 0 { - return nil, fmt.Errorf("no accessible keyboard devices found in /dev/input — add user to 'input' group") + 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 type, 2 code, 4 value = 24 bytes + // 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 { @@ -153,7 +291,7 @@ func startHotkeyEvdev(keybind string) (<-chan struct{}, error) { value := int32(binary.LittleEndian.Uint32(buf[20:24])) mu.Lock() switch value { - case 1: // press + case 1: held[code] = true if comboActive(held, modGroups, mainKey) { select { @@ -161,7 +299,7 @@ func startHotkeyEvdev(keybind string) (<-chan struct{}, error) { default: } } - case 0: // release + case 0: delete(held, code) } mu.Unlock() @@ -171,8 +309,23 @@ func startHotkeyEvdev(keybind string) (<-chan struct{}, error) { return ch, nil } -// startHotkey tries evdev first (Wayland + X11), falls back to X11 XGrabKey. +// ── 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") @@ -180,13 +333,14 @@ func startHotkey(keybind string) (<-chan struct{}, error) { } 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("X11 hotkey also failed (%v) — on Wayland add user to 'input' group", err2) + 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{})