diff --git a/src-tauri/src/actions.rs b/src-tauri/src/actions.rs index 6dfa52d31..f118e2b93 100644 --- a/src-tauri/src/actions.rs +++ b/src-tauri/src/actions.rs @@ -4,6 +4,7 @@ use crate::audio_feedback::{play_feedback_sound, play_feedback_sound_blocking, S use crate::managers::audio::AudioRecordingManager; use crate::managers::history::HistoryManager; use crate::managers::transcription::TranscriptionManager; +use crate::global_controller::GlobalController; use crate::settings::{get_settings, AppSettings, APPLE_INTELLIGENCE_PROVIDER_ID}; use crate::shortcut; use crate::tray::{change_tray_icon, TrayIconState}; @@ -218,6 +219,13 @@ 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(); @@ -275,6 +283,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!( @@ -290,10 +301,15 @@ impl ShortcutAction for TranscribeAction { let stop_time = Instant::now(); debug!("TranscribeAction::stop called for binding: {}", binding_id); + // Transition to Processing phase + let controller = app.state::>(); + controller.advance(); + let ah = app.clone(); let rm = Arc::clone(&app.state::>()); let tm = Arc::clone(&app.state::>()); let hm = Arc::clone(&app.state::>()); + let ctrl = Arc::clone(&controller); change_tray_icon(app, TrayIconState::Transcribing); show_transcribing_overlay(app); @@ -424,6 +440,9 @@ impl ShortcutAction for TranscribeAction { 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..c0d639054 --- /dev/null +++ b/src-tauri/src/global_controller.rs @@ -0,0 +1,149 @@ +//! Global lock preventing concurrent operations. +//! +//! # Dependency Injection for Testing +//! +//! Inject Recorder/Transcriber so tests can use mocks. + +use std::sync::Mutex; + +/// Recorder trait - implement for real hardware and mocks. +pub trait Recorder: Send + Sync { + fn start(&self) -> Result<(), String>; + fn stop(&self) -> Result, String>; +} + +/// Transcriber trait - implement for real transcription and mocks. +pub trait Transcriber: Send + Sync { + fn transcribe(&self, samples: Vec) -> Result; +} + +pub enum GlobalPhase { + Idle, + Recording, + Processing, +} + +pub struct GlobalController { + phase: Mutex, +} + +impl GlobalController { + pub fn new() -> Self { + Self { + phase: Mutex::new(GlobalPhase::Idle), + } + } + + /// Acquire lock. Returns false if busy. + pub fn begin(&self) -> bool { + let mut phase = self.phase.lock().unwrap(); + if matches!(*phase, GlobalPhase::Idle) { + *phase = GlobalPhase::Recording; + true + } else { + false + } + } + + /// Recording -> Processing. + pub fn advance(&self) { + let mut phase = self.phase.lock().unwrap(); + if matches!(*phase, GlobalPhase::Recording) { + *phase = GlobalPhase::Processing; + } + } + + /// Release lock. + pub fn complete(&self) { + *self.phase.lock().unwrap() = GlobalPhase::Idle; + } + + pub fn is_busy(&self) -> bool { + !matches!(*self.phase.lock().unwrap(), GlobalPhase::Idle) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn starts_idle() { + let c = GlobalController::new(); + assert!(!c.is_busy()); + } + + #[test] + fn begin_acquires_lock() { + let c = GlobalController::new(); + assert!(c.begin()); + assert!(c.is_busy()); + } + + #[test] + fn begin_fails_when_busy() { + let c = GlobalController::new(); + assert!(c.begin()); + assert!(!c.begin()); + } + + #[test] + fn complete_releases_lock() { + let c = GlobalController::new(); + c.begin(); + c.complete(); + assert!(!c.is_busy()); + } + + #[test] + fn full_lifecycle() { + let c = GlobalController::new(); + assert!(c.begin()); // Idle -> Recording + c.advance(); // Recording -> Processing + c.complete(); // Processing -> Idle + assert!(!c.is_busy()); + assert!(c.begin()); // Can start again + } + + /// Mock recorder that can be configured to fail. + struct MockRecorder { + should_fail: bool, + } + + impl MockRecorder { + fn that_fails() -> Self { + Self { should_fail: true } + } + } + + impl Recorder for MockRecorder { + fn start(&self) -> Result<(), String> { + if self.should_fail { + Err("mock failure".into()) + } else { + Ok(()) + } + } + + fn stop(&self) -> Result, String> { + Ok(vec![]) + } + } + + #[test] + fn recorder_failure_releases_lock() { + let controller = GlobalController::new(); + let recorder = MockRecorder::that_fails(); + + // Acquire lock + assert!(controller.begin()); + + // Recorder fails + let result = recorder.start(); + assert!(result.is_err()); + + // Caller must release lock on failure + controller.complete(); + assert!(!controller.is_busy()); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 556dd3a12..2715fae35 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())); + // Initialize the shortcuts shortcut::init_shortcuts(app_handle); 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..f9184f3ad 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; @@ -41,6 +42,10 @@ pub fn cancel_current_operation(app: &AppHandle) { 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"); }