mod audio; mod state; mod storage; mod timer; use std::sync::{Arc, Mutex}; use chrono::Utc; use serde::Serialize; use tauri::{AppHandle, Manager, State}; use uuid::Uuid; use audio::AudioState; use state::{AppDataWrapper, TimerPhase, TimerState, TimerStateWrapper}; use storage::{Settings, Task}; // LOCK ORDERING: When acquiring multiple locks, always take timer_state // before app_data to prevent deadlock. Never acquire app_data first // while timer_state is not held. // ── Helper ────────────────────────────────────────────────────────────────── fn app_data_dir(app: &AppHandle) -> std::path::PathBuf { app.path().app_data_dir().expect("Failed to resolve app data dir") } fn fallback_bundled_audio_root() -> std::path::PathBuf { std::path::PathBuf::from("src-tauri") } fn bundled_audio_dir(app: &AppHandle) -> std::path::PathBuf { app.path() .resource_dir() .unwrap_or_else(|_| fallback_bundled_audio_root()) .join("audio") } fn persist_timer_snapshot( timer: &TimerState, data: &State<'_, AppDataWrapper>, app: &AppHandle, ) -> Result<(), String> { let mut app_data = data.data.lock().unwrap(); app_data.current_task_id = timer.current_task_id.clone(); app_data.timer_snapshot = Some(timer.snapshot()); storage::save(&app_data_dir(app), &app_data) } fn sync_audio_ducking(audio: &AudioState, phase: TimerPhase) { if let Some(engine) = audio.0.lock().unwrap().as_mut() { if audio::should_duck_for_phase(phase) { engine.duck(); } else { engine.unduck(); } } } fn clear_session_count_only(timer: &mut TimerState) { timer.session_count = 0; } // ── Timer commands ────────────────────────────────────────────────────────── #[tauri::command] fn get_timer_status(timer: State<'_, TimerStateWrapper>) -> serde_json::Value { let ts = timer.0.lock().unwrap(); serde_json::json!({ "phase": ts.phase, "remaining_secs": ts.remaining_secs, "total_secs": ts.total_secs, "running": ts.running, "session_count": ts.session_count, "current_task_id": ts.current_task_id, }) } #[tauri::command] fn start_timer( timer: State<'_, TimerStateWrapper>, data: State<'_, AppDataWrapper>, app: AppHandle, ) -> Result<(), String> { let mut ts = timer.0.lock().unwrap(); ts.running = true; persist_timer_snapshot(&ts, &data, &app) } #[tauri::command] fn pause_timer( timer: State<'_, TimerStateWrapper>, data: State<'_, AppDataWrapper>, app: AppHandle, ) -> Result<(), String> { let mut ts = timer.0.lock().unwrap(); ts.running = false; persist_timer_snapshot(&ts, &data, &app) } #[tauri::command] fn reset_timer( timer: State<'_, TimerStateWrapper>, data: State<'_, AppDataWrapper>, app: AppHandle, ) -> Result<(), String> { let mut ts = timer.0.lock().unwrap(); let total = { let app_data = data.data.lock().unwrap(); match ts.phase { TimerPhase::Work => app_data.settings.work_duration_secs, TimerPhase::ShortBreak => app_data.settings.short_break_secs, TimerPhase::LongBreak => app_data.settings.long_break_secs, } }; ts.remaining_secs = total; ts.total_secs = total; ts.running = false; persist_timer_snapshot(&ts, &data, &app) } #[tauri::command] fn clear_session_count( timer: State<'_, TimerStateWrapper>, data: State<'_, AppDataWrapper>, app: AppHandle, ) -> Result<(), String> { let mut ts = timer.0.lock().unwrap(); clear_session_count_only(&mut ts); persist_timer_snapshot(&ts, &data, &app) } #[tauri::command] fn skip_phase( timer: State<'_, TimerStateWrapper>, data: State<'_, AppDataWrapper>, audio: State<'_, AudioState>, app: AppHandle, ) -> Result<(), String> { use tauri::Emitter; let mut ts = timer.0.lock().unwrap(); let (work_duration_secs, short_break_secs, long_break_secs, sessions_before_long_break) = { let app_data = data.data.lock().unwrap(); let settings = &app_data.settings; ( settings.work_duration_secs, settings.short_break_secs, settings.long_break_secs, settings.sessions_before_long_break, ) }; match ts.phase { TimerPhase::Work => { ts.session_count += 1; let (next_phase, next_secs) = if ts.session_count % sessions_before_long_break == 0 { (TimerPhase::LongBreak, long_break_secs) } else { (TimerPhase::ShortBreak, short_break_secs) }; ts.phase = next_phase; ts.total_secs = next_secs; ts.remaining_secs = next_secs; } TimerPhase::ShortBreak | TimerPhase::LongBreak => { ts.phase = TimerPhase::Work; ts.total_secs = work_duration_secs; ts.remaining_secs = work_duration_secs; } } ts.running = false; let phase = ts.phase; let session_count = ts.session_count; persist_timer_snapshot(&ts, &data, &app)?; drop(ts); sync_audio_ducking(&audio, phase); let _ = app.emit( "timer-phase-changed", timer::PhaseChangedPayload { phase, session_count, }, ); Ok(()) } #[tauri::command] fn set_current_task( task_id: Option, timer: State<'_, TimerStateWrapper>, data: State<'_, AppDataWrapper>, app: AppHandle, ) -> Result<(), String> { { let app_data = data.data.lock().unwrap(); if let Some(ref tid) = task_id { if !app_data.tasks.iter().any(|t| &t.id == tid) { return Err(format!("Task {} not found", tid)); } } } { let mut ts = timer.0.lock().unwrap(); ts.current_task_id = task_id; persist_timer_snapshot(&ts, &data, &app)?; } Ok(()) } // ── Settings commands ─────────────────────────────────────────────────────── #[tauri::command] fn get_settings(data: State<'_, AppDataWrapper>) -> Settings { data.data.lock().unwrap().settings.clone() } #[tauri::command] fn update_settings( settings: Settings, data: State<'_, AppDataWrapper>, timer: State<'_, TimerStateWrapper>, audio: State<'_, AudioState>, app: AppHandle, ) -> Result<(), String> { if settings.sessions_before_long_break == 0 { return Err("sessions_before_long_break must be at least 1".to_string()); } { let mut app_data = data.data.lock().unwrap(); app_data.settings = settings.clone(); } // Restart timer at new work duration { let mut ts = timer.0.lock().unwrap(); ts.phase = TimerPhase::Work; ts.total_secs = settings.work_duration_secs; ts.remaining_secs = settings.work_duration_secs; ts.running = false; ts.session_count = 0; persist_timer_snapshot(&ts, &data, &app)?; } sync_audio_ducking(&audio, TimerPhase::Work); Ok(()) } // ── Task commands ─────────────────────────────────────────────────────────── #[tauri::command] fn get_tasks(data: State<'_, AppDataWrapper>) -> Vec { data.data.lock().unwrap().tasks.clone() } #[tauri::command] fn add_task( name: String, total_sessions: u32, data: State<'_, AppDataWrapper>, app: AppHandle, ) -> Result { let name = name.trim().to_string(); if name.is_empty() { return Err("Task name cannot be empty".to_string()); } if total_sessions == 0 { return Err("total_sessions must be at least 1".to_string()); } let task = Task { id: Uuid::new_v4().to_string(), name, total_sessions, remaining_sessions: total_sessions, completed: false, created_at: Utc::now().to_rfc3339(), }; { let mut app_data = data.data.lock().unwrap(); app_data.tasks.push(task.clone()); storage::save(&app_data_dir(&app), &app_data)?; } Ok(task) } #[tauri::command] fn update_task( id: String, remaining_sessions: Option, completed: Option, data: State<'_, AppDataWrapper>, app: AppHandle, ) -> Result<(), String> { // Collect mutated data inside block, then save outside so the lock is // not held across the I/O call (consistent with LOCK ORDERING above). let data_snapshot = { let mut app_data = data.data.lock().unwrap(); let task = app_data .tasks .iter_mut() .find(|t| t.id == id) .ok_or_else(|| format!("Task {} not found", id))?; if let Some(r) = remaining_sessions { task.remaining_sessions = r; } if let Some(c) = completed { task.completed = c; } app_data.clone() }; storage::save(&app_data_dir(&app), &data_snapshot)?; Ok(()) } #[tauri::command] fn delete_task( id: String, data: State<'_, AppDataWrapper>, timer: State<'_, TimerStateWrapper>, app: AppHandle, ) -> Result<(), String> { { let mut app_data = data.data.lock().unwrap(); let before = app_data.tasks.len(); app_data.tasks.retain(|t| t.id != id); if app_data.tasks.len() == before { return Err(format!("Task {} not found", id)); } // Clear current_task_id if it was this task if app_data.current_task_id.as_deref() == Some(&id) { app_data.current_task_id = None; } storage::save(&app_data_dir(&app), &app_data)?; } { let mut ts = timer.0.lock().unwrap(); if ts.current_task_id.as_deref() == Some(&id) { ts.current_task_id = None; persist_timer_snapshot(&ts, &data, &app)?; } } Ok(()) } // ── Audio commands ────────────────────────────────────────────────────────── #[derive(Serialize)] struct AudioStatus { available: bool, playing: bool, sound: Option, volume: f32, } #[tauri::command] fn play_ambient( sound: String, audio: State<'_, AudioState>, timer: State<'_, TimerStateWrapper>, ) -> Result<(), String> { let mut guard = audio.0.lock().unwrap(); match guard.as_mut() { None => Err("Audio not available".to_string()), Some(engine) => { if sound == "none" { engine.stop(); Ok(()) } else { match audio::AmbientSound::from_str(&sound) { Some(s) => { engine.play(s)?; let phase = timer.0.lock().unwrap().phase; if audio::should_duck_for_phase(phase) { engine.duck(); } else { engine.unduck(); } Ok(()) } None => Err(format!("Unknown sound: {sound}")), } } } } } #[tauri::command] fn stop_ambient(audio: State<'_, AudioState>) { if let Some(ref mut engine) = *audio.0.lock().unwrap() { engine.stop(); } } #[tauri::command] fn set_ambient_volume(volume: f32, audio: State<'_, AudioState>) -> Result<(), String> { match audio.0.lock().unwrap().as_mut() { None => Err("Audio not available".to_string()), Some(engine) => { engine.set_volume(volume); Ok(()) } } } #[tauri::command] fn get_audio_status(audio: State<'_, AudioState>) -> AudioStatus { match audio.0.lock().unwrap().as_ref() { None => AudioStatus { available: false, playing: false, sound: None, volume: 0.5 }, Some(engine) => AudioStatus { available: engine.has_any_audio_files(), playing: engine.is_playing(), sound: engine.current_sound().map(|s| s.name().to_string()), volume: engine.volume(), }, } } // ── App entry point ───────────────────────────────────────────────────────── #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_notification::init()) .setup(|app| { let data_dir = app.handle().path().app_data_dir() .expect("Failed to resolve app data dir"); let app_audio_dir = data_dir.join("audio"); std::fs::create_dir_all(&app_audio_dir) .expect("Failed to create app audio dir"); // Load persisted data let app_data = storage::load(&data_dir); let work_secs = app_data.settings.work_duration_secs; let timer_snapshot = app_data.timer_snapshot.clone(); let data_arc = Arc::new(Mutex::new(app_data)); let timer_arc = Arc::new(Mutex::new( timer_snapshot .map(TimerState::from_snapshot) .unwrap_or_else(|| TimerState::new(work_secs)) )); // Register managed state app.manage(TimerStateWrapper(Arc::clone(&timer_arc))); app.manage(AppDataWrapper { data: Arc::clone(&data_arc), }); // Initialise audio engine (graceful if no device) let bundled_audio_dir = bundled_audio_dir(app.handle()); let audio_state = audio::init_audio(app_audio_dir, bundled_audio_dir); let audio_arc = Arc::clone(&audio_state.0); app.manage(audio_state); // Spawn background timer thread timer::spawn_timer_thread( app.handle().clone(), Arc::clone(&timer_arc), Arc::clone(&data_arc), audio_arc, data_dir, ); Ok(()) }) .invoke_handler(tauri::generate_handler![ get_timer_status, start_timer, pause_timer, reset_timer, clear_session_count, skip_phase, set_current_task, get_settings, update_settings, get_tasks, add_task, update_task, delete_task, play_ambient, stop_ambient, set_ambient_volume, get_audio_status, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } #[cfg(test)] mod tests { use super::{clear_session_count_only, fallback_bundled_audio_root}; use crate::state::{TimerPhase, TimerState}; #[test] fn dev_audio_fallback_points_at_src_tauri() { assert_eq!(fallback_bundled_audio_root(), std::path::PathBuf::from("src-tauri")); } #[test] fn clear_session_count_preserves_phase_and_duration() { let mut state = TimerState { phase: TimerPhase::ShortBreak, remaining_secs: 120, total_secs: 300, running: false, session_count: 4, current_task_id: Some("task-1".to_string()), }; clear_session_count_only(&mut state); assert_eq!(state.phase, TimerPhase::ShortBreak); assert_eq!(state.remaining_secs, 120); assert_eq!(state.total_secs, 300); assert!(!state.running); assert_eq!(state.session_count, 0); assert_eq!(state.current_task_id.as_deref(), Some("task-1")); } }