From 52bb65f3a089be5f125db07d60eb6236f4b088d4 Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 9 Jun 2026 09:16:55 -0700 Subject: [PATCH] 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. --- app.go | 40 +++++---- .../{index.839c3199.js => index.bbf41643.js} | 2 +- frontend/dist/index.html | 18 +++- frontend/index.html | 21 ++++- frontend/wailsjs/runtime/runtime.d.ts | 83 ++++++++++++++++++- frontend/wailsjs/runtime/runtime.js | 56 +++++++++++++ internal/audio/recorder/recorder.go | 80 +++++++++++++----- internal/config/config.go | 26 ++++-- internal/hotkey/hotkey.go | 26 ++++-- internal/services/transcriber/transcriber.go | 18 +++- internal/services/whisper/whisper.go | 4 +- internal/ui/tray/tray.go | 43 +++++++++- internal/windows/overlay.go | 54 +++++++----- internal/windows/process.go | 24 +++++- 14 files changed, 410 insertions(+), 85 deletions(-) rename frontend/dist/assets/{index.839c3199.js => index.bbf41643.js} (95%) diff --git a/app.go b/app.go index 7e9777e..fe74d3b 100644 --- a/app.go +++ b/app.go @@ -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" } diff --git a/frontend/dist/assets/index.839c3199.js b/frontend/dist/assets/index.bbf41643.js similarity index 95% rename from frontend/dist/assets/index.839c3199.js rename to frontend/dist/assets/index.bbf41643.js index aef9fce..4d80ce8 100644 --- a/frontend/dist/assets/index.839c3199.js +++ b/frontend/dist/assets/index.bbf41643.js @@ -1,4 +1,4 @@ (function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const i of document.querySelectorAll('link[rel="modulepreload"]'))n(i);new MutationObserver(i=>{for(const s of i)if(s.type==="childList")for(const r of s.addedNodes)r.tagName==="LINK"&&r.rel==="modulepreload"&&n(r)}).observe(document,{childList:!0,subtree:!0});function o(i){const s={};return i.integrity&&(s.integrity=i.integrity),i.referrerpolicy&&(s.referrerPolicy=i.referrerpolicy),i.crossorigin==="use-credentials"?s.credentials="include":i.crossorigin==="anonymous"?s.credentials="omit":s.credentials="same-origin",s}function n(i){if(i.ep)return;i.ep=!0;const s=o(i);fetch(i.href,s)}})();let u=!1;window.toggleApiKey=function(){const e=document.getElementById("apiKey"),t=document.getElementById("eyeBtn");u=!u,e.type=u?"text":"password",t.textContent=u?"\u{1F648}":"\u{1F441}\uFE0F"};window.recordShortcut=function(){const e=document.getElementById("recordBtn"),t=document.getElementById("shortcutInput"),o=a=>a.metaKey||a.key==="Meta"||a.key==="OS"||a.code==="MetaLeft"||a.code==="MetaRight";e.textContent="Recording...",e.disabled=!0,t.value="";let n="";const i=a=>{a.preventDefault(),a.stopPropagation();const c=[];a.ctrlKey&&c.push("ctrl"),a.altKey&&c.push("alt"),a.shiftKey&&c.push("shift"),o(a)&&c.push("win");let l=a.key.toLowerCase();l==="control"&&(l="ctrl"),l==="alt"&&(l="alt"),l==="shift"&&(l="shift"),(l==="meta"||l==="os")&&(l="win"),(a.code==="MetaLeft"||a.code==="MetaRight")&&(l="win"),l===" "&&(l="space");const f=new Set(c);f.add(l);const g=Array.from(f).filter(w=>w!==l);g.push(l),n=g.join("+"),t.value=n,["ctrl","alt","shift","win"].includes(l)||r()},s=a=>{a.preventDefault(),a.stopPropagation(),n&&(n.split("+").every(l=>["ctrl","alt","shift","win"].includes(l))&&n.includes("+")?setTimeout(r,100):!a.ctrlKey&&!a.altKey&&!a.shiftKey&&!o(a)&&r())},r=()=>{if(!n){e.textContent="Record",e.disabled=!1,y();return}window.go.main.App.SaveSettings({shortcut:n}).then(a=>{a&&a.indexOf("Invalid shortcut")!==-1&&alert(a),e.textContent="Record",e.disabled=!1,y()})},y=()=>{window.removeEventListener("keydown",i,!0),window.removeEventListener("keyup",s,!0)};window.addEventListener("keydown",i,!0),window.addEventListener("keyup",s,!0)};function m(e){const t=document.getElementById("historyList");if(!e||e.length===0){t.innerHTML='
No history yet.
';return}t.innerHTML="",[...e].reverse().forEach(o=>{const n=document.createElement("div");n.className="history-item";const i=document.createElement("span");i.className="history-time",i.textContent=new Date(o.timestamp).toLocaleString(void 0,{month:"short",day:"numeric",hour:"2-digit",minute:"2-digit"});const s=document.createElement("div");s.textContent=o.text,n.appendChild(i),n.appendChild(s),t.appendChild(n)})}function h(e){const t=document.getElementById("linuxYdotoolStatus");if(!t||!e)return;const o=!!e.ready;t.style.display="block",t.style.background=o?"var(--green-dim)":"var(--red-dim)",t.style.color=o?"var(--green)":"var(--red)",t.style.border=o?"1px solid rgba(61, 186, 110, 0.25)":"1px solid rgba(224, 82, 82, 0.25)",t.innerHTML="";const n=document.createElement("div");n.style.fontWeight="600",n.textContent=o?"Automatic paste ready":"Automatic paste needs ydotool setup",t.appendChild(n);const i=document.createElement("div");if(i.style.marginTop="4px",i.textContent=e.message||"",t.appendChild(i),!o&&Array.isArray(e.setup_commands)&&e.setup_commands.length){const s=document.createElement("pre");s.style.whiteSpace="pre-wrap",s.style.margin="8px 0 0",s.style.color="inherit",s.textContent=e.setup_commands.join(` `),t.appendChild(s);const r=document.createElement("div");r.style.marginTop="8px",r.style.fontWeight="600",r.textContent="Restart your computer after setup, then open WIS Free V3 again.",t.appendChild(r)}}async function v(){try{const e=await window.go.main.App.GetSettings();m(e.history||[])}catch(e){console.error("Failed to refresh history:",e)}}async function E(){try{const e=await window.go.main.App.GetSettings();document.getElementById("apiKey").value=e.api_key||"";const t=document.getElementById("shortcutInput");if(t&&(t.value=e.shortcut||"alt+z"),e.linux_press_mode){const n=document.getElementById("linuxPressSection"),i=document.getElementById("linuxPressCommand");n&&(n.style.display="block"),i&&(i.value=e.linux_press_command||""),h(e.linux_ydotool_status)}document.getElementById("whisperModel").value=e.whisper_model||"whisper-large-v3-turbo",document.getElementById("aiModel").value=e.ai_model||"llama-3.3-70b-versatile",document.getElementById("aiPrompt").value=e.ai_prompt||"",document.getElementById("language").value=e.language||"en",document.getElementById("startupToggle").checked=e.startup||!1;const o=e.app_version&&String(e.app_version).trim()||"dev";document.getElementById("appVersion").textContent=o==="dev"?"dev":"v"+o,m(e.history||[])}catch(e){console.error("Failed to load settings:",e)}}async function I(){try{const e=await window.go.main.App.GetMicrophones(),t=document.getElementById("micDevice");e.forEach(o=>{if(o.index!==-1){const n=document.createElement("option");n.value=o.index,n.textContent=o.name,t.appendChild(n)}})}catch(e){console.error("Failed to load mics:",e)}}window.saveApiKey=async function(){const e=document.getElementById("apiKey").value.trim();await window.go.main.App.SaveSettings({api_key:e}),d()};function d(e="Saved"){const t=document.getElementById("saveStatus");t.textContent=e,t.classList.add("show"),setTimeout(()=>t.classList.remove("show"),2e3)}window.saveWhisperModel=async function(){await window.go.main.App.SaveSettings({whisper_model:document.getElementById("whisperModel").value}),d()};window.saveAiModel=async function(){await window.go.main.App.SaveSettings({ai_model:document.getElementById("aiModel").value}),d()};window.saveLanguage=async function(){await window.go.main.App.SaveSettings({language:document.getElementById("language").value}),d()};window.saveMicDevice=async function(){const e=document.getElementById("micDevice").value;await window.go.main.App.SaveSettings({microphone_device:e==="default"?null:parseInt(e)}),d()};window.savePrompt=async function(){await window.go.main.App.SaveSettings({ai_prompt:document.getElementById("aiPrompt").value.trim()}),d("Prompt saved")};window.copyLinuxPressCommand=async function(){const e=document.getElementById("linuxPressCommand");if(!e)return;const t=e.value||"";try{navigator.clipboard&&navigator.clipboard.writeText?await navigator.clipboard.writeText(t):(e.focus(),e.select(),document.execCommand("copy")),d("Command copied")}catch(o){console.error("Failed to copy Linux command:",o)}};window.toggleStartup=async function(){await window.go.main.App.ToggleStartup(document.getElementById("startupToggle").checked)};window.clearHistory=async function(){confirm("Clear all transcription history?")&&(await window.go.main.App.ClearHistory(),m([]))};async function x(){try{const e=await window.go.main.App.CheckOnline(),t=document.getElementById("connectionStatus");if(e)t.innerHTML="\u25CF Online",t.style.background="var(--green-dim)",t.style.color="var(--green)",t.style.border="1px solid rgba(61, 186, 110, 0.2)";else{t.innerHTML="\u25CF Offline \u2014 install local Whisper for transcription",t.style.background="var(--red-dim)",t.style.color="var(--red)",t.style.border="1px solid rgba(224, 82, 82, 0.2)";const o=document.getElementById("aiModel");for(let n of o.options)n.value!=="None"&&!n.value.startsWith("local-")&&(n.disabled=!0,n.text.includes("(offline)")||(n.text+=" (offline)"))}}catch(e){console.error("Connection check failed:",e)}}async function p(){try{const e=await window.go.main.App.GetWhisperInfo(),t=document.getElementById("whisperModel"),o=t.querySelector('option[value^="local-"]');if(o&&o.remove(),e.installed){document.getElementById("whisperNotInstalled").style.display="none",document.getElementById("whisperInstalled").style.display="block",document.getElementById("installedModel").textContent=e.model+" model";const n=document.createElement("option");n.value="local-"+e.model,n.textContent="\u2B21 Local \u2014 "+e.model+" (offline)",n.style.fontWeight="bold",t.insertBefore(n,t.firstChild)}else document.getElementById("whisperNotInstalled").style.display="block",document.getElementById("whisperInstalled").style.display="none"}catch(e){console.error("Whisper status check failed:",e)}}window.installWhisper=async function(){const e=document.getElementById("whisperModelSelect").value,t=document.getElementById("installBtn");t.disabled=!0,t.textContent="Starting installation...";try{const o=await window.go.main.App.InstallWhisper(e);alert(o+` -A terminal window will open showing the installation progress. This may take several minutes depending on your internet speed.`),setTimeout(p,5e3)}catch(o){alert("Installation failed: "+o)}t.disabled=!1,t.textContent="Install Offline Whisper"};window.uninstallWhisper=async function(){if(!!confirm("Uninstall offline Whisper? This will delete the downloaded model."))try{const e=await window.go.main.App.UninstallWhisper();alert(e),p()}catch(e){alert("Uninstall failed: "+e)}};async function S(){try{if(navigator.userAgent.toLowerCase().includes("linux")){const t=document.getElementById("offlineWhisperSection");t&&(t.style.display="none");const o=document.getElementById("startupLabel");o&&(o.innerText="Run on Startup")}}catch(e){console.error("Failed to get environment:",e)}if(await E(),await I(),await x(),await p(),window.runtime&&typeof window.runtime.EventsOn=="function"&&window.runtime.EventsOn("history:updated",function(){v()}),navigator.userAgent.toLowerCase().includes("linux")){const e=document.getElementById("whisperModel");e&&Array.from(e.options).forEach(t=>{t.value.startsWith("local-")&&t.remove()})}}S(); +A terminal window will open showing the installation progress. This may take several minutes depending on your internet speed.`),setTimeout(p,5e3)}catch(o){alert("Installation failed: "+o)}t.disabled=!1,t.textContent="Install Offline Whisper"};window.uninstallWhisper=async function(){if(!!confirm("Uninstall offline Whisper? This will delete the downloaded model."))try{const e=await window.go.main.App.UninstallWhisper();alert(e),p()}catch(e){alert("Uninstall failed: "+e)}};async function S(){try{if(navigator.userAgent.toLowerCase().includes("linux")){const t=document.getElementById("offlineWhisperSection");t&&(t.style.display="none");const o=document.getElementById("startupLabel");o&&(o.innerText="Run on Startup")}}catch(e){console.error("Failed to get environment:",e)}if(await E(),await I(),await x(),await p(),window.runtime&&typeof window.runtime.EventsOn=="function"&&(window.runtime.EventsOn("history:updated",function(){v()}),window.runtime.EventsOn("startup:changed",function(e){document.getElementById("startupToggle").checked=e})),navigator.userAgent.toLowerCase().includes("linux")){const e=document.getElementById("whisperModel");e&&Array.from(e.options).forEach(t=>{t.value.startsWith("local-")&&t.remove()})}}S(); diff --git a/frontend/dist/index.html b/frontend/dist/index.html index 3c3b05f..5fa34e9 100644 --- a/frontend/dist/index.html +++ b/frontend/dist/index.html @@ -6,7 +6,7 @@ Wisp Settings - + @@ -38,7 +38,21 @@ -

