diff options
| author | Solstice <solstice@local> | 2026-06-09 01:10:46 -0700 |
|---|---|---|
| committer | Solstice <solstice@local> | 2026-06-09 01:10:46 -0700 |
| commit | 887c0bc6f968f80ac90220f24bb578438e05708a (patch) | |
| tree | 563925e9bc82ae0eee582dc9128ea753d0082ab0 /src-tauri | |
| parent | 4e2d978eb5fc9457d5b913bc10faf1266e6dcda4 (diff) | |
fix: resolve final release blockers
Diffstat (limited to 'src-tauri')
| -rw-r--r-- | src-tauri/src/lib.rs | 66 | ||||
| -rw-r--r-- | src-tauri/src/state.rs | 26 | ||||
| -rw-r--r-- | src-tauri/src/storage.rs | 108 | ||||
| -rw-r--r-- | src-tauri/src/timer.rs | 35 |
4 files changed, 211 insertions, 24 deletions
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c298fdf..aff41cc 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -23,6 +23,17 @@ fn app_data_dir(app: &AppHandle) -> std::path::PathBuf { app.path().app_data_dir().expect("Failed to resolve app data dir") } +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) { @@ -49,19 +60,33 @@ fn get_timer_status(timer: State<'_, TimerStateWrapper>) -> serde_json::Value { } #[tauri::command] -fn start_timer(timer: State<'_, TimerStateWrapper>) { +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>) { +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>) { +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(); @@ -74,6 +99,7 @@ fn reset_timer(timer: State<'_, TimerStateWrapper>, data: State<'_, AppDataWrapp ts.remaining_secs = total; ts.total_secs = total; ts.running = false; + persist_timer_snapshot(&ts, &data, &app) } #[tauri::command] @@ -82,7 +108,7 @@ fn skip_phase( data: State<'_, AppDataWrapper>, audio: State<'_, AudioState>, app: AppHandle, -) { +) -> Result<(), String> { use tauri::Emitter; let mut ts = timer.0.lock().unwrap(); let app_data = data.data.lock().unwrap(); @@ -111,6 +137,7 @@ fn skip_phase( 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); @@ -122,6 +149,8 @@ fn skip_phase( session_count, }, ); + + Ok(()) } #[tauri::command] @@ -132,19 +161,17 @@ fn set_current_task( app: AppHandle, ) -> Result<(), String> { { - let mut app_data = data.data.lock().unwrap(); - // Validate task exists if Some + 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)); } } - 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; + persist_timer_snapshot(&ts, &data, &app)?; } Ok(()) } @@ -170,7 +197,6 @@ fn update_settings( { 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 { @@ -180,6 +206,7 @@ fn update_settings( 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(()) @@ -199,6 +226,14 @@ fn add_task( data: State<'_, AppDataWrapper>, app: AppHandle, ) -> Result<Task, String> { + 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, @@ -268,6 +303,7 @@ fn delete_task( 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(()) @@ -363,14 +399,14 @@ pub fn run() { // 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 timer_snapshot = app_data.timer_snapshot.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 - })); + 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))); diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 8f3d52b..c6b4d03 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -1,6 +1,6 @@ use std::sync::{Arc, Mutex}; use serde::{Deserialize, Serialize}; -use crate::storage::AppData; +use crate::storage::{AppData, TimerSnapshot}; #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -10,7 +10,7 @@ pub enum TimerPhase { LongBreak, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct TimerState { pub phase: TimerPhase, pub remaining_secs: u64, @@ -39,4 +39,26 @@ impl TimerState { current_task_id: None, } } + + pub fn from_snapshot(snapshot: TimerSnapshot) -> Self { + Self { + phase: snapshot.phase, + remaining_secs: snapshot.remaining_secs, + total_secs: snapshot.total_secs, + running: snapshot.running, + session_count: snapshot.session_count, + current_task_id: snapshot.current_task_id, + } + } + + pub fn snapshot(&self) -> TimerSnapshot { + TimerSnapshot { + phase: self.phase, + remaining_secs: self.remaining_secs, + total_secs: self.total_secs, + running: self.running, + session_count: self.session_count, + current_task_id: self.current_task_id.clone(), + } + } } diff --git a/src-tauri/src/storage.rs b/src-tauri/src/storage.rs index 8e2f148..2e726a3 100644 --- a/src-tauri/src/storage.rs +++ b/src-tauri/src/storage.rs @@ -2,6 +2,8 @@ use std::fs; use std::path::PathBuf; use serde::{Deserialize, Serialize}; +use crate::state::TimerPhase; + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Settings { pub work_duration_secs: u64, @@ -32,10 +34,22 @@ pub struct Task { } #[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TimerSnapshot { + pub phase: TimerPhase, + pub remaining_secs: u64, + pub total_secs: u64, + pub running: bool, + pub session_count: u32, + pub current_task_id: Option<String>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct AppData { pub settings: Settings, pub tasks: Vec<Task>, pub current_task_id: Option<String>, + #[serde(default)] + pub timer_snapshot: Option<TimerSnapshot>, } impl Default for AppData { @@ -44,6 +58,7 @@ impl Default for AppData { settings: Settings::default(), tasks: Vec::new(), current_task_id: None, + timer_snapshot: None, } } } @@ -77,7 +92,100 @@ pub fn save(app_data_dir: &PathBuf, data: &AppData) -> Result<(), String> { let tmp_path = app_data_dir.join("data.json.tmp"); fs::write(&tmp_path, contents) .map_err(|e| format!("Failed to write temp data file: {}", e))?; + + if cfg!(windows) { + if path.exists() { + fs::remove_file(&path) + .map_err(|e| format!("Failed to remove existing data file: {}", e))?; + } + } + fs::rename(&tmp_path, &path) .map_err(|e| format!("Failed to finalize data file: {}", e))?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::{load, save, AppData, Settings, Task, TimerSnapshot}; + use crate::state::TimerPhase; + use std::fs; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn temp_dir(label: &str) -> std::path::PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!("solstice-storage-{label}-{unique}")) + } + + #[test] + fn load_preserves_persisted_timer_snapshot() { + let dir = temp_dir("load-timer-snapshot"); + fs::create_dir_all(&dir).unwrap(); + + let json = serde_json::json!({ + "settings": { + "work_duration_secs": 1500, + "short_break_secs": 300, + "long_break_secs": 900, + "sessions_before_long_break": 4 + }, + "tasks": [], + "current_task_id": "task-1", + "timer_snapshot": { + "phase": "short_break", + "remaining_secs": 120, + "total_secs": 300, + "running": false, + "session_count": 2, + "current_task_id": "task-1" + } + }) + .to_string(); + fs::write(dir.join("data.json"), json).unwrap(); + + let data = load(&dir); + + let snapshot = data.timer_snapshot.expect("expected timer snapshot"); + assert_eq!(snapshot.phase, TimerPhase::ShortBreak); + assert_eq!(snapshot.remaining_secs, 120); + assert_eq!(snapshot.total_secs, 300); + assert_eq!(snapshot.session_count, 2); + assert_eq!(snapshot.current_task_id.as_deref(), Some("task-1")); + } + + #[test] + fn save_persists_timer_snapshot() { + let dir = temp_dir("save-timer-snapshot"); + let data = AppData { + settings: Settings::default(), + tasks: vec![Task { + id: "task-1".to_string(), + name: "Deep work".to_string(), + total_sessions: 4, + remaining_sessions: 3, + completed: false, + created_at: "2026-06-09T00:00:00Z".to_string(), + }], + current_task_id: Some("task-1".to_string()), + timer_snapshot: Some(TimerSnapshot { + phase: TimerPhase::Work, + remaining_secs: 600, + total_secs: 1500, + running: true, + session_count: 1, + current_task_id: Some("task-1".to_string()), + }), + }; + + save(&dir, &data).unwrap(); + let reloaded = load(&dir); + + let snapshot = reloaded.timer_snapshot.expect("expected timer snapshot"); + assert!(snapshot.running); + assert_eq!(snapshot.remaining_secs, 600); + assert_eq!(snapshot.total_secs, 1500); + } +} diff --git a/src-tauri/src/timer.rs b/src-tauri/src/timer.rs index 322795e..02ae285 100644 --- a/src-tauri/src/timer.rs +++ b/src-tauri/src/timer.rs @@ -8,6 +8,12 @@ use crate::audio::{self, AudioEngine}; use crate::state::{TimerPhase, TimerState}; use crate::storage::{self, AppData}; +fn persist_timer_state(data: &mut AppData, timer: &TimerState, data_dir: &std::path::PathBuf) { + data.current_task_id = timer.current_task_id.clone(); + data.timer_snapshot = Some(timer.snapshot()); + let _ = storage::save(data_dir, data); +} + // 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. @@ -68,20 +74,22 @@ pub fn spawn_timer_thread( session_count: ts.session_count, current_task_id: ts.current_task_id.clone(), }; + + { + let mut data = data_arc.lock().unwrap(); + persist_timer_state(&mut data, &ts, &data_dir); + } + let _ = app_handle.emit("timer-tick", tick); // Check if phase has reached zero if ts.remaining_secs == 0 { ts.running = false; + let mut emit_completed = false; + let mut completed_task_id = None; match ts.phase { TimerPhase::Work => { - // Emit completion before transitioning - let completed = CompletedPayload { - task_id: ts.current_task_id.clone(), - }; - let _ = app_handle.emit("timer-completed", completed); - // Decrement task remaining sessions let task_id = ts.current_task_id.clone(); { @@ -96,8 +104,9 @@ pub fn spawn_timer_thread( } } } - let _ = storage::save(&data_dir, &data); } + emit_completed = true; + completed_task_id = task_id; ts.session_count += 1; @@ -129,6 +138,11 @@ pub fn spawn_timer_thread( } } + { + let mut data = data_arc.lock().unwrap(); + persist_timer_state(&mut data, &ts, &data_dir); + } + // Emit phase-changed after state is updated if let Some(engine) = audio_arc.lock().unwrap().as_mut() { if audio::should_duck_for_phase(ts.phase) { @@ -138,6 +152,13 @@ pub fn spawn_timer_thread( } } + if emit_completed { + let completed = CompletedPayload { + task_id: completed_task_id, + }; + let _ = app_handle.emit("timer-completed", completed); + } + let phase_changed = PhaseChangedPayload { phase: ts.phase, session_count: ts.session_count, |
