Rework library sync around a JSON document; add card filters

Sync

The previous design put the merge inside SQL: timestamp-stamping triggers,
a tombstone table, per-device SQLite snapshots, a device id in the database,
and row merges written as correlated-subquery UPDATEs. All of that schema
was inherited by desktop, and the merge was very hard to verify.

Replace it with a single JSON document plus a three-way merge in plain Rust.
Storing the last-read remote document as the merge base makes "who changed
what" and "what was deleted" derivable, which removes the tombstone table,
the updated_at columns and all eight triggers. It also makes a single shared
file safe: if a publish is lost the remote is unchanged, so the next merge
re-detects the local edits and republishes rather than reading their absence
as a deletion. That removes the need for per-device files and for device
identity entirely. Schema impact drops to one column, decks.uuid.

Conflicts resolve to "theirs", which is required for convergence rather than
chosen for taste — preferring ours has each device re-asserting its own value
forever. Every such choice is reported to the user.

SQLite becomes invisible: the working copy lives in the app data dir and the
user only ever names a .json file. There is always a document, defaulting to
private storage, so edits are never stranded with nowhere to be saved.
Pointing at a file that doesn't exist yet moves the current library there;
pointing at one that does adopts it. A converter brings a pre-document
library.db across.

Android reaches the folder through the Storage Access Framework rather than
MANAGE_EXTERNAL_STORAGE, so no storage permission is declared at all.

Filters

Track card origin in reference.db: Universes Beyond, digital-only and
Un-set cards, plus numeric power/toughness. Each origin flag is derived as
"every printing", not "any printing" — ordinary cards get reprinted into
those sets, and Lightning Bolt appearing in Marvel Commander must not hide
it. Non-numeric power ("*", "1+*") stays NULL so it drops out of range
filters instead of being compared as zero.

Also wire up filters over data already present: price range, format legality
(restricted counts as legal) and "can be a commander". All of these degrade
silently against an older reference.db rather than failing every query.

The shared criteria live in one component used by both filter panels, as a
compact row of icon toggles.

Card Search gains the Library tab's bulk selection: checkboxes in both views,
select-all, and a bulk add-to-deck with a single undo for the whole batch.
This commit is contained in:
2026-08-07 22:03:49 -05:00
parent 663af32562
commit 04e9c3cbbb
21 changed files with 3370 additions and 100 deletions

View File

@@ -2,6 +2,11 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<!-- Note: library sync needs no storage permission. The user grants access
to one folder through the system folder picker (SAF), and that grant is
persisted across restarts — see SyncPlugin.kt. -->
<!-- AndroidTV support -->
<uses-feature android:name="android.software.leanback" android:required="false" />

View File

