//! 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, /// Which file inside the granted folder holds the library. Android only: /// there the user grants a *folder* (SAF has no path to hand back), so the /// file within it has to be chosen separately. Defaults to "library.json" /// for a fresh setup, but must be settable — a library named anything else /// would otherwise never be found, and the phone would quietly create an /// empty second one beside it. #[serde(default)] document_name: Option, } fn config_path(data_dir: &Path) -> PathBuf { data_dir.join("config.json") } fn load_config(data_dir: &Path) -> PersistedConfig { let Ok(text) = std::fs::read_to_string(config_path(data_dir)) else { return PersistedConfig::default(); }; // Every field defaults, so an older or partial file parses fine and simply // leaves the unknown settings unset. Deliberately *not* keyed on which // fields happen to be populated: an Android install has neither a // `document_path` (its folder is a SAF grant) nor a legacy library, so a // "does this look empty?" test threw away its `document_name` on every // launch and silently reverted the chosen library file. let mut cfg: PersistedConfig = serde_json::from_str(&text).unwrap_or_default(); // Builds before the document format stored `library_path` pointing at a // SQLite file the user managed themselves. Pick that up so the UI can offer // to convert it once — but never open it as the working copy. if cfg.legacy_library_path.is_none() { #[derive(Deserialize)] struct OldConfig { library_path: Option, } cfg.legacy_library_path = serde_json::from_str::(&text) .ok() .and_then(|c| c.library_path) .filter(|p| p.extension().is_some_and(|e| e != "json")); } cfg } 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); } /// The library file within the granted folder. /// /// Android-only: desktop names the whole path at once, so these are dead /// there rather than genuinely unused. #[cfg_attr(not(target_os = "android"), allow(dead_code))] pub fn document_name(&self) -> Option<&str> { self.config.document_name.as_deref() } #[cfg_attr(not(target_os = "android"), allow(dead_code))] pub fn set_document_name(&mut self, name: Option) { self.config.document_name = name; 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()) } } #[cfg(test)] mod tests { use super::*; fn scratch(name: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!("mtgc-cfg-{name}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); dir } #[test] fn a_document_name_alone_survives_a_reload() { // An Android install has no document_path (its folder is a SAF grant) // and no legacy library, so this config's *only* meaningful field is // the chosen library file. A loader that decided emptiness meant "must // be an old config" discarded it on every launch, silently reverting // the user's choice back to library.json. let dir = scratch("android-shape"); std::fs::write( config_path(&dir), r#"{"document_path":null,"legacy_library_path":null,"document_name":"decks-v1.json"}"#, ) .unwrap(); let cfg = load_config(&dir); assert_eq!(cfg.document_name.as_deref(), Some("decks-v1.json")); std::fs::remove_dir_all(&dir).unwrap(); } #[test] fn every_setting_round_trips() { let dir = scratch("round-trip"); let cfg = PersistedConfig { document_path: Some(PathBuf::from("/tmp/lib.json")), legacy_library_path: Some(PathBuf::from("/tmp/old.db")), document_name: Some("mine.json".into()), }; save_config(&dir, &cfg); let back = load_config(&dir); assert_eq!(back.document_path, cfg.document_path); assert_eq!(back.legacy_library_path, cfg.legacy_library_path); assert_eq!(back.document_name, cfg.document_name); std::fs::remove_dir_all(&dir).unwrap(); } #[test] fn a_pre_document_config_offers_its_library_for_conversion() { let dir = scratch("legacy"); std::fs::write( config_path(&dir), r#"{"library_path":"/home/u/Seafile/Decks-v1.db"}"#, ) .unwrap(); let cfg = load_config(&dir); assert_eq!( cfg.legacy_library_path, Some(PathBuf::from("/home/u/Seafile/Decks-v1.db")) ); assert!(cfg.document_path.is_none(), "a .db is never the document"); std::fs::remove_dir_all(&dir).unwrap(); } #[test] fn a_library_path_that_is_already_json_is_not_treated_as_legacy() { let dir = scratch("json-path"); std::fs::write(config_path(&dir), r#"{"library_path":"/home/u/lib.json"}"#).unwrap(); assert!(load_config(&dir).legacy_library_path.is_none()); std::fs::remove_dir_all(&dir).unwrap(); } #[test] fn an_unreadable_or_corrupt_config_falls_back_to_defaults() { let dir = scratch("corrupt"); assert!(load_config(&dir).document_name.is_none(), "missing file"); std::fs::write(config_path(&dir), "{not json").unwrap(); assert!(load_config(&dir).document_name.is_none(), "truncated file"); std::fs::remove_dir_all(&dir).unwrap(); } }