//! Reading and writing the user's library document, wherever they keep it. //! //! The reconciliation itself lives in [`mtgc_core::sync`] as a pure function. //! This module is only the plumbing: getting the document in and out of a //! folder that differs by platform, and running the cycle in the right order. //! //! Desktop points at an ordinary file. Android can't: the working database must //! stay in app-private storage (SQLite can't open a `content://` URI), and the //! user's folder is reachable only through the Storage Access Framework. So on //! Android every transfer is staged through an app-private file that Rust owns //! and the Kotlin side copies to or from — deliberately avoiding //! `MANAGE_EXTERNAL_STORAGE`, which would hand the app the entire shared volume //! just to reach one folder. [`Folder`] hides that split. use std::path::{Path, PathBuf}; use mtgc_core::sync::{self as core_sync, LibraryDoc, MergeReport}; use serde::Serialize; #[cfg(target_os = "android")] use serde::Deserialize; use tauri::{AppHandle, Manager}; 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. #[cfg(target_os = "android")] const ANDROID_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. /// Android-only: desktop reads and writes the document directly. #[cfg(target_os = "android")] fn staging_path(data_dir: &Path, name: &str) -> PathBuf { data_dir.join("sync-staging").join(name) } fn base_path(data_dir: &Path) -> PathBuf { data_dir.join("sync-base.json") } /// State of the library document, as shown in the UI. #[derive(Serialize, Default)] pub struct SyncStatus { /// Whether a document has been chosen at all. pub chosen: bool, /// Whether it's currently reachable. Distinct from `chosen`: an Android SAF /// grant can be revoked and a desktop folder can be deleted or unmounted, /// both of which need re-picking rather than first-time setup. pub granted: bool, pub label: Option, /// True where the folder is chosen through the system picker (Android) /// rather than by path — the one thing the UI branches on. pub picker_based: bool, /// A pre-document `library.db` waiting to be converted, if any. pub legacy_library: Option, } #[cfg(target_os = "android")] mod bridge { use super::*; /// Handle on the Kotlin-side `SyncPlugin`, put into Tauri's state store /// during plugin setup (see `lib.rs`). pub struct SyncBridge(pub tauri::plugin::PluginHandle); #[derive(Serialize)] struct FileArgs<'a> { name: &'a str, path: &'a str, } #[derive(Serialize)] struct NameArgs<'a> { name: &'a str, } #[derive(Deserialize)] struct ReadResult { found: bool, } #[derive(Deserialize)] struct ListResult { #[serde(default)] names: Vec, } #[derive(Deserialize, Default)] pub struct FolderStatus { #[serde(default)] pub chosen: bool, #[serde(default)] pub granted: bool, #[serde(default)] pub label: Option, } fn call( app: &AppHandle, command: &str, payload: impl Serialize, ) -> Result { let bridge = app .try_state::() .ok_or_else(|| "Sync plugin failed to load — see the logs.".to_string())?; bridge .0 .run_mobile_plugin::(command, payload) .map_err(|e| e.to_string()) } pub fn status(app: &AppHandle) -> Result { call(app, "status", ()) } pub fn pick_folder(app: &AppHandle) -> Result { call(app, "pickFolder", ()) } pub fn forget_folder(app: &AppHandle) -> Result { call(app, "forgetFolder", ()) } pub fn list(app: &AppHandle) -> Result, String> { Ok(call::(app, "list", ())?.names) } pub fn read_into(app: &AppHandle, name: &str, path: &Path) -> Result { let res: ReadResult = call( app, "readInto", FileArgs { name, path: &path.display().to_string() }, )?; Ok(res.found) } pub fn write_from(app: &AppHandle, name: &str, path: &Path) -> Result<(), String> { let _: serde_json::Value = call( app, "writeFrom", FileArgs { name, path: &path.display().to_string() }, )?; Ok(()) } pub fn delete(app: &AppHandle, name: &str) -> Result<(), String> { let _: serde_json::Value = call(app, "deleteFile", NameArgs { name })?; Ok(()) } } #[cfg(target_os = "android")] pub use bridge::SyncBridge; /// The folder as an ordinary directory — desktop's whole implementation. Free /// functions so they stay callable, and testable, without a running app. mod plain { use std::path::Path; pub fn list(dir: &Path) -> Result, String> { let entries = std::fs::read_dir(dir).map_err(|e| format!("Can't read {}: {e}", dir.display()))?; let mut names = Vec::new(); for entry in entries.flatten() { if entry.file_type().map(|t| t.is_file()).unwrap_or(false) { names.push(entry.file_name().to_string_lossy().into_owned()); } } Ok(names) } pub fn read(dir: &Path, name: &str) -> Result, String> { let path = dir.join(name); if !path.is_file() { return Ok(None); } std::fs::read_to_string(&path) .map(Some) .map_err(|e| format!("Can't read {}: {e}", path.display())) } pub fn write(dir: &Path, name: &str, text: &str) -> Result<(), String> { std::fs::create_dir_all(dir).map_err(|e| e.to_string())?; // Write beside the target and rename, so whatever is watching the // folder never observes a half-written document. let tmp = dir.join(format!("{name}.tmp")); std::fs::write(&tmp, text) .map_err(|e| format!("Can't write in {}: {e}", dir.display()))?; std::fs::rename(&tmp, dir.join(name)).map_err(|e| { let _ = std::fs::remove_file(&tmp); format!("Can't save {name} in {}: {e}", dir.display()) }) } pub fn delete(dir: &Path, name: &str) -> Result<(), String> { std::fs::remove_file(dir.join(name)) .map_err(|e| format!("Can't remove {name}: {e}")) } } /// Where the library document lives, however this platform reaches it. struct Location { folder: Folder, doc_name: String, } enum Folder { #[cfg(target_os = "android")] Picker, Path(PathBuf), } impl Location { fn list(&self, #[allow(unused_variables)] app: &AppHandle) -> Result, String> { match &self.folder { #[cfg(target_os = "android")] Folder::Picker => bridge::list(app), Folder::Path(dir) => plain::list(dir), } } /// Read a document by name. `Ok(None)` means it isn't there — a normal /// state (nothing published yet), not a failure. fn read( &self, #[allow(unused_variables)] app: &AppHandle, #[allow(unused_variables)] data_dir: &Path, name: &str, ) -> Result, String> { match &self.folder { #[cfg(target_os = "android")] Folder::Picker => { let staged = staging_path(data_dir, "incoming.json"); if !bridge::read_into(app, name, &staged)? { return Ok(None); } let text = std::fs::read_to_string(&staged).map_err(|e| e.to_string())?; let _ = std::fs::remove_file(&staged); Ok(Some(text)) } Folder::Path(dir) => plain::read(dir, name), } } fn write( &self, #[allow(unused_variables)] app: &AppHandle, #[allow(unused_variables)] data_dir: &Path, text: &str, ) -> Result<(), String> { match &self.folder { #[cfg(target_os = "android")] Folder::Picker => { let staged = staging_path(data_dir, "outgoing.json"); if let Some(parent) = staged.parent() { std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; } std::fs::write(&staged, text).map_err(|e| e.to_string())?; let result = bridge::write_from(app, &self.doc_name, &staged); let _ = std::fs::remove_file(&staged); result } Folder::Path(dir) => plain::write(dir, &self.doc_name, text), } } fn delete(&self, #[allow(unused_variables)] app: &AppHandle, name: &str) -> Result<(), String> { match &self.folder { #[cfg(target_os = "android")] Folder::Picker => bridge::delete(app, name), Folder::Path(dir) => plain::delete(dir, name), } } /// Names of files that look like conflict copies of our document — what /// sync tools leave behind when two devices publish while offline. /// /// Matched by shape (same stem, `.json`) rather than by any tool's naming /// convention, so it works for Syncthing, Dropbox, Nextcloud and the rest /// without knowing about any of them. fn conflict_copies(&self, app: &AppHandle) -> Vec { conflict_copies_of(&self.doc_name, self.list(app).unwrap_or_default()) } } /// Pick the conflict copies of `doc_name` out of a directory listing. /// /// Split out from [`Location::conflict_copies`] so the matching rule — the part /// with all the edge cases — is testable without a running app. fn conflict_copies_of(doc_name: &str, names: Vec) -> Vec { let stem = doc_name.strip_suffix(".json").unwrap_or(doc_name); let mut found: Vec = names .into_iter() .filter(|n| { n != doc_name && n.starts_with(stem) && n.ends_with(".json") // Our own atomic-write temporary, if a previous run died // between writing and renaming. && !n.ends_with(".json.tmp") }) .collect(); found.sort(); found } /// Resolve where the document lives. /// /// Never fails for want of a choice: with nothing picked, the document sits in /// app-private storage, so the library is always saved somewhere. Choosing a /// synced location is a preference on top of that. fn locate(#[allow(unused_variables)] app: &AppHandle, db: &Db) -> Result { #[cfg(target_os = "android")] { let status = bridge::status(app)?; if status.chosen && !status.granted { // Silently falling back to private storage here would look like the // library had been emptied, so this is worth surfacing. return Err("Access to your library folder was lost — choose it again.".into()); } if status.granted { return Ok(Location { folder: Folder::Picker, doc_name: ANDROID_DOC_NAME.into(), }); } } let path = db.lock().unwrap().effective_document_path(); let dir = path .parent() .ok_or_else(|| format!("{} has no folder.", path.display()))? .to_path_buf(); let doc_name = path .file_name() .ok_or_else(|| format!("{} isn't a file name.", path.display()))? .to_string_lossy() .into_owned(); Ok(Location { folder: Folder::Path(dir), doc_name }) } #[tauri::command] pub fn sync_status( #[allow(unused_variables)] app: AppHandle, db: Db, ) -> Result { let legacy_library = db .lock() .unwrap() .legacy_library_path() .map(|p| p.display().to_string()); #[cfg(target_os = "android")] { let s = bridge::status(&app)?; if s.chosen { return Ok(SyncStatus { chosen: true, granted: s.granted, label: s.label, picker_based: true, legacy_library, }); } // No folder picked: the library is in private storage and works, it // just isn't synced anywhere yet. return Ok(SyncStatus { chosen: false, granted: true, label: Some( db.lock() .unwrap() .effective_document_path() .display() .to_string(), ), picker_based: true, legacy_library, }); } #[cfg(not(target_os = "android"))] { let s = db.lock().unwrap(); let path = s.effective_document_path(); Ok(SyncStatus { chosen: s.document_path().is_some(), // The folder can be gone — deleted, or on a drive that isn't // mounted. The document itself needn't exist: sync creates it. granted: path.parent().map(Path::is_dir).unwrap_or(false), label: Some(path.display().to_string()), picker_based: false, legacy_library, }) } } /// Android only: grant access to a folder through the system picker. #[tauri::command] pub fn sync_pick_folder( #[allow(unused_variables)] app: AppHandle, db: Db, ) -> Result { #[cfg(target_os = "android")] { crate::logged("sync_pick_folder", bridge::pick_folder(&app))?; sync_status(app, db) } #[cfg(not(target_os = "android"))] { let _ = (app, db); Err("This platform chooses the library file by path.".into()) } } /// Desktop only: adopt a library document the frontend picked. #[tauri::command] pub fn sync_set_document( app: AppHandle, db: Db, #[allow(unused_variables)] path: String, ) -> Result { #[cfg(target_os = "android")] { let _ = (&app, &db, path); Err("This platform chooses the library folder through the system picker.".into()) } #[cfg(not(target_os = "android"))] { let mut path = PathBuf::from(path); if path.extension().is_none() { path.set_extension("json"); } match path.parent() { Some(dir) if dir.is_dir() => {} _ => return Err(format!("{} isn't in a folder that exists.", path.display())), } // Two very different intents share this one command, told apart by // whether the target already holds a library: // // * an existing document — "open that library", so the working copy // is dropped and rebuilt from it; // * a new file — "keep my library here from now on", so the working // copy is *kept* and published into it. // // Getting this wrong destroys data: always resetting would discard // everything the user had whenever they pointed at a fresh file. let adopting = path.is_file(); db.lock().unwrap().set_document_path(Some(path)); if adopting { reset_working_copy(&app, &db)?; } else { // Clear only the merge base, so the new file is seen as empty and // the whole current library gets published into it. let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; let _ = std::fs::remove_file(base_path(&data_dir)); } // Either way, get the document and the working copy agreeing now rather // than at some later trigger. if let Err(e) = sync_now(app.clone(), db.clone()) { crate::mlog!("sync_set_document: initial sync failed: {e}"); } sync_status(app, db) } } #[tauri::command] pub fn sync_forget_document(app: AppHandle, db: Db) -> Result { #[cfg(target_os = "android")] { crate::logged("sync_forget_document", bridge::forget_folder(&app))?; } #[cfg(not(target_os = "android"))] { db.lock().unwrap().set_document_path(None); } sync_status(app, db) } /// Empty the working copy and forget the merge base, so the next sync adopts /// whatever the document says from scratch. fn reset_working_copy(app: &AppHandle, db: &Db) -> Result<(), String> { let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; let _ = std::fs::remove_file(base_path(&data_dir)); let mut s = db.lock().unwrap(); let working = s.working_db_path.clone(); // Close first: the file can't be replaced while the catalog holds it open. s.close(); let _ = std::fs::remove_file(&working); if let Err(e) = s.reopen() { crate::mlog!("reset_working_copy: reopen failed: {e}"); } Ok(()) } /// Outcome of one sync. #[derive(Serialize, Default)] pub struct SyncReport { pub changed: bool, pub published: bool, /// Conflict copies absorbed and cleaned up, if any. pub absorbed: usize, pub report: MergeReport, } /// Reconcile the working copy with the library document. /// /// `Ok(None)` when a sync was already in flight and this call stood down. Sync /// runs automatically on launch and on every foreground/background transition, /// so overlap is routine rather than exceptional. #[tauri::command] pub fn sync_now(app: AppHandle, db: Db) -> Result, String> { { let mut state = db.lock().unwrap(); if state.sync_running { return Ok(None); } state.sync_running = true; } let result = sync_inner(&app, &db); db.lock().unwrap().sync_running = false; result.map(Some) } fn sync_inner(app: &AppHandle, db: &Db) -> Result { let location = locate(app, db)?; let data_dir = app.path().app_data_dir().map_err(|e| e.to_string())?; let working = db.lock().unwrap().working_db_path.clone(); // 1. Read the document. Absent simply means nothing has been written yet — // the file the user named gets created by the first sync. let existing = location.read(app, &data_dir, &location.doc_name)?; let doc_exists = existing.is_some(); let mut theirs = match existing { Some(text) => LibraryDoc::from_json(&text).map_err(|e| e.to_string())?, None => LibraryDoc::default(), }; // 2. Absorb any conflict copies, so nothing is stranded in one. let base = read_base(&data_dir); let copies = location.conflict_copies(app); let mut absorbed = Vec::new(); for name in &copies { let Ok(Some(text)) = location.read(app, &data_dir, name) else { continue; }; let Ok(copy) = LibraryDoc::from_json(&text) else { continue; // Not one of ours, or truncated mid-sync; leave it alone. }; let (merged, _) = core_sync::merge3(&base, &theirs, ©); theirs = merged; absorbed.push(name.clone()); } // 3-4. Merge our edits with theirs and make the working copy match. let conn = mtgc_core::db::open_library(&working).map_err(|e| e.to_string())?; let ours = core_sync::read_doc(&conn).map_err(|e| e.to_string())?; let (merged, report) = core_sync::merge3(&base, &ours, &theirs); if merged != ours { core_sync::apply_doc(&conn, &merged).map_err(|e| e.to_string())?; } // 5. Publish only when we have something to contribute, or the file the // user named doesn't exist yet — otherwise every sync would look like a // modification to whatever is watching the folder. let published = merged != theirs || !doc_exists; if published { let text = merged.to_json().map_err(|e| e.to_string())?; location.write(app, &data_dir, &text)?; } // Only now that their content is safely in the published document is it // safe to remove the copies it came from. if published { for name in &absorbed { if let Err(e) = location.delete(app, name) { crate::mlog!("sync: couldn't remove absorbed copy {name}: {e}"); } } } // 6. Remember the document *as read*, never the merged result. If the write // above was lost, the remote still matches this base, so the next sync sees // our edits as ours and republishes them instead of reading their absence // from the document as a deletion. write_base(&data_dir, &theirs); crate::mlog!( "sync_now: changed={} published={} absorbed={} conflicts={}", !report.is_empty(), published, absorbed.len(), report.conflicts.len() ); Ok(SyncReport { changed: !report.is_empty(), published, absorbed: absorbed.len(), report, }) } /// The document as last read. Missing or unreadable yields an empty document, /// which is the safe default: with no ancestor, a merge never deletes anything. fn read_base(data_dir: &Path) -> LibraryDoc { std::fs::read_to_string(base_path(data_dir)) .ok() .and_then(|t| LibraryDoc::from_json(&t).ok()) .unwrap_or_default() } fn write_base(data_dir: &Path, doc: &LibraryDoc) { if let Ok(text) = doc.to_json() { let _ = std::fs::write(base_path(data_dir), text); } } /// Convert a pre-document `library.db` into the current format, merging it into /// the working copy so the next sync publishes it. #[tauri::command] pub fn convert_legacy_library(app: AppHandle, db: Db, path: String) -> Result { let legacy = PathBuf::from(&path); if !legacy.is_file() { return Err(format!("{} isn't there.", legacy.display())); } let doc = crate::logged( "convert_legacy_library", mtgc_core::sync::convert_legacy_library(&legacy).map_err(|e| e.to_string()), )?; let working = db.lock().unwrap().working_db_path.clone(); let conn = mtgc_core::db::open_library(&working).map_err(|e| e.to_string())?; let ours = core_sync::read_doc(&conn).map_err(|e| e.to_string())?; // Merged rather than applied outright, so converting doesn't discard // anything already in the working copy. With an empty ancestor nothing is // treated as a deletion. let (merged, report) = core_sync::merge3(&LibraryDoc::default(), &ours, &doc); core_sync::apply_doc(&conn, &merged).map_err(|e| e.to_string())?; db.lock().unwrap().clear_legacy_library(); crate::mlog!( "convert_legacy_library: {} cards, {} decks from {}", doc.cards.len(), doc.decks.len(), legacy.display() ); // Publish immediately when a document is already configured, so the // converted library reaches the other devices without waiting. if locate(&app, &db).is_ok() { if let Some(r) = sync_now(app, db)? { return Ok(r); } } Ok(SyncReport { changed: !report.is_empty(), published: false, absorbed: 0, report, }) } #[cfg(test)] mod tests { use super::*; fn scratch(name: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!("mtgc-doc-{name}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); dir } #[test] fn writing_replaces_the_document_and_leaves_no_temporary() { // A stray ".tmp" would be picked up as a conflict copy and, worse, // synced as a partial document. let dir = scratch("write"); plain::write(&dir, "library.json", "{\"version\":1}").unwrap(); plain::write(&dir, "library.json", "{}").unwrap(); assert_eq!(plain::read(&dir, "library.json").unwrap().unwrap(), "{}"); assert_eq!(plain::list(&dir).unwrap(), vec!["library.json"]); std::fs::remove_dir_all(&dir).unwrap(); } #[test] fn reading_a_document_that_isnt_there_is_not_an_error() { // Normal before anything has been published. let dir = scratch("missing"); assert_eq!(plain::read(&dir, "library.json").unwrap(), None); std::fs::remove_dir_all(&dir).unwrap(); } #[test] fn conflict_copies_are_matched_by_shape_not_by_tool() { let dir = scratch("conflicts"); for name in [ "library.json", "library.sync-conflict-20260803-120000-ABCDEFG.json", // Syncthing "library (SFConflict garrettmills 2026-08-03).json", // Seafile "library (conflicted copy 2026-08-03).json", // Dropbox "library.json.tmp", // our own, mid-write "notes.json", // unrelated "reference.db", // unrelated ] { std::fs::write(dir.join(name), "{}").unwrap(); } let found = conflict_copies_of("library.json", plain::list(&dir).unwrap()); assert_eq!( found, vec![ "library (SFConflict garrettmills 2026-08-03).json", "library (conflicted copy 2026-08-03).json", "library.sync-conflict-20260803-120000-ABCDEFG.json", ], "should catch every tool's naming, and nothing else" ); std::fs::remove_dir_all(&dir).unwrap(); } #[test] fn a_differently_named_document_only_claims_its_own_copies() { // The user names their own file on desktop, so the rule has to key off // that name rather than assuming "library". let found = conflict_copies_of( "Decks.json", vec![ "Decks.json".into(), "Decks (conflicted copy).json".into(), "library.sync-conflict-1.json".into(), "Decks-backup.txt".into(), ], ); assert_eq!(found, vec!["Decks (conflicted copy).json"]); } }