@@ -0,0 +1,295 @@
package com.garrettmills.mtgc
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.database.Cursor
import android.net.Uri
import android.provider.DocumentsContract
import androidx.activity.result.ActivityResult
import app.tauri.annotation.ActivityCallback
import app.tauri.annotation.Command
import app.tauri.annotation.InvokeArg
import app.tauri.annotation.TauriPlugin
import app.tauri.plugin.Invoke
import app.tauri.plugin.JSArray
import app.tauri.plugin.JSObject
import app.tauri.plugin.Plugin
import java.io.File
@InvokeArg
class FileArgs {
/** File name inside the chosen folder, e.g. "library.json". */
lateinit var name: String
/** App-private staging path the bytes are copied to/from. */
lateinit var path: String
}
@InvokeArg
class NameArgs {
lateinit var name: String
}
/**
* Bridges the Storage Access Framework to Rust for library sync.
*
* The user picks one folder through the system picker; we take a *persistable*
* URI permission on it so the grant survives restarts. That folder is a real
* directory in shared storage (e.g. /storage/emulated/0/mtgc), so a sync tool
* like Syncthing can see it, but this app reaches it only through SAF and
* therefore needs no storage permission in the manifest.
*
* SAF hands back `content://` URIs, which SQLite cannot open — so nothing here
* exposes a URI to Rust. Instead every operation copies bytes between the
* chosen folder and an app-private staging path that Rust picks, and Rust only
* ever does plain file I/O on its own private storage. That is sufficient
* because the synced copy is never opened as a database; it is only ever
* written whole or read whole.
*/
@TauriPlugin
class SyncPlugin(private val activity: Activity) : Plugin(activity) {
companion object {
private const val PREFS = "mtgc-sync"
private const val KEY_TREE_URI = "tree-uri"
/** Generic type: a SQLite file has no useful registered MIME type. */
private const val MIME = "application/octet-stream"
private const val NO_FOLDER =
"No synced folder is set up yet — choose one first."
}
private fun prefs() = activity.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
/**
* The chosen folder, but only when the persisted grant is still held.
*
* A saved URI isn't proof of access: the user can revoke it in system
* settings, and clearing app data drops it. Checking the live grant list
* keeps us from failing later with an opaque SecurityException.
*/
private fun grantedTree(): Uri? {
val saved = prefs().getString(KEY_TREE_URI, null) ?: return null
val uri = Uri.parse(saved)
val held = activity.contentResolver.persistedUriPermissions.any {
it.uri == uri && it.isReadPermission && it.isWritePermission
}
return if (held) uri else null
}
/** Best-effort human-readable name for the folder, for display in the UI. */
private fun label(uri: Uri): String = try {
val docId = DocumentsContract.getTreeDocumentId(uri)
val parts = docId.split(":", limit = 2)
if (parts.size == 2 && parts[0].equals("primary", ignoreCase = true)) {
"Internal storage/${parts[1]}"
} else {
docId
}
} catch (_: Exception) {
uri.toString()
}
private fun statusObject(): JSObject {
val saved = prefs().getString(KEY_TREE_URI, null)
val granted = grantedTree()
return JSObject().apply {
// "chosen but not granted" is a distinct, recoverable state worth
// telling the user about — it means re-pick, not first-time setup.
put("chosen", saved != null)
put("granted", granted != null)
put("label", saved?.let { label(Uri.parse(it)) })
}
}
@Command
fun status(invoke: Invoke) {
invoke.resolve(statusObject())
}
@Command
fun pickFolder(invoke: Invoke) {
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE).apply {
addFlags(
Intent.FLAG_GRANT_READ_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or
Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
)
}
startActivityForResult(invoke, intent, "folderPicked")
}
@ActivityCallback
fun folderPicked(invoke: Invoke, result: ActivityResult) {
val uri = result.data?.data
if (result.resultCode != Activity.RESULT_OK || uri == null) {
// Backing out of the picker is a normal choice, not a failure.
invoke.resolve(statusObject().apply { put("cancelled", true) })
return
}
try {
// Without this the grant dies with the activity.
activity.contentResolver.takePersistableUriPermission(
uri,
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
)
} catch (e: Exception) {
invoke.reject("Couldn't keep access to that folder: ${e.message}")
return
}
prefs().edit().putString(KEY_TREE_URI, uri.toString()).apply()
invoke.resolve(statusObject().apply { put("cancelled", false) })
}
@Command
fun forgetFolder(invoke: Invoke) {
prefs().getString(KEY_TREE_URI, null)?.let {
try {
activity.contentResolver.releasePersistableUriPermission(
Uri.parse(it),
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
)
} catch (_: Exception) {
// Already gone; clearing our own record below is what matters.
}
}
prefs().edit().remove(KEY_TREE_URI).apply()
invoke.resolve(statusObject())
}
/** Locate a direct child of the chosen folder by display name. */
private fun findChild(tree: Uri, name: String): Uri? {
val children = DocumentsContract.buildChildDocumentsUriUsingTree(
tree,
DocumentsContract.getTreeDocumentId(tree)
)
val projection = arrayOf(
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME
)
activity.contentResolver.query(children, projection, null, null, null)
?.use { c: Cursor ->
while (c.moveToNext()) {
if (c.getString(1) == name) {
return DocumentsContract.buildDocumentUriUsingTree(tree, c.getString(0))
}
}
}
return null
}
/**
* Names of the files directly inside the chosen folder. Rust uses this to
* discover the other devices' snapshots; which names matter is entirely its
* business, so everything is returned unfiltered.
*/
@Command
fun list(invoke: Invoke) {
val tree = grantedTree() ?: return invoke.reject(NO_FOLDER)
try {
val names = JSArray()
val children = DocumentsContract.buildChildDocumentsUriUsingTree(
tree,
DocumentsContract.getTreeDocumentId(tree)
)
val projection = arrayOf(DocumentsContract.Document.COLUMN_DISPLAY_NAME)
activity.contentResolver.query(children, projection, null, null, null)
?.use { c: Cursor ->
while (c.moveToNext()) {
c.getString(0)?.let { names.put(it) }
}
}
invoke.resolve(JSObject().apply { put("names", names) })
} catch (e: Exception) {
invoke.reject("Couldn't list the synced folder: ${e.message}")
}
}
/**
* Remove a file from the chosen folder.
*
* Used only to clear away conflict copies once their contents have been
* merged and republished — never to delete the library document itself.
*/
@Command
fun deleteFile(invoke: Invoke) {
val args = invoke.parseArgs(NameArgs::class.java)
val tree = grantedTree() ?: return invoke.reject(NO_FOLDER)
val doc = findChild(tree, args.name)
if (doc == null) {
invoke.resolve(JSObject().apply { put("removed", false) })
return
}
try {
val removed = DocumentsContract.deleteDocument(activity.contentResolver, doc)
invoke.resolve(JSObject().apply { put("removed", removed) })
} catch (e: Exception) {
invoke.reject("Couldn't remove ${args.name}: ${e.message}")
}
}
/** Copy `name` out of the chosen folder into the app-private `path`. */
@Command
fun readInto(invoke: Invoke) {
val args = invoke.parseArgs(FileArgs::class.java)
val tree = grantedTree() ?: return invoke.reject(NO_FOLDER)
val doc = findChild(tree, args.name)
if (doc == null) {
// A missing file is an expected state (nothing synced here yet), not an
// error — the caller decides what to do about it.
invoke.resolve(JSObject().apply {
put("found", false)
put("bytes", 0L)
})
return
}
try {
val dest = File(args.path)
dest.parentFile?.mkdirs()
val copied = activity.contentResolver.openInputStream(doc)!!.use { input ->
dest.outputStream().use { out -> input.copyTo(out) }
}
invoke.resolve(JSObject().apply {
put("found", true)
put("bytes", copied)
})
} catch (e: Exception) {
invoke.reject("Couldn't read ${args.name} from the synced folder: ${e.message}")
}
}
/** Copy the app-private `path` into the chosen folder as `name`. */
@Command
fun writeFrom(invoke: Invoke) {
val args = invoke.parseArgs(FileArgs::class.java)
val tree = grantedTree() ?: return invoke.reject(NO_FOLDER)
val src = File(args.path)
if (!src.isFile) return invoke.reject("Nothing to upload at ${args.path}")
try {
// Overwrite the existing document when there is one, rather than
// delete-and-recreate: a sync tool then sees one modified file instead
// of a removal followed by an unrelated addition.
val doc = findChild(tree, args.name)
?: DocumentsContract.createDocument(
activity.contentResolver,
DocumentsContract.buildDocumentUriUsingTree(
tree,
DocumentsContract.getTreeDocumentId(tree)
),
MIME,
args.name
)
?: return invoke.reject("Couldn't create ${args.name} in the synced folder")
// "wt" truncates first. Plain "w" leaves any trailing bytes of a longer
// previous file in place, which would silently corrupt a database that
// got smaller.
val copied = activity.contentResolver.openOutputStream(doc, "wt")!!.use { out ->
src.inputStream().use { input -> input.copyTo(out) }
}
invoke.resolve(JSObject().apply { put("bytes", copied) })
} catch (e: Exception) {
invoke.reject("Couldn't write ${args.name} to the synced folder: ${e.message}")
}
}
}

