use std::{ collections::VecDeque, fs, path::{Path, PathBuf}, sync::Arc, }; use anyhow::{Context, Result}; use parking_lot::Mutex; use serde::{Deserialize, Serialize}; use serde_json::json; use tauri::{AppHandle, Emitter}; use crate::model::{AppConfig, AuthState, JobSummary, ScanEvent}; const MAX_JOBS: usize = 200; #[derive(Clone)] pub struct SharedState { pub config: Arc>, pub jobs: Arc>>, pub pending: Arc>>, pub auth: Arc>, pub data_dir: PathBuf, } impl SharedState { pub fn load(data_dir: PathBuf) -> Result { fs::create_dir_all(&data_dir).context("create FieldLens Print data directory")?; let mut config = read_json::(&data_dir.join("config.json")).unwrap_or_default(); if config.spool_dir.trim().is_empty() { config.spool_dir = data_dir.join("spool").to_string_lossy().into_owned(); } if config.client_id == "fieldlens-print" { config.client_id = "fieldlens-print-desktop".into(); } fs::create_dir_all(&config.spool_dir).ok(); let pending = read_json::>(&data_dir.join("pending.json")).unwrap_or_default(); let persisted_auth = read_json::(&data_dir.join("auth.json")).unwrap_or_default(); let auth = AuthState { refresh_token: persisted_auth.refresh_token, ..AuthState::default() }; let state = Self { config: Arc::new(Mutex::new(config)), jobs: Arc::new(Mutex::new(VecDeque::new())), pending: Arc::new(Mutex::new(pending)), auth: Arc::new(Mutex::new(auth)), data_dir, }; state.persist_config()?; Ok(state) } pub fn persist_config(&self) -> Result<()> { write_json_atomic(&self.data_dir.join("config.json"), &*self.config.lock()) } pub fn persist_pending(&self) -> Result<()> { write_json_atomic(&self.data_dir.join("pending.json"), &*self.pending.lock()) } pub fn persist_auth(&self) -> Result<()> { let path = self.data_dir.join("auth.json"); let persisted = PersistedAuth { refresh_token: self.auth.lock().refresh_token.clone(), }; write_json_atomic(&path, &persisted)?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; fs::set_permissions(&path, fs::Permissions::from_mode(0o600)) .context("secure FieldLens auth state")?; } Ok(()) } pub fn clear_auth(&self) -> Result<()> { *self.auth.lock() = AuthState::default(); self.persist_auth() } pub fn replace_config(&self, next: AppConfig) -> Result { fs::create_dir_all(&next.spool_dir).ok(); *self.config.lock() = next.clone(); self.persist_config()?; Ok(next) } pub fn push_job(&self, app: &AppHandle, job: JobSummary) { { let mut jobs = self.jobs.lock(); if let Some(index) = jobs.iter().position(|existing| existing.id == job.id) { jobs.remove(index); } jobs.push_front(job.clone()); while jobs.len() > MAX_JOBS { jobs.pop_back(); } } let _ = app.emit("capture-job", &job); } pub fn queue_scan( &self, raw_value: String, format: String, source: String, job_id: &str, label: &str, ) -> ScanEvent { let mut pending = self.pending.lock(); let duplicate = pending.iter().any(|event| event.raw_value == raw_value); let event = ScanEvent { id: uuid::Uuid::new_v4().to_string(), raw_value, format, duplicate, captured_at: chrono::Utc::now().to_rfc3339(), source, source_metadata: json!({ "jobId": job_id, "label": label, }), }; pending.push(event.clone()); drop(pending); let _ = self.persist_pending(); event } pub fn pending_count(&self) -> usize { self.pending.lock().len() } } #[derive(Debug, Default, Serialize, Deserialize)] struct PersistedAuth { refresh_token: Option, } fn read_json(path: &Path) -> Option { let bytes = fs::read(path).ok()?; serde_json::from_slice(&bytes).ok() } fn write_json_atomic(path: &Path, value: &T) -> Result<()> { let bytes = serde_json::to_vec_pretty(value)?; let temp = path.with_extension("tmp"); fs::write(&temp, bytes)?; if path.exists() { fs::remove_file(path)?; } fs::rename(&temp, path)?; Ok(()) }