mirror of
https://github.com/jahruz67/wisp-open.git
synced 2026-08-08 18:14:08 +00:00
feat: add hotkey registration error handling and dynamic icon generation
This commit is contained in:
@@ -465,6 +465,15 @@ func (a *App) SaveSettings(settings map[string]interface{}) string {
|
|||||||
a.hotkeyListener = hotkey.NewListener(val, a.StartRecording, a.StopRecording)
|
a.hotkeyListener = hotkey.NewListener(val, a.StartRecording, a.StopRecording)
|
||||||
} else {
|
} else {
|
||||||
a.hotkeyListener = hotkey.NewListener(val, a.ToggleRecording, func() {})
|
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()
|
a.hotkeyListener.Start()
|
||||||
}
|
}
|
||||||
@@ -622,6 +631,15 @@ func (a *App) startupHeadless() {
|
|||||||
} else {
|
} else {
|
||||||
// Linux (Wayland fallback) uses toggle mode: keydown toggles, keyup ignored
|
// Linux (Wayland fallback) uses toggle mode: keydown toggles, keyup ignored
|
||||||
a.hotkeyListener = hotkey.NewListener(a.config.Shortcut, a.ToggleRecording, func() {})
|
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()
|
a.hotkeyListener.Start()
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 373 B |
Binary file not shown.
@@ -13,13 +13,14 @@ import (
|
|||||||
// Listener handles global hotkey events and triggers callbacks when the
|
// Listener handles global hotkey events and triggers callbacks when the
|
||||||
// configured shortcut is pressed and released.
|
// configured shortcut is pressed and released.
|
||||||
type Listener struct {
|
type Listener struct {
|
||||||
startCallback func()
|
startCallback func()
|
||||||
stopCallback func()
|
stopCallback func()
|
||||||
isListening bool
|
registrationErrorCallback func(error)
|
||||||
shortcut string
|
isListening bool
|
||||||
hk *xhk.Hotkey
|
shortcut string
|
||||||
stopModPoll chan struct{}
|
hk *xhk.Hotkey
|
||||||
mu sync.RWMutex
|
stopModPoll chan struct{}
|
||||||
|
mu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewListener creates a new hotkey listener with the specified shortcut and callbacks.
|
// 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.
|
// UpdateShortcut changes the shortcut without stopping the listener.
|
||||||
// This allows hot-swapping the shortcut while the application is running.
|
// This allows hot-swapping the shortcut while the application is running.
|
||||||
func (l *Listener) UpdateShortcut(shortcut string) {
|
func (l *Listener) UpdateShortcut(shortcut string) {
|
||||||
@@ -90,11 +98,15 @@ func (l *Listener) Start() {
|
|||||||
if err := hkToRegister.Register(); err != nil {
|
if err := hkToRegister.Register(); err != nil {
|
||||||
logger.Error("Failed to register hotkey %s: %v", shortcutToRegister, err)
|
logger.Error("Failed to register hotkey %s: %v", shortcutToRegister, err)
|
||||||
l.mu.Lock()
|
l.mu.Lock()
|
||||||
|
cb := l.registrationErrorCallback
|
||||||
if l.hk == hkToRegister {
|
if l.hk == hkToRegister {
|
||||||
l.hk = nil
|
l.hk = nil
|
||||||
l.isListening = false
|
l.isListening = false
|
||||||
}
|
}
|
||||||
l.mu.Unlock()
|
l.mu.Unlock()
|
||||||
|
if cb != nil {
|
||||||
|
cb(err)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"wis-free-v3/internal/logger"
|
"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
|
// (notify-send). There is no full-screen overlay on Linux to avoid a GTK/Cairo
|
||||||
// dependency beyond what Wails already pulls in.
|
// dependency beyond what Wails already pulls in.
|
||||||
type linuxOverlay struct {
|
type linuxOverlay struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
lastMsg string
|
lastMsg string
|
||||||
|
lastUpdate time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewOverlay() *linuxOverlay {
|
func NewOverlay() *linuxOverlay {
|
||||||
@@ -75,6 +77,13 @@ func (o *linuxOverlay) SetVolume(level float64) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
o.mu.Lock()
|
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
|
base := o.lastMsg
|
||||||
o.mu.Unlock()
|
o.mu.Unlock()
|
||||||
if strings.TrimSpace(base) == "" {
|
if strings.TrimSpace(base) == "" {
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 373 B |
@@ -7,9 +7,13 @@ import (
|
|||||||
_ "embed"
|
_ "embed"
|
||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/png"
|
||||||
"os"
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"wis-free-v3/internal/config"
|
"wis-free-v3/internal/config"
|
||||||
"wis-free-v3/internal/logger"
|
"wis-free-v3/internal/logger"
|
||||||
@@ -45,6 +49,7 @@ var trayLabel = "wis-free-v3"
|
|||||||
var statusMenuItem *systray.MenuItem
|
var statusMenuItem *systray.MenuItem
|
||||||
var triggerCountItem *systray.MenuItem
|
var triggerCountItem *systray.MenuItem
|
||||||
var triggerCount int
|
var triggerCount int
|
||||||
|
var iconsInitOnce sync.Once
|
||||||
|
|
||||||
func appDisplayName(app App) string {
|
func appDisplayName(app App) string {
|
||||||
v := app.Version()
|
v := app.Version()
|
||||||
@@ -56,6 +61,7 @@ func appDisplayName(app App) string {
|
|||||||
|
|
||||||
// onReady is called when the system tray is ready to be configured.
|
// onReady is called when the system tray is ready to be configured.
|
||||||
func onReady(app App) {
|
func onReady(app App) {
|
||||||
|
initDynamicIcons()
|
||||||
trayLabel = appDisplayName(app)
|
trayLabel = appDisplayName(app)
|
||||||
|
|
||||||
// Configure tray icon and tooltip
|
// Configure tray icon and tooltip
|
||||||
@@ -142,6 +148,7 @@ func buildTooltip(app App) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getDefaultIcon() []byte {
|
func getDefaultIcon() []byte {
|
||||||
|
initDynamicIcons()
|
||||||
if runtime.GOOS == "linux" {
|
if runtime.GOOS == "linux" {
|
||||||
return iconPNGData
|
return iconPNGData
|
||||||
}
|
}
|
||||||
@@ -198,6 +205,7 @@ func UpdateStatus(status string) {
|
|||||||
statusMenuItem.SetTitle("Status: " + status)
|
statusMenuItem.SetTitle("Status: " + status)
|
||||||
systray.SetTooltip(trayLabel + " - " + status)
|
systray.SetTooltip(trayLabel + " - " + status)
|
||||||
|
|
||||||
|
initDynamicIcons()
|
||||||
if strings.Contains(status, "Recording") {
|
if strings.Contains(status, "Recording") {
|
||||||
systray.SetIcon(iconRecordingData)
|
systray.SetIcon(iconRecordingData)
|
||||||
} else if strings.Contains(status, "Transcribing") {
|
} else if strings.Contains(status, "Transcribing") {
|
||||||
@@ -220,3 +228,76 @@ func IncrementTriggerCount() {
|
|||||||
func onExit() {
|
func onExit() {
|
||||||
logger.Info("System tray terminated")
|
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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ func portalKeySpecName(key Key) string {
|
|||||||
func portalWaitRequest(conn *dbus.Conn, reqPath dbus.ObjectPath) (uint32, map[string]dbus.Variant, error) {
|
func portalWaitRequest(conn *dbus.Conn, reqPath dbus.ObjectPath) (uint32, map[string]dbus.Variant, error) {
|
||||||
ch := make(chan *dbus.Signal, 8)
|
ch := make(chan *dbus.Signal, 8)
|
||||||
conn.Signal(ch)
|
conn.Signal(ch)
|
||||||
|
defer conn.RemoveSignal(ch)
|
||||||
rule := fmt.Sprintf(
|
rule := fmt.Sprintf(
|
||||||
"type='signal',path='%s',interface='%s',member='Response'",
|
"type='signal',path='%s',interface='%s',member='Response'",
|
||||||
string(reqPath), ifaceRequest,
|
string(reqPath), ifaceRequest,
|
||||||
@@ -318,6 +319,7 @@ func (hk *Hotkey) portalSignalLoop() {
|
|||||||
|
|
||||||
ch := make(chan *dbus.Signal, 32)
|
ch := make(chan *dbus.Signal, 32)
|
||||||
conn.Signal(ch)
|
conn.Signal(ch)
|
||||||
|
defer conn.RemoveSignal(ch)
|
||||||
rule := fmt.Sprintf(
|
rule := fmt.Sprintf(
|
||||||
"type='signal',interface='%s'",
|
"type='signal',interface='%s'",
|
||||||
ifaceGlobalShortcuts,
|
ifaceGlobalShortcuts,
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Clean up resources on exit
|
// Clean up resources on exit
|
||||||
|
cleanupSecondInstanceListener()
|
||||||
releaseInstanceLock()
|
releaseInstanceLock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,3 +24,4 @@ func tryNotifyRunningInstanceAction(action string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func runSecondInstanceListener() {}
|
func runSecondInstanceListener() {}
|
||||||
|
func cleanupSecondInstanceListener() {}
|
||||||
|
|||||||
@@ -112,3 +112,9 @@ func runSecondInstanceListener() {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cleanupSecondInstanceListener() {
|
||||||
|
if path, err := instanceSocketPath(); err == nil {
|
||||||
|
_ = os.Remove(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user