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

@@ -20,7 +20,8 @@ type Res<T> = Result<T, String>;
/// High-level status for the top bar / first-run gating.
#[derive(Serialize)]
pub struct Status {
pub library_path: String,
/// Where the library syncs: the account and server, or this device only.
pub library_label: String,
pub reference_path: String,
pub reference_ready: bool,
pub card_count: usize,
@@ -28,13 +29,27 @@ pub struct Status {
/// when "Update card data" last completed. `None` if unavailable.
pub reference_updated_at: Option<i64>,
/// Whether this is a mobile build. The UI uses it to hide affordances that
/// only make sense with an arbitrary-path filesystem (choosing where
/// library.db lives) — see `set_library_path`. Reported from the build
/// only make sense with an arbitrary-path filesystem. Reported from the build
/// target rather than sniffed from the user agent so it can't disagree with
/// what the backend will actually allow.
pub is_mobile: bool,
}
/// Where the library syncs, for the top bar — never the internal working
/// database, which is an implementation detail the user never sees.
fn library_label(s: &AppState) -> String {
match s.sync_account() {
Some(a) if a.token.is_some() => {
let host = url::Url::parse(&a.server_url)
.ok()
.and_then(|u| u.host_str().map(str::to_string))
.unwrap_or_else(|| a.server_url.clone());
format!("{} @ {host}", a.user.username)
}
_ => "This device only (not syncing)".to_string(),
}
}
fn status_of(s: &AppState) -> Status {
let reference_updated_at = std::fs::metadata(&s.reference_path)
.and_then(|m| m.modified())
@@ -42,11 +57,7 @@ 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 {
// 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(),
library_label: library_label(s),
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),

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,

View File

@@ -1,36 +1,50 @@
//! App state: the open [`Catalog`] and fuzzy [`NameIndex`], plus the paths.
//! App state: the open [`Catalog`] and fuzzy [`NameIndex`], plus the paths and
//! the sync account.
//!
//! 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`]).
//! The user's library lives in an internal SQLite working copy under the app
//! data dir, which works fully offline. When the device is signed in to a sync
//! server, that copy is reconciled with the server's whenever it can be reached
//! (see `sync.rs` and [`mtgc_core::sync::remote`]).
use std::path::{Path, PathBuf};
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use mtgc_core::sync::account::AccountUser;
use mtgc_core::{Catalog, NameIndex};
use serde::{Deserialize, Serialize};
/// Small app-shell preferences persisted across restarts, distinct from the
/// user's library data.
///
/// Fields from older builds (`document_path`, `document_name`, from when the
/// library was a file in a synced folder) are simply ignored when read.
#[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.
/// The sync server this device is (or was last) signed in to.
#[serde(default)]
document_name: Option<String>,
sync: Option<SyncAccount>,
}
/// This device's sync server account.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct SyncAccount {
pub server_url: String,
/// This device's token. `None` once signed out, or once the server stopped
/// accepting it; the rest is kept so signing back in can be offered with
/// the address already filled in.
#[serde(default)]
pub token: Option<String>,
pub user: AccountUser,
#[serde(default)]
pub device_id: i64,
/// Unix seconds of the last sync that completed.
#[serde(default)]
pub last_synced_at: Option<i64>,
}
fn config_path(data_dir: &Path) -> PathBuf {
@@ -42,11 +56,7 @@ fn load_config(data_dir: &Path) -> PersistedConfig {
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.
// leaves the unknown settings unset.
let mut cfg: PersistedConfig = serde_json::from_str(&text).unwrap_or_default();
// Builds before the document format stored `library_path` pointing at a
@@ -66,19 +76,43 @@ fn load_config(data_dir: &Path) -> PersistedConfig {
}
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);
let Ok(text) = serde_json::to_string_pretty(cfg) else {
return;
};
// Written beside and renamed into place, so a crash mid-write can't leave
// a truncated config that silently signs the device out.
let path = config_path(data_dir);
let tmp = path.with_extension("json.tmp");
if let Err(e) = write_private(&tmp, &text).and_then(|_| std::fs::rename(&tmp, &path)) {
crate::mlog!("save_config: {e}");
let _ = std::fs::remove_file(&tmp);
}
}
/// Write a file only this user can read: the config holds the device's sync
/// token. (App data is already private on Android; this covers desktop.)
fn write_private(path: &Path, text: &str) -> std::io::Result<()> {
use std::io::Write;
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let mut f = opts.open(path)?;
f.write_all(text.as_bytes())?;
f.sync_all()
}
/// 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.
/// Internal SQLite working copy: the library itself, on this device. Never
/// chosen by, shown to, or moved by the user.
pub working_db_path: PathBuf,
pub reference_path: PathBuf,
pub catalog: Option<Catalog>,
@@ -86,10 +120,20 @@ pub struct AppState {
/// 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.
/// Set while a sync is in flight. Sync fires automatically on launch, on
/// every foreground/background transition, and on reconnecting, so
/// overlapping calls are easy to provoke; a second one would only repeat
/// the first one's work.
pub sync_running: bool,
/// Why the last sync failed, until one succeeds. Not persisted: after a
/// restart the next sync says soon enough.
pub sync_error: Option<String>,
/// The first sync with this account found a library both here and on the
/// server, and only the user can say whether to combine them or take the
/// server's. Syncing waits until they do.
pub sync_needs_choice: bool,
/// Set while a sign-in waits on the browser; flipping it abandons the wait.
pub sign_in_cancel: Option<Arc<AtomicBool>>,
}
impl AppState {
@@ -104,10 +148,13 @@ impl AppState {
index: None,
ingest_cancel: None,
sync_running: false,
sync_error: None,
sync_needs_choice: false,
sign_in_cancel: None,
};
crate::mlog!(
"AppState::new: document={:?} working={} reference={} (exists={})",
s.config.document_path,
"AppState::new: sync={:?} working={} reference={} (exists={})",
s.config.sync.as_ref().map(|a| (&a.server_url, &a.user.username, a.token.is_some())),
s.working_db_path.display(),
s.reference_path.display(),
s.reference_path.exists()
@@ -123,48 +170,26 @@ impl AppState {
s
}
/// The library document the user explicitly chose, if they have.
pub fn document_path(&self) -> Option<&Path> {
self.config.document_path.as_deref()
pub fn data_dir(&self) -> &Path {
&self.data_dir
}
/// 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())
/// The sync account, signed in or not.
pub fn sync_account(&self) -> Option<&SyncAccount> {
self.config.sync.as_ref()
}
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;
pub fn set_sync_account(&mut self, account: Option<SyncAccount>) {
self.config.sync = account;
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);
/// Change the sync account in place and persist it. A no-op without one.
pub fn update_sync_account(&mut self, f: impl FnOnce(&mut SyncAccount)) {
if let Some(a) = self.config.sync.as_mut() {
f(a);
save_config(&self.data_dir, &self.config);
}
}
/// A pre-document `library.db` still waiting to be converted.
@@ -227,39 +252,63 @@ mod tests {
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();
fn account() -> SyncAccount {
SyncAccount {
server_url: "https://mtgc.example".into(),
token: Some("mtgc_secret".into()),
user: AccountUser {
id: "u1".into(),
username: "alice".into(),
display_name: "Alice".into(),
email: "alice@example.com".into(),
method: "local".into(),
},
device_id: 7,
last_synced_at: Some(1_700_000_000),
}
}
#[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()),
sync: Some(account()),
};
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);
assert_eq!(back.sync, cfg.sync);
assert!(!dir.join("config.json.tmp").exists());
std::fs::remove_dir_all(&dir).unwrap();
}
#[cfg(unix)]
#[test]
fn the_config_holding_the_token_is_private() {
use std::os::unix::fs::PermissionsExt;
let dir = scratch("private");
save_config(&dir, &PersistedConfig { legacy_library_path: None, sync: Some(account()) });
let mode = std::fs::metadata(config_path(&dir)).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_folder_sync_config_still_loads() {
// What the previous, folder-based build wrote. Its settings no longer
// mean anything, but the file must not fail to load over them.
let dir = scratch("folder-era");
std::fs::write(
config_path(&dir),
r#"{"document_path":"/home/u/Sync/library.json","legacy_library_path":"/tmp/old.db","document_name":"decks.json"}"#,
)
.unwrap();
let cfg = load_config(&dir);
assert_eq!(cfg.legacy_library_path, Some(PathBuf::from("/tmp/old.db")));
assert!(cfg.sync.is_none());
std::fs::remove_dir_all(&dir).unwrap();
}
@@ -277,7 +326,6 @@ mod tests {
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();
}
@@ -293,9 +341,9 @@ mod tests {
#[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");
assert!(load_config(&dir).sync.is_none(), "missing file");
std::fs::write(config_path(&dir), "{not json").unwrap();
assert!(load_config(&dir).document_name.is_none(), "truncated file");
assert!(load_config(&dir).sync.is_none(), "truncated file");
std::fs::remove_dir_all(&dir).unwrap();
}
}

File diff suppressed because it is too large Load Diff