Get your free key at console.groq.com/keys

+

Get your free key at console.groq.com/keys

+ + + +
+ +
+
+
+ +
+ +
+
+

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.

Input
diff --git a/frontend/index.html b/frontend/index.html index 27b8fc4..da6061e 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -36,7 +36,21 @@ -

Get your free key at console.groq.com/keys

+

Get your free key at console.groq.com/keys

+ + + +
+ +
+
+
+ +
+ +
+
+

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.

Input
@@ -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 diff --git a/frontend/wailsjs/runtime/runtime.d.ts b/frontend/wailsjs/runtime/runtime.d.ts index 4445dac..3bbea84 100644 --- a/frontend/wailsjs/runtime/runtime.d.ts +++ b/frontend/wailsjs/runtime/runtime.d.ts @@ -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 \ No newline at end of file +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; + +// [CleanupNotifications](https://wails.io/docs/reference/runtime/notification#cleanupnotifications) +// Cleans up notification resources and releases any held connections. +export function CleanupNotifications(): Promise; + +// [IsNotificationAvailable](https://wails.io/docs/reference/runtime/notification#isnotificationavailable) +// Checks if notifications are available on the current platform. +export function IsNotificationAvailable(): Promise; + +// [RequestNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#requestnotificationauthorization) +// Requests notification authorization from the user (macOS only). +export function RequestNotificationAuthorization(): Promise; + +// [CheckNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#checknotificationauthorization) +// Checks the current notification authorization status (macOS only). +export function CheckNotificationAuthorization(): Promise; + +// [SendNotification](https://wails.io/docs/reference/runtime/notification#sendnotification) +// Sends a basic notification with the given options. +export function SendNotification(options: NotificationOptions): Promise; + +// [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; + +// [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; + +// [RemoveNotificationCategory](https://wails.io/docs/reference/runtime/notification#removenotificationcategory) +// Removes a previously registered notification category. +export function RemoveNotificationCategory(categoryId: string): Promise; + +// [RemoveAllPendingNotifications](https://wails.io/docs/reference/runtime/notification#removeallpendingnotifications) +// Removes all pending notifications from the notification center. +export function RemoveAllPendingNotifications(): Promise; + +// [RemovePendingNotification](https://wails.io/docs/reference/runtime/notification#removependingnotification) +// Removes a specific pending notification by its identifier. +export function RemovePendingNotification(identifier: string): Promise; + +// [RemoveAllDeliveredNotifications](https://wails.io/docs/reference/runtime/notification#removealldeliverednotifications) +// Removes all delivered notifications from the notification center. +export function RemoveAllDeliveredNotifications(): Promise; + +// [RemoveDeliveredNotification](https://wails.io/docs/reference/runtime/notification#removedeliverednotification) +// Removes a specific delivered notification by its identifier. +export function RemoveDeliveredNotification(identifier: string): Promise; + +// [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; \ No newline at end of file diff --git a/frontend/wailsjs/runtime/runtime.js b/frontend/wailsjs/runtime/runtime.js index 7cb89d7..556621e 100644 --- a/frontend/wailsjs/runtime/runtime.js +++ b/frontend/wailsjs/runtime/runtime.js @@ -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); } \ No newline at end of file diff --git a/internal/audio/recorder/recorder.go b/internal/audio/recorder/recorder.go index 13c9681..8164bd4 100644 --- a/internal/audio/recorder/recorder.go +++ b/internal/audio/recorder/recorder.go @@ -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 -} \ No newline at end of file + if _, err := r.outputFile.Write([]byte("data")); err != nil { + return err + } + return binary.Write(r.outputFile, binary.LittleEndian, dataSize) +} diff --git a/internal/config/config.go b/internal/config/config.go index 0cdb939..a567925 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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:] } } diff --git a/internal/hotkey/hotkey.go b/internal/hotkey/hotkey.go index b0dd06e..8ea0393 100644 --- a/internal/hotkey/hotkey.go +++ b/internal/hotkey/hotkey.go @@ -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 } diff --git a/internal/services/transcriber/transcriber.go b/internal/services/transcriber/transcriber.go index a1c890e..d54b249 100644 --- a/internal/services/transcriber/transcriber.go +++ b/internal/services/transcriber/transcriber.go @@ -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) } diff --git a/internal/services/whisper/whisper.go b/internal/services/whisper/whisper.go index d23114f..ebf16e6 100644 --- a/internal/services/whisper/whisper.go +++ b/internal/services/whisper/whisper.go @@ -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{ diff --git a/internal/ui/tray/tray.go b/internal/ui/tray/tray.go index bf2ccaa..efc0e80 100644 --- a/internal/ui/tray/tray.go +++ b/internal/ui/tray/tray.go @@ -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. diff --git a/internal/windows/overlay.go b/internal/windows/overlay.go index 46d766f..ca2ae55 100644 --- a/internal/windows/overlay.go +++ b/internal/windows/overlay.go @@ -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 diff --git a/internal/windows/process.go b/internal/windows/process.go index 64d384f..6cfe8d4 100644 --- a/internal/windows/process.go +++ b/internal/windows/process.go @@ -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 }