//! App state: the open [`Catalog`] and fuzzy [`NameIndex`], plus the paths, //! the device's libraries, and the sync account. //! //! Each library lives in an internal SQLite working copy under the app data //! dir, which works fully offline. One of them is open at a time — the active //! library — and every command works on that one. 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, /// The libraries on this device, the default one first. Never empty once /// loaded: a config from before named libraries gets the default. #[serde(default)] libraries: Vec, /// The id of the library that's open. #[serde(default)] active_library: String, /// Server libraries removed from this device, which syncing therefore /// doesn't download again. #[serde(default)] dismissed_remote: Vec, } /// The id of the library every device starts with. Its files stay where the /// one library always lived, so upgrading moves nothing. pub const DEFAULT_LIBRARY: &str = "default"; /// One of the libraries on this device. #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] pub struct LocalLibrary { /// Local only: the folder it lives in. Never shown. pub id: String, pub name: String, /// The server's id for it, once linked to a library there. #[serde(default)] pub remote_id: Option, /// Whether it should be on the server. Off for a library kept on this /// device only — a scratch library to try things in, say. #[serde(default = "yes")] pub sync: bool, /// It was linked, and another device deleted it from the server. The copy /// here stays until the user removes it or puts it back on the server. #[serde(default)] pub server_deleted: bool, } fn yes() -> bool { true } impl LocalLibrary { pub fn is_default(&self) -> bool { self.id == DEFAULT_LIBRARY } } fn default_library() -> LocalLibrary { LocalLibrary { id: DEFAULT_LIBRARY.into(), name: "Main".into(), remote_id: None, sync: true, server_deleted: false, } } /// A fresh id for a library's folder. fn new_library_id() -> String { mtgc_core::sync::account::random_token() .chars() .filter(char::is_ascii_alphanumeric) .take(16) .collect() } /// 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 mut cfg = read_config(data_dir); // Before named libraries there was one, which is the default now. if !cfg.libraries.iter().any(LocalLibrary::is_default) { cfg.libraries.insert(0, default_library()); } if !cfg.libraries.iter().any(|l| l.id == cfg.active_library) { cfg.active_library = DEFAULT_LIBRARY.into(); } cfg } fn read_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() } /// The folder a library's files live in. fn library_dir(data_dir: &Path, id: &str) -> PathBuf { if id == DEFAULT_LIBRARY { data_dir.to_path_buf() } else { data_dir.join("libraries").join(id) } } /// 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 of the active library: 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: library_dir(&data_dir, &config.active_library).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, }; // A library whose folder vanished would otherwise be recreated empty // by opening it, which is fine — but its folder must exist first. let _ = std::fs::create_dir_all(s.library_dir(&s.config.active_library.clone())); crate::mlog!( "AppState::new: sync={:?} library={} working={} reference={} (exists={})", s.config.sync.as_ref().map(|a| (&a.server_url, &a.user.username, a.token.is_some())), s.config.active_library, 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 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); } } /// Forget every link to a server library. For signing in to a different /// account or server, where the old ids mean nothing. pub fn forget_remote_links(&mut self) { for lib in &mut self.config.libraries { lib.remote_id = None; lib.server_deleted = false; } self.config.dismissed_remote.clear(); save_config(&self.data_dir, &self.config); } // --- libraries ------------------------------------------------------------ pub fn libraries(&self) -> &[LocalLibrary] { &self.config.libraries } pub fn library(&self, id: &str) -> Option<&LocalLibrary> { self.config.libraries.iter().find(|l| l.id == id) } pub fn active_library(&self) -> &LocalLibrary { self.library(&self.config.active_library).expect("load_config keeps the active library listed") } /// The folder a library's files live in. pub fn library_dir(&self, id: &str) -> PathBuf { library_dir(&self.data_dir, id) } pub fn working_path(&self, id: &str) -> PathBuf { self.library_dir(id).join("working.db") } /// Where a library's sync merge base is kept. The default library keeps /// the name it had before there were others. pub fn base_path(&self, id: &str) -> PathBuf { if id == DEFAULT_LIBRARY { self.data_dir.join("sync-server-base.json") } else { self.library_dir(id).join("sync-base.json") } } /// Server libraries removed from this device. pub fn dismissed_remote(&self) -> &[String] { &self.config.dismissed_remote } /// Start a new, empty library. It isn't opened. pub fn create_library(&mut self, name: &str, sync: bool, remote_id: Option) -> Result { let name = self.check_name(name, None)?; let lib = LocalLibrary { id: new_library_id(), name, remote_id, sync, server_deleted: false }; let dir = self.library_dir(&lib.id); std::fs::create_dir_all(&dir).map_err(|e| format!("Couldn't create the library: {e}"))?; // Created now, so the library exists on disk even before anything // is added to it. mtgc_core::db::open_library(&self.working_path(&lib.id)).map_err(|e| e.to_string())?; if let Some(r) = &lib.remote_id { self.config.dismissed_remote.retain(|d| d != r); } self.config.libraries.push(lib.clone()); save_config(&self.data_dir, &self.config); Ok(lib) } /// A trimmed name, if no other library here already has it. pub fn check_name(&self, name: &str, except: Option<&str>) -> Result { let name = name.trim(); if name.is_empty() { return Err("A library needs a name.".into()); } let taken = self .config .libraries .iter() .any(|l| Some(l.id.as_str()) != except && l.name.eq_ignore_ascii_case(name)); if taken { return Err(format!("There's already a library called {name}.")); } Ok(name.to_string()) } /// Change a library in place and persist it. A no-op for an unknown id. pub fn update_library(&mut self, id: &str, f: impl FnOnce(&mut LocalLibrary)) { if let Some(lib) = self.config.libraries.iter_mut().find(|l| l.id == id) { f(lib); save_config(&self.data_dir, &self.config); } } /// Stop downloading a server library to this device. pub fn dismiss_remote(&mut self, remote_id: &str) { if !self.config.dismissed_remote.iter().any(|d| d == remote_id) { self.config.dismissed_remote.push(remote_id.to_string()); save_config(&self.data_dir, &self.config); } } pub fn undismiss_remote(&mut self, remote_id: &str) { self.config.dismissed_remote.retain(|d| d != remote_id); save_config(&self.data_dir, &self.config); } /// Open another library. pub fn switch_library(&mut self, id: &str) -> Result<(), String> { if self.library(id).is_none() { return Err("There's no such library on this device.".into()); } if self.config.active_library == id { return Ok(()); } // A sync works on paths it took when it started. if self.sync_running { return Err("Wait for the sync to finish, then try again.".into()); } let _ = std::fs::create_dir_all(self.library_dir(id)); self.config.active_library = id.to_string(); save_config(&self.data_dir, &self.config); self.working_db_path = self.working_path(id); self.reopen_catalog() } /// Delete a library and its files from this device. The active one is /// left by switching to the default first; the default itself stays. pub fn delete_library(&mut self, id: &str) -> Result { let lib = self.library(id).cloned().ok_or("There's no such library on this device.")?; if lib.is_default() { return Err("The main library can't be deleted.".into()); } if self.sync_running { return Err("Wait for the sync to finish, then try again.".into()); } if self.config.active_library == id { self.switch_library(DEFAULT_LIBRARY)?; } self.config.libraries.retain(|l| l.id != id); save_config(&self.data_dir, &self.config); let dir = self.library_dir(id); if let Err(e) = std::fs::remove_dir_all(&dir) { crate::mlog!("delete_library: couldn't remove {}: {e}", dir.display()); } Ok(lib) } /// 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(()) } /// Reopen just the catalog, on the active library's working copy. The name /// index only reads card data, so it survives a switch of library. fn reopen_catalog(&mut self) -> Result<(), String> { if self.index.is_none() { return self.reopen(); } self.catalog = None; if self.reference_path.exists() { self.catalog = Some( Catalog::open(&self.working_db_path, &self.reference_path).map_err(|e| e.to_string())?, ); } 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 scratch_lib = LocalLibrary { id: "abc".into(), name: "Scratch".into(), remote_id: Some("r1".into()), sync: false, server_deleted: true, }; let cfg = PersistedConfig { legacy_library_path: Some(PathBuf::from("/tmp/old.db")), sync: Some(account()), libraries: vec![default_library(), scratch_lib], active_library: "abc".into(), dismissed_remote: vec!["r2".into()], }; 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_eq!(back.libraries, cfg.libraries); assert_eq!(back.active_library, "abc"); assert_eq!(back.dismissed_remote, cfg.dismissed_remote); 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 { sync: Some(account()), ..Default::default() }); 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(); } #[test] fn a_config_from_before_named_libraries_has_just_the_default() { let dir = scratch("pre-libraries"); save_config(&dir, &PersistedConfig { sync: Some(account()), ..Default::default() }); let cfg = load_config(&dir); assert_eq!(cfg.libraries, vec![default_library()]); assert_eq!(cfg.active_library, DEFAULT_LIBRARY); std::fs::remove_dir_all(&dir).unwrap(); } #[test] fn the_default_library_keeps_the_files_it_always_had() { let dir = scratch("paths"); let s = AppState::new(dir.clone()); assert_eq!(s.working_db_path, dir.join("working.db")); assert_eq!(s.base_path(DEFAULT_LIBRARY), dir.join("sync-server-base.json")); assert_eq!(s.working_path("abc"), dir.join("libraries/abc/working.db")); assert_eq!(s.base_path("abc"), dir.join("libraries/abc/sync-base.json")); std::fs::remove_dir_all(&dir).unwrap(); } #[test] fn libraries_can_be_created_switched_to_and_deleted() { let dir = scratch("libraries"); let mut s = AppState::new(dir.clone()); let lib = s.create_library(" Scratch ", false, None).unwrap(); assert_eq!(lib.name, "Scratch"); assert!(s.working_path(&lib.id).exists()); assert!(s.create_library("scratch", true, None).is_err(), "names are unique"); assert!(s.create_library(" ", true, None).is_err()); s.switch_library(&lib.id).unwrap(); assert_eq!(s.working_db_path, s.working_path(&lib.id)); // Remembered across restarts. let mut s = AppState::new(dir.clone()); assert_eq!(s.active_library().id, lib.id); s.sync_running = true; assert!(s.switch_library(DEFAULT_LIBRARY).is_err(), "not mid-sync"); assert!(s.delete_library(&lib.id).is_err(), "not mid-sync"); s.sync_running = false; assert!(s.delete_library(DEFAULT_LIBRARY).is_err()); s.delete_library(&lib.id).unwrap(); assert_eq!(s.active_library().id, DEFAULT_LIBRARY, "deleting the open one switches away"); assert!(!s.library_dir(&lib.id).exists()); assert_eq!(s.libraries().len(), 1); std::fs::remove_dir_all(&dir).unwrap(); } }