2 Commits

Author SHA1 Message Date
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
8 changed files with 250 additions and 26 deletions
+3 -1
View File
@@ -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
)
+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/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=
+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
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
}
+55 -18
View File
@@ -3,14 +3,16 @@ package main
import (
"encoding/json"
"fmt"
"io"
"log"
"os"
"path/filepath"
"strings"
"time"
"fyne.io/systray"
"github.com/bwmarrin/discordgo"
"golang.design/x/hotkey"
"golang.design/x/mainthread"
)
type Config struct {
@@ -107,25 +109,46 @@ 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 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() {
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 {
@@ -138,35 +161,49 @@ 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
fmt.Println("Connected to Discord.")
mods, key, err := parseKeybind(cfg.Keybind)
if err != nil {
log.Fatalf("keybind: %v", err)
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")
hk := hotkey.New(mods, key)
if err := hk.Register(); err != nil {
keydownCh, err := startHotkey(cfg.Keybind)
if 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)
go func() {
muted := false
for range hk.Keydown() {
for range keydownCh {
muted = !muted
if muted {
fmt.Print("Muting all users... ")
log.Print("Muting all users...")
} else {
fmt.Print("Unmuting all users... ")
log.Print("Unmuting all users...")
}
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() }