From b5f8fba700ead47d481ae17d84ee4c7a706e9896 Mon Sep 17 00:00:00 2001 From: Josh Ribakoff Date: Sun, 25 Jan 2026 20:58:38 -0800 Subject: [PATCH] fix: global lock preventing concurrent operations Fixes #641, #462 Co-Authored-By: Claude Opus 4.5 --- src-tauri/src/actions.rs | 49 +++++++++--------- src-tauri/src/global_controller.rs | 83 ++++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 10 +++- src-tauri/src/overlay.rs | 12 +---- src-tauri/src/shortcut/handler.rs | 8 +-- src-tauri/src/utils.rs | 9 ++-- 6 files changed, 128 insertions(+), 43 deletions(-) create mode 100644 src-tauri/src/global_controller.rs diff --git a/src-tauri/src/actions.rs b/src-tauri/src/actions.rs index 6dfa52d31..0f7d0f5b3 100644 --- a/src-tauri/src/actions.rs +++ b/src-tauri/src/actions.rs @@ -1,13 +1,13 @@ #[cfg(all(target_os = "macos", target_arch = "aarch64"))] use crate::apple_intelligence; use crate::audio_feedback::{play_feedback_sound, play_feedback_sound_blocking, SoundType}; +use crate::global_controller::GlobalController; use crate::managers::audio::AudioRecordingManager; use crate::managers::history::HistoryManager; use crate::managers::transcription::TranscriptionManager; use crate::settings::{get_settings, AppSettings, APPLE_INTELLIGENCE_PROVIDER_ID}; use crate::shortcut; -use crate::tray::{change_tray_icon, TrayIconState}; -use crate::utils::{self, show_recording_overlay, show_transcribing_overlay}; +use crate::utils; use crate::ManagedToggleState; use ferrous_opencc::{config::BuiltinConfig, OpenCC}; use log::{debug, error}; @@ -218,14 +218,18 @@ impl ShortcutAction for TranscribeAction { let start_time = Instant::now(); debug!("TranscribeAction::start called for binding: {}", binding_id); + // Acquire global lock - block if already busy + let controller = app.state::>(); + if !controller.begin() { + debug!("TranscribeAction::start blocked - already busy"); + return; + } + // Load model in the background let tm = app.state::>(); tm.initiate_model_load(); let binding_id = binding_id.to_string(); - change_tray_icon(app, TrayIconState::Recording); - show_recording_overlay(app); - let rm = app.state::>(); // Get the microphone mode to determine audio feedback timing @@ -275,6 +279,9 @@ impl ShortcutAction for TranscribeAction { if recording_started { // Dynamically register the cancel shortcut in a separate task to avoid deadlock shortcut::register_cancel_shortcut(app); + } else { + // Recording failed - release the lock + controller.complete(); } debug!( @@ -284,19 +291,24 @@ impl ShortcutAction for TranscribeAction { } fn stop(&self, app: &AppHandle, binding_id: &str, _shortcut_str: &str) { - // Unregister the cancel shortcut when transcription stops - shortcut::unregister_cancel_shortcut(app); - let stop_time = Instant::now(); debug!("TranscribeAction::stop called for binding: {}", binding_id); + // Transition to Processing phase - only proceed if we were Recording + let controller = app.state::>(); + if !controller.advance() { + debug!("TranscribeAction::stop ignored - not in Recording phase"); + return; + } + + // Unregister the cancel shortcut + shortcut::unregister_cancel_shortcut(app); + let ah = app.clone(); let rm = Arc::clone(&app.state::>()); let tm = Arc::clone(&app.state::>()); let hm = Arc::clone(&app.state::>()); - - change_tray_icon(app, TrayIconState::Transcribing); - show_transcribing_overlay(app); + let ctrl = Arc::clone(&controller); // Unmute before playing audio feedback so the stop sound is audible rm.remove_mute(); @@ -394,36 +406,27 @@ impl ShortcutAction for TranscribeAction { ), Err(e) => error!("Failed to paste transcription: {}", e), } - // Hide the overlay after transcription is complete - utils::hide_recording_overlay(&ah_clone); - change_tray_icon(&ah_clone, TrayIconState::Idle); }) .unwrap_or_else(|e| { error!("Failed to run paste on main thread: {:?}", e); - utils::hide_recording_overlay(&ah); - change_tray_icon(&ah, TrayIconState::Idle); }); - } else { - utils::hide_recording_overlay(&ah); - change_tray_icon(&ah, TrayIconState::Idle); } } Err(err) => { debug!("Global Shortcut Transcription error: {}", err); - utils::hide_recording_overlay(&ah); - change_tray_icon(&ah, TrayIconState::Idle); } } } else { debug!("No samples retrieved from recording stop"); - utils::hide_recording_overlay(&ah); - change_tray_icon(&ah, TrayIconState::Idle); } // Clear toggle state now that transcription is complete if let Ok(mut states) = ah.state::().lock() { states.active_toggles.insert(binding_id, false); } + + // Release the global lock + ctrl.complete(); }); debug!( diff --git a/src-tauri/src/global_controller.rs b/src-tauri/src/global_controller.rs new file mode 100644 index 000000000..223e334f5 --- /dev/null +++ b/src-tauri/src/global_controller.rs @@ -0,0 +1,83 @@ +//! Global lock with declarative UI sync. + +use std::sync::Mutex; +use tauri::AppHandle; + +use crate::overlay::{hide_recording_overlay, show_recording_overlay, show_transcribing_overlay}; +use crate::tray::{change_tray_icon, TrayIconState}; + +#[derive(Debug, Clone, PartialEq)] +pub enum GlobalPhase { + Idle, + Recording, + Processing, +} + +pub struct GlobalController { + phase: Mutex, + app: AppHandle, +} + +impl GlobalController { + pub fn new(app: AppHandle) -> Self { + Self { + phase: Mutex::new(GlobalPhase::Idle), + app, + } + } + + fn sync_ui(&self, phase: &GlobalPhase) { + match phase { + GlobalPhase::Idle => { + change_tray_icon(&self.app, TrayIconState::Idle); + hide_recording_overlay(&self.app); + } + GlobalPhase::Recording => { + change_tray_icon(&self.app, TrayIconState::Recording); + show_recording_overlay(&self.app); + } + GlobalPhase::Processing => { + change_tray_icon(&self.app, TrayIconState::Transcribing); + show_transcribing_overlay(&self.app); + } + } + } + + pub fn begin(&self) -> bool { + let mut phase = self.phase.lock().unwrap(); + if *phase == GlobalPhase::Idle { + *phase = GlobalPhase::Recording; + self.sync_ui(&phase); + true + } else { + false + } + } + + pub fn advance(&self) -> bool { + let mut phase = self.phase.lock().unwrap(); + if *phase == GlobalPhase::Recording { + *phase = GlobalPhase::Processing; + self.sync_ui(&phase); + true + } else { + false + } + } + + pub fn complete(&self) { + let mut phase = self.phase.lock().unwrap(); + *phase = GlobalPhase::Idle; + self.sync_ui(&phase); + } + + pub fn is_busy(&self) -> bool { + *self.phase.lock().unwrap() != GlobalPhase::Idle + } + + /// Re-sync UI to current phase (e.g., after theme change) + pub fn refresh_ui(&self) { + let phase = self.phase.lock().unwrap(); + self.sync_ui(&phase); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 556dd3a12..0939ac558 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,6 +5,7 @@ mod audio_feedback; pub mod audio_toolkit; mod clipboard; mod commands; +mod global_controller; mod helpers; mod input; mod llm_client; @@ -39,6 +40,7 @@ use tauri::{AppHandle, Manager}; use tauri_plugin_autostart::{MacosLauncher, ManagerExt}; use tauri_plugin_log::{Builder as LogBuilder, RotationStrategy, Target, TargetKind}; +use crate::global_controller::GlobalController; use crate::settings::get_settings; // Global atomic to store the file log level filter @@ -134,6 +136,9 @@ fn initialize_core_logic(app_handle: &AppHandle) { app_handle.manage(transcription_manager.clone()); app_handle.manage(history_manager.clone()); + // Global lock - prevents concurrent operations + app_handle.manage(Arc::new(GlobalController::new(app_handle.clone()))); + // Initialize the shortcuts shortcut::init_shortcuts(app_handle); @@ -413,8 +418,9 @@ pub fn run() { } tauri::WindowEvent::ThemeChanged(theme) => { log::info!("Theme changed to: {:?}", theme); - // Update tray icon to match new theme, maintaining idle state - utils::change_tray_icon(&window.app_handle(), utils::TrayIconState::Idle); + // Re-sync UI to current phase with new theme + let controller = window.app_handle().state::>(); + controller.refresh_ui(); } _ => {} }) diff --git a/src-tauri/src/overlay.rs b/src-tauri/src/overlay.rs index 4d328a4ca..325dd7e37 100644 --- a/src-tauri/src/overlay.rs +++ b/src-tauri/src/overlay.rs @@ -262,19 +262,11 @@ pub fn update_overlay_position(app_handle: &AppHandle) { } } -/// Hides the recording overlay window with fade-out animation +/// Hides the recording overlay window pub fn hide_recording_overlay(app_handle: &AppHandle) { - // Always hide the overlay regardless of settings - if setting was changed while recording, - // we still want to hide it properly if let Some(overlay_window) = app_handle.get_webview_window("recording_overlay") { - // Emit event to trigger fade-out animation let _ = overlay_window.emit("hide-overlay", ()); - // Hide the window after a short delay to allow animation to complete - let window_clone = overlay_window.clone(); - std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_millis(300)); - let _ = window_clone.hide(); - }); + let _ = overlay_window.hide(); } } diff --git a/src-tauri/src/shortcut/handler.rs b/src-tauri/src/shortcut/handler.rs index c00fb47b5..225091594 100644 --- a/src-tauri/src/shortcut/handler.rs +++ b/src-tauri/src/shortcut/handler.rs @@ -8,7 +8,7 @@ use std::sync::Arc; use tauri::{AppHandle, Manager}; use crate::actions::ACTION_MAP; -use crate::managers::audio::AudioRecordingManager; +use crate::global_controller::GlobalController; use crate::settings::get_settings; use crate::ManagedToggleState; @@ -41,10 +41,10 @@ pub fn handle_shortcut_event( return; }; - // Cancel binding: only fires when recording and key is pressed + // Cancel binding: only fires when busy and key is pressed if binding_id == "cancel" { - let audio_manager = app.state::>(); - if audio_manager.is_recording() && is_pressed { + let controller = app.state::>(); + if controller.is_busy() && is_pressed { action.start(app, binding_id, hotkey_string); } return; diff --git a/src-tauri/src/utils.rs b/src-tauri/src/utils.rs index 29d870451..768b82837 100644 --- a/src-tauri/src/utils.rs +++ b/src-tauri/src/utils.rs @@ -1,3 +1,4 @@ +use crate::global_controller::GlobalController; use crate::managers::audio::AudioRecordingManager; use crate::managers::transcription::TranscriptionManager; use crate::shortcut; @@ -33,14 +34,14 @@ pub fn cancel_current_operation(app: &AppHandle) { let audio_manager = app.state::>(); audio_manager.cancel_recording(); - // Update tray icon and hide overlay - change_tray_icon(app, crate::tray::TrayIconState::Idle); - hide_recording_overlay(app); - // Unload model if immediate unload is enabled let tm = app.state::>(); tm.maybe_unload_immediately("cancellation"); + // Release the global lock + let controller = app.state::>(); + controller.complete(); + info!("Operation cancellation completed - returned to idle state"); }