mod state; mod storage; mod timer; use std::sync::{Arc, Mutex}; use chrono::Utc; use tauri::{AppHandle, Manager, State}; use uuid::Uuid; 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") } // ── 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>) { let mut ts = timer.0.lock().unwrap(); ts.running = true; } #[tauri::command] fn pause_timer(timer: State<'_, TimerStateWrapper>) { let mut ts = timer.0.lock().unwrap(); ts.running = false; } #[tauri::command] fn reset_timer(timer: State<'_, TimerStateWrapper>, data: State<'_, AppDataWrapper>) { 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; } #[tauri::command] fn skip_phase( timer: State<'_, TimerStateWrapper>, data: State<'_, AppDataWrapper>, app: AppHandle, ) { use tauri::Emitter; let mut ts = timer.0.lock().unwrap(); let app_data = data.data.lock().unwrap(); let s = &app_data.settings; match ts.phase { TimerPhase::Work => { ts.session_count += 1; let (next_phase, next_secs) = if ts.session_count % s.sessions_before_long_break == 0 { (TimerPhase::LongBreak, s.long_break_secs) } else { (TimerPhase::ShortBreak, s.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 = s.work_duration_secs; ts.remaining_secs = s.work_duration_secs; } } ts.running = false; let _ = app.emit( "timer-phase-changed", timer::PhaseChangedPayload { phase: ts.phase, session_count: ts.session_count, }, ); } #[tauri::command] fn set_current_task( task_id: Option, timer: State<'_, TimerStateWrapper>, data: State<'_, AppDataWrapper>, app: AppHandle, ) -> Result<(), String> { { let mut app_data = data.data.lock().unwrap(); // Validate task exists if Some if let Some(ref tid) = task_id { if !app_data.tasks.iter().any(|t| &t.id == tid) { return Err(format!("Task {} not found", tid)); } } app_data.current_task_id = task_id.clone(); storage::save(&app_data_dir(&app), &app_data)?; } { let mut ts = timer.0.lock().unwrap(); ts.current_task_id = task_id; } 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>, 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(); storage::save(&app_data_dir(&app), &app_data)?; } // 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; } 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 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; } } Ok(()) } // ── 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"); // Load persisted data let app_data = storage::load(&data_dir); let work_secs = app_data.settings.work_duration_secs; let current_task_id = app_data.current_task_id.clone(); let data_arc = Arc::new(Mutex::new(app_data)); let timer_arc = Arc::new(Mutex::new({ let mut ts = TimerState::new(work_secs); ts.current_task_id = current_task_id; ts })); // Register managed state app.manage(TimerStateWrapper(Arc::clone(&timer_arc))); app.manage(AppDataWrapper { data: Arc::clone(&data_arc), }); // Spawn background timer thread timer::spawn_timer_thread( app.handle().clone(), Arc::clone(&timer_arc), Arc::clone(&data_arc), data_dir, ); Ok(()) }) .invoke_handler(tauri::generate_handler![ get_timer_status, start_timer, pause_timer, reset_timer, skip_phase, set_current_task, get_settings, update_settings, get_tasks, add_task, update_task, delete_task, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); }