From 75c113909df97e85b4293d62be83c941bb64f710 Mon Sep 17 00:00:00 2001 From: Richard Aasa Date: Mon, 1 Jun 2026 19:24:20 +0300 Subject: [PATCH] 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 --- go.mod | 4 ++- go.sum | 6 ++++ hotkey_notwindows.go | 24 ++++++++++++++ icon.go | 68 ++++++++++++++++++++++++++++++++++++++ keybind_windows.go | 77 +++++++++++++++++++++++++++++++++++++++++++- main.go | 64 ++++++++++++++++++++++-------------- main_other.go | 7 ++++ main_windows.go | 5 +++ 8 files changed, 229 insertions(+), 26 deletions(-) create mode 100644 hotkey_notwindows.go create mode 100644 icon.go create mode 100644 main_other.go create mode 100644 main_windows.go diff --git a/go.mod b/go.mod index 896c51f..7e980ad 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,9 @@ 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 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 ) diff --git a/go.sum b/go.sum index d4f70df..093101a 100644 --- a/go.sum +++ b/go.sum @@ -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/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/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 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-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw= 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/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/hotkey_notwindows.go b/hotkey_notwindows.go new file mode 100644 index 0000000..5b265f2 --- /dev/null +++ b/hotkey_notwindows.go @@ -0,0 +1,24 @@ +//go:build !windows + +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 +} diff --git a/icon.go b/icon.go new file mode 100644 index 0000000..a73d0a0 --- /dev/null +++ b/icon.go @@ -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) + } + } + } +} diff --git a/keybind_windows.go b/keybind_windows.go index d408356..86112e8 100644 --- a/keybind_windows.go +++ b/keybind_windows.go @@ -2,7 +2,14 @@ package main -import "golang.design/x/hotkey" +import ( + "fmt" + "runtime" + "syscall" + "unsafe" + + "golang.design/x/hotkey" +) var modMap = map[string]hotkey.Modifier{ "ctrl": hotkey.ModCtrl, @@ -11,3 +18,71 @@ var modMap = map[string]hotkey.Modifier{ "win": 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 +} diff --git a/main.go b/main.go index 069a0d8..4c0d4ed 100644 --- a/main.go +++ b/main.go @@ -3,14 +3,15 @@ package main import ( "encoding/json" "fmt" + "io" "log" "os" "path/filepath" "strings" + "fyne.io/systray" "github.com/bwmarrin/discordgo" "golang.design/x/hotkey" - "golang.design/x/mainthread" ) type Config struct { @@ -107,21 +108,27 @@ func toggleMute(dg *discordgo.Session, cfg *Config, muted bool) { for _, vs := range guild.VoiceStates { if vs.ChannelID == cfg.ChannelID && !exempt[vs.UserID] { 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++ } } if count == 0 { - fmt.Println(" (no users found in channel)") + log.Println("(no users found in channel)") } else { - fmt.Printf(" %d user(s) affected\n", count) + log.Printf("%d user(s) affected", count) } } -func main() { mainthread.Init(run) } - func run() { + // Log to file next to the executable so errors are visible even without a console. + 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 { + log.SetOutput(io.MultiWriter(os.Stderr, f)) + } + } + cfg, err := loadConfig() if err != nil { log.Fatal(err) @@ -144,29 +151,38 @@ func run() { defer dg.Close() <-ready - fmt.Println("Connected to Discord.") + log.Println("Connected to Discord.") - mods, key, err := parseKeybind(cfg.Keybind) + keydownCh, err := startHotkey(cfg.Keybind) if err != nil { - log.Fatalf("keybind: %v", err) - } - - hk := hotkey.New(mods, key) - if err := hk.Register(); err != nil { 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 - for range hk.Keydown() { - muted = !muted - if muted { - fmt.Print("Muting all users... ") - } else { - fmt.Print("Unmuting all users... ") + go func() { + muted := false + for range keydownCh { + muted = !muted + if muted { + log.Print("Muting 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() {}, + ) } diff --git a/main_other.go b/main_other.go new file mode 100644 index 0000000..49308c6 --- /dev/null +++ b/main_other.go @@ -0,0 +1,7 @@ +//go:build !windows + +package main + +import "golang.design/x/mainthread" + +func main() { mainthread.Init(run) } diff --git a/main_windows.go b/main_windows.go new file mode 100644 index 0000000..92d6b53 --- /dev/null +++ b/main_windows.go @@ -0,0 +1,5 @@ +//go:build windows + +package main + +func main() { run() }