feat: add hotkey registration error handling and dynamic icon generation

This commit is contained in:
jahruz67
2026-05-19 16:10:40 -07:00
parent b0f4ea3e6f
commit b6ae530876
11 changed files with 139 additions and 9 deletions
+18
View File
@@ -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()
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 373 B

Binary file not shown.
+12
View File
@@ -15,6 +15,7 @@ import (
type Listener struct {
startCallback func()
stopCallback func()
registrationErrorCallback func(error)
isListening bool
shortcut string
hk *xhk.Hotkey
@@ -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
}
+9
View File
@@ -6,6 +6,7 @@ import (
"os/exec"
"strings"
"sync"
"time"
"wis-free-v3/internal/logger"
)
@@ -16,6 +17,7 @@ import (
type linuxOverlay struct {
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) == "" {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 373 B

+81
View File
@@ -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()
}
+2
View File
@@ -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,
+1
View File
@@ -93,6 +93,7 @@ func main() {
}
// Clean up resources on exit
cleanupSecondInstanceListener()
releaseInstanceLock()
}
+1
View File
@@ -24,3 +24,4 @@ func tryNotifyRunningInstanceAction(action string) bool {
}
func runSecondInstanceListener() {}
func cleanupSecondInstanceListener() {}
+6
View File
@@ -112,3 +112,9 @@ func runSecondInstanceListener() {
}
}()
}
func cleanupSecondInstanceListener() {
if path, err := instanceSocketPath(); err == nil {
_ = os.Remove(path)
}
}