mirror of
https://github.com/jahruz67/wisp-open.git
synced 2026-08-08 18:14:08 +00:00
Refactor config history management to enforce byte size limit and optimize item prepending; improve hotkey listener synchronization to prevent deadlocks; enhance transcriber error handling to avoid leaking sensitive information; address multiple race conditions and potential crashes in audio processing; implement logging improvements and fix resource leaks; add comprehensive issue tracking documentation.
This commit is contained in:
@@ -41,6 +41,7 @@ type AudioRecorder struct {
|
||||
outputFile *os.File
|
||||
dataSize uint32
|
||||
isRecording bool
|
||||
writing int32 // atomic: 1 = safe to write to outputFile, 0 = no longer writing
|
||||
deviceID *string
|
||||
OnVolume VolumeCallback
|
||||
mu sync.Mutex
|
||||
@@ -188,6 +189,7 @@ func (r *AudioRecorder) Start(filename string) error {
|
||||
}
|
||||
|
||||
r.isRecording = true
|
||||
atomic.StoreInt32(&r.writing, 1)
|
||||
logger.Info("Recording started: %s", filename)
|
||||
return nil
|
||||
}
|
||||
@@ -201,6 +203,10 @@ func (r *AudioRecorder) Stop() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Mark writing as unsafe BEFORE stopping device to prevent in-flight
|
||||
// callbacks from writing to a closed file.
|
||||
atomic.StoreInt32(&r.writing, 0)
|
||||
|
||||
// Stop the capture device. We keep the device initialized between recordings
|
||||
// to avoid the expensive re-initialization cycle. ALSA privacy indicators
|
||||
// will still turn off because we've stopped the stream.
|
||||
@@ -256,15 +262,21 @@ func (r *AudioRecorder) Cleanup() {
|
||||
}
|
||||
|
||||
// onAudioData is called by miniaudio when audio data is available.
|
||||
// Safe to call without r.mu because atomic writing flag prevents writes
|
||||
// after Stop() clears the flag.
|
||||
func (r *AudioRecorder) onAudioData(_, inputSamples []byte, _ uint32) {
|
||||
if r.outputFile != nil && len(inputSamples) > 0 {
|
||||
n, _ := r.outputFile.Write(inputSamples)
|
||||
atomic.AddUint32(&r.dataSize, uint32(n))
|
||||
if atomic.LoadInt32(&r.writing) == 0 || r.outputFile == nil || len(inputSamples) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate volume if callback is set
|
||||
if r.OnVolume != nil {
|
||||
r.calculateVolume(inputSamples[:n])
|
||||
}
|
||||
n, err := r.outputFile.Write(inputSamples)
|
||||
if err == nil {
|
||||
atomic.AddUint32(&r.dataSize, uint32(n))
|
||||
}
|
||||
|
||||
// Calculate volume if callback is set (uses atomic-read-only fields)
|
||||
if r.OnVolume != nil {
|
||||
r.calculateVolume(inputSamples[:n])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,23 +319,45 @@ func (r *AudioRecorder) writeWAVHeader(dataSize uint32) error {
|
||||
blockAlign := numChannels * (bitsPerSample / 8)
|
||||
|
||||
// RIFF chunk
|
||||
r.outputFile.Write([]byte("RIFF"))
|
||||
binary.Write(r.outputFile, binary.LittleEndian, uint32(36+dataSize))
|
||||
r.outputFile.Write([]byte("WAVE"))
|
||||
if _, err := r.outputFile.Write([]byte("RIFF")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := binary.Write(r.outputFile, binary.LittleEndian, uint32(36+dataSize)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := r.outputFile.Write([]byte("WAVE")); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// fmt sub-chunk
|
||||
r.outputFile.Write([]byte("fmt "))
|
||||
binary.Write(r.outputFile, binary.LittleEndian, uint32(16)) // Subchunk1Size
|
||||
binary.Write(r.outputFile, binary.LittleEndian, uint16(1)) // AudioFormat (PCM)
|
||||
binary.Write(r.outputFile, binary.LittleEndian, numChannels)
|
||||
binary.Write(r.outputFile, binary.LittleEndian, sampleRate)
|
||||
binary.Write(r.outputFile, binary.LittleEndian, byteRate)
|
||||
binary.Write(r.outputFile, binary.LittleEndian, blockAlign)
|
||||
binary.Write(r.outputFile, binary.LittleEndian, bitsPerSample)
|
||||
if _, err := r.outputFile.Write([]byte("fmt ")); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := binary.Write(r.outputFile, binary.LittleEndian, uint32(16)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := binary.Write(r.outputFile, binary.LittleEndian, uint16(1)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := binary.Write(r.outputFile, binary.LittleEndian, numChannels); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := binary.Write(r.outputFile, binary.LittleEndian, sampleRate); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := binary.Write(r.outputFile, binary.LittleEndian, byteRate); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := binary.Write(r.outputFile, binary.LittleEndian, blockAlign); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := binary.Write(r.outputFile, binary.LittleEndian, bitsPerSample); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// data sub-chunk
|
||||
r.outputFile.Write([]byte("data"))
|
||||
binary.Write(r.outputFile, binary.LittleEndian, dataSize)
|
||||
|
||||
return nil
|
||||
}
|
||||
if _, err := r.outputFile.Write([]byte("data")); err != nil {
|
||||
return err
|
||||
}
|
||||
return binary.Write(r.outputFile, binary.LittleEndian, dataSize)
|
||||
}
|
||||
|
||||
@@ -147,19 +147,35 @@ func (c *Config) applyDefaults() {
|
||||
}
|
||||
}
|
||||
|
||||
// AddHistoryItem adds a new transcription to the history, enforcing a maximum limit.
|
||||
// AddHistoryItem adds a new transcription to the history, enforcing limits on
|
||||
// both the item count and the total byte size of the history payload.
|
||||
func (c *Config) AddHistoryItem(text, timestamp string) {
|
||||
newItem := HistoryItem{
|
||||
Text: text,
|
||||
Timestamp: timestamp,
|
||||
}
|
||||
|
||||
// Prepend to history so the newest items are at the top
|
||||
// Bounded item count: keep only the most recent items.
|
||||
if len(c.History) >= MaxHistoryItems {
|
||||
c.History = c.History[:MaxHistoryItems-1]
|
||||
}
|
||||
|
||||
// Prepend to history so the newest items are at the top.
|
||||
c.History = append([]HistoryItem{newItem}, c.History...)
|
||||
|
||||
// Bounded history: keep only the most recent items
|
||||
if len(c.History) > MaxHistoryItems {
|
||||
c.History = c.History[:MaxHistoryItems]
|
||||
// Bounded total size: drop oldest items until under the byte cap.
|
||||
const maxHistoryBytes = 256 * 1024 // 256 KB upper bound on history payload
|
||||
total := 0
|
||||
cutoff := 0
|
||||
for i, item := range c.History {
|
||||
total += len(item.Text) + len(item.Timestamp) + 32 // rough JSON overhead per item
|
||||
if total > maxHistoryBytes {
|
||||
cutoff = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
if cutoff > 0 {
|
||||
c.History = c.History[cutoff:]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,9 +49,17 @@ func (l *Listener) UpdateShortcut(shortcut string) {
|
||||
l.mu.Lock()
|
||||
l.shortcut = shortcut
|
||||
wasListening := l.isListening
|
||||
doneCh := l.eventLoopDone
|
||||
l.eventLoopDone = nil // prevent stopListeningLocked from waiting on it
|
||||
l.stopListeningLocked()
|
||||
l.mu.Unlock()
|
||||
|
||||
// Wait for the old event loop to fully exit, but WITHOUT holding the
|
||||
// mutex (otherwise the loop can't acquire the lock to signal done).
|
||||
if doneCh != nil {
|
||||
<-doneCh
|
||||
}
|
||||
|
||||
logger.Info("Hotkey updated: shortcut=%s", shortcut)
|
||||
|
||||
if wasListening {
|
||||
@@ -128,9 +136,16 @@ func (l *Listener) Start() {
|
||||
// Stop terminates the hotkey listener.
|
||||
func (l *Listener) Stop() {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
doneCh := l.eventLoopDone
|
||||
l.eventLoopDone = nil
|
||||
l.stopListeningLocked()
|
||||
l.mu.Unlock()
|
||||
|
||||
// Wait for the old event loop to fully exit, but WITHOUT holding the
|
||||
// mutex (otherwise the loop can't acquire the lock to signal done).
|
||||
if doneCh != nil {
|
||||
<-doneCh
|
||||
}
|
||||
logger.Info("Hotkey listener stopped")
|
||||
}
|
||||
|
||||
@@ -145,11 +160,8 @@ func (l *Listener) stopListeningLocked() {
|
||||
}
|
||||
l.hk = nil
|
||||
}
|
||||
// Wait for event loop to fully terminate before allowing re-registration
|
||||
if l.eventLoopDone != nil {
|
||||
<-l.eventLoopDone
|
||||
l.eventLoopDone = nil
|
||||
}
|
||||
// Note: we no longer wait on eventLoopDone here — the caller does that
|
||||
// after releasing l.mu, to avoid a self-deadlock.
|
||||
l.isListening = false
|
||||
}
|
||||
|
||||
|
||||
@@ -123,16 +123,22 @@ func (c *Client) RefineText(text string, activeContext string) (string, error) {
|
||||
if c.apiKey == "" || c.aiModel == "None" {
|
||||
return text, nil
|
||||
}
|
||||
_ = activeContext
|
||||
|
||||
systemPrompt := c.aiPrompt
|
||||
systemPrompt += "\n\nSafety check: the output must remain the same transcript. If you are unsure, return the input unchanged."
|
||||
|
||||
// Fold the active window context into the user message to give the LLM
|
||||
// situational awareness without changing the cleanup system prompt.
|
||||
userContent := text
|
||||
if activeContext != "" {
|
||||
userContent = "[" + activeContext + "]\n" + text
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"model": c.aiModel,
|
||||
"messages": []map[string]string{
|
||||
{"role": "system", "content": systemPrompt},
|
||||
{"role": "user", "content": text},
|
||||
{"role": "user", "content": userContent},
|
||||
},
|
||||
"temperature": RefinementTemp,
|
||||
}
|
||||
@@ -318,8 +324,14 @@ func (c *Client) prepareAudioRequest(audioFilePath, language string) (*bytes.Buf
|
||||
}
|
||||
|
||||
// handleAPIError logs and formats API error responses.
|
||||
// Truncates the body to avoid leaking secrets if the API echoes request data.
|
||||
func (c *Client) handleAPIError(resp *http.Response, operation string) error {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
logger.Error("API %s error: status=%d body=%s", operation, resp.StatusCode, string(bodyBytes))
|
||||
const maxLogLen = 200
|
||||
bodyStr := string(bodyBytes)
|
||||
if len(bodyStr) > maxLogLen {
|
||||
bodyStr = bodyStr[:maxLogLen] + "...(truncated)"
|
||||
}
|
||||
logger.Error("API %s error: status=%d body=%s", operation, resp.StatusCode, bodyStr)
|
||||
return fmt.Errorf("%s failed with status %d", operation, resp.StatusCode)
|
||||
}
|
||||
|
||||
@@ -433,7 +433,9 @@ func downloadFile(url, dest string, progress chan<- DownloadProgress) error {
|
||||
for {
|
||||
n, err := resp.Body.Read(buf)
|
||||
if n > 0 {
|
||||
out.Write(buf[:n])
|
||||
if _, werr := out.Write(buf[:n]); werr != nil {
|
||||
return werr
|
||||
}
|
||||
downloaded += int64(n)
|
||||
if progress != nil && total > 0 {
|
||||
progress <- DownloadProgress{
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -50,6 +49,11 @@ var statusMenuItem *systray.MenuItem
|
||||
var triggerCountItem *systray.MenuItem
|
||||
var triggerCount int
|
||||
var iconsInitOnce sync.Once
|
||||
var startupMenuItem *systray.MenuItem
|
||||
|
||||
// onStartupChanged is called when the tray startup menu item is toggled.
|
||||
// It receives the new enabled state. The callback is set by the app layer.
|
||||
var onStartupChanged func(bool)
|
||||
|
||||
func appDisplayName(app App) string {
|
||||
v := app.Version()
|
||||
@@ -91,6 +95,9 @@ func onReady(app App) {
|
||||
|
||||
menuExit := systray.AddMenuItem("Exit", "Close the application")
|
||||
|
||||
// Store reference for external updates
|
||||
startupMenuItem = menuStartup
|
||||
|
||||
// Handle menu events in background
|
||||
go handleMenuEvents(app, menuSettings, menuStartup, menuExit)
|
||||
}
|
||||
@@ -119,6 +126,7 @@ func toggleStartup(item *systray.MenuItem) {
|
||||
} else {
|
||||
item.Uncheck()
|
||||
logger.Info("Removed from system startup")
|
||||
notifyStartupChanged(false)
|
||||
}
|
||||
} else {
|
||||
if err := platform.AddToStartup(); err != nil {
|
||||
@@ -126,16 +134,45 @@ func toggleStartup(item *systray.MenuItem) {
|
||||
} else {
|
||||
item.Check()
|
||||
logger.Info("Added to system startup")
|
||||
notifyStartupChanged(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// notifyStartupChanged calls the onStartupChanged callback if set.
|
||||
func notifyStartupChanged(enabled bool) {
|
||||
if onStartupChanged != nil {
|
||||
onStartupChanged(enabled)
|
||||
}
|
||||
}
|
||||
|
||||
// SetOnStartupChanged registers a callback that is invoked when the tray
|
||||
// startup menu item is toggled. The callback receives the new enabled state.
|
||||
func SetOnStartupChanged(fn func(bool)) {
|
||||
onStartupChanged = fn
|
||||
}
|
||||
|
||||
// SetStartupChecked updates the tray startup menu item checkbox state.
|
||||
// This is used to keep the tray in sync when the startup option is changed
|
||||
// from the settings UI.
|
||||
func SetStartupChecked(checked bool) {
|
||||
if startupMenuItem == nil {
|
||||
return
|
||||
}
|
||||
if checked {
|
||||
startupMenuItem.Check()
|
||||
} else {
|
||||
startupMenuItem.Uncheck()
|
||||
}
|
||||
}
|
||||
|
||||
// handleExit cleanly shuts down the application.
|
||||
// Calls app.Quit() and lets wails run deferred shutdown handlers instead of
|
||||
// os.Exit(0) which would skip instance-lock / socket cleanup in main().
|
||||
func handleExit(app App) {
|
||||
logger.Info("User requested application exit")
|
||||
app.Quit()
|
||||
systray.Quit()
|
||||
os.Exit(0)
|
||||
app.Quit()
|
||||
}
|
||||
|
||||
// buildTooltip creates the tray icon tooltip text.
|
||||
|
||||
+34
-20
@@ -6,6 +6,7 @@ import (
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
"unsafe"
|
||||
@@ -86,12 +87,13 @@ type Overlay struct {
|
||||
text string
|
||||
isShowing bool
|
||||
running bool
|
||||
closed int32 // atomic: 1 = Close() has been called
|
||||
mu sync.RWMutex
|
||||
stopCh chan struct{}
|
||||
bgBrush syscall.Handle
|
||||
hFont syscall.Handle
|
||||
volume float64 // Current raw volume (0.0 - 1.0)
|
||||
smoothedVolume float64 // Moving average volume for smoother animations
|
||||
volume uint64 // atomic: float64 stored via math.Float64bits
|
||||
smoothedVolume uint64 // atomic: float64 stored via math.Float64bits
|
||||
}
|
||||
|
||||
var globalOverlay *Overlay
|
||||
@@ -131,17 +133,27 @@ func (o *Overlay) Hide() {
|
||||
}
|
||||
}
|
||||
|
||||
// SetVolume updates the current audio volume level
|
||||
// SetVolume updates the current audio volume level using lock-free atomics
|
||||
// so the audio callback thread never blocks on a mutex.
|
||||
func (o *Overlay) SetVolume(level float64) {
|
||||
o.mu.Lock()
|
||||
o.volume = level
|
||||
// Stronger smoothing: 10% new value, 90% old value to reduce jitter/flicker
|
||||
o.smoothedVolume = (o.smoothedVolume * 0.9) + (level * 0.1)
|
||||
o.mu.Unlock()
|
||||
atomic.StoreUint64(&o.volume, math.Float64bits(level))
|
||||
|
||||
for {
|
||||
oldBits := atomic.LoadUint64(&o.smoothedVolume)
|
||||
old := math.Float64frombits(oldBits)
|
||||
// Stronger smoothing: 10% new value, 90% old value to reduce jitter/flicker
|
||||
smoothed := (old * 0.9) + (level * 0.1)
|
||||
if atomic.CompareAndSwapUint64(&o.smoothedVolume, oldBits, math.Float64bits(smoothed)) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close stops the overlay
|
||||
// Close stops the overlay. Safe to call multiple times.
|
||||
func (o *Overlay) Close() {
|
||||
if !atomic.CompareAndSwapInt32(&o.closed, 0, 1) {
|
||||
return // Already closed
|
||||
}
|
||||
if o.hwnd != 0 {
|
||||
procPostMessage.Call(uintptr(o.hwnd), WM_CLOSE, 0, 0)
|
||||
}
|
||||
@@ -256,17 +268,18 @@ func (o *Overlay) run() {
|
||||
procTranslateMessage.Call(uintptr(unsafe.Pointer(&msg)))
|
||||
procDispatchMessage.Call(uintptr(unsafe.Pointer(&msg)))
|
||||
} else {
|
||||
o.mu.RLock()
|
||||
isShowing := o.isShowing
|
||||
o.mu.RUnlock()
|
||||
o.mu.RLock()
|
||||
isShowing := o.isShowing
|
||||
text := o.text
|
||||
o.mu.RUnlock()
|
||||
|
||||
if isShowing {
|
||||
// Higher resolution sleep for smooth animation when visible
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
} else {
|
||||
// Greatly reduce wakeups to save CPU when hidden (app is idle 99% of the time)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
if isShowing && (strings.HasPrefix(text, "Recording") || strings.HasPrefix(text, "Transcribing")) {
|
||||
// Higher resolution sleep only when animating
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
} else {
|
||||
// Reduce wakeups when hidden or showing static text (Ready, errors)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -297,8 +310,9 @@ func overlayWndProc(hwnd syscall.Handle, msg uint32, wParam, lParam uintptr) uin
|
||||
text = globalOverlay.text
|
||||
bgBrush = globalOverlay.bgBrush
|
||||
hFont = globalOverlay.hFont
|
||||
volume = globalOverlay.smoothedVolume
|
||||
globalOverlay.mu.RUnlock()
|
||||
// Read volume atomically (written by audio thread without lock)
|
||||
volume = math.Float64frombits(atomic.LoadUint64(&globalOverlay.smoothedVolume))
|
||||
}
|
||||
|
||||
// 1. Clear memory DC with background
|
||||
|
||||
@@ -1,16 +1,34 @@
|
||||
//go:build windows
|
||||
package windows
|
||||
|
||||
import "syscall"
|
||||
import (
|
||||
"errors"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// IsProcessRunning checks if a process with the given PID exists on Windows.
|
||||
// Uses GetExitCodeProcess to distinguish "not running" from "running but
|
||||
// access denied" (protected/system processes), avoiding false negatives
|
||||
// that would allow duplicate instances to launch.
|
||||
func IsProcessRunning(pid int) bool {
|
||||
const PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
||||
|
||||
handle, err := syscall.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid))
|
||||
if err != nil {
|
||||
// ACCESS_DENIED means the process is running but we can't query it.
|
||||
// Treat that as "running" to avoid clobbering the lock file.
|
||||
if errors.Is(err, syscall.ERROR_ACCESS_DENIED) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
syscall.CloseHandle(handle)
|
||||
return true
|
||||
defer syscall.CloseHandle(handle)
|
||||
|
||||
// Still confirm the process hasn't exited by checking its exit code.
|
||||
// STILL_ACTIVE (259) is the documented value for a live process.
|
||||
var exitCode uint32
|
||||
if err := syscall.GetExitCodeProcess(handle, &exitCode); err != nil {
|
||||
return true // We got a handle, so the process exists.
|
||||
}
|
||||
return exitCode == 259
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user