Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions src-tauri/src/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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::<Arc<GlobalController>>();
if !controller.begin() {
debug!("TranscribeAction::start blocked - already busy");
return;
}

// Load model in the background
let tm = app.state::<Arc<TranscriptionManager>>();
tm.initiate_model_load();
Expand Down Expand Up @@ -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!(
Expand All @@ -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::<Arc<GlobalController>>();
controller.advance();

let ah = app.clone();
let rm = Arc::clone(&app.state::<Arc<AudioRecordingManager>>());
let tm = Arc::clone(&app.state::<Arc<TranscriptionManager>>());
let hm = Arc::clone(&app.state::<Arc<HistoryManager>>());
let ctrl = Arc::clone(&controller);

change_tray_icon(app, TrayIconState::Transcribing);
show_transcribing_overlay(app);
Expand Down Expand Up @@ -424,6 +440,9 @@ impl ShortcutAction for TranscribeAction {
if let Ok(mut states) = ah.state::<ManagedToggleState>().lock() {
states.active_toggles.insert(binding_id, false);
}

// Release the global lock
ctrl.complete();
});

debug!(
Expand Down
149 changes: 149 additions & 0 deletions src-tauri/src/global_controller.rs
Original file line number Diff line number Diff line change
@@ -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<Vec<f32>, String>;
}

/// Transcriber trait - implement for real transcription and mocks.
pub trait Transcriber: Send + Sync {
fn transcribe(&self, samples: Vec<f32>) -> Result<String, String>;
}

pub enum GlobalPhase {
Idle,
Recording,
Processing,
}

pub struct GlobalController {
phase: Mutex<GlobalPhase>,
}

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<Vec<f32>, 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());
}
}
5 changes: 5 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);

Expand Down
8 changes: 4 additions & 4 deletions src-tauri/src/shortcut/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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::<Arc<AudioRecordingManager>>();
if audio_manager.is_recording() && is_pressed {
let controller = app.state::<Arc<GlobalController>>();
if controller.is_busy() && is_pressed {
action.start(app, binding_id, hotkey_string);
}
return;
Expand Down
5 changes: 5 additions & 0 deletions src-tauri/src/utils.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use crate::global_controller::GlobalController;
use crate::managers::audio::AudioRecordingManager;
use crate::managers::transcription::TranscriptionManager;
use crate::shortcut;
Expand Down Expand Up @@ -41,6 +42,10 @@ pub fn cancel_current_operation(app: &AppHandle) {
let tm = app.state::<Arc<TranscriptionManager>>();
tm.maybe_unload_immediately("cancellation");

// Release the global lock
let controller = app.state::<Arc<GlobalController>>();
controller.complete();

info!("Operation cancellation completed - returned to idle state");
}

Expand Down
Loading