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