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
+25 -15
View File
@@ -117,6 +117,14 @@ func (a *App) startup(ctx context.Context) {
}
}()
// Register callback so the tray can notify the frontend when the startup
// menu item is toggled directly from the tray icon context menu.
tray.SetOnStartupChanged(func(enabled bool) {
if a.ctx != nil {
wailsruntime.EventsEmit(a.ctx, "startup:changed", enabled)
}
})
// Start system tray in a goroutine
if runtime.GOOS == "linux" {
tray.Start(a)
@@ -233,17 +241,14 @@ func (a *App) StartRecording() {
}
}
// 3. Handle secondary tasks in background
go func() {
// Update tray status
tray.UpdateStatus("Recording...")
// 3. Update tray status
tray.UpdateStatus("Recording...")
// Pause media if playing (this is slow due to PowerShell)
a.wasMediaPlaying = platform.PauseMedia()
if a.wasMediaPlaying {
logger.Info("Media paused for recording")
}
}()
// Pause media if playing (do synchronously so wasMediaPlaying is ready before Stop)
a.wasMediaPlaying = platform.PauseMedia()
if a.wasMediaPlaying {
logger.Info("Media paused for recording")
}
}
// StopRecording stops the audio recording and triggers transcription
@@ -267,8 +272,6 @@ func (a *App) StopRecording() {
err := a.audioRecorder.Stop()
if err != nil {
logger.Error("Failed to stop recording: %v", err)
// Attempt to keep state consistent: if stop failed, we are likely still recording.
atomic.StoreInt32(&a.recording, 1)
return
}
@@ -348,7 +351,7 @@ func (a *App) processRecording(recordingPath string) {
return
}
logger.Info("Transcribed: %s", text)
logger.Info("Transcribed %d characters", len(text))
activeWindow := robotgo.GetTitle()
logger.Info("Active window for context: %s", activeWindow)
@@ -362,7 +365,7 @@ func (a *App) processRecording(recordingPath string) {
refinedText = text
} else {
refineDuration := time.Since(startRefine)
logger.Info("AI Refinement completed in %v (Refined: %s)", refineDuration, refinedText)
logger.Info("AI Refinement completed in %v (%d chars)", refineDuration, len(refinedText))
}
// Save to history
@@ -476,7 +479,10 @@ func (a *App) SaveSettings(settings map[string]interface{}) string {
}
// Save to file
config.Save(a.config, "")
if err := config.Save(a.config, ""); err != nil {
logger.Error("Failed to save config: %v", err)
return fmt.Sprintf("Error saving settings: %v", err)
}
// Re-init transcriber with new settings
a.transcriber = transcriber.NewClient(
@@ -529,6 +535,10 @@ func (a *App) ToggleStartup(enable bool) string {
logger.Error("Startup toggle error: %v", err)
return fmt.Sprintf("Error: %v", err)
}
// Keep the tray menu item in sync when changed from the settings UI
tray.SetStartupChecked(enable)
return "Success"
}
File diff suppressed because one or more lines are too long
+16 -2
View File
@@ -6,7 +6,7 @@
<meta content="width=device-width, initial-scale=1.0" name="viewport">
<title>Wisp Settings</title>
<script type="module" crossorigin src="/assets/index.839c3199.js"></script>
<script type="module" crossorigin src="/assets/index.bbf41643.js"></script>
<link rel="stylesheet" href="/assets/index.6e77aa4d.css">
</head>
@@ -38,7 +38,21 @@
<button onclick="saveApiKey()">Save</button>
</div>
</div>
<p class="hint">Get your free key at <a href="https://console.groq.com/keys" target="_blank" style="color: var(--accent);">console.groq.com/keys</a></p>
<p class="hint">Get your free key at <a href="#" onclick="window.runtime.BrowserOpenURL('https://console.groq.com/keys'); return false;" style="color: var(--accent);">console.groq.com/keys</a></p>
</div>
<!-- Global Hotkey -->
<div class="section" id="shortcutSection">
<label>Global Hotkey</label>
<div class="form-control">
<div class="flex-row">
<div class="input-wrapper">
<input type="text" id="shortcutInput" readonly placeholder="Click Record then press a key combo">
</div>
<button id="recordBtn" onclick="recordShortcut()">Record</button>
</div>
</div>
<p class="hint">Press the key combination you want to use to start/stop recording (e.g. alt+z, ctrl+shift+space). Modifier-only combos like ctrl+alt are also supported on Windows.</p>
</div>
<div class="section-group-label">Input</div>
+20 -1
View File
@@ -36,7 +36,21 @@
<button onclick="saveApiKey()">Save</button>
</div>
</div>
<p class="hint">Get your free key at <a href="https://console.groq.com/keys" target="_blank" style="color: var(--accent);">console.groq.com/keys</a></p>
<p class="hint">Get your free key at <a href="#" onclick="window.runtime.BrowserOpenURL('https://console.groq.com/keys'); return false;" style="color: var(--accent);">console.groq.com/keys</a></p>
</div>
<!-- Global Hotkey -->
<div class="section" id="shortcutSection">
<label>Global Hotkey</label>
<div class="form-control">
<div class="flex-row">
<div class="input-wrapper">
<input type="text" id="shortcutInput" readonly placeholder="Click Record then press a key combo">
</div>
<button id="recordBtn" onclick="recordShortcut()">Record</button>
</div>
</div>
<p class="hint">Press the key combination you want to use to start/stop recording (e.g. alt+z, ctrl+shift+space). Modifier-only combos like ctrl+alt are also supported on Windows.</p>
</div>
<div class="section-group-label">Input</div>
@@ -611,6 +625,11 @@
window.runtime.EventsOn('history:updated', function () {
refreshHistoryFromBackend();
});
// Keep the startup checkbox in sync when toggled from the tray menu
window.runtime.EventsOn('startup:changed', function (enabled) {
document.getElementById('startupToggle').checked = enabled;
});
}
// If Linux, ensure Local Whisper option is removed
+82 -1
View File
@@ -246,4 +246,85 @@ export function OnFileDropOff() :void
export function CanResolveFilePaths(): boolean;
// Resolves file paths for an array of files
export function ResolveFilePaths(files: File[]): void
export function ResolveFilePaths(files: File[]): void
// Notification types
export interface NotificationOptions {
id: string;
title: string;
subtitle?: string; // macOS and Linux only
body?: string;
categoryId?: string;
data?: { [key: string]: any };
}
export interface NotificationAction {
id?: string;
title?: string;
destructive?: boolean; // macOS-specific
}
export interface NotificationCategory {
id?: string;
actions?: NotificationAction[];
hasReplyField?: boolean;
replyPlaceholder?: string;
replyButtonTitle?: string;
}
// [InitializeNotifications](https://wails.io/docs/reference/runtime/notification#initializenotifications)
// Initializes the notification service for the application.
// This must be called before sending any notifications.
export function InitializeNotifications(): Promise<void>;
// [CleanupNotifications](https://wails.io/docs/reference/runtime/notification#cleanupnotifications)
// Cleans up notification resources and releases any held connections.
export function CleanupNotifications(): Promise<void>;
// [IsNotificationAvailable](https://wails.io/docs/reference/runtime/notification#isnotificationavailable)
// Checks if notifications are available on the current platform.
export function IsNotificationAvailable(): Promise<boolean>;
// [RequestNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#requestnotificationauthorization)
// Requests notification authorization from the user (macOS only).
export function RequestNotificationAuthorization(): Promise<boolean>;
// [CheckNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#checknotificationauthorization)
// Checks the current notification authorization status (macOS only).
export function CheckNotificationAuthorization(): Promise<boolean>;
// [SendNotification](https://wails.io/docs/reference/runtime/notification#sendnotification)
// Sends a basic notification with the given options.
export function SendNotification(options: NotificationOptions): Promise<void>;
// [SendNotificationWithActions](https://wails.io/docs/reference/runtime/notification#sendnotificationwithactions)
// Sends a notification with action buttons. Requires a registered category.
export function SendNotificationWithActions(options: NotificationOptions): Promise<void>;
// [RegisterNotificationCategory](https://wails.io/docs/reference/runtime/notification#registernotificationcategory)
// Registers a notification category that can be used with SendNotificationWithActions.
export function RegisterNotificationCategory(category: NotificationCategory): Promise<void>;
// [RemoveNotificationCategory](https://wails.io/docs/reference/runtime/notification#removenotificationcategory)
// Removes a previously registered notification category.
export function RemoveNotificationCategory(categoryId: string): Promise<void>;
// [RemoveAllPendingNotifications](https://wails.io/docs/reference/runtime/notification#removeallpendingnotifications)
// Removes all pending notifications from the notification center.
export function RemoveAllPendingNotifications(): Promise<void>;
// [RemovePendingNotification](https://wails.io/docs/reference/runtime/notification#removependingnotification)
// Removes a specific pending notification by its identifier.
export function RemovePendingNotification(identifier: string): Promise<void>;
// [RemoveAllDeliveredNotifications](https://wails.io/docs/reference/runtime/notification#removealldeliverednotifications)
// Removes all delivered notifications from the notification center.
export function RemoveAllDeliveredNotifications(): Promise<void>;
// [RemoveDeliveredNotification](https://wails.io/docs/reference/runtime/notification#removedeliverednotification)
// Removes a specific delivered notification by its identifier.
export function RemoveDeliveredNotification(identifier: string): Promise<void>;
// [RemoveNotification](https://wails.io/docs/reference/runtime/notification#removenotification)
// Removes a notification by its identifier (cross-platform convenience function).
export function RemoveNotification(identifier: string): Promise<void>;
+56
View File
@@ -239,4 +239,60 @@ export function CanResolveFilePaths() {
export function ResolveFilePaths(files) {
return window.runtime.ResolveFilePaths(files);
}
export function InitializeNotifications() {
return window.runtime.InitializeNotifications();
}
export function CleanupNotifications() {
return window.runtime.CleanupNotifications();
}
export function IsNotificationAvailable() {
return window.runtime.IsNotificationAvailable();
}
export function RequestNotificationAuthorization() {
return window.runtime.RequestNotificationAuthorization();
}
export function CheckNotificationAuthorization() {
return window.runtime.CheckNotificationAuthorization();
}
export function SendNotification(options) {
return window.runtime.SendNotification(options);
}
export function SendNotificationWithActions(options) {
return window.runtime.SendNotificationWithActions(options);
}
export function RegisterNotificationCategory(category) {
return window.runtime.RegisterNotificationCategory(category);
}
export function RemoveNotificationCategory(categoryId) {
return window.runtime.RemoveNotificationCategory(categoryId);
}
export function RemoveAllPendingNotifications() {
return window.runtime.RemoveAllPendingNotifications();
}
export function RemovePendingNotification(identifier) {
return window.runtime.RemovePendingNotification(identifier);
}
export function RemoveAllDeliveredNotifications() {
return window.runtime.RemoveAllDeliveredNotifications();
}
export function RemoveDeliveredNotification(identifier) {
return window.runtime.RemoveDeliveredNotification(identifier);
}
export function RemoveNotification(identifier) {
return window.runtime.RemoveNotification(identifier);
}
+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
}