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

@@ -136,7 +136,8 @@ export interface LibraryFilter {
}
export interface Status {
library_path: string;
/** Where the library syncs: "user @ host", or this device only. */
library_label: string;
reference_path: string;
reference_ready: boolean;
card_count: number;
@@ -147,41 +148,74 @@ export interface Status {
is_mobile: boolean;
}
/** State of the user's library document — the JSON file they keep in whatever
* folder they sync. The SQLite database behind it is internal and never shown. */
/** The account a device is signed in to on a sync server. */
export interface SyncAccount {
id: string;
username: string;
display_name: string;
email: string;
/** "local" (username/password) or "oidc" (single sign-on). */
method: string;
}
/** Library sync with a sync server. The library itself always lives on this
* device and works offline; syncing exchanges edits with the other devices. */
export interface SyncStatus {
/** Whether a document has been chosen. */
chosen: boolean;
/** Whether it's currently reachable. Distinct from `chosen`: an Android grant
* can be revoked and a desktop folder can be deleted or unmounted — both need
* re-picking rather than first-time setup. */
granted: boolean;
label: string | null;
/** True where the folder is chosen through the system picker (Android) rather
* than by path — the one thing the UI branches on. */
picker_based: boolean;
/** A pre-document library.db from an older build, waiting to be converted. */
signed_in: boolean;
/** Kept after signing out, to offer signing back in. */
server_url: string | null;
account: SyncAccount | null;
/** Unix seconds of the last completed sync. */
last_synced_at: number | null;
/** Why the last sync failed, until one succeeds. */
last_error: string | null;
/** The first sync found libraries both here and on the server; waiting on
* `syncConnect` to say whether to combine them or take the server's. */
needs_choice: boolean;
/** Edits made here that the server hasn't acknowledged yet. */
pending_changes: boolean;
/** A sign-in is waiting on the browser. */
signing_in: boolean;
/** A pre-document library.db from an older build, waiting to be imported. */
legacy_library: string | null;
/** Which file in the granted folder is the library (Android). Null on
* desktop, where the whole path is chosen at once. */
document_name: string | null;
}
/** One of the account's signed-in devices. */
export interface SyncDevice {
id: number;
name: string;
created_at: number;
last_seen_at: number;
/** Whether it's this device. */
current: boolean;
}
export type FirstSync = "combine" | "use_server";
export interface MergeReport {
cards_changed: number;
decks_changed: number;
decks_removed: number;
/** Fields both sides changed, where the server's value was kept. */
conflicts: string[];
}
/** What an import of a library file brought in. */
export interface ImportReport {
cards: number;
decks: number;
report: MergeReport;
}
/** What one sync did. */
export interface SyncReport {
/** Whether the merge altered the local library. */
/** Whether the merge altered this device's library. */
changed: boolean;
/** Whether the document was rewritten (skipped when we had nothing to add). */
/** Whether the server's copy was replaced (skipped when there was nothing to add). */
published: boolean;
/** Conflict copies absorbed and cleaned up. */
absorbed: number;
report: {
cards_changed: number;
decks_changed: number;
decks_removed: number;
/** Fields both sides changed differently, described in user terms. */
conflicts: string[];
};
/** The first sync is waiting on the user — see `SyncStatus.needs_choice`. */
needs_choice: boolean;
report: MergeReport;
}
/** A published or upcoming Magic set, from Scryfall. */
@@ -299,22 +333,26 @@ export const api = {
/** Adopt an already-built reference.db from `path` instead of downloading it. */
importReferenceDb: (path: string) => invoke<Status>("import_reference_db", { path }),
syncStatus: () => invoke<SyncStatus>("sync_status"),
/** Merge the library document with local edits and publish the result.
* Resolves with `null` when a sync was already running and this call stood
* down — expected, since sync also fires automatically. */
/** Sign in through the browser, then sync. Resolves once the user finishes
* (or abandons) signing in there. */
syncSignIn: (serverUrl: string, deviceName?: string) =>
invoke<SyncStatus>("sync_sign_in", { serverUrl, deviceName: deviceName || null }),
syncCancelSignIn: () => invoke<void>("sync_cancel_sign_in"),
/** Sign this device out. The library stays here. */
syncSignOut: () => invoke<SyncStatus>("sync_sign_out"),
/** Merge with the server and publish local edits. Resolves with `null` when
* not signed in, or a sync was already running and this call stood down —
* expected, since sync also fires automatically. */
syncNow: () => invoke<SyncReport | null>("sync_now"),
/** Android: grant access to a folder through the system picker. */
syncPickFolder: () => invoke<SyncStatus>("sync_pick_folder"),
/** Desktop: adopt a library document chosen with the file dialog. */
syncSetDocument: (path: string) => invoke<SyncStatus>("sync_set_document", { path }),
syncForgetDocument: () => invoke<SyncStatus>("sync_forget_document"),
/** Library files already in the chosen folder, for Android's second step. */
syncListDocuments: () => invoke<string[]>("sync_list_documents"),
/** Android: choose which file in the granted folder is the library. */
syncSetDocumentName: (name: string) =>
invoke<SyncStatus>("sync_set_document_name", { name }),
/** Convert a pre-document library.db into the current format. */
convertLegacyLibrary: (path: string) => invoke<SyncReport>("convert_legacy_library", { path }),
/** Answer the first-sync question. */
syncConnect: (choice: FirstSync) => invoke<SyncReport | null>("sync_connect", { choice }),
syncDevices: () => invoke<SyncDevice[]>("sync_devices"),
/** Sign another device out (a lost phone, say); returns the remaining list. */
syncRevokeDevice: (id: number) => invoke<SyncDevice[]>("sync_revoke_device", { id }),
/** Merge a library file into this one: a JSON backup, or an old library.db. */
importLibraryFile: (path: string) => invoke<ImportReport>("import_library_file", { path }),
/** Save the whole library as JSON, as a backup. */
exportLibraryDocument: (path: string) => invoke<void>("export_library_document", { path }),
/** Set releases and article links. `refresh` forces a fetch past the cache. */
getNews: (refresh = false) => invoke<News>("get_news", { refresh }),