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:
Your Name
2026-06-09 09:16:55 -07:00
parent 05c8fbc474
commit 52bb65f3a0
14 changed files with 410 additions and 85 deletions
+57 -23
View File
@@ -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)
}
+21 -5
View File
@@ -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:]
}
}
+19 -7
View File
@@ -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
}
+15 -3
View File
@@ -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)
}
+3 -1
View File
@@ -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{
+40 -3
View File
@@ -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
View File
@@ -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
+21 -3
View File
@@ -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
}