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 @@
-
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.
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
}