Files
mtgc/src-tauri/src/state.rs
Garrett Mills dff52a9d83 Let Android pick which file in the synced folder is the library
Android grants a *folder* through SAF, not a file, and the document name was
hardcoded to "library.json". A library named anything else was therefore never
found: the phone created an empty second library beside it and the two devices
silently never synced, while appearing to work.

The Info tab now lists the .json files in the granted folder and lets you choose
one. Picking an existing file adopts it — the working copy is dropped and
rebuilt from it, matching what desktop does when opening a document — while a
name that isn't there yet keeps the current library and publishes it, matching
New Library. The name is validated as a plain filename, since a path would
escape the granted folder that the SAF grant exists to bound.

Two bugs found while testing this on-device:

load_config decided whether a config was the pre-document format by testing
whether document_path or legacy_library_path were set. An Android install has
neither — its folder is a grant, not a path, and there is no legacy library —
so a valid config's document_name was discarded on every launch and the chosen
library file silently reverted to the default. It no longer infers the format
from which fields happen to be populated; every field defaults, and the legacy
library_path is picked up as a separate additive step. Covered by tests,
including one built from the exact Android config shape, because a setting that
quietly resets itself is not something a careful read catches.

The Info tab's sync refresh fired at mount when the app relaunched onto that
tab — the active tab is persisted — which is before setup() calls manage(),
producing "state not managed for field db". Gated on statusLoaded like the
startup refresh already was; get_status retries until the backend is ready, so
waiting on it is what guarantees the state exists.
2026-08-09 12:17:57 -05:00

302 lines
12 KiB
Rust

//! 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<PathBuf>,
/// 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<PathBuf>,
/// 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<String>,
}
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<PathBuf>,
}
cfg.legacy_library_path = serde_json::from_str::<OldConfig>(&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<Catalog>,
pub index: Option<NameIndex>,
/// Set while `update_card_data` is running; `cancel_update_card_data`
/// flips it to abort the in-progress ingest.
pub ingest_cancel: Option<mtgc_core::ingest::CancelToken>,
/// 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<PathBuf>) {
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<String>) {
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();
}
}