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:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user