package main import ( "context" "fmt" "strings" "sync" "sync/atomic" "time" "voicesnap/internal/audio" "voicesnap/internal/config" "voicesnap/internal/dock" "voicesnap/internal/engine" "voicesnap/internal/history" "voicesnap/internal/hotkey" "voicesnap/internal/input" "voicesnap/internal/language" "voicesnap/internal/logger" "voicesnap/internal/overlay" "voicesnap/internal/sound" "voicesnap/internal/textproc" "voicesnap/internal/userdict" "voicesnap/services" "github.com/wailsapp/wails/v3/pkg/application" "github.com/wailsapp/wails/v3/pkg/events" ) const ( appVersion = "2.1.30" appBuild = "20260607.1127" appDisplayVersion = appVersion + " (build " + appBuild + ")" appName = "PrivateVoice Dictation" silenceThreshold = 0.05 // RMS below this = silence (matches HasVoiceActivity) silenceTimeoutDuration = 3 * time.Second // auto-stop after this much silence in free-talk silenceGracePeriod = 2 * time.Second // don't auto-stop within first 2s of free-talk holdActivationDelay = 180 * time.Millisecond doneIndicatorHideDelayMs = 250 liveCaptionInterval = 120 * time.Millisecond liveCaptionMaxBytes = 108 ) // App holds all application state and orchestration logic. type App struct { ctx context.Context cancel context.CancelFunc wg sync.WaitGroup cfg *config.Config recorder *audio.Recorder eng engine.Engine engineMu sync.Mutex hk hotkey.Listener paster input.Paster history *history.Store userdict *userdict.Store engineService *services.EngineService wailsApp *application.App settingsWindow *application.WebviewWindow indicator overlay.Overlay // State mu sync.Mutex isRecording bool isFreetalking bool hotkeyActive bool hotkeyPressTime time.Time isCombination bool tapStopOnPress bool isRecordingHotkey bool hideGen atomic.Uint64 lastStopTime time.Time hotkeyPollSeen bool lastHotkeyDown bool lastHotkeyBlocked time.Time silenceSince time.Time // when continuous silence started (free-talk only) freeTalkStart time.Time // when free-talk mode started liveCaptionCancel context.CancelFunc liveCaptionSeq uint64 } func RunApp() error { ctx, cancel := context.WithCancel(context.Background()) defer cancel() app := &App{ ctx: ctx, cancel: cancel, } // Load config cfg, err := config.Load() if err != nil { logger.Error("Failed to load config: %v", err) cfg = config.Default() } app.cfg = cfg // Initialize audio recorder app.recorder = audio.NewRecorder() // Initialize platform-specific hotkey listener app.hk = hotkey.New() // Initialize platform-specific paster app.paster = input.NewPaster() // Initialize history store app.history = history.New() // Initialize user dictionary store app.userdict = userdict.New() // Create services for Wails bindings appService := services.NewAppService(app.cfg, appDisplayVersion) configService := services.NewConfigService(app.cfg) engineService := services.NewEngineService(app.cfg) app.engineService = engineService hotkeyService := services.NewHotkeyService(app.cfg) permissionService := services.NewPermissionService() updaterService := services.NewUpdaterService(appVersion) audioService := services.NewAudioService(app.recorder, app.cfg) historyService := services.NewHistoryService(app.history) userDictService := services.NewUserDictService(app.userdict) correctionCSVService := services.NewCorrectionCSVService(app.history, app.userdict) // Create Wails application wailsApp := application.New(application.Options{ Name: appName, Icon: appIcon, Services: []application.Service{ application.NewService(appService), application.NewService(configService), application.NewService(engineService), application.NewService(hotkeyService), application.NewService(permissionService), application.NewService(updaterService), application.NewService(audioService), application.NewService(historyService), application.NewService(userDictService), application.NewService(correctionCSVService), }, Assets: application.AssetOptions{ Handler: application.AssetFileServerFS(assets), }, OnShutdown: func() { app.cleanup() }, }) app.wailsApp = wailsApp if app.cfg.HideDockIcon { dock.SetHidden(true) } // Create settings window (visible on startup, close → hide to tray) app.settingsWindow = wailsApp.Window.NewWithOptions(application.WebviewWindowOptions{ Name: "settings", Title: appName, Width: 820, Height: 580, URL: "/", Hidden: false, BackgroundColour: application.NewRGB(242, 242, 247), // #F2F2F7 Apple grouped bg Windows: application.WindowsWindow{ Theme: application.SystemDefault, }, }) // Intercept window close: hide to tray instead of closing app.settingsWindow.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) { e.Cancel() app.settingsWindow.Hide() }) userDictService.SetApp(wailsApp, app.settingsWindow) correctionCSVService.SetApp(wailsApp, app.settingsWindow) // Create overlay indicator (native Win32 on Windows, native AppKit on macOS) app.indicator = overlay.New() app.indicator.OnDragged(func(x, y int) { x, y = app.clampIndicatorPosition(x, y) app.indicator.SetPosition(x, y) app.cfg.IndicatorX = x app.cfg.IndicatorY = y config.Save(app.cfg) }) // Create system tray tray := wailsApp.SystemTray.New() tray.SetTemplateIcon(trayIcon) showLabel, exitLabel := "Show Settings", "Exit" if language.Resolve(app.cfg.LanguageMode, app.cfg.LanguageID, language.NewSystemDetector()).UILocale == "zh" { showLabel, exitLabel = "显示设置", "退出" } trayMenu := wailsApp.Menu.New() trayMenu.Add(showLabel).OnClick(func(data *application.Context) { app.showSettings() }) trayMenu.AddSeparator() trayMenu.Add(exitLabel).OnClick(func(data *application.Context) { app.cleanup() wailsApp.Quit() }) tray.SetMenu(trayMenu) tray.OnClick(func() { app.toggleSettings() }) // Start background goroutines app.wg.Add(1) go app.hotkeyLoop() // Initialize engine asynchronously go app.initEngine() // Volume callback → native overlay + silence auto-stop app.recorder.OnVolume(func(vol float64) { app.indicator.SetVolume(vol) app.checkSilenceTimeout(vol) }) // Device change callback app.recorder.OnDeviceChange(func(name string) { wailsApp.Event.Emit("device:changed", map[string]string{"name": name}) }) // Store services references for orchestration callbacks appService.SetApp(app) hotkeyService.SetApp(app) engineService.SetApp(wailsApp) engineService.SetInitCallback(app.initEngine) logger.Info("Application initialized, starting Wails") return wailsApp.Run() } // hotkeyLoop polls the hotkey state at 30ms intervals. func (a *App) hotkeyLoop() { defer a.wg.Done() ticker := time.NewTicker(30 * time.Millisecond) defer ticker.Stop() for { select { case <-a.ctx.Done(): return case <-ticker.C: a.pollHotkey() } } } func (a *App) pollHotkey() { a.mu.Lock() defer a.mu.Unlock() isDown := a.hk.IsKeyDown(a.cfg.HotkeyVK) if !a.hotkeyPollSeen || a.lastHotkeyDown != isDown { a.hotkeyPollSeen = true a.lastHotkeyDown = isDown logger.Info( "Hotkey poll state: key=%s vk=0x%X mode=%s down=%t active=%t recording=%t freetalk=%t engineReady=%t recordingHotkey=%t", hotkey.GetKeyName(a.cfg.HotkeyVK), a.cfg.HotkeyVK, config.NormalizeHotkeyMode(a.cfg.HotkeyMode), isDown, a.hotkeyActive, a.isRecording, a.isFreetalking, a.eng != nil, a.isRecordingHotkey, ) } if a.eng == nil || a.isRecordingHotkey { if isDown && time.Since(a.lastHotkeyBlocked) > time.Second { a.lastHotkeyBlocked = time.Now() logger.Info("Hotkey ignored: key=%s engineReady=%t recordingHotkey=%t", hotkey.GetKeyName(a.cfg.HotkeyVK), a.eng != nil, a.isRecordingHotkey) } return } // Escape cancels any active recording if a.cfg.HotkeyVK != 0x1B && (a.isRecording || a.isFreetalking) && a.hk.IsKeyDown(0x1B) { a.isFreetalking = false a.isRecording = false a.lastStopTime = time.Now() a.recorder.Stop() logger.Info("Recording cancelled (Escape)") a.indicator.SetStatus(overlay.StatusCancelled, "已取消") if a.cfg.SoundFeedback { sound.PlayCancel() } a.delayedHide(1000) return } switch config.NormalizeHotkeyMode(a.cfg.HotkeyMode) { case config.HotkeyModeTap: a.pollTapHotkeyLocked(isDown) default: a.pollHoldHotkeyLocked(isDown) } } func (a *App) pollHoldHotkeyLocked(isDown bool) { if isDown { if !a.hotkeyActive { // Key just pressed a.hotkeyActive = true a.isCombination = false a.hotkeyPressTime = time.Now() logger.Info("Hotkey accepted: key=%s vk=0x%X mode=%s freetalk=%t", hotkey.GetKeyName(a.cfg.HotkeyVK), a.cfg.HotkeyVK, config.HotkeyModeHold, a.isFreetalking) // If the mode changed while a tap recording was active, let the next press stop it. if a.isFreetalking { logger.Info("Hotkey action: stop tap recording after mode switch") a.stopTapRecordingLocked() return } } else { // Key held down - check for combination keys if !a.isCombination && a.hk.IsAnyOtherKeyPressedSince(a.cfg.HotkeyVK, a.hotkeyPressTime) { a.isCombination = true logger.Info("Hotkey marked as combination: key=%s", hotkey.GetKeyName(a.cfg.HotkeyVK)) if a.isRecording { a.stopRecordingLocked(true) } } // If held long enough without combo, start hold-to-talk recording. if !a.isRecording && !a.isCombination && time.Since(a.hotkeyPressTime) > holdActivationDelay && time.Since(a.lastStopTime) > 500*time.Millisecond { logger.Info("Hotkey action: start hold-to-talk after %dms", time.Since(a.hotkeyPressTime).Milliseconds()) a.startRecordingLocked() } } } else if a.hotkeyActive { // Key released pressDuration := time.Since(a.hotkeyPressTime) logger.Info("Hotkey released: key=%s mode=%s duration=%dms recording=%t combination=%t", hotkey.GetKeyName(a.cfg.HotkeyVK), config.HotkeyModeHold, pressDuration.Milliseconds(), a.isRecording, a.isCombination) a.hotkeyActive = false if a.isRecording { // Hold-to-talk: release stops recording a.stopRecordingLocked(a.isCombination) } } } func (a *App) pollTapHotkeyLocked(isDown bool) { if isDown { if !a.hotkeyActive { a.hotkeyActive = true a.isCombination = false a.tapStopOnPress = false a.hotkeyPressTime = time.Now() logger.Info("Hotkey accepted: key=%s vk=0x%X mode=%s recording=%t", hotkey.GetKeyName(a.cfg.HotkeyVK), a.cfg.HotkeyVK, config.HotkeyModeTap, a.isFreetalking) if a.isFreetalking { logger.Info("Hotkey action: stop tap recording on press") a.tapStopOnPress = true a.stopTapRecordingLocked() } return } if !a.isCombination && a.hk.IsAnyOtherKeyPressedSince(a.cfg.HotkeyVK, a.hotkeyPressTime) { a.isCombination = true logger.Info("Hotkey marked as combination: key=%s mode=%s", hotkey.GetKeyName(a.cfg.HotkeyVK), config.HotkeyModeTap) } return } if !a.hotkeyActive { return } pressDuration := time.Since(a.hotkeyPressTime) logger.Info("Hotkey released: key=%s mode=%s duration=%dms recording=%t combination=%t", hotkey.GetKeyName(a.cfg.HotkeyVK), config.HotkeyModeTap, pressDuration.Milliseconds(), a.isRecording, a.isCombination) a.hotkeyActive = false if a.isCombination { return } if a.tapStopOnPress { a.tapStopOnPress = false return } if a.isFreetalking { logger.Info("Hotkey action: stop tap recording") a.stopTapRecordingLocked() return } if time.Since(a.lastStopTime) > 500*time.Millisecond { logger.Info("Hotkey action: start tap recording") a.startTapRecordingLocked() } } func (a *App) startRecordingLocked() { if a.isRecording { return } a.isRecording = true a.hideGen.Add(1) // cancel any pending delayed hide logger.Info("Recording started") a.positionIndicator() a.indicator.SetStatus(overlay.StatusRecording, "0:00") a.indicator.Show() if a.cfg.SoundFeedback { sound.PlayStart() } if err := a.recorder.Start(); err != nil { logger.Error("Failed to start recording: %v", err) a.isRecording = false return } a.startLiveCaptionLocked(overlay.StatusRecording) a.startRecordingTimer(overlay.StatusRecording) } func (a *App) stopRecordingLocked(cancel bool) { if !a.isRecording { return } a.isRecording = false a.lastStopTime = time.Now() if cancel { logger.Info("Recording cancelled (combination key)") a.stopLiveCaptionLocked() a.indicator.SetStatus(overlay.StatusCancelled, "已取消") a.recorder.Stop() if a.cfg.SoundFeedback { sound.PlayCancel() } a.delayedHide(1000) return } a.stopLiveCaptionLocked() hasVoice := a.recorder.HasVoiceActivity() a.indicator.SetStatus(overlay.StatusProcessing, "识别中") go func() { samples := a.recorder.StopAndGetSamples() a.recognizeAndPaste(hasVoice, samples) }() } func (a *App) startTapRecordingLocked() { if a.isRecording || a.isFreetalking { return } a.isFreetalking = true a.isRecording = true a.freeTalkStart = time.Now() a.silenceSince = time.Time{} a.hideGen.Add(1) logger.Info("Tap recording started") a.positionIndicator() a.indicator.SetStatus(overlay.StatusFreetalking, "0:00") a.indicator.Show() if a.cfg.SoundFeedback { sound.PlayStart() } if err := a.recorder.Start(); err != nil { logger.Error("Failed to start tap recording: %v", err) a.isFreetalking = false a.isRecording = false return } a.startLiveCaptionLocked(overlay.StatusFreetalking) a.startRecordingTimer(overlay.StatusFreetalking) } // startRecordingTimer updates the indicator text with elapsed time every second. // It exits automatically when a.isRecording becomes false. func (a *App) startRecordingTimer(status overlay.Status) { start := time.Now() go func() { ticker := time.NewTicker(time.Second) defer ticker.Stop() for { select { case <-a.ctx.Done(): return case <-ticker.C: a.mu.Lock() recording := a.isRecording captionActive := a.liveCaptionCancel != nil a.mu.Unlock() if !recording { return } if captionActive { continue } d := time.Since(start) a.indicator.SetStatus(status, fmt.Sprintf("%d:%02d", int(d.Minutes()), int(d.Seconds())%60)) } } }() } func (a *App) startLiveCaptionLocked(status overlay.Status) { if a.liveCaptionCancel != nil { return } a.engineMu.Lock() streamingEng, ok := a.eng.(engine.StreamingEngine) if !ok { a.engineMu.Unlock() return } ctx, cancel := context.WithCancel(a.ctx) a.liveCaptionSeq++ seq := a.liveCaptionSeq a.liveCaptionCancel = cancel go a.runLiveCaption(ctx, seq, status, streamingEng, a.engineMu.Unlock) } func (a *App) stopLiveCaptionLocked() { if a.liveCaptionCancel == nil { return } a.liveCaptionCancel() a.liveCaptionCancel = nil a.liveCaptionSeq++ } func (a *App) clearLiveCaption(seq uint64) { a.mu.Lock() defer a.mu.Unlock() if a.liveCaptionSeq == seq { a.liveCaptionCancel = nil } } func (a *App) runLiveCaption(ctx context.Context, seq uint64, status overlay.Status, streamingEng engine.StreamingEngine, releaseEngine func()) { defer a.clearLiveCaption(seq) defer releaseEngine() session, err := streamingEng.NewStreamingSession() if err != nil { logger.Error("Live caption disabled: %v", err) return } defer session.Close() ticker := time.NewTicker(liveCaptionInterval) defer ticker.Stop() offset := 0 lastDisplay := "" for { select { case <-ctx.Done(): return case <-ticker.C: samples, nextOffset := a.recorder.ReadSamplesSince(offset) offset = nextOffset if len(samples) == 0 { continue } partial, err := session.Accept(samples) if err != nil { logger.Error("Live caption failed: %v", err) return } display := liveCaptionDisplayText(a.userdict.Apply(textproc.PostProcess(partial))) if display == "" || display == lastDisplay { continue } a.mu.Lock() active := a.isRecording && a.liveCaptionSeq == seq a.mu.Unlock() if !active { return } lastDisplay = display a.indicator.SetStatus(status, display) } } } func liveCaptionDisplayText(text string) string { text = strings.TrimSpace(text) if text == "" || len([]byte(text)) <= liveCaptionMaxBytes { return text } runes := []rune(text) for len(runes) > 0 && len([]byte("..."+string(runes))) > liveCaptionMaxBytes { runes = runes[1:] } if len(runes) == 0 { return "" } return "..." + string(runes) } func (a *App) stopTapRecordingLocked() { if !a.isFreetalking { return } a.isFreetalking = false a.isRecording = false a.lastStopTime = time.Now() a.stopLiveCaptionLocked() hasVoice := a.recorder.HasVoiceActivity() logger.Info("Tap recording stopped") a.indicator.SetStatus(overlay.StatusProcessing, "识别中") go func() { samples := a.recorder.StopAndGetSamples() a.recognizeAndPaste(hasVoice, samples) }() } // checkSilenceTimeout monitors legacy free-talk recordings. The explicit tap // mode is user-stopped by pressing the hotkey again, so it does not auto-stop // on silence. // Called from the audio volume callback (~every 50ms). func (a *App) checkSilenceTimeout(vol float64) { a.mu.Lock() defer a.mu.Unlock() if !a.isFreetalking || config.NormalizeHotkeyMode(a.cfg.HotkeyMode) == config.HotkeyModeTap { a.silenceSince = time.Time{} return } // Grace period: don't auto-stop in the first 2 seconds if time.Since(a.freeTalkStart) < silenceGracePeriod { return } if vol > silenceThreshold { a.silenceSince = time.Time{} // voice detected, reset return } // Silence detected if a.silenceSince.IsZero() { a.silenceSince = time.Now() return } if time.Since(a.silenceSince) >= silenceTimeoutDuration { logger.Info("Silence timeout in free talk mode, auto-stopping") a.silenceSince = time.Time{} // Mark state immediately to prevent re-triggering from subsequent callbacks // and to block pollHotkey from starting a new recording before the device stops. a.isFreetalking = false a.isRecording = false a.lastStopTime = time.Now() a.stopLiveCaptionLocked() // MUST stop device in a separate goroutine: we are inside the audio // data callback, and device.Stop() waits for in-flight callbacks to // finish — calling it here would deadlock. go a.silenceAutoStop() } } // silenceAutoStop performs the actual device stop and ASR after a silence timeout. // Runs in its own goroutine to avoid deadlocking the audio callback thread. func (a *App) silenceAutoStop() { hasVoice := a.recorder.HasVoiceActivity() samples := a.recorder.StopAndGetSamples() logger.Info("Free talk stopped (silence auto-stop)") a.indicator.SetStatus(overlay.StatusProcessing, "识别中") a.recognizeAndPaste(hasVoice, samples) } // recognizeAndPaste runs ASR on the recorded samples and pastes the result. // Shared by both hold and tap modes. func (a *App) recognizeAndPaste(hasVoice bool, samples []float32) { // Snapshot config values under lock to avoid data races a.mu.Lock() soundFeedback := a.cfg.SoundFeedback hotkeyVK := a.cfg.HotkeyVK autoHide := a.cfg.AutoHide copyToClipboard := a.cfg.CopyToClipboard a.mu.Unlock() if !hasVoice { logger.Info("No voice activity detected") a.indicator.SetStatus(overlay.StatusNoVoice, "无语音") a.delayedHideIf(autoHide, 1500) return } if len(samples) == 0 { a.indicator.SetStatus(overlay.StatusNoContent, "无内容") a.delayedHideIf(autoHide, 1500) return } a.engineMu.Lock() eng := a.eng if eng == nil { a.engineMu.Unlock() logger.Error("Recognition skipped: engine not ready") a.indicator.SetStatus(overlay.StatusError, "引擎未就绪") a.delayedHideIf(autoHide, 2000) return } text, err := eng.Recognize(samples) a.engineMu.Unlock() if err != nil { logger.Error("Recognition failed: %v", err) a.indicator.SetStatus(overlay.StatusError, "错误") a.delayedHideIf(autoHide, 2000) return } text = textproc.PostProcess(text) text = a.userdict.Apply(text) if text == "" { a.indicator.SetStatus(overlay.StatusNoContent, "无内容") a.delayedHideIf(autoHide, 1500) return } logger.Info("Recognized: %s", text) a.history.Add(text) a.waitForHotkeyRelease(hotkeyVK) if err := a.paster.Paste(text, copyToClipboard); err != nil { logger.Error("Paste failed, trying fallback: %v", err) if err := a.paster.TypeText(text); err != nil { logger.Error("Fallback type also failed: %v", err) a.indicator.SetStatus(overlay.StatusError, "需辅助权限") a.delayedHideIf(autoHide, 2500) return } } a.indicator.SetStatus(overlay.StatusDone, "完成") if soundFeedback { sound.PlayDone() } a.delayedHideIf(autoHide, doneIndicatorHideDelayMs) } // waitForHotkeyRelease polls until the hotkey is released (max 500ms), // then waits an additional 50ms settling delay. func (a *App) waitForHotkeyRelease(hotkeyVK int) { for i := 0; i < 50; i++ { if !a.hk.IsKeyDown(hotkeyVK) { break } time.Sleep(10 * time.Millisecond) } time.Sleep(50 * time.Millisecond) } // delayedHide hides the indicator after a delay if AutoHide is enabled. // Used from contexts where a.mu is held (reads a.cfg.AutoHide safely). func (a *App) delayedHide(delayMs int) { if a.cfg.AutoHide { a.scheduleHide(delayMs) } } // delayedHideIf hides the indicator after a delay if autoHide is true. // Used from goroutines with a pre-snapshotted config value. func (a *App) delayedHideIf(autoHide bool, delayMs int) { if autoHide { a.scheduleHide(delayMs) } } func (a *App) scheduleHide(delayMs int) { gen := a.hideGen.Load() go func() { time.Sleep(time.Duration(delayMs) * time.Millisecond) if a.hideGen.Load() == gen { a.indicator.Hide() } }() } func (a *App) initEngine() { logger.Info("Initializing ASR engine...") if a.engineService != nil { a.engineService.SetStatus("loading", "", "") } a.wailsApp.Event.Emit("engine:status", map[string]interface{}{ "status": "loading", }) modelExists := engine.ModelExists() eng, err := engine.New() if err != nil { logger.Error("Engine initialization failed: %v", err) a.replaceEngine(nil) status := "need_model" if modelExists { status = "error" } if a.engineService != nil { a.engineService.SetStatus(status, "", err.Error()) } a.wailsApp.Event.Emit("engine:status", map[string]interface{}{ "status": status, "error": err.Error(), }) return } a.replaceEngine(eng) logger.Info("ASR engine ready: %s", eng.HardwareInfo()) if a.engineService != nil { a.engineService.SetStatus("ready", eng.HardwareInfo(), "") } a.wailsApp.Event.Emit("engine:status", map[string]interface{}{ "status": "ready", "hardwareInfo": eng.HardwareInfo(), }) // Show indicator briefly — snapshot config under lock a.mu.Lock() hotkeyVK := a.cfg.HotkeyVK hotkeyMode := config.NormalizeHotkeyMode(a.cfg.HotkeyMode) autoHide := a.cfg.AutoHide indX := a.cfg.IndicatorX indY := a.cfg.IndicatorY a.mu.Unlock() keyName := hotkey.GetKeyName(hotkeyVK) a.positionIndicatorAt(indX, indY) a.indicator.SetStatus(overlay.StatusReady, hotkeyReadyText(keyName, hotkeyMode)) a.indicator.Show() a.delayedHideIf(autoHide, 2000) } func (a *App) replaceEngine(eng engine.Engine) { a.engineMu.Lock() old := a.eng a.eng = eng a.engineMu.Unlock() if old != nil && old != eng { old.Close() } } func hotkeyReadyText(keyName, mode string) string { if mode == config.HotkeyModeTap { return "点按" + keyName + "说话" } return "按住" + keyName + "说话" } // positionIndicator places the indicator at the saved position, or bottom-center if not saved. // Caller must hold a.mu (reads a.cfg). func (a *App) positionIndicator() { a.positionIndicatorAt(a.cfg.IndicatorX, a.cfg.IndicatorY) } // positionIndicatorAt places the indicator at (x, y), or bottom-center if both are 0. func (a *App) positionIndicatorAt(x, y int) { indicatorW, indicatorH := a.indicator.Size() if indicatorW <= 0 { indicatorW = 170 } if indicatorH <= 0 { indicatorH = 48 } // On macOS, place the overlay near the currently focused input window each // time instead of reusing a stale saved position from another display. if autoPositioner, ok := a.indicator.(interface{ AutoPosition() }); ok { autoPositioner.AutoPosition() return } screen := a.wailsApp.Screen.GetPrimary() if screen == nil { if x != 0 || y != 0 { a.indicator.SetPosition(x, y) } return } wa := screen.WorkArea if x == 0 && y == 0 { x = wa.X + (wa.Width-indicatorW)/2 y = wa.Y + wa.Height - indicatorH - 100 x, y = clampOverlayPosition(x, y, indicatorW, indicatorH, wa.X, wa.Y, wa.Width, wa.Height) a.indicator.SetPosition(x, y) return } x, y = a.clampIndicatorPositionWithSize(x, y, indicatorW, indicatorH) a.indicator.SetPosition(x, y) } func (a *App) clampIndicatorPosition(x, y int) (int, int) { if a.wailsApp == nil || a.indicator == nil { return x, y } indicatorW, indicatorH := a.indicator.Size() if indicatorW <= 0 { indicatorW = 170 } if indicatorH <= 0 { indicatorH = 48 } return a.clampIndicatorPositionWithSize(x, y, indicatorW, indicatorH) } func (a *App) clampIndicatorPositionWithSize(x, y, indicatorW, indicatorH int) (int, int) { screen := a.bestScreenForOverlay(x, y, indicatorW, indicatorH) if screen == nil { return x, y } wa := screen.WorkArea return clampOverlayPosition(x, y, indicatorW, indicatorH, wa.X, wa.Y, wa.Width, wa.Height) } func (a *App) bestScreenForOverlay(x, y, overlayW, overlayH int) *application.Screen { if a.wailsApp == nil { return nil } screens := a.wailsApp.Screen.GetAll() if len(screens) == 0 { if primary := a.wailsApp.Screen.GetPrimary(); primary != nil { return primary } return nil } centerX := x + overlayW/2 centerY := y + overlayH/2 for _, screen := range screens { wa := screen.WorkArea if pointInRect(centerX, centerY, wa.X, wa.Y, wa.Width, wa.Height) { return screen } } var best *application.Screen bestScore := 0 bestDistance := 0 for _, screen := range screens { wa := screen.WorkArea intersection := intersectionArea(x, y, overlayW, overlayH, wa.X, wa.Y, wa.Width, wa.Height) distance := rectDistanceSquared(x, y, overlayW, overlayH, wa.X, wa.Y, wa.Width, wa.Height) if best == nil || intersection > bestScore || (intersection == bestScore && distance < bestDistance) { best = screen bestScore = intersection bestDistance = distance } } return best } func clampOverlayPosition(x, y, overlayW, overlayH, areaX, areaY, areaW, areaH int) (int, int) { return clampOverlayAxis(x, areaX, areaW, overlayW), clampOverlayAxis(y, areaY, areaH, overlayH) } func clampOverlayAxis(pos, areaStart, areaSize, overlaySize int) int { if areaSize <= 0 || overlaySize <= 0 { return pos } if overlaySize >= areaSize { return areaStart + (areaSize-overlaySize)/2 } minPos := areaStart maxPos := areaStart + areaSize - overlaySize if pos < minPos { return minPos } if pos > maxPos { return maxPos } return pos } func pointInRect(x, y, rx, ry, rw, rh int) bool { return x >= rx && x < rx+rw && y >= ry && y < ry+rh } func intersectionArea(ax, ay, aw, ah, bx, by, bw, bh int) int { left := max(ax, bx) top := max(ay, by) right := min(ax+aw, bx+bw) bottom := min(ay+ah, by+bh) if right <= left || bottom <= top { return 0 } return (right - left) * (bottom - top) } func rectDistanceSquared(ax, ay, aw, ah, bx, by, bw, bh int) int { dx := max(0, max(bx-(ax+aw), ax-(bx+bw))) dy := max(0, max(by-(ay+ah), ay-(by+bh))) return dx*dx + dy*dy } func (a *App) showSettings() { if a.settingsWindow != nil { a.settingsWindow.Show() a.settingsWindow.Focus() } } func (a *App) toggleSettings() { if a.settingsWindow != nil { if a.settingsWindow.IsVisible() { a.settingsWindow.Hide() } else { a.settingsWindow.Show() a.settingsWindow.Focus() } } } func (a *App) cleanup() { logger.Info("Cleaning up...") a.cancel() if a.indicator != nil { a.indicator.Close() } if a.recorder != nil { a.recorder.Close() } a.replaceEngine(nil) a.wg.Wait() logger.Info("Cleanup complete") } // SetRecordingHotkey is called by the hotkey service when entering hotkey recording mode. func (a *App) SetRecordingHotkey(recording bool) { a.mu.Lock() defer a.mu.Unlock() a.isRecordingHotkey = recording if !recording { a.hotkeyActive = false a.lastStopTime = time.Now() } } // UpdateHotkeyVK updates the hotkey virtual key code. func (a *App) UpdateHotkeyVK(vk int) { a.mu.Lock() defer a.mu.Unlock() a.cfg.HotkeyVK = vk config.Save(a.cfg) } // GetEngine returns the current engine (may be nil). func (a *App) GetEngine() engine.Engine { a.engineMu.Lock() defer a.engineMu.Unlock() return a.eng } // SetEngine sets the engine after model download. func (a *App) SetEngine(eng engine.Engine) { a.replaceEngine(eng) }