summaryrefslogtreecommitdiff
path: root/src-tauri/src
diff options
context:
space:
mode:
authorSolstice <solstice@local>2026-06-09 01:10:46 -0700
committerSolstice <solstice@local>2026-06-09 01:10:46 -0700
commit887c0bc6f968f80ac90220f24bb578438e05708a (patch)
tree563925e9bc82ae0eee582dc9128ea753d0082ab0 /src-tauri/src
parent4e2d978eb5fc9457d5b913bc10faf1266e6dcda4 (diff)
fix: resolve final release blockers
Diffstat (limited to 'src-tauri/src')
-rw-r--r--src-tauri/src/lib.rs66
-rw-r--r--src-tauri/src/state.rs26
-rw-r--r--src-tauri/src/storage.rs108
-rw-r--r--src-tauri/src/timer.rs35
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,