diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2c5a07f..a808e83 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -73,6 +73,8 @@ pub fn run() { sync::sync_status, sync::sync_pick_folder, sync::sync_set_document, + sync::sync_list_documents, + sync::sync_set_document_name, sync::sync_forget_document, sync::sync_now, sync::convert_legacy_library, diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 8fb920b..8872c52 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -23,6 +23,14 @@ struct PersistedConfig { /// 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 { @@ -30,32 +38,31 @@ fn config_path(data_dir: &Path) -> PathBuf { } 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(), + let Ok(text) = std::fs::read_to_string(config_path(data_dir)) else { + 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; + // 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")); } - // 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, - } + cfg } fn save_config(data_dir: &Path, cfg: &PersistedConfig) { @@ -145,6 +152,21 @@ impl AppState { 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() @@ -193,3 +215,87 @@ impl AppState { .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(); + } +} diff --git a/src-tauri/src/sync.rs b/src-tauri/src/sync.rs index bcd7a96..115fee6 100644 --- a/src-tauri/src/sync.rs +++ b/src-tauri/src/sync.rs @@ -24,10 +24,10 @@ use crate::state::AppState; type Db<'a> = tauri::State<'a, std::sync::Mutex>; -/// Document name used on Android, where the user grants a folder rather than -/// choosing a file. Desktop uses whatever they named their file. +/// Fallback name when the user has granted a folder but not yet said which file +/// in it is their library — i.e. a fresh setup with nothing to adopt. #[cfg(target_os = "android")] -const ANDROID_DOC_NAME: &str = "library.json"; +const DEFAULT_DOC_NAME: &str = "library.json"; /// Where transfers are staged across the SAF boundary. Inside the app data dir, /// so it inherits the same private-storage guarantees as the working copy. @@ -56,6 +56,9 @@ pub struct SyncStatus { pub picker_based: bool, /// A pre-document `library.db` waiting to be converted, if any. pub legacy_library: Option, + /// Which file in the granted folder is the library (Android). `None` on + /// desktop, where the whole path is chosen at once. + pub document_name: Option, } #[cfg(target_os = "android")] @@ -324,7 +327,12 @@ fn locate(#[allow(unused_variables)] app: &AppHandle, db: &Db) -> Result Result, String> { + let location = locate(&app, &db)?; + let mut names: Vec = location + .list(&app)? + .into_iter() + .filter(|n| n.ends_with(".json") && !n.ends_with(".json.tmp")) + .collect(); + names.sort(); + Ok(names) +} + +/// Android only: choose which file in the granted folder is the library. +/// +/// Switching to a different file means adopting a different library, so the +/// working copy is dropped and rebuilt from it — the same rule desktop applies +/// when opening an existing document. +#[tauri::command] +pub fn sync_set_document_name( + app: AppHandle, + db: Db, + #[allow(unused_variables)] name: String, +) -> Result { + #[cfg(not(target_os = "android"))] + { + let _ = (&app, &db, name); + return Err("This platform chooses the library file by full path.".into()); + } + #[cfg(target_os = "android")] + { + let mut name = name.trim().to_string(); + if name.is_empty() { + return Err("Give the library file a name.".into()); + } + if !name.ends_with(".json") { + name.push_str(".json"); + } + // Keep it a plain file name: a path would escape the granted folder, + // which is the whole boundary the SAF grant establishes. + if name.contains('/') || name.contains('\\') { + return Err("The library file must sit directly in the chosen folder.".into()); + } + + let existed = { + let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + let location = locate(&app, &db)?; + location.read(&app, &data_dir, &name)?.is_some() + }; + db.lock().unwrap().set_document_name(Some(name)); + if existed { + // Adopting an existing library: start from it, not from whatever + // this device happened to be holding. + reset_working_copy(&app, &db)?; + } else { + let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; + let _ = std::fs::remove_file(base_path(&data_dir)); + } + if let Err(e) = sync_now(app.clone(), db.clone()) { + crate::mlog!("sync_set_document_name: initial sync failed: {e}"); + } + sync_status(app, db) + } +} + #[tauri::command] pub fn sync_forget_document(app: AppHandle, db: Db) -> Result { #[cfg(target_os = "android")] diff --git a/src/App.tsx b/src/App.tsx index adb97d7..f7e78d9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -460,9 +460,15 @@ export default function App() { // And whenever the Info tab is opened, which is also when the user gets back // from the folder picker, and when a revoked grant would first matter. + // + // Gated on `statusLoaded` as well as the tab: the active tab is persisted, so + // relaunching while on Info makes this fire at mount — before setup() has + // called manage(), which fails with "state not managed for field db". + // `getStatus` retries until the backend is ready, so waiting on it is what + // guarantees the state exists. useEffect(() => { - if (tab === "info") refreshSync(); - }, [tab, refreshSync]); + if (tab === "info" && statusLoaded) refreshSync(); + }, [tab, statusLoaded, refreshSync]); /** @@ -544,6 +550,30 @@ export default function App() { } } + /** Android's second step: which file in the granted folder is the library. + * Loaded lazily, since it costs a SAF round trip. */ + const [docChoices, setDocChoices] = useState(null); + useEffect(() => { + if (tab !== "info" || !sync?.picker_based || !sync.granted) return; + api.syncListDocuments().then(setDocChoices).catch(() => setDocChoices([])); + }, [tab, sync?.picker_based, sync?.granted, sync?.label]); + + async function chooseDocumentName(name: string) { + setSyncBusy(true); + setSyncTest(null); + try { + setSync(await api.syncSetDocumentName(name)); + setStatus(await api.getStatus()); + setDeckRefreshSignal((v) => v + 1); + if (readyRef.current) api.getLibraryStats().then(setLibStats).catch(() => {}); + setSyncTest({ ok: true, message: `Using ${name}.` }); + } catch (err) { + setSyncTest({ ok: false, message: String(err) }); + } finally { + setSyncBusy(false); + } + } + async function forgetLibrary() { setSyncBusy(true); setSyncTest(null); @@ -927,6 +957,47 @@ export default function App() { )} + {/* Android grants a *folder*, so which file in it is the + library is a separate choice. Without this the app + assumes "library.json" and would create an empty second + library beside one named anything else. */} + {sync.picker_based && sync.chosen && sync.granted && ( +
+ + {docChoices === null ? ( +
Looking…
+ ) : ( + <> + + {docChoices.length === 0 && ( + + No library files here yet — a new one will be created. + + )} + + )} +
+ )} + {/* Only shown while an old library.db is still waiting; the offer disappears for good once it's been converted. */} {sync.legacy_library && ( diff --git a/src/api.ts b/src/api.ts index da1bde1..ee7d13c 100644 --- a/src/api.ts +++ b/src/api.ts @@ -145,6 +145,9 @@ export interface SyncStatus { picker_based: boolean; /** A pre-document library.db from an older build, waiting to be converted. */ legacy_library: string | null; + /** Which file in the granted folder is the library (Android). Null on + * desktop, where the whole path is chosen at once. */ + document_name: string | null; } /** What one sync did. */ @@ -288,6 +291,11 @@ export const api = { /** Desktop: adopt a library document chosen with the file dialog. */ syncSetDocument: (path: string) => invoke("sync_set_document", { path }), syncForgetDocument: () => invoke("sync_forget_document"), + /** Library files already in the chosen folder, for Android's second step. */ + syncListDocuments: () => invoke("sync_list_documents"), + /** Android: choose which file in the granted folder is the library. */ + syncSetDocumentName: (name: string) => + invoke("sync_set_document_name", { name }), /** Convert a pre-document library.db into the current format. */ convertLegacyLibrary: (path: string) => invoke("convert_legacy_library", { path }),