//! App state: the open [`Catalog`] and fuzzy [`NameIndex`], plus the paths. //! //! The user's library is a JSON *document* in a folder they sync. The SQLite //! database behind it is an internal working copy under the app data dir that //! they never see, name, or move — it can always be rebuilt by merging the //! document into an empty one (see [`mtgc_core::sync`]). use std::path::{Path, PathBuf}; use mtgc_core::{Catalog, NameIndex}; use serde::{Deserialize, Serialize}; /// Small app-shell preferences persisted across restarts, distinct from the /// user's library data. #[derive(Default, Serialize, Deserialize, Clone)] struct PersistedConfig { /// The library document the user chose. Desktop only — Android reaches its /// folder through a SAF grant held on the Java side rather than by path, /// so there is no path to persist there (see `sync.rs`). #[serde(default)] document_path: Option, /// A pre-document `library.db` inherited from an older build, kept until /// it has been converted so the app can offer to do that once. #[serde(default)] legacy_library_path: Option, } fn config_path(data_dir: &Path) -> PathBuf { data_dir.join("config.json") } fn load_config(data_dir: &Path) -> PersistedConfig { let text = match std::fs::read_to_string(config_path(data_dir)) { Ok(t) => t, Err(_) => return PersistedConfig::default(), }; if let Ok(cfg) = serde_json::from_str::(&text) { // Nothing to carry over if it already parses as the current shape, // unless it's an old config that only had `library_path`. if cfg.document_path.is_some() || cfg.legacy_library_path.is_some() { return cfg; } } // Older builds stored `library_path` pointing at a SQLite file the user // managed themselves. Remember it so the UI can offer to convert it, but // never open it as the working copy. #[derive(Deserialize)] struct OldConfig { library_path: Option, } let legacy = serde_json::from_str::(&text) .ok() .and_then(|c| c.library_path) .filter(|p| p.extension().is_some_and(|e| e != "json")); PersistedConfig { document_path: None, legacy_library_path: legacy, } } fn save_config(data_dir: &Path, cfg: &PersistedConfig) { if let Ok(s) = serde_json::to_string_pretty(cfg) { let _ = std::fs::write(config_path(data_dir), s); } } /// Managed application state, guarded by a `Mutex` in Tauri's state store. pub struct AppState { data_dir: PathBuf, /// Held whole so that writing one preference can't drop the others — the /// setters below persist this rather than reconstructing a fresh config. config: PersistedConfig, /// Internal SQLite working copy. Derived from the document; never chosen /// by, shown to, or moved by the user. pub working_db_path: PathBuf, pub reference_path: PathBuf, pub catalog: Option, pub index: Option, /// Set while `update_card_data` is running; `cancel_update_card_data` /// flips it to abort the in-progress ingest. pub ingest_cancel: Option, /// Set while a sync is in flight. Sync fires automatically on launch and on /// every foreground/background transition, so overlapping calls are easy to /// provoke; a second one would merge the same document concurrently. pub sync_running: bool, } impl AppState { pub fn new(data_dir: PathBuf) -> Self { let config = load_config(&data_dir); let mut s = Self { reference_path: data_dir.join("reference.db"), working_db_path: data_dir.join("working.db"), config, data_dir, catalog: None, index: None, ingest_cancel: None, sync_running: false, }; crate::mlog!( "AppState::new: document={:?} working={} reference={} (exists={})", s.config.document_path, s.working_db_path.display(), s.reference_path.display(), s.reference_path.exists() ); // Best-effort open; failures just leave the catalog unloaded (the UI // falls back to "no card data yet" and offers Download/Import again). // That fallback previously made a real open failure indistinguishable // from a first run — log it so it shows up in `adb logcat -s // RustStdoutStderr` instead of silently reappearing every launch. if let Err(e) = s.reopen() { crate::mlog!("AppState::new: reopen failed: {e}"); } s } /// The library document the user explicitly chose, if they have. pub fn document_path(&self) -> Option<&Path> { self.config.document_path.as_deref() } /// Where the library document actually lives right now. /// /// Falls back to app-private storage so there is *always* a document. The /// alternative — no document until the user picks one — meant edits piled /// up in the working copy with nowhere to be saved, and choosing a /// destination later would discard them. Picking a location is a /// preference, not a precondition for using the app. pub fn effective_document_path(&self) -> PathBuf { self.config .document_path .clone() .unwrap_or_else(|| self.default_document_path()) } pub fn default_document_path(&self) -> PathBuf { self.data_dir.join("library.json") } /// Point at (or, with `None`, forget) the library document. pub fn set_document_path(&mut self, path: Option) { self.config.document_path = path; save_config(&self.data_dir, &self.config); } /// A pre-document `library.db` still waiting to be converted. pub fn legacy_library_path(&self) -> Option<&Path> { self.config.legacy_library_path.as_deref() } /// Forget the legacy library, once it's been converted or dismissed. pub fn clear_legacy_library(&mut self) { self.config.legacy_library_path = None; save_config(&self.data_dir, &self.config); } /// Drop the open catalog (and its name index), releasing the handles on both /// db files. Call before replacing `reference.db` on disk so nothing is /// still reading the file being swapped out. pub fn close(&mut self) { self.catalog = None; self.index = None; } /// (Re)open the catalog + rebuild the name index. Only opens when a reference /// db exists — attaching a non-existent file would create an empty (tableless) /// db and make every query fail. pub fn reopen(&mut self) -> Result<(), String> { if self.reference_path.exists() { let catalog = Catalog::open(&self.working_db_path, &self.reference_path) .map_err(|e| e.to_string())?; let index = catalog.build_name_index().map_err(|e| e.to_string())?; self.index = Some(index); self.catalog = Some(catalog); } else { self.catalog = None; self.index = None; } Ok(()) } pub fn catalog(&self) -> Result<&Catalog, String> { self.catalog .as_ref() .ok_or_else(|| "Card data not loaded yet — click 'Download card data'.".to_string()) } pub fn index(&self) -> Result<&NameIndex, String> { self.index .as_ref() .ok_or_else(|| "Card data not loaded yet — click 'Download card data'.".to_string()) } }