Skip to main content

mimick/
state_manager.rs

1//! Stores persistent status snapshots to restore basic UI state across application launches.
2//!
3//! Tracks the current sync progress, folder-level status, error history,
4//! and recent queue events. The state file is written atomically so a
5//! crash mid-write never corrupts the snapshot.
6
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::fs;
10use std::path::PathBuf;
11use std::time::SystemTime;
12
13/// Represents a rolling queue/event status used by the settings window inspector.
14#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
15pub struct QueueEvent {
16    /// File path where the event occurred.
17    pub path: String,
18    /// Outcome label of this synchronization event.
19    pub status: String,
20    /// Detailed warning or cause description if failed.
21    #[serde(default)]
22    pub detail: Option<String>,
23    /// Number of sync attempts performed.
24    #[serde(default)]
25    pub attempts: u32,
26    /// Epoch timestamp of this event.
27    pub timestamp: f64,
28}
29
30/// Represents the status of an individual watch folder.
31#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)]
32pub struct FolderSyncStatus {
33    /// Epoch timestamp of the last folder synchronization sweep.
34    pub last_sync_at: Option<f64>,
35    /// Number of local files waiting in queue for this folder.
36    pub pending_count: usize,
37    /// Target Immich album mapped to this folder.
38    pub target_album: Option<String>,
39    /// Error warning recorded on last folder sync failure.
40    pub last_error: Option<String>,
41}
42
43/// Target movement direction of active transfers.
44#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)]
45pub enum TransferDirection {
46    /// Files uploaded from local directories to Immich server.
47    #[default]
48    Upload,
49    /// Files downloaded from Immich server to local directories.
50    Download,
51}
52
53/// Snapshot describing currently executing and active file transfers.
54#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq)]
55pub struct TransferSnapshot {
56    /// True if a transfer batch session is currently running.
57    #[serde(default)]
58    pub active: bool,
59    /// Direction of current active transfer session.
60    #[serde(default)]
61    pub direction: TransferDirection,
62    /// Total bytes processed in current transfer task.
63    #[serde(default)]
64    pub current_bytes: u64,
65    /// Total bytes target across current session.
66    #[serde(default)]
67    pub total_bytes: Option<u64>,
68    /// Estimated sum of all item file sizes in the active batch.
69    #[serde(default)]
70    pub session_total_bytes: u64,
71    /// Sum of all bytes successfully sent or received in this session.
72    #[serde(default)]
73    pub session_transferred_bytes: u64,
74    /// Total target bytes of previously finished items in the batch.
75    #[serde(default)]
76    pub completed_total_bytes: u64,
77    /// Total bytes completed of previously finished items in the batch.
78    #[serde(default)]
79    pub completed_transferred_bytes: u64,
80    /// Instant speed in bytes per second.
81    #[serde(default)]
82    pub instant_bps: f64,
83    /// Average session speed in bytes per second.
84    #[serde(default)]
85    pub session_avg_bps: f64,
86    /// Epoch timestamp when the transfer session was started.
87    #[serde(default)]
88    pub session_started_at: Option<f64>,
89    /// Snapshot count of completed session bytes.
90    #[serde(default)]
91    pub session_bytes_done: u64,
92    /// Label representing the file currently being processed.
93    #[serde(default)]
94    pub active_item_label: Option<String>,
95    /// Active network route segment used for connection.
96    #[serde(default)]
97    pub active_route: Option<String>,
98    /// Average bytes per second achieved on the last upload batch.
99    #[serde(default)]
100    pub last_upload_avg_bps: f64,
101    /// Average bytes per second achieved on the last download batch.
102    #[serde(default)]
103    pub last_download_avg_bps: f64,
104    /// Tick epoch timestamp used to recalculate instant speed.
105    #[serde(skip)]
106    pub last_tick_at: Option<f64>,
107    /// Bytes processed on the last instant speed tick check.
108    #[serde(skip)]
109    pub last_tick_bytes: u64,
110    /// Number of active parallel upload workers.
111    #[serde(skip)]
112    pub active_uploads: usize,
113    /// Number of active parallel download workers.
114    #[serde(skip)]
115    pub active_downloads: usize,
116    /// Track of active item transferred bytes maps.
117    #[serde(skip)]
118    pub active_item_bytes: HashMap<String, u64>,
119    /// Track of active item total sizes maps.
120    #[serde(skip)]
121    pub active_item_totals: HashMap<String, u64>,
122}
123
124/// Contains shared progress counters exposed to the settings window.
125#[derive(Serialize, Deserialize, Clone, Debug)]
126pub struct AppState {
127    /// Number of items remaining in background queue.
128    pub queue_size: usize,
129    /// Total number of items scheduled this session.
130    pub total_queued: usize,
131    /// Number of processed items during current session.
132    pub processed_count: usize,
133    /// Number of items failing transmission.
134    #[serde(default)]
135    pub failed_count: usize,
136    /// In-flight worker count — not persisted to disk.
137    #[serde(skip)]
138    pub active_workers: usize,
139    /// Relative path of file currently undergoing transmission.
140    pub current_file: Option<String>,
141    /// Global application status label.
142    pub status: String,
143    /// Percentage progress indicator of active batch.
144    pub progress: u8,
145    /// Epoch timestamp of this state snapshot.
146    pub timestamp: f64,
147    /// True if all processing queues are manually paused.
148    #[serde(default)]
149    pub paused: bool,
150    /// User or system reason why transmission is suspended.
151    #[serde(default)]
152    pub pause_reason: Option<String>,
153    /// Number of watched folders configured.
154    #[serde(default)]
155    pub watched_folder_count: usize,
156    /// Connection route label of the current session.
157    #[serde(default)]
158    pub active_server_route: Option<String>,
159    /// Epoch timestamp when the last sweep successfully finished.
160    #[serde(default)]
161    pub last_successful_sync_at: Option<f64>,
162    /// Diagnostic warning of the last critical error.
163    #[serde(default)]
164    pub last_error: Option<String>,
165    /// Troubleshooting guidance instructions for last_error.
166    #[serde(default)]
167    pub last_error_guidance: Option<String>,
168    /// Filename of the last successfully processed asset.
169    #[serde(default)]
170    pub last_completed_file: Option<String>,
171    /// Count of diagnostic bundles exported.
172    #[serde(default)]
173    pub diagnostics_exports: usize,
174    /// Historic rolling logs of recent queue event results.
175    #[serde(default)]
176    pub recent_events: Vec<QueueEvent>,
177    /// Current sync states mapped by watched folder paths.
178    #[serde(default)]
179    pub folder_statuses: std::collections::HashMap<String, FolderSyncStatus>,
180    /// Active metrics representing ongoing network transfers.
181    #[serde(default)]
182    pub transfer: TransferSnapshot,
183    /// Total completed batches of uploads executed.
184    #[serde(skip)]
185    pub completed_upload_batches: u64,
186}
187
188impl Default for AppState {
189    fn default() -> Self {
190        Self {
191            queue_size: 0,
192            total_queued: 0,
193            processed_count: 0,
194            failed_count: 0,
195            active_workers: 0,
196            current_file: None,
197            status: "idle".to_string(),
198            progress: 0,
199            timestamp: 0.0,
200            paused: false,
201            pause_reason: None,
202            watched_folder_count: 0,
203            active_server_route: None,
204            last_successful_sync_at: None,
205            last_error: None,
206            last_error_guidance: None,
207            last_completed_file: None,
208            diagnostics_exports: 0,
209            recent_events: Vec::new(),
210            folder_statuses: std::collections::HashMap::new(),
211            transfer: TransferSnapshot::default(),
212            completed_upload_batches: 0,
213        }
214    }
215}
216
217impl AppState {
218    const MAX_EVENTS: usize = 80;
219
220    /// Add or update a rolling queue event entry in history.
221    pub fn record_event(
222        &mut self,
223        path: impl Into<String>,
224        status: impl Into<String>,
225        detail: Option<String>,
226        attempts: u32,
227    ) {
228        let path = path.into();
229        let status = status.into();
230        let timestamp = SystemTime::now()
231            .duration_since(SystemTime::UNIX_EPOCH)
232            .unwrap_or_default()
233            .as_secs_f64();
234
235        if let Some(existing) = self.recent_events.iter_mut().find(|evt| evt.path == path) {
236            existing.status = status;
237            existing.detail = detail;
238            existing.attempts = attempts;
239            existing.timestamp = timestamp;
240        } else {
241            self.recent_events.push(QueueEvent {
242                path,
243                status,
244                detail,
245                attempts,
246                timestamp,
247            });
248        }
249
250        self.recent_events
251            .sort_by(|a, b| b.timestamp.total_cmp(&a.timestamp));
252        self.recent_events.truncate(Self::MAX_EVENTS);
253    }
254
255    /// Discard in-progress transfer states at application boot time.
256    pub fn reset_runtime_state(&mut self) {
257        self.transfer.reset_runtime();
258    }
259}
260
261impl TransferSnapshot {
262    /// Reset active metrics back to zero.
263    pub fn reset_runtime(&mut self) {
264        self.active = false;
265        self.direction = TransferDirection::Upload;
266        self.current_bytes = 0;
267        self.total_bytes = None;
268        self.session_total_bytes = 0;
269        self.session_transferred_bytes = 0;
270        self.completed_total_bytes = 0;
271        self.completed_transferred_bytes = 0;
272        self.instant_bps = 0.0;
273        self.session_avg_bps = 0.0;
274        self.session_started_at = None;
275        self.session_bytes_done = 0;
276        self.active_item_label = None;
277        self.active_route = None;
278        self.last_tick_at = None;
279        self.last_tick_bytes = 0;
280        self.active_uploads = 0;
281        self.active_downloads = 0;
282        self.active_item_bytes.clear();
283        self.active_item_totals.clear();
284    }
285
286    /// Check if target transfer direction should be switched.
287    fn should_switch_to(&self, direction: &TransferDirection) -> bool {
288        if !self.active {
289            return true;
290        }
291        match (&self.direction, direction) {
292            (TransferDirection::Upload, TransferDirection::Download) => self.active_uploads == 0,
293            (_, TransferDirection::Upload) => true,
294            _ => self.direction == *direction,
295        }
296    }
297
298    /// Ensure an active transfer session group is initialized.
299    fn ensure_session(&mut self, direction: TransferDirection, route: Option<String>) {
300        if self.should_switch_to(&direction) {
301            self.active = true;
302            self.direction = direction;
303            self.current_bytes = 0;
304            self.total_bytes = Some(0);
305            self.session_total_bytes = 0;
306            self.session_transferred_bytes = 0;
307            self.completed_total_bytes = 0;
308            self.completed_transferred_bytes = 0;
309            self.instant_bps = 0.0;
310            self.session_avg_bps = 0.0;
311            self.session_started_at = None;
312            self.session_bytes_done = 0;
313            self.active_item_label = None;
314            self.active_route = route;
315            self.last_tick_at = None;
316            self.last_tick_bytes = 0;
317            self.active_item_bytes.clear();
318            self.active_item_totals.clear();
319        } else if route.is_some() {
320            self.active_route = route;
321        }
322    }
323
324    /// Add an active item structure into the ongoing transfer snapshot.
325    pub fn register_item(
326        &mut self,
327        direction: TransferDirection,
328        item_id: impl Into<String>,
329        total_bytes: Option<u64>,
330        item_label: Option<String>,
331        route: Option<String>,
332    ) {
333        self.ensure_session(direction.clone(), route.clone());
334        let item_id = item_id.into();
335        let previous_total = self
336            .active_item_totals
337            .insert(item_id.clone(), total_bytes.unwrap_or(0));
338        if previous_total.is_none() {
339            match direction {
340                TransferDirection::Upload => self.active_uploads += 1,
341                TransferDirection::Download => self.active_downloads += 1,
342            }
343        }
344        self.active_item_bytes.entry(item_id).or_insert(0);
345        let active_totals: u64 = self.active_item_totals.values().copied().sum();
346        self.session_total_bytes = self.completed_total_bytes + active_totals;
347        self.total_bytes = Some(self.session_total_bytes);
348        if self.active_item_label.is_none() {
349            self.active_item_label = item_label;
350        }
351    }
352
353    /// Start tracking a group of transfer operations.
354    pub fn begin_group(
355        &mut self,
356        direction: TransferDirection,
357        item_label: Option<String>,
358        route: Option<String>,
359    ) {
360        self.ensure_session(direction, route.clone());
361        if self.active_item_label.is_none() {
362            self.active_item_label = item_label;
363        }
364        if route.is_some() {
365            self.active_route = route;
366        }
367    }
368
369    /// Update total sizes for a specific active item in the transfer.
370    pub fn update_item_total(&mut self, item_id: &str, total_bytes: u64) {
371        self.active_item_totals
372            .insert(item_id.to_string(), total_bytes);
373        let active_totals: u64 = self.active_item_totals.values().copied().sum();
374        self.session_total_bytes = self.completed_total_bytes + active_totals;
375        self.total_bytes = Some(self.session_total_bytes);
376    }
377
378    /// Update bytes completed so far for a specific active item.
379    pub fn update_item_bytes(
380        &mut self,
381        direction: TransferDirection,
382        item_id: &str,
383        bytes_done: u64,
384        route: Option<String>,
385    ) {
386        if !self.active || self.direction != direction {
387            return;
388        }
389
390        self.active_item_bytes
391            .insert(item_id.to_string(), bytes_done);
392        let active_bytes: u64 = self.active_item_bytes.values().copied().sum();
393        self.session_transferred_bytes = self.completed_transferred_bytes + active_bytes;
394        self.current_bytes = self.session_transferred_bytes;
395        self.session_bytes_done = self.session_transferred_bytes;
396        self.total_bytes = Some(self.session_total_bytes);
397
398        let now = unix_timestamp_now();
399        if self.session_started_at.is_none() {
400            self.session_started_at = Some(now);
401            self.last_tick_at = Some(now);
402            self.last_tick_bytes = self.session_transferred_bytes;
403            self.instant_bps = 0.0;
404            self.session_avg_bps = 0.0;
405            if route.is_some() {
406                self.active_route = route;
407            }
408            return;
409        }
410        let mut do_update_speed = false;
411        let mut previous_bytes = 0;
412
413        let previous_tick = self.last_tick_at.unwrap_or(now);
414        let elapsed = now - previous_tick;
415        if elapsed >= 0.25 {
416            do_update_speed = true;
417            previous_bytes = self.last_tick_bytes;
418        }
419
420        if let Some(started) = self.session_started_at {
421            self.session_avg_bps =
422                self.session_transferred_bytes as f64 / (now - started).max(0.001);
423        }
424
425        if do_update_speed {
426            let delta = self
427                .session_transferred_bytes
428                .saturating_sub(previous_bytes);
429            self.instant_bps = delta as f64 / elapsed;
430            self.last_tick_at = Some(now);
431            self.last_tick_bytes = self.session_transferred_bytes;
432        }
433
434        if route.is_some() {
435            self.active_route = route;
436        }
437    }
438
439    /// Finish tracking a specific item, returning true if the session is empty.
440    pub fn finish_item(
441        &mut self,
442        direction: TransferDirection,
443        item_id: &str,
444        route: Option<String>,
445    ) -> bool {
446        let completed_avg = self.session_avg_bps;
447        let item_final_bytes = self.active_item_bytes.remove(item_id).unwrap_or(0);
448        let item_final_total = self.active_item_totals.remove(item_id).unwrap_or(0);
449
450        self.completed_transferred_bytes += item_final_bytes;
451        self.completed_total_bytes += item_final_total;
452
453        let active_bytes: u64 = self.active_item_bytes.values().copied().sum();
454        let active_total: u64 = self.active_item_totals.values().copied().sum();
455
456        self.session_transferred_bytes = self.completed_transferred_bytes + active_bytes;
457        self.session_total_bytes = self.completed_total_bytes + active_total;
458
459        self.current_bytes = self.session_transferred_bytes;
460        self.total_bytes = Some(self.session_total_bytes);
461
462        match direction {
463            TransferDirection::Upload => {
464                self.active_uploads = self.active_uploads.saturating_sub(1)
465            }
466            TransferDirection::Download => {
467                self.active_downloads = self.active_downloads.saturating_sub(1)
468            }
469        }
470
471        match direction {
472            TransferDirection::Upload => self.last_upload_avg_bps = completed_avg,
473            TransferDirection::Download => self.last_download_avg_bps = completed_avg,
474        }
475
476        if route.is_some() {
477            self.active_route = route;
478        }
479
480        if self.active_uploads == 0 && self.active_downloads == 0 {
481            self.reset_runtime();
482            return true;
483        } else if self.active_uploads == 0 && self.direction == TransferDirection::Upload {
484            self.direction = TransferDirection::Download;
485            self.current_bytes = 0;
486            self.total_bytes = Some(self.session_total_bytes);
487            self.instant_bps = 0.0;
488            self.session_avg_bps = 0.0;
489            self.session_started_at = None;
490            self.session_bytes_done = self.session_transferred_bytes;
491            self.active_item_label = None;
492            self.last_tick_at = None;
493            self.last_tick_bytes = 0;
494        }
495        false
496    }
497}
498
499/// Return current epoch timestamp.
500fn unix_timestamp_now() -> f64 {
501    SystemTime::now()
502        .duration_since(SystemTime::UNIX_EPOCH)
503        .unwrap_or_default()
504        .as_secs_f64()
505}
506
507/// Helper that reads and writes redacted app states to disk.
508pub struct StateManager {
509    /// Resolved absolute path of status JSON cache.
510    state_file: PathBuf,
511}
512
513impl StateManager {
514    /// Point at the standard `status.json` cache path used by Mimick.
515    pub fn new() -> Self {
516        let cache_dir = crate::profile::cache_dir().unwrap_or_else(|| {
517            std::path::PathBuf::from("~/.cache").join(crate::profile::dir_segment())
518        });
519
520        let state_file = cache_dir.join("status.json");
521        Self { state_file }
522    }
523
524    /// Persist a status snapshot using a write-then-rename pattern.
525    pub fn write_state(&self, mut state: AppState) {
526        state.timestamp = SystemTime::now()
527            .duration_since(SystemTime::UNIX_EPOCH)
528            .unwrap_or_default()
529            .as_secs_f64();
530
531        if let Some(parent) = self.state_file.parent() {
532            let _ = fs::create_dir_all(parent);
533        }
534
535        if let Ok(content) = serde_json::to_string(&state) {
536            let unique_ext = format!(
537                "tmp.{}",
538                SystemTime::now()
539                    .duration_since(SystemTime::UNIX_EPOCH)
540                    .unwrap_or_default()
541                    .as_nanos()
542            );
543            let tmp_file = self.state_file.with_extension(unique_ext);
544            if fs::write(&tmp_file, &content).is_ok() {
545                if fs::rename(&tmp_file, &self.state_file).is_ok() {
546                    log::debug!(
547                        "State written: status={} progress={} processed={}/{}",
548                        state.status,
549                        state.progress,
550                        state.processed_count,
551                        state.total_queued
552                    );
553                } else {
554                    let _ = fs::remove_file(&tmp_file); // cleanup on fail
555                    log::warn!("Failed to atomically rename state file");
556                }
557            } else {
558                log::warn!("Failed to write temp state file");
559            }
560        }
561    }
562
563    /// Load the last saved state or return defaults when no cache exists.
564    pub fn read_state(&self) -> AppState {
565        match fs::read_to_string(&self.state_file) {
566            Ok(content) => match serde_json::from_str::<AppState>(&content) {
567                Ok(mut state) => {
568                    state.reset_runtime_state();
569                    log::debug!("State read: status={}", state.status);
570                    state
571                }
572                Err(e) => {
573                    log::warn!("Failed to parse state file: {}", e);
574                    AppState::default()
575                }
576            },
577            Err(_) => AppState::default(),
578        }
579    }
580}
581
582#[cfg(test)]
583mod tests {
584    use super::*;
585    use tempfile::tempdir;
586
587    #[test]
588    fn test_app_state_default() {
589        let state = AppState::default();
590        assert_eq!(state.queue_size, 0);
591        assert_eq!(state.status, "idle");
592        assert_eq!(state.progress, 0);
593        assert_eq!(state.watched_folder_count, 0);
594        assert!(state.active_server_route.is_none());
595        assert!(state.last_successful_sync_at.is_none());
596        assert!(state.last_error_guidance.is_none());
597        assert!(!state.transfer.active);
598    }
599
600    #[test]
601    fn test_state_manager_write_read() {
602        let dir = tempdir().unwrap();
603        let file_path = dir.path().join("status.json");
604
605        // We override the state_file manually for testing
606        let manager = StateManager {
607            state_file: file_path.clone(),
608        };
609
610        let state = AppState {
611            status: "syncing".to_string(),
612            progress: 50,
613            ..AppState::default()
614        };
615
616        manager.write_state(state.clone());
617
618        assert!(file_path.exists());
619
620        let read_state = manager.read_state();
621        assert_eq!(read_state.status, "syncing");
622        assert_eq!(read_state.progress, 50);
623        assert!(!read_state.transfer.active);
624    }
625
626    #[test]
627    fn test_state_manager_preserves_health_dashboard_fields() {
628        let dir = tempdir().unwrap();
629        let file_path = dir.path().join("status.json");
630        let manager = StateManager {
631            state_file: file_path,
632        };
633
634        let state = AppState {
635            watched_folder_count: 3,
636            active_server_route: Some("LAN".into()),
637            last_successful_sync_at: Some(1234.5),
638            last_error: Some("Immich rejected the API key".into()),
639            last_error_guidance: Some("Update the API key in Settings.".into()),
640            ..AppState::default()
641        };
642
643        manager.write_state(state);
644        let read_state = manager.read_state();
645
646        assert_eq!(read_state.watched_folder_count, 3);
647        assert_eq!(read_state.active_server_route.as_deref(), Some("LAN"));
648        assert_eq!(read_state.last_successful_sync_at, Some(1234.5));
649        assert_eq!(
650            read_state.last_error.as_deref(),
651            Some("Immich rejected the API key")
652        );
653        assert_eq!(
654            read_state.last_error_guidance.as_deref(),
655            Some("Update the API key in Settings.")
656        );
657        assert!(!read_state.transfer.active);
658    }
659
660    #[test]
661    fn test_record_event_updates_existing_entry() {
662        let mut state = AppState::default();
663        state.record_event("/tmp/a.jpg", "pending", Some("queued".into()), 1);
664        state.record_event("/tmp/a.jpg", "failed", Some("retry".into()), 2);
665
666        assert_eq!(state.recent_events.len(), 1);
667        assert_eq!(state.recent_events[0].status, "failed");
668        assert_eq!(state.recent_events[0].attempts, 2);
669        assert_eq!(state.recent_events[0].detail.as_deref(), Some("retry"));
670    }
671
672    #[test]
673    fn test_record_event_truncates_history() {
674        let mut state = AppState::default();
675        for i in 0..100 {
676            state.record_event(format!("/tmp/{i}.jpg"), "pending", None, 1);
677        }
678
679        assert_eq!(state.recent_events.len(), 80);
680    }
681
682    #[test]
683    fn test_transfer_snapshot_speed_updates() {
684        let mut transfer = TransferSnapshot::default();
685        transfer.register_item(
686            TransferDirection::Upload,
687            "file.jpg",
688            Some(1_000),
689            Some("file.jpg".into()),
690            Some("LAN".into()),
691        );
692        let start = unix_timestamp_now() - 1.0;
693        transfer.session_started_at = Some(start);
694        transfer.last_tick_at = Some(start);
695        transfer.last_tick_bytes = 0;
696        transfer.update_item_bytes(
697            TransferDirection::Upload,
698            "file.jpg",
699            512,
700            Some("LAN".into()),
701        );
702
703        assert!(transfer.active);
704        assert_eq!(transfer.current_bytes, 512);
705        assert!(transfer.instant_bps > 0.0);
706        assert!(transfer.session_avg_bps > 0.0);
707
708        transfer.finish_item(TransferDirection::Upload, "file.jpg", Some("LAN".into()));
709        assert!(!transfer.active);
710    }
711}