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:
124
src/api.ts
124
src/api.ts
@@ -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 }),
|
||||
|
||||
Reference in New Issue
Block a user