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>
This commit is contained in:
2026-06-01 19:24:20 +03:00
parent 4f94c4f20b
commit 75c113909d
8 changed files with 229 additions and 26 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
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)
}
}
}
}
+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
}
+40 -24
View File
@@ -3,14 +3,15 @@ package main
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"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,21 +108,27 @@ 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 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() cfg, err := loadConfig()
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
@@ -144,29 +151,38 @@ func run() {
defer dg.Close() defer dg.Close()
<-ready <-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 { 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) 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() }