diff --git a/app.go b/app.go index 4c20be8..d19acb5 100644 --- a/app.go +++ b/app.go @@ -465,6 +465,15 @@ func (a *App) SaveSettings(settings map[string]interface{}) string { a.hotkeyListener = hotkey.NewListener(val, a.StartRecording, a.StopRecording) } else { a.hotkeyListener = hotkey.NewListener(val, a.ToggleRecording, func() {}) + a.hotkeyListener.SetRegistrationErrorCallback(func(err error) { + logger.Error("Linux hotkey registration failed: %v", err) + go func() { + time.Sleep(2 * time.Second) + if a.overlay != nil { + a.overlay.Show("Shortcut registration failed. Please add a custom system shortcut calling 'wis-free-v3 --action=toggle' as a fallback.") + } + }() + }) } a.hotkeyListener.Start() } @@ -622,6 +631,15 @@ func (a *App) startupHeadless() { } else { // Linux (Wayland fallback) uses toggle mode: keydown toggles, keyup ignored a.hotkeyListener = hotkey.NewListener(a.config.Shortcut, a.ToggleRecording, func() {}) + a.hotkeyListener.SetRegistrationErrorCallback(func(err error) { + logger.Error("Linux hotkey registration failed: %v", err) + go func() { + time.Sleep(2 * time.Second) + if a.overlay != nil { + a.overlay.Show("Shortcut registration failed. Please add a custom system shortcut calling 'wis-free-v3 --action=toggle' as a fallback.") + } + }() + }) } a.hotkeyListener.Start() diff --git a/build/appicon.png b/build/appicon.png index 63617fe..2d7dd4b 100644 Binary files a/build/appicon.png and b/build/appicon.png differ diff --git a/build/bin/wis-free-v3.exe b/build/bin/wis-free-v3.exe new file mode 100644 index 0000000..1e0e3ed Binary files /dev/null and b/build/bin/wis-free-v3.exe differ diff --git a/internal/hotkey/hotkey.go b/internal/hotkey/hotkey.go index 784aa1d..4b3db96 100644 --- a/internal/hotkey/hotkey.go +++ b/internal/hotkey/hotkey.go @@ -13,13 +13,14 @@ import ( // Listener handles global hotkey events and triggers callbacks when the // configured shortcut is pressed and released. type Listener struct { - startCallback func() - stopCallback func() - isListening bool - shortcut string - hk *xhk.Hotkey - stopModPoll chan struct{} - mu sync.RWMutex + startCallback func() + stopCallback func() + registrationErrorCallback func(error) + isListening bool + shortcut string + hk *xhk.Hotkey + stopModPoll chan struct{} + mu sync.RWMutex } // NewListener creates a new hotkey listener with the specified shortcut and callbacks. @@ -34,6 +35,13 @@ func NewListener(shortcut string, onStart, onStop func()) *Listener { } } +// SetRegistrationErrorCallback sets a callback to be invoked if hotkey registration fails. +func (l *Listener) SetRegistrationErrorCallback(cb func(error)) { + l.mu.Lock() + l.registrationErrorCallback = cb + l.mu.Unlock() +} + // UpdateShortcut changes the shortcut without stopping the listener. // This allows hot-swapping the shortcut while the application is running. func (l *Listener) UpdateShortcut(shortcut string) { @@ -90,11 +98,15 @@ func (l *Listener) Start() { if err := hkToRegister.Register(); err != nil { logger.Error("Failed to register hotkey %s: %v", shortcutToRegister, err) l.mu.Lock() + cb := l.registrationErrorCallback if l.hk == hkToRegister { l.hk = nil l.isListening = false } l.mu.Unlock() + if cb != nil { + cb(err) + } return } diff --git a/internal/linux/overlay.go b/internal/linux/overlay.go index 92c3e6e..e3554d2 100644 --- a/internal/linux/overlay.go +++ b/internal/linux/overlay.go @@ -6,6 +6,7 @@ import ( "os/exec" "strings" "sync" + "time" "wis-free-v3/internal/logger" ) @@ -14,8 +15,9 @@ import ( // (notify-send). There is no full-screen overlay on Linux to avoid a GTK/Cairo // dependency beyond what Wails already pulls in. type linuxOverlay struct { - mu sync.Mutex - lastMsg string + mu sync.Mutex + lastMsg string + lastUpdate time.Time } func NewOverlay() *linuxOverlay { @@ -75,6 +77,13 @@ func (o *linuxOverlay) SetVolume(level float64) { return } o.mu.Lock() + now := time.Now() + // Throttle to at most once per 300ms to prevent spawning notify-send processes at ~100Hz (buffer rate) + if now.Sub(o.lastUpdate) < 300*time.Millisecond { + o.mu.Unlock() + return + } + o.lastUpdate = now base := o.lastMsg o.mu.Unlock() if strings.TrimSpace(base) == "" { diff --git a/internal/ui/tray/icon.png b/internal/ui/tray/icon.png index 63617fe..2d7dd4b 100644 Binary files a/internal/ui/tray/icon.png and b/internal/ui/tray/icon.png differ diff --git a/internal/ui/tray/tray.go b/internal/ui/tray/tray.go index 0cd94fc..bf2ccaa 100644 --- a/internal/ui/tray/tray.go +++ b/internal/ui/tray/tray.go @@ -7,9 +7,13 @@ import ( _ "embed" "encoding/binary" "fmt" + "image" + "image/color" + "image/png" "os" "runtime" "strings" + "sync" "wis-free-v3/internal/config" "wis-free-v3/internal/logger" @@ -45,6 +49,7 @@ var trayLabel = "wis-free-v3" var statusMenuItem *systray.MenuItem var triggerCountItem *systray.MenuItem var triggerCount int +var iconsInitOnce sync.Once func appDisplayName(app App) string { v := app.Version() @@ -56,6 +61,7 @@ func appDisplayName(app App) string { // onReady is called when the system tray is ready to be configured. func onReady(app App) { + initDynamicIcons() trayLabel = appDisplayName(app) // Configure tray icon and tooltip @@ -142,6 +148,7 @@ func buildTooltip(app App) string { } func getDefaultIcon() []byte { + initDynamicIcons() if runtime.GOOS == "linux" { return iconPNGData } @@ -198,6 +205,7 @@ func UpdateStatus(status string) { statusMenuItem.SetTitle("Status: " + status) systray.SetTooltip(trayLabel + " - " + status) + initDynamicIcons() if strings.Contains(status, "Recording") { systray.SetIcon(iconRecordingData) } else if strings.Contains(status, "Transcribing") { @@ -220,3 +228,76 @@ func IncrementTriggerCount() { func onExit() { logger.Info("System tray terminated") } + +func initDynamicIcons() { + iconsInitOnce.Do(func() { + white := color.RGBA{R: 255, G: 255, B: 255, A: 255} + red := color.RGBA{R: 255, G: 59, B: 48, A: 255} + yellow := color.RGBA{R: 255, G: 204, B: 0, A: 255} + + iconPNGData = createMicPNG(white) + iconRecordingData = createMicPNG(red) + iconTranscribingData = createMicPNG(yellow) + }) +} + +func createMicPNG(c color.Color) []byte { + img := image.NewRGBA(image.Rect(0, 0, 64, 64)) + // All pixels are transparent by default (new RGBA starts with 0 alpha). + + // Let's draw the microphone parts + for y := 0; y < 64; y++ { + for x := 0; x < 64; x++ { + drawPixel := false + + // 1. Capsule Body (Rounded Rectangle) + // Capsule center is X=32, Y=25. Width=14 (radius 7), height of straight part = 10 (Y from 20 to 30) + if x >= 25 && x <= 39 && y >= 20 && y <= 30 { + drawPixel = true + } else if y < 20 { + // Top cap: center (32, 20), radius 7 + dx := float64(x - 32) + dy := float64(y - 20) + if dx*dx+dy*dy <= 49 { // 7^2 + drawPixel = true + } + } else if y > 30 && y <= 37 { + // Bottom cap: center (32, 30), radius 7 + dx := float64(x - 32) + dy := float64(y - 30) + if dx*dx+dy*dy <= 49 { + drawPixel = true + } + } + + // 2. U-stand + // Center of U-stand circle is (32, 25). + // Outer radius = 15, inner radius = 12 (thickness 3) + // Only draw for Y >= 25 and Y <= 40 + dx := float64(x - 32) + dy := float64(y - 25) + distSq := dx*dx + dy*dy + if y >= 25 && y <= 40 && distSq >= 144 && distSq <= 225 { // 12^2 to 15^2 + drawPixel = true + } + + // 3. Stem (Vertical line from Y=40 to 50, X=31 to 33) + if x >= 31 && x <= 33 && y >= 40 && y <= 50 { + drawPixel = true + } + + // 4. Base (Horizontal line at Y=50 to 52, X=20 to 44) + if x >= 20 && x <= 44 && y >= 50 && y <= 52 { + drawPixel = true + } + + if drawPixel { + img.Set(x, y, c) + } + } + } + + var buf bytes.Buffer + _ = png.Encode(&buf, img) + return buf.Bytes() +} diff --git a/internal/xhotkey/hotkey_linux_portal.go b/internal/xhotkey/hotkey_linux_portal.go index 90934d9..19c5b2e 100644 --- a/internal/xhotkey/hotkey_linux_portal.go +++ b/internal/xhotkey/hotkey_linux_portal.go @@ -135,6 +135,7 @@ func portalKeySpecName(key Key) string { func portalWaitRequest(conn *dbus.Conn, reqPath dbus.ObjectPath) (uint32, map[string]dbus.Variant, error) { ch := make(chan *dbus.Signal, 8) conn.Signal(ch) + defer conn.RemoveSignal(ch) rule := fmt.Sprintf( "type='signal',path='%s',interface='%s',member='Response'", string(reqPath), ifaceRequest, @@ -318,6 +319,7 @@ func (hk *Hotkey) portalSignalLoop() { ch := make(chan *dbus.Signal, 32) conn.Signal(ch) + defer conn.RemoveSignal(ch) rule := fmt.Sprintf( "type='signal',interface='%s'", ifaceGlobalShortcuts, diff --git a/main.go b/main.go index 182ba3f..a65e71f 100644 --- a/main.go +++ b/main.go @@ -93,6 +93,7 @@ func main() { } // Clean up resources on exit + cleanupSecondInstanceListener() releaseInstanceLock() } diff --git a/main_instance_stub.go b/main_instance_stub.go index d1fc59c..3bf8331 100644 --- a/main_instance_stub.go +++ b/main_instance_stub.go @@ -24,3 +24,4 @@ func tryNotifyRunningInstanceAction(action string) bool { } func runSecondInstanceListener() {} +func cleanupSecondInstanceListener() {} diff --git a/main_instance_unix.go b/main_instance_unix.go index fb6a0bc..af9af83 100644 --- a/main_instance_unix.go +++ b/main_instance_unix.go @@ -112,3 +112,9 @@ func runSecondInstanceListener() { } }() } + +func cleanupSecondInstanceListener() { + if path, err := instanceSocketPath(); err == nil { + _ = os.Remove(path) + } +}