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

@@ -1,9 +1,10 @@
import { useEffect, useRef, useState, useCallback, ReactNode } from "react";
import { listen } from "@tauri-apps/api/event";
import { open, save } from "@tauri-apps/plugin-dialog";
import { open } from "@tauri-apps/plugin-dialog";
import { api, IngestProgress, IngestStageInfo, LibraryStats, Status, SyncStatus } from "./api";
import {
ColorDistributionBar,
formatRelativeTime,
HoverPreviewProvider,
IconButton,
Icons,
@@ -12,6 +13,7 @@ import {
usePersistedState,
} from "./components";
import { CommandPalette } from "./CommandPalette";
import { SyncIndicator, SyncPanel } from "./SyncPanel";
import { CardEntry } from "./views/CardEntry";
import { LibraryBrowser } from "./views/LibraryBrowser";
import { CardSearch } from "./views/CardSearch";
@@ -39,14 +41,6 @@ const ICON_OPEN_LIBRARY = svg(
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />,
16,
);
const ICON_NEW_LIBRARY = svg(
<>
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z" />
<line x1="12" y1="11" x2="12" y2="17" />
<line x1="9" y1="14" x2="15" y2="14" />
</>,
16,
);
const ICON_DOWNLOAD = svg(
<>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
@@ -145,21 +139,6 @@ const INFO_TAB: { id: Tab; label: string; icon: ReactNode } = {
),
};
/** Compact relative time, e.g. "2h ago" — for the "database updated" stamp. */
function formatRelativeTime(epochSeconds: number): string {
const diffSec = Math.round((Date.now() - epochSeconds * 1000) / 1000);
if (diffSec < 30) return "just now";
const mins = Math.round(diffSec / 60);
if (mins < 60) return `${mins}m ago`;
const hours = Math.round(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.round(hours / 24);
if (days < 30) return `${days}d ago`;
const months = Math.round(days / 30);
if (months < 12) return `${months}mo ago`;
return `${Math.round(months / 12)}y ago`;
}
/** "Your library" summary: unique/copy/deck counts, color distribution, and
* the reference-db freshness stamp. Shared by the desktop nav rail's footer
* and the mobile Info tab, which otherwise have no equivalent place for it. */
@@ -231,9 +210,10 @@ function segmentFill(stages: IngestStageInfo[], index: number, overallPercent: n
// so missing the snackbar isn't the last chance to undo.
const UNDO_SNACKBAR_MS = 8000;
/** How often an open, visible window re-syncs. Long enough not to churn the
* synced folder, short enough that a second device sees edits without being
* restarted. Launch and foreground/background transitions sync too. */
/** How often an open, visible window re-syncs. Long enough not to load the
* server, short enough that a second device sees edits without being
* restarted. Launch, reconnecting, and foreground/background transitions sync
* too. */
const AUTO_SYNC_MS = 3 * 60 * 1000;
export default function App() {
@@ -242,14 +222,11 @@ export default function App() {
const [tab, setTab] = usePersistedState<Tab>("active-tab", "add");
const [progress, setProgress] = useState<IngestProgress | null>(null);
const [busy, setBusy] = useState(false);
// Synced-folder grant for library sync. Only fetched when the Info tab is
// open: the grant can be revoked from system settings while the app is
// backgrounded, so a value cached at startup would be stale exactly when the
// user comes back to check it.
// Library sync state: signed in or not, and how the last sync went. Fetched
// at startup, after every sync, and whenever the Info tab opens.
const [sync, setSync] = useState<SyncStatus | null>(null);
const [syncBusy, setSyncBusy] = useState(false);
// Result of the last round-trip self-test, shown until the next action.
const [syncTest, setSyncTest] = useState<{ ok: boolean; message: string } | null>(null);
// A sync is in flight, for the top-bar indicator.
const [syncing, setSyncing] = useState(false);
const lastProgress = useRef<IngestProgress | null>(null);
// Set by the ingest-progress listener below; reset before each
// updateCardData() call so it can detect a stalled invoke — see there.
@@ -439,212 +416,82 @@ export default function App() {
}
/** Refresh the synced-folder grant state. Never throws: "no folder chosen" is
* a normal state, reported in the panel rather than as an error. */
/** Refresh the sync state. Never throws: being offline or signed out is a
* normal state, reported in the panel rather than as an error. */
const refreshSync = useCallback(async () => {
setSyncBusy(true);
try {
setSync(await api.syncStatus());
} catch (err) {
setSyncTest({ ok: false, message: String(err) });
} finally {
setSyncBusy(false);
console.error("syncStatus failed:", err);
}
}, []);
// Once at startup: the automatic sync below needs to know whether a folder is
// configured at all.
// Once at startup: the automatic sync below needs to know whether this
// device is signed in at all.
//
// Gated on `statusLoaded`: `getStatus` retries until the backend is ready,
// so waiting on it guarantees setup() has registered the state these
// commands need.
useEffect(() => {
if (statusLoaded) refreshSync();
}, [statusLoaded, refreshSync]);
// And whenever the Info tab is opened, which is also when the user gets back
// from the folder picker, and when a revoked grant would first matter.
//
// Gated on `statusLoaded` as well as the tab: the active tab is persisted, so
// relaunching while on Info makes this fire at mount — before setup() has
// called manage(), which fails with "state not managed for field db".
// `getStatus` retries until the backend is ready, so waiting on it is what
// guarantees the state exists.
// And whenever the Info tab is opened, so "synced 5m ago" is current there.
useEffect(() => {
if (tab === "info" && statusLoaded) refreshSync();
}, [tab, statusLoaded, refreshSync]);
/**
* Point the app at `path` and bring the whole UI into line with it.
*
* Switching documents changes the entire library underneath every view, so
* refreshing the sync panel alone isn't enough: the path bar reads
* `status.library_path`, the sidebar reads `libStats`, and each tab is
* holding rows fetched from the *previous* library. Returns a short " — N
* cards, M decks" suffix so the caller can confirm what actually opened.
* Bring every view into line after the library may have changed wholesale:
* signing in or out, adopting the server's copy, importing a file. The top
* bar's label comes from get_status, which nothing else re-fetches; the
* sidebar reads `libStats`; and each tab holds rows it fetched earlier.
*/
async function adoptDocument(path: string): Promise<string> {
setSync(await api.syncSetDocument(path));
// The path bar comes from get_status, which nothing else re-fetches.
setStatus(await api.getStatus());
const refreshEverything = useCallback(() => {
api.getStatus().then(setStatus).catch(() => {});
if (readyRef.current) api.getLibraryStats().then(setLibStats).catch(() => {});
setDeckRefreshSignal((v) => v + 1);
if (!readyRef.current) return "";
try {
const stats = await api.getLibraryStats();
setLibStats(stats);
return ` — ${stats.unique_cards} card${stats.unique_cards === 1 ? "" : "s"}, ${
stats.deck_count
} deck${stats.deck_count === 1 ? "" : "s"}`;
} catch {
return "";
}
}
/** Choose where the library lives. Android grants a folder through the system
* picker (SAF has no path to hand back); desktop picks the JSON file itself. */
async function pickLibrary() {
setSyncTest(null);
try {
if (sync?.picker_based) {
setSyncBusy(true);
setSync(await api.syncPickFolder());
return;
}
const path = await open({
title: "Choose your library file",
// Start where the library currently lives, so the dialog opens
// somewhere useful rather than at an arbitrary default.
defaultPath: sync?.label ?? undefined,
filters: [
{ name: "mtgc library", extensions: ["json"] },
{ name: "All files", extensions: ["*"] },
],
});
if (typeof path !== "string") return;
setSyncBusy(true);
const opened = await adoptDocument(path);
setSyncTest({ ok: true, message: `Opened ${path}${opened}.` });
} catch (err) {
setSyncTest({ ok: false, message: String(err) });
} finally {
setSyncBusy(false);
}
}
/** Desktop: start a new library file, e.g. inside a synced folder. */
async function newLibrary() {
setSyncTest(null);
try {
const path = await save({
title: "Save your library here (put it in a synced folder)",
defaultPath: "mtgc-library.json",
filters: [{ name: "mtgc library", extensions: ["json"] }],
});
if (typeof path !== "string") return;
setSyncBusy(true);
// Pointing at a file that doesn't exist yet moves the current library
// there rather than starting empty — see sync_set_document.
const moved = await adoptDocument(path);
setSyncTest({ ok: true, message: `Your library is now kept at ${path}${moved}.` });
} catch (err) {
setSyncTest({ ok: false, message: String(err) });
} finally {
setSyncBusy(false);
}
}
/** Android's second step: which file in the granted folder is the library.
* Loaded lazily, since it costs a SAF round trip. */
const [docChoices, setDocChoices] = useState<string[] | null>(null);
useEffect(() => {
if (tab !== "info" || !sync?.picker_based || !sync.granted) return;
api.syncListDocuments().then(setDocChoices).catch(() => setDocChoices([]));
}, [tab, sync?.picker_based, sync?.granted, sync?.label]);
async function chooseDocumentName(name: string) {
setSyncBusy(true);
setSyncTest(null);
try {
setSync(await api.syncSetDocumentName(name));
setStatus(await api.getStatus());
setDeckRefreshSignal((v) => v + 1);
if (readyRef.current) api.getLibraryStats().then(setLibStats).catch(() => {});
setSyncTest({ ok: true, message: `Using ${name}.` });
} catch (err) {
setSyncTest({ ok: false, message: String(err) });
} finally {
setSyncBusy(false);
}
}
async function forgetLibrary() {
setSyncBusy(true);
setSyncTest(null);
try {
setSync(await api.syncForgetDocument());
} catch (err) {
setSyncTest({ ok: false, message: String(err) });
} finally {
setSyncBusy(false);
}
}
/** Convert a library.db from an older build into the current format. */
async function convertLegacy(path: string) {
setSyncBusy(true);
setSyncTest(null);
try {
const r = await api.convertLegacyLibrary(path);
const n = r.report.cards_changed + r.report.decks_changed;
const status = await api.syncStatus();
setSync(status);
// Say where it landed — otherwise a successful conversion looks like
// nothing happened.
setSyncTest({
ok: true,
message: `Converted your old library (${n} entries). It's now kept at ${status.label ?? "your library file"}.`,
});
if (readyRef.current) api.getLibraryStats().then(setLibStats).catch(() => {});
setDeckRefreshSignal((v) => v + 1);
} catch (err) {
setSyncTest({ ok: false, message: String(err) });
} finally {
setSyncBusy(false);
}
}
/** Merge the library document with local edits and publish the result.
*
* Shared by the Info tab's button and the automatic triggers below. Returns
* the report so the caller can decide whether to say anything; throws only
* what the backend threw. */
const runSync = useCallback(async () => {
const report = await api.syncNow();
if (!report) return null; // Another sync had it in hand.
const changed = report.changed;
if (changed) {
// Library and deck views hold fetched copies; a merge changes rows
// underneath them, so pull them back into line rather than waiting for
// the summary's 5s poll. Needs card data loaded, hence the guard.
if (readyRef.current) {
api.getLibraryStats().then(setLibStats).catch(() => {});
}
setDeckRefreshSignal((v) => v + 1);
}
return { report, changed };
}, []);
// Automatic sync. Runs once the folder is usable, then on every trip to or
// from the background: leaving publishes what changed here, returning picks
// up what changed elsewhere. Keyed on `granted` alone so merely re-checking
// the folder's status doesn't re-arm the listeners and resync.
const syncGranted = sync?.granted ?? false;
/** Merge with the server and publish local edits.
*
* Shared by the Info tab's button and the automatic triggers below. Resolves
* with the report (null when nothing ran); throws only what the backend
* threw. Either way the sync state is re-read, so the indicator follows. */
const runSync = useCallback(async () => {
setSyncing(true);
try {
const report = await api.syncNow();
if (report?.changed) {
// Library and deck views hold fetched copies; a merge changes rows
// underneath them, so pull them back into line rather than waiting
// for the summary's 5s poll. Needs card data loaded, hence the guard.
if (readyRef.current) {
api.getLibraryStats().then(setLibStats).catch(() => {});
}
setDeckRefreshSignal((v) => v + 1);
}
return report;
} finally {
setSyncing(false);
api.syncStatus().then(setSync).catch(() => {});
}
}, []);
// Automatic sync, whenever signed in (and not waiting on the first-sync
// choice): once now, on every trip to or from the background — leaving
// publishes what changed here, returning picks up what changed elsewhere —
// and on reconnecting. Keyed on that flag alone so re-reading the status
// after each sync doesn't re-arm the listeners and sync again.
const syncActive = (sync?.signed_in ?? false) && !(sync?.needs_choice ?? false);
useEffect(() => {
if (!syncGranted) return;
if (!syncActive) return;
let cancelled = false;
// Failures here are deliberately silent: the folder can be temporarily
// unavailable (drive unmounted, grant revoked), and nagging on every app
// switch would be worse than the Info tab reporting it on demand. Nothing
// is lost by a skipped run — the merge base only advances to a document we
// actually read, so a lost publish is simply re-detected next time.
// Failures surface through the indicator rather than as errors: being
// offline is routine, and nothing is lost by a skipped run — edits stay
// in the working copy, and the merge base only ever records what the
// server confirmed, so the next run picks up exactly where this left off.
const run = () => {
if (cancelled) return;
runSync().catch(() => {});
@@ -652,6 +499,7 @@ export default function App() {
run();
document.addEventListener("visibilitychange", run);
window.addEventListener("online", run);
// Visibility changes alone would strand a desktop session that simply stays
// open: edits made after launch would never reach the other devices. Poll
// while visible so an always-open window still publishes. Skipped while
@@ -663,49 +511,9 @@ export default function App() {
cancelled = true;
clearInterval(timer);
document.removeEventListener("visibilitychange", run);
window.removeEventListener("online", run);
};
}, [syncGranted, runSync]);
/** The Info tab's explicit "Sync now" — same work, but reports the outcome. */
async function syncNow() {
setSyncBusy(true);
setSyncTest(null);
try {
const result = await runSync();
if (!result) {
setSyncTest({ ok: true, message: "A sync was already running." });
return;
}
const { report, changed } = result;
const parts: string[] = [];
if (changed) {
const r = report.report;
const bits = [
r.cards_changed && `${r.cards_changed} card${r.cards_changed === 1 ? "" : "s"}`,
r.decks_changed && `${r.decks_changed} deck${r.decks_changed === 1 ? "" : "s"}`,
r.decks_removed && `${r.decks_removed} removed`,
].filter(Boolean);
parts.push(`Merged ${bits.join(", ")}.`);
} else {
parts.push(report.published ? "Published your changes." : "Already up to date.");
}
if (report.absorbed > 0) {
parts.push(
`Absorbed ${report.absorbed} conflict cop${report.absorbed === 1 ? "y" : "ies"}.`,
);
}
// Conflicts are the one thing worth spelling out — the user may want to
// correct a value that lost.
if (report.report.conflicts.length > 0) {
parts.push(`Kept the other copy's version of: ${report.report.conflicts.join("; ")}.`);
}
setSyncTest({ ok: true, message: parts.join(" ") });
} catch (err) {
setSyncTest({ ok: false, message: String(err) });
} finally {
setSyncBusy(false);
}
}
}, [syncActive, runSync]);
/** Adopt a reference.db built elsewhere (e.g. copied from a desktop install)
* instead of streaming ~740MB to rebuild it on this device. */
@@ -762,23 +570,10 @@ export default function App() {
<div className="main">
<header className="topbar">
<span className="path" title={status?.library_path ?? ""}>
{status?.library_path ?? ""}
<span className="path" title={status?.library_label ?? ""}>
{status?.library_label ?? ""}
</span>
{/* Desktop-only: Android grants a folder through the system picker
instead of naming a path, so these live in the Info tab there. */}
{status && !status.is_mobile && (
<>
<button onClick={pickLibrary} disabled={busy || syncBusy}>
{ICON_OPEN_LIBRARY}
Open Library
</button>
<button onClick={newLibrary} disabled={busy || syncBusy}>
{ICON_NEW_LIBRARY}
New Library
</button>
</>
)}
<SyncIndicator sync={sync} syncing={syncing} onClick={() => setTab("info")} />
{/* On mobile this lives in the bottom nav's Info tab instead —
see below — rather than crowding the topbar. */}
{status && !status.is_mobile && (
@@ -844,8 +639,7 @@ export default function App() {
No card database yet. <strong>Download card data</strong> fetches the
latest cards from Scryfall (a few hundred MB
{status?.is_mobile ? " — slow over mobile data" : ", about a minute"}).
The reference data stays on this device — only your library file is meant
to be synced.
The reference data stays on this device — only your library is synced.
</p>
{status?.is_mobile && (
<p className="hint">
@@ -865,20 +659,20 @@ export default function App() {
</div>
) : tab === "add" ? (
<CardEntry />
) : /* Keyed on the library document: switching to a different one
replaces every card, deck and id these views are holding, so
) : /* Keyed on the sync account: switching to a different one can
replace every card, deck and id these views are holding, so
they're remounted rather than left to reconcile state that
belongs to a library that's no longer open. Stable across
ordinary status refreshes, so it costs nothing otherwise. */
tab === "library" ? (
<LibraryBrowser key={status?.library_path} onOfferUndo={offerUndo} />
<LibraryBrowser key={status?.library_label} onOfferUndo={offerUndo} />
) : tab === "search" ? (
<CardSearch key={status?.library_path} onOfferUndo={offerUndo} />
<CardSearch key={status?.library_label} onOfferUndo={offerUndo} />
) : tab === "decks" ? (
<DeckView key={status?.library_path} refreshSignal={deckRefreshSignal} />
<DeckView key={status?.library_label} refreshSignal={deckRefreshSignal} />
) : tab === "news" ? (
// Library-independent, so unlike the others it isn't keyed on the
// document — switching libraries doesn't change the news.
// account — switching libraries doesn't change the news.
<NewsView />
) : (
// The per-install status page: card data, library sync, and the
@@ -889,9 +683,6 @@ export default function App() {
// lives only here.
<div className="panel info-panel">
<h2 style={{ margin: 0 }}>Card database</h2>
<div className="hint" style={{ wordBreak: "break-all" }}>
{status?.library_path}
</div>
<div className="row" style={{ gap: "var(--space-3)", flexWrap: "wrap" }}>
<button className="primary" onClick={updateCardData} disabled={busy}>
{ICON_DOWNLOAD}
@@ -903,124 +694,19 @@ export default function App() {
</button>
</div>
<hr className="deck-section-divider" style={{ margin: 0 }} />
<h2 style={{ margin: 0 }}>Your library</h2>
{sync === null ? (
<div className="hint">Checking…</div>
) : (
<>
<div className="hint">
Your collection is kept in a single file. Put it in a folder you sync and
every device stays in step — edits made in two places are combined, not
overwritten.
</div>
{sync.label && (
<div className="sync-folder" style={{ wordBreak: "break-word" }}>
{sync.label}
</div>
)}
{sync.chosen && !sync.granted && (
<div className="sync-bad">
✕{" "}
{sync.picker_based
? "Access to that folder was lost — choose it again."
: "That folder isn't there right now — is the drive mounted?"}
</div>
)}
{syncTest && (
<div className={syncTest.ok ? "sync-ok" : "sync-bad"}>
{syncTest.ok ? "✓ " : "✕ "}
{syncTest.message}
</div>
)}
<div className="row" style={{ gap: "var(--space-3)", flexWrap: "wrap" }}>
{sync.granted && (
<button className="primary" onClick={syncNow} disabled={syncBusy}>
{syncBusy ? "Syncing…" : "Sync now"}
</button>
)}
<button
className={sync.granted ? "" : "primary"}
onClick={pickLibrary}
disabled={syncBusy}
>
{sync.chosen
? sync.picker_based
? "Change folder…"
: "Change file…"
: sync.picker_based
? "Choose folder…"
: "Choose file…"}
</button>
{sync.chosen && (
<button onClick={forgetLibrary} disabled={syncBusy}>
Forget
</button>
)}
</div>
{/* Android grants a *folder*, so which file in it is the
library is a separate choice. Without this the app
assumes "library.json" and would create an empty second
library beside one named anything else. */}
{sync.picker_based && sync.chosen && sync.granted && (
<div className="field">
<label>Library file in that folder</label>
{docChoices === null ? (
<div className="hint">Looking…</div>
) : (
<>
<select
value={sync.document_name ?? ""}
onChange={(e) => chooseDocumentName(e.target.value)}
disabled={syncBusy}
>
{/* The current name may not exist in the folder
yet (a library this device hasn't published),
so it's always offered. */}
{[
...new Set(
[sync.document_name ?? "library.json", ...docChoices].filter(
Boolean,
),
),
].map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
{docChoices.length === 0 && (
<span className="hint">
No library files here yet — a new one will be created.
</span>
)}
</>
)}
</div>
)}
{/* Only shown while an old library.db is still waiting; the
offer disappears for good once it's been converted. */}
{sync.legacy_library && (
<>
<div className="hint">
An older library from a previous version is still on this device. Convert
it to bring your collection across.
</div>
<div className="sync-folder" style={{ wordBreak: "break-word" }}>
{sync.legacy_library}
</div>
<div className="row">
<button
className="primary"
onClick={() => convertLegacy(sync.legacy_library!)}
disabled={syncBusy}
>
Convert old library
</button>
</div>
</>
)}
<h2 style={{ margin: 0 }}>Your library</h2>
<div className="hint">Checking…</div>
</>
) : (
<SyncPanel
sync={sync}
setSync={setSync}
syncing={syncing}
runSync={runSync}
onLibraryReplaced={refreshEverything}
/>
)}
<hr className="deck-section-divider" style={{ margin: 0 }} />
<LibrarySummary ready={ready} libStats={libStats} status={status} />
@@ -1058,8 +744,7 @@ export default function App() {
<CommandPalette
onNavigate={setTab}
onUpdateCardData={updateCardData}
onOpenLibrary={pickLibrary}
onNewLibrary={newLibrary}
onSyncNow={() => runSync().catch(() => {})}
undo={undo}
onRunUndo={runUndo}
/>