3 Commits

Author SHA1 Message Date
richard fe74d20a3b Add xdg-desktop-portal GlobalShortcuts backend for Wayland (Linux)
Implements a three-tier hotkey backend chain on Linux:
1. xdg-desktop-portal GlobalShortcuts — works on Hyprland, GNOME, and KDE
   without any privilege setup; requires one bind line in hyprland.conf for Hyprland
2. evdev — fallback for users in the input group or with systemd-logind seat ACLs
3. X11 XGrabKey — last resort for plain X11 sessions

Fixes double-fire on Hyprland by filtering signals by sig.Name to guard
against Deactivated events leaking through the Activated match rule.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 20:40:18 +03:00
richard 72f3ddde5f 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>
2026-06-01 19:55:49 +03:00
richard 6c56192141 Add startup logging and Discord ready timeout
Adds log lines at each startup step so the log file is never empty on
failure. Adds a 30s timeout on the Discord ready event so a bad token or
blocked network produces a clear log message instead of a silent hang.
Falls back to %TEMP% for the log file if the exe directory isn't writable.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 19:31:21 +03:00
3 changed files with 372 additions and 11 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
//go:build !windows
//go:build !windows && !linux
package main
+342 -2
View File
@@ -2,9 +2,24 @@
package main
import "golang.design/x/hotkey"
import (
"encoding/binary"
"fmt"
"io"
"log"
"os"
"path/filepath"
"strings"
"sync"
"syscall"
"time"
"unsafe"
// Mod1 = Alt, Mod4 = Super/Win on most Linux desktop environments.
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,
@@ -12,3 +27,328 @@ var modMap = map[string]hotkey.Modifier{
"win": hotkey.Mod4,
"super": hotkey.Mod4,
}
// ── Portal (xdg-desktop-portal GlobalShortcuts) ──────────────────────────────
// toPortalTrigger converts "ctrl+alt+m" → "<Control><Alt>m".
func toPortalTrigger(keybind string) string {
parts := strings.Split(strings.ToLower(strings.TrimSpace(keybind)), "+")
mods := map[string]string{
"ctrl": "<Control>", "shift": "<Shift>",
"alt": "<Alt>", "win": "<Super>", "super": "<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
}
+27 -6
View File
@@ -8,6 +8,7 @@ import (
"os"
"path/filepath"
"strings"
"time"
"fyne.io/systray"
"github.com/bwmarrin/discordgo"
@@ -120,19 +121,34 @@ func toggleMute(dg *discordgo.Session, cfg *Config, muted bool) {
}
}
func run() {
// Log to file next to the executable so errors are visible even without a console.
func setupLogging() {
// Try exe directory first, fall back to temp dir (e.g. if installed in Program Files).
var dirs []string
if exe, err := os.Executable(); err == nil {
logPath := filepath.Join(filepath.Dir(exe), "discord-lobby-mute.log")
if f, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644); err == nil {
dirs = append(dirs, filepath.Dir(exe))
}
dirs = append(dirs, os.TempDir())
for _, dir := range dirs {
logPath := filepath.Join(dir, "discord-lobby-mute.log")
f, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
continue
}
log.SetOutput(io.MultiWriter(os.Stderr, f))
return
}
}
func run() {
setupLogging()
log.Println("starting")
cfg, err := loadConfig()
if err != nil {
log.Fatal(err)
}
log.Println("config loaded")
dg, err := discordgo.New("Bot " + cfg.Token)
if err != nil {
@@ -145,13 +161,18 @@ func run() {
close(ready)
})
log.Println("connecting to Discord...")
if err := dg.Open(); err != nil {
log.Fatalf("connect to Discord: %v", err)
}
defer dg.Close()
<-ready
log.Println("Connected to Discord.")
select {
case <-ready:
case <-time.After(30 * time.Second):
log.Fatal("timed out waiting for Discord ready — check token and network connection")
}
log.Println("connected to Discord")
keydownCh, err := startHotkey(cfg.Keybind)
if err != nil {