View File

@@ -42,7 +42,11 @@ fn status_of(s: &AppState) -> Status {
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs() as i64);
Status {
library_path: s.library_path.display().to_string(),
// Where the library document lives, not the internal working database —
// that one is an implementation detail the user never sees. Always
// populated: with nothing chosen it's the default in private storage,
// so the path bar never goes blank.
library_path: s.effective_document_path().display().to_string(),
reference_path: s.reference_path.display().to_string(),
reference_ready: s.catalog.is_some(),
card_count: s.index.as_ref().map(|i| i.len()).unwrap_or(0),
@@ -66,18 +70,6 @@ pub fn get_status(db: Db) -> Res<Status> {
Ok(status)
}
#[tauri::command]
pub fn set_library_path(db: Db, path: String) -> Res<Status> {
let mut s = db.lock().unwrap();
s.set_library_path(PathBuf::from(path));
// Best-effort reopen: the chosen path must stick (and be reported back)
// even if the reference db can't currently be attached (e.g. stale
// schema pending an "Update card data" run) — that's a separate concern
// from which library file is open.
let _ = s.reopen();
Ok(status_of(&s))
}
/// Download Scryfall bulk data and (re)build reference.db, then reopen.
/// Runs on a blocking thread so the UI stays responsive and progress events flow.
#[tauri::command]

View File

@@ -1,5 +1,6 @@
mod commands;
mod state;
mod sync;
use std::sync::Mutex;
@@ -31,10 +32,27 @@ pub(crate) fn logged<T>(what: &str, result: Result<T, String>) -> Result<T, Stri
result
}
/// Register the Kotlin-side `SyncPlugin` and stash its handle in app state, so
/// the sync commands can call across to SAF. See `sync.rs` for why library sync
/// goes through a Java plugin rather than plain `std::fs`.
#[cfg(target_os = "android")]
fn sync_plugin() -> tauri::plugin::TauriPlugin<tauri::Wry> {
tauri::plugin::Builder::new("mtgc-sync")
.setup(|app, api| {
let handle = api.register_android_plugin("com.garrettmills.mtgc", "SyncPlugin")?;
app.manage(sync::SyncBridge(handle));
Ok(())
})
.build()
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
let builder = tauri::Builder::default().plugin(tauri_plugin_dialog::init());
#[cfg(target_os = "android")]
let builder = builder.plugin(sync_plugin());
builder
.setup(|app| {
let data_dir = app
.path()
@@ -47,10 +65,15 @@ pub fn run() {
})
.invoke_handler(tauri::generate_handler![
commands::get_status,
commands::set_library_path,
commands::update_card_data,
commands::cancel_update_card_data,
commands::import_reference_db,
sync::sync_status,
sync::sync_pick_folder,
sync::sync_set_document,
sync::sync_forget_document,
sync::sync_now,
sync::convert_legacy_library,
commands::search_cards,
commands::add_owned,
commands::set_owned,

View File

@@ -1,15 +1,28 @@
//! App state: the open [`Catalog`] and fuzzy [`NameIndex`], plus the db paths.
//! 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) — currently just which library file was last open.
#[derive(Default, Serialize, Deserialize)]
/// Small app-shell preferences persisted across restarts, distinct from the
/// user's library data.
#[derive(Default, Serialize, Deserialize, Clone)]
struct PersistedConfig {
library_path: Option<PathBuf>,
/// 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>,
}
fn config_path(data_dir: &Path) -> PathBuf {
@@ -17,10 +30,32 @@ fn config_path(data_dir: &Path) -> PathBuf {
}
fn load_config(data_dir: &Path) -> PersistedConfig {
std::fs::read_to_string(config_path(data_dir))
let text = match std::fs::read_to_string(config_path(data_dir)) {
Ok(t) => t,
Err(_) => return PersistedConfig::default(),
};
if let Ok(cfg) = serde_json::from_str::<PersistedConfig>(&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;
}
}
// 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<PathBuf>,
}
let legacy = serde_json::from_str::<OldConfig>(&text)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
.and_then(|c| c.library_path)
.filter(|p| p.extension().is_some_and(|e| e != "json"));
PersistedConfig {
document_path: None,
legacy_library_path: legacy,
}
}
fn save_config(data_dir: &Path, cfg: &PersistedConfig) {
@@ -32,35 +67,41 @@ fn save_config(data_dir: &Path, cfg: &PersistedConfig) {
/// Managed application state, guarded by a `Mutex` in Tauri's state store.
pub struct AppState {
data_dir: PathBuf,
pub library_path: 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 {
/// Initialize with the last-opened library path (or the default under the
/// app data dir, on first run) and open the catalog if a reference db
/// already exists.
pub fn new(data_dir: PathBuf) -> Self {
let cfg = load_config(&data_dir);
let library_path = cfg
.library_path
.unwrap_or_else(|| data_dir.join("library.db"));
let config = load_config(&data_dir);
let mut s = Self {
reference_path: data_dir.join("reference.db"),
library_path,
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: library={} reference={} (exists={})",
s.library_path.display(),
"AppState::new: document={:?} working={} reference={} (exists={})",
s.config.document_path,
s.working_db_path.display(),
s.reference_path.display(),
s.reference_path.exists()
);
@@ -75,16 +116,44 @@ impl AppState {
s
}
/// Point at a different library file and persist it so the next launch
/// reopens the same one.
pub fn set_library_path(&mut self, path: PathBuf) {
self.library_path = path.clone();
save_config(
&self.data_dir,
&PersistedConfig {
library_path: Some(path),
},
);
/// 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);
}
/// 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
@@ -100,7 +169,7 @@ impl AppState {
/// db and make every query fail.
pub fn reopen(&mut self) -> Result<(), String> {
if self.reference_path.exists() {
let catalog = Catalog::open(&self.library_path, &self.reference_path)
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);

744
src-tauri/src/sync.rs Normal file
View File

@@ -0,0 +1,744 @@
//! 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<AppState>>;
/// 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<String>,
/// 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<String>,
}
#[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<tauri::Wry>);
#[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<String>,
}
#[derive(Deserialize, Default)]
pub struct FolderStatus {
#[serde(default)]
pub chosen: bool,
#[serde(default)]
pub granted: bool,
#[serde(default)]
pub label: Option<String>,
}
fn call<T: serde::de::DeserializeOwned>(
app: &AppHandle,
command: &str,
payload: impl Serialize,
) -> Result<T, String> {
let bridge = app
.try_state::<SyncBridge>()
.ok_or_else(|| "Sync plugin failed to load — see the logs.".to_string())?;
bridge
.0
.run_mobile_plugin::<T>(command, payload)
.map_err(|e| e.to_string())
}
pub fn status(app: &AppHandle) -> Result<FolderStatus, String> {
call(app, "status", ())
}
pub fn pick_folder(app: &AppHandle) -> Result<FolderStatus, String> {
call(app, "pickFolder", ())
}
pub fn forget_folder(app: &AppHandle) -> Result<FolderStatus, String> {
call(app, "forgetFolder", ())
}
pub fn list(app: &AppHandle) -> Result<Vec<String>, String> {
Ok(call::<ListResult>(app, "list", ())?.names)
}
pub fn read_into(app: &AppHandle, name: &str, path: &Path) -> Result<bool, String> {
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<Vec<String>, 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<Option<String>, 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<Vec<String>, 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<Option<String>, 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<String> {
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<String>) -> Vec<String> {
let stem = doc_name.strip_suffix(".json").unwrap_or(doc_name);
let mut found: Vec<String> = 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<Location, String> {
#[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<SyncStatus, String> {
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<SyncStatus, String> {
#[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<SyncStatus, String> {
#[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<SyncStatus, String> {
#[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<Option<SyncReport>, 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<SyncReport, String> {
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, &copy);
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<SyncReport, String> {
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 "<name>.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"]);
}
}