Replace folder sync with an HTTP sync server, keeping the app offline-first

Folder sync left too much to the user's sync tool: platform file access
(the Android SAF plugin), choosing the document, merging conflict copies —
and its merge base could fall out of step with the document (absorbing
conflict copies, "forget", picking a new Android folder), reading every
difference as a deletion.

The new mtgc-server crate stores one versioned library per user and accepts
a write only when it names the version it replaces. It never merges: devices
still do that with merge3, through a new sync cycle over a `Remote` trait in
mtgc-core. The base advances only on knowledge — to what was read, or to a
write the server confirmed — and is stored with the server and account it
belongs to, so a different account starts from an empty ancestor instead of
deleting. The working copy is only rewritten under the app-state lock, and
sync commands now run off the main thread.

Sign-in happens in the browser on the server's pages, via the owner's OIDC
provider and/or local username/password accounts (Argon2id, managed with
`mtgc-server user ...`), and returns a per-device revocable token to the app
by PKCE-protected code: a loopback port on desktop, the mtgc:// scheme on
Android.

The Info tab gains sign-in, the first-sync combine/replace choice, the
account's devices, and JSON export/import; the top bar shows sync state.
Includes a Dockerfile, Compose/Caddy example, and scripts to build and push
${DOCKER_REGISTRY}/mtgc/sync-server.
This commit is contained in:
2026-10-08 22:32:08 -05:00
parent df2cbb372a
commit de55fc634a
41 changed files with 6365 additions and 1662 deletions

View File

@@ -33,26 +33,14 @@ 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() {
let builder = tauri::Builder::default().plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init());
// Android gets sync sign-ins back through the `mtgc://` scheme; desktop
// uses a loopback port instead and needs no scheme registered.
#[cfg(target_os = "android")]
let builder = builder.plugin(sync_plugin());
let builder = builder.plugin(tauri_plugin_deep_link::init());
builder
.setup(|app| {
@@ -63,6 +51,15 @@ pub fn run() {
std::fs::create_dir_all(&data_dir).ok();
mlog!("data dir: {}", data_dir.display());
app.manage(Mutex::new(AppState::new(data_dir)));
#[cfg(target_os = "android")]
{
use tauri_plugin_deep_link::DeepLinkExt;
app.deep_link().on_open_url(|event| {
for url in event.urls() {
sync::deliver_callback(url.as_str());
}
});
}
Ok(())
})
.invoke_handler(tauri::generate_handler![
@@ -71,13 +68,15 @@ pub fn run() {
commands::cancel_update_card_data,
commands::import_reference_db,
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_sign_in,
sync::sync_cancel_sign_in,
sync::sync_sign_out,
sync::sync_now,
sync::convert_legacy_library,
sync::sync_connect,
sync::sync_devices,
sync::sync_revoke_device,
sync::import_library_file,
sync::export_library_document,
news::get_news,
news::get_set_cards,
commands::search_cards,