//! App state: the open [`Catalog`] and fuzzy [`NameIndex`], plus the paths and //! the sync account. //! //! The user's library lives in an internal SQLite working copy under the app //! data dir, which works fully offline. When the device is signed in to a sync //! server, that copy is reconciled with the server's whenever it can be reached //! (see `sync.rs` and [`mtgc_core::sync::remote`]). use std::path::{Path, PathBuf}; use std::sync::atomic::AtomicBool; use std::sync::Arc; use mtgc_core::sync::account::AccountUser; use mtgc_core::{Catalog, NameIndex}; use serde::{Deserialize, Serialize}; /// Small app-shell preferences persisted across restarts, distinct from the /// user's library data. /// /// Fields from older builds (`document_path`, `document_name`, from when the /// library was a file in a synced folder) are simply ignored when read. #[derive(Default, Serialize, Deserialize, Clone)] struct PersistedConfig { /// 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, /// The sync server this device is (or was last) signed in to. #[serde(default)] sync: Option, } /// This device's sync server account. #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] pub struct SyncAccount { pub server_url: String, /// This device's token. `None` once signed out, or once the server stopped /// accepting it; the rest is kept so signing back in can be offered with /// the address already filled in. #[serde(default)] pub token: Option, pub user: AccountUser, #[serde(default)] pub device_id: i64, /// Unix seconds of the last sync that completed. #[serde(default)] pub last_synced_at: 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. 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) { let Ok(text) = serde_json::to_string_pretty(cfg) else { return; }; // Written beside and renamed into place, so a crash mid-write can't leave // a truncated config that silently signs the device out. let path = config_path(data_dir); let tmp = path.with_extension("json.tmp"); if let Err(e) = write_private(&tmp, &text).and_then(|_| std::fs::rename(&tmp, &path)) { crate::mlog!("save_config: {e}"); let _ = std::fs::remove_file(&tmp); } } /// Write a file only this user can read: the config holds the device's sync /// token. (App data is already private on Android; this covers desktop.) fn write_private(path: &Path, text: &str) -> std::io::Result<()> { use std::io::Write; let mut opts = std::fs::OpenOptions::new(); opts.write(true).create(true).truncate(true); #[cfg(unix)] { use std::os::unix::fs::OpenOptionsExt; opts.mode(0o600); } let mut f = opts.open(path)?; f.write_all(text.as_bytes())?; f.sync_all() } /// 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: the library itself, on this device. 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, on /// every foreground/background transition, and on reconnecting, so /// overlapping calls are easy to provoke; a second one would only repeat /// the first one's work. pub sync_running: bool, /// Why the last sync failed, until one succeeds. Not persisted: after a /// restart the next sync says soon enough. pub sync_error: Option, /// The first sync with this account found a library both here and on the /// server, and only the user can say whether to combine them or take the /// server's. Syncing waits until they do. pub sync_needs_choice: bool, /// Set while a sign-in waits on the browser; flipping it abandons the wait. pub sign_in_cancel: Option>, } 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, sync_error: None, sync_needs_choice: false, sign_in_cancel: None, }; crate::mlog!( "AppState::new: sync={:?} working={} reference={} (exists={})", s.config.sync.as_ref().map(|a| (&a.server_url, &a.user.username, a.token.is_some())), 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 } pub fn data_dir(&self) -> &Path { &self.data_dir } /// The sync account, signed in or not. pub fn sync_account(&self) -> Option<&SyncAccount> { self.config.sync.as_ref() } pub fn set_sync_account(&mut self, account: Option) { self.config.sync = account; save_config(&self.data_dir, &self.config); } /// Change the sync account in place and persist it. A no-op without one. pub fn update_sync_account(&mut self, f: impl FnOnce(&mut SyncAccount)) { if let Some(a) = self.config.sync.as_mut() { f(a); 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 } fn account() -> SyncAccount { SyncAccount { server_url: "https://mtgc.example".into(), token: Some("mtgc_secret".into()), user: AccountUser { id: "u1".into(), username: "alice".into(), display_name: "Alice".into(), email: "alice@example.com".into(), method: "local".into(), }, device_id: 7, last_synced_at: Some(1_700_000_000), } } #[test] fn every_setting_round_trips() { let dir = scratch("round-trip"); let cfg = PersistedConfig { legacy_library_path: Some(PathBuf::from("/tmp/old.db")), sync: Some(account()), }; save_config(&dir, &cfg); let back = load_config(&dir); assert_eq!(back.legacy_library_path, cfg.legacy_library_path); assert_eq!(back.sync, cfg.sync); assert!(!dir.join("config.json.tmp").exists()); std::fs::remove_dir_all(&dir).unwrap(); } #[cfg(unix)] #[test] fn the_config_holding_the_token_is_private() { use std::os::unix::fs::PermissionsExt; let dir = scratch("private"); save_config(&dir, &PersistedConfig { legacy_library_path: None, sync: Some(account()) }); let mode = std::fs::metadata(config_path(&dir)).unwrap().permissions().mode(); assert_eq!(mode & 0o777, 0o600); std::fs::remove_dir_all(&dir).unwrap(); } #[test] fn a_folder_sync_config_still_loads() { // What the previous, folder-based build wrote. Its settings no longer // mean anything, but the file must not fail to load over them. let dir = scratch("folder-era"); std::fs::write( config_path(&dir), r#"{"document_path":"/home/u/Sync/library.json","legacy_library_path":"/tmp/old.db","document_name":"decks.json"}"#, ) .unwrap(); let cfg = load_config(&dir); assert_eq!(cfg.legacy_library_path, Some(PathBuf::from("/tmp/old.db"))); assert!(cfg.sync.is_none()); 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")) ); 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).sync.is_none(), "missing file"); std::fs::write(config_path(&dir), "{not json").unwrap(); assert!(load_config(&dir).sync.is_none(), "truncated file"); std::fs::remove_dir_all(&dir).unwrap(); } }