4 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
richard 75c113909d Add system tray icon, file logging, and fix Windows hotkey conflict
- Adds fyne.io/systray with a programmatic monkey face icon and Quit menu item
- Logs to discord-lobby-mute.log next to the exe so startup errors are visible
  without a console window
- Windows now uses raw Win32 RegisterHotKey in a locked goroutine instead of
  golang.design/x/hotkey, avoiding a deadlock where systray and the hotkey
  library both try to own the main thread message loop
- Build with: GOOS=windows GOARCH=amd64 go build -ldflags "-H windowsgui" -o discord-lobby-mute.exe .

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 19:24:20 +03:00
9 changed files with 592 additions and 28 deletions
+3 -1
View File
@@ -9,7 +9,9 @@ require (
) )
require ( require (
fyne.io/systray v1.12.1 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/gorilla/websocket v1.4.2 // indirect github.com/gorilla/websocket v1.4.2 // indirect
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b // indirect golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b // indirect
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 // indirect golang.org/x/sys v0.15.0 // indirect
) )
+6
View File
@@ -1,5 +1,9 @@
fyne.io/systray v1.12.1 h1:ygBD6aZXwiOmZoY5N+ukbH9pih0Kq6fYgVeMYbr5skQ=
fyne.io/systray v1.12.1/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno= github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno=
github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
golang.design/x/hotkey v0.4.1 h1:zLP/2Pztl4WjyxURdW84GoZ5LUrr6hr69CzJFJ5U1go= golang.design/x/hotkey v0.4.1 h1:zLP/2Pztl4WjyxURdW84GoZ5LUrr6hr69CzJFJ5U1go=
@@ -12,6 +16,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/sys v0.0.0-20201022201747-fb209a7c41cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201022201747-fb209a7c41cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+24
View File
@@ -0,0 +1,24 @@
//go:build !windows && !linux
package main
import "golang.design/x/hotkey"
func startHotkey(keybind string) (<-chan struct{}, error) {
mods, key, err := parseKeybind(keybind)
if err != nil {
return nil, err
}
hk := hotkey.New(mods, key)
if err := hk.Register(); err != nil {
return nil, err
}
out := make(chan struct{})
go func() {
for range hk.Keydown() {
out <- struct{}{}
}
close(out)
}()
return out, nil
}
+68
View File
@@ -0,0 +1,68 @@
package main
import (
"bytes"
"image"
"image/color"
"image/png"
)
func monkeyIcon() []byte {
const size = 32
img := image.NewRGBA(image.Rect(0, 0, size, size))
brown := color.RGBA{110, 72, 38, 255}
tan := color.RGBA{210, 175, 130, 255}
pink := color.RGBA{190, 130, 110, 255}
dark := color.RGBA{15, 8, 3, 255}
white := color.RGBA{255, 255, 255, 255}
// Ears drawn first so the head circle overlaps the inner edge naturally.
fillCircle(img, 4, 13, 5, brown)
fillCircle(img, 28, 13, 5, brown)
fillCircle(img, 4, 13, 3, pink)
fillCircle(img, 28, 13, 3, pink)
fillCircle(img, 16, 15, 13, brown) // head
fillEllipse(img, 16, 21, 7, 4, tan) // muzzle
// Eyes: light surround, dark iris, white highlight.
fillCircle(img, 11, 12, 3, tan)
fillCircle(img, 21, 12, 3, tan)
fillCircle(img, 11, 12, 2, dark)
fillCircle(img, 21, 12, 2, dark)
fillCircle(img, 12, 11, 1, white)
fillCircle(img, 22, 11, 1, white)
// Nostrils.
fillCircle(img, 14, 21, 1, dark)
fillCircle(img, 18, 21, 1, dark)
var buf bytes.Buffer
_ = png.Encode(&buf, img)
return buf.Bytes()
}
func fillCircle(img *image.RGBA, cx, cy, r int, c color.RGBA) {
for y := cy - r; y <= cy+r; y++ {
for x := cx - r; x <= cx+r; x++ {
dx, dy := x-cx, y-cy
if dx*dx+dy*dy <= r*r {
img.SetRGBA(x, y, c)
}
}
}
}
func fillEllipse(img *image.RGBA, cx, cy, rx, ry int, c color.RGBA) {
for y := cy - ry; y <= cy+ry; y++ {
for x := cx - rx; x <= cx+rx; x++ {
dx := float64(x-cx) / float64(rx)
dy := float64(y-cy) / float64(ry)
if dx*dx+dy*dy <= 1.0 {
img.SetRGBA(x, y, c)
}
}
}
}
+342 -2
View File
@@ -2,9 +2,24 @@
package main 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{ var modMap = map[string]hotkey.Modifier{
"ctrl": hotkey.ModCtrl, "ctrl": hotkey.ModCtrl,
"shift": hotkey.ModShift, "shift": hotkey.ModShift,
@@ -12,3 +27,328 @@ var modMap = map[string]hotkey.Modifier{
"win": hotkey.Mod4, "win": hotkey.Mod4,
"super": 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
}
+76 -1
View File
@@ -2,7 +2,14 @@
package main package main
import "golang.design/x/hotkey" import (
"fmt"
"runtime"
"syscall"
"unsafe"
"golang.design/x/hotkey"
)
var modMap = map[string]hotkey.Modifier{ var modMap = map[string]hotkey.Modifier{
"ctrl": hotkey.ModCtrl, "ctrl": hotkey.ModCtrl,
@@ -11,3 +18,71 @@ var modMap = map[string]hotkey.Modifier{
"win": hotkey.ModWin, "win": hotkey.ModWin,
"super": hotkey.ModWin, "super": hotkey.ModWin,
} }
var (
user32 = syscall.NewLazyDLL("user32.dll")
procRegisterHotKey = user32.NewProc("RegisterHotKey")
procGetMessageW = user32.NewProc("GetMessageW")
)
// winMsg mirrors the Win32 MSG struct.
type winMsg struct {
hwnd uintptr
message uint32
wParam uintptr
lParam uintptr
time uint32
ptX int32
ptY int32
}
func startHotkey(keybind string) (<-chan struct{}, error) {
mods, key, err := parseKeybind(keybind)
if err != nil {
return nil, err
}
// hotkey.Modifier and hotkey.Key values on Windows are the native Win32
// MOD_* and VK_* constants, so they can be passed directly to RegisterHotKey.
var modFlags uintptr
for _, m := range mods {
modFlags |= uintptr(m)
}
vk := uintptr(key)
ch := make(chan struct{}, 1)
ready := make(chan error, 1)
go func() {
runtime.LockOSThread()
// Intentionally no defer UnlockOSThread — this goroutine owns its OS
// thread for the lifetime of the app to keep the hotkey message queue alive.
ret, _, e := procRegisterHotKey.Call(0, 1, modFlags, vk)
if ret == 0 {
ready <- fmt.Errorf("RegisterHotKey: %w", e)
return
}
close(ready)
const wmHotkey = 0x0312
var msg winMsg
for {
r, _, _ := procGetMessageW.Call(
uintptr(unsafe.Pointer(&msg)), 0, wmHotkey, wmHotkey,
)
if r == 0 || r == ^uintptr(0) { // WM_QUIT or error
return
}
select {
case ch <- struct{}{}:
default:
}
}
}()
if err := <-ready; err != nil {
return nil, err
}
return ch, nil
}
+61 -24
View File
@@ -3,14 +3,16 @@ package main
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
"fyne.io/systray"
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
"golang.design/x/hotkey" "golang.design/x/hotkey"
"golang.design/x/mainthread"
) )
type Config struct { type Config struct {
@@ -107,25 +109,46 @@ func toggleMute(dg *discordgo.Session, cfg *Config, muted bool) {
for _, vs := range guild.VoiceStates { for _, vs := range guild.VoiceStates {
if vs.ChannelID == cfg.ChannelID && !exempt[vs.UserID] { if vs.ChannelID == cfg.ChannelID && !exempt[vs.UserID] {
if err := dg.GuildMemberMute(cfg.GuildID, vs.UserID, muted); err != nil { if err := dg.GuildMemberMute(cfg.GuildID, vs.UserID, muted); err != nil {
log.Printf(" warning: user %s: %v", vs.UserID, err) log.Printf("warning: user %s: %v", vs.UserID, err)
} }
count++ count++
} }
} }
if count == 0 { if count == 0 {
fmt.Println(" (no users found in channel)") log.Println("(no users found in channel)")
} else { } else {
fmt.Printf(" %d user(s) affected\n", count) log.Printf("%d user(s) affected", count)
} }
} }
func main() { mainthread.Init(run) } 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 {
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() { func run() {
setupLogging()
log.Println("starting")
cfg, err := loadConfig() cfg, err := loadConfig()
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
log.Println("config loaded")
dg, err := discordgo.New("Bot " + cfg.Token) dg, err := discordgo.New("Bot " + cfg.Token)
if err != nil { if err != nil {
@@ -138,35 +161,49 @@ func run() {
close(ready) close(ready)
}) })
log.Println("connecting to Discord...")
if err := dg.Open(); err != nil { if err := dg.Open(); err != nil {
log.Fatalf("connect to Discord: %v", err) log.Fatalf("connect to Discord: %v", err)
} }
defer dg.Close() defer dg.Close()
<-ready select {
fmt.Println("Connected to Discord.") case <-ready:
case <-time.After(30 * time.Second):
mods, key, err := parseKeybind(cfg.Keybind) log.Fatal("timed out waiting for Discord ready — check token and network connection")
if err != nil {
log.Fatalf("keybind: %v", err)
} }
log.Println("connected to Discord")
hk := hotkey.New(mods, key) keydownCh, err := startHotkey(cfg.Keybind)
if err := hk.Register(); err != nil { if err != nil {
log.Fatalf("register hotkey %q: %v", cfg.Keybind, err) log.Fatalf("register hotkey %q: %v", cfg.Keybind, err)
} }
defer hk.Unregister()
fmt.Printf("Listening on %s — toggles server mute for channel %s\n", cfg.Keybind, cfg.ChannelID) log.Printf("Listening on %s — toggles server mute for channel %s", cfg.Keybind, cfg.ChannelID)
muted := false go func() {
for range hk.Keydown() { muted := false
muted = !muted for range keydownCh {
if muted { muted = !muted
fmt.Print("Muting all users... ") if muted {
} else { log.Print("Muting all users...")
fmt.Print("Unmuting all users... ") } else {
log.Print("Unmuting all users...")
}
toggleMute(dg, cfg, muted)
} }
toggleMute(dg, cfg, muted) }()
}
systray.Run(
func() {
systray.SetIcon(monkeyIcon())
systray.SetTooltip("Discord Lobby Mute — " + cfg.Keybind)
mQuit := systray.AddMenuItem("Quit", "Quit Discord Lobby Mute")
go func() {
<-mQuit.ClickedCh
systray.Quit()
}()
},
func() {},
)
} }
+7
View File
@@ -0,0 +1,7 @@
//go:build !windows
package main
import "golang.design/x/mainthread"
func main() { mainthread.Init(run) }
+5
View File
@@ -0,0 +1,5 @@
//go:build windows
package main
func main() { run() }