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:
424
src/SyncPanel.tsx
Normal file
424
src/SyncPanel.tsx
Normal file
@@ -0,0 +1,424 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { open, save } from "@tauri-apps/plugin-dialog";
|
||||
import { api, FirstSync, SyncDevice, SyncReport, SyncStatus } from "./api";
|
||||
import { ConfirmDialog, formatRelativeTime } from "./components";
|
||||
|
||||
/** The server's answer when this device's token stops working. */
|
||||
const SIGNED_OUT_BY_SERVER = "This device is no longer signed in to the sync server.";
|
||||
|
||||
/** A one-line summary of a sync, for the panel's outcome line. */
|
||||
export function describeSync(report: SyncReport): string {
|
||||
if (report.needs_choice) return "Choose how to combine the two libraries below.";
|
||||
const r = report.report;
|
||||
const parts: string[] = [];
|
||||
if (report.changed) {
|
||||
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(`Brought in ${bits.join(", ")} from your other devices.`);
|
||||
}
|
||||
if (report.published) parts.push("Sent this device's changes.");
|
||||
if (parts.length === 0) parts.push("Already up to date.");
|
||||
// Conflicts are the one thing worth spelling out — the user may want to
|
||||
// correct a value that lost.
|
||||
if (r.conflicts.length > 0) {
|
||||
parts.push(`Changed in two places, so the server's version was kept for: ${r.conflicts.join("; ")}.`);
|
||||
}
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact sync state for the top bar. Clicking it opens the Info tab, where
|
||||
* the details and controls live.
|
||||
*/
|
||||
export function SyncIndicator({
|
||||
sync,
|
||||
syncing,
|
||||
onClick,
|
||||
}: {
|
||||
sync: SyncStatus | null;
|
||||
syncing: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
if (!sync) return null;
|
||||
let tone: "ok" | "warn" | "off" = "ok";
|
||||
let label: string;
|
||||
if (!sync.signed_in) {
|
||||
tone = "off";
|
||||
label = sync.last_error === SIGNED_OUT_BY_SERVER ? "Signed out" : "Not syncing";
|
||||
} else if (sync.needs_choice) {
|
||||
tone = "warn";
|
||||
label = "Sync needs you";
|
||||
} else if (syncing) {
|
||||
label = "Syncing…";
|
||||
} else if (sync.last_error) {
|
||||
tone = "warn";
|
||||
label = sync.pending_changes ? "Offline — changes will sync" : "Offline";
|
||||
} else if (sync.pending_changes) {
|
||||
tone = "warn";
|
||||
label = "Not yet synced";
|
||||
} else {
|
||||
label = "Synced";
|
||||
}
|
||||
const title = sync.last_error
|
||||
? `Last sync failed: ${sync.last_error}`
|
||||
: sync.last_synced_at
|
||||
? `Last synced ${formatRelativeTime(sync.last_synced_at)}`
|
||||
: label;
|
||||
return (
|
||||
<button className={`sync-indicator sync-indicator-${tone}`} onClick={onClick} title={title}>
|
||||
<span className={`status-dot ${tone === "ok" ? "on" : tone === "warn" ? "warn" : "off"}`} />
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Info tab's library sync section: signing in to a sync server, the
|
||||
* first-sync choice, the account's devices, and library backups.
|
||||
*/
|
||||
export function SyncPanel({
|
||||
sync,
|
||||
setSync,
|
||||
syncing,
|
||||
runSync,
|
||||
onLibraryReplaced,
|
||||
}: {
|
||||
sync: SyncStatus;
|
||||
setSync: (s: SyncStatus) => void;
|
||||
syncing: boolean;
|
||||
/** Sync now; resolves with the report, or null when nothing ran. */
|
||||
runSync: () => Promise<SyncReport | null>;
|
||||
/** The library may have changed wholesale (signed in, imported, adopted the
|
||||
* server's copy): every view should refetch. */
|
||||
onLibraryReplaced: () => void;
|
||||
}) {
|
||||
const [serverUrl, setServerUrl] = useState(sync.server_url ?? "");
|
||||
const [deviceName, setDeviceName] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [outcome, setOutcome] = useState<{ ok: boolean; message: string } | null>(null);
|
||||
const [devices, setDevices] = useState<SyncDevice[] | null>(null);
|
||||
const [confirm, setConfirm] = useState<
|
||||
{ kind: "use_server" } | { kind: "revoke"; device: SyncDevice } | null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!serverUrl && sync.server_url) setServerUrl(sync.server_url);
|
||||
}, [sync.server_url]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// The device list is only worth a round trip while it's on screen.
|
||||
useEffect(() => {
|
||||
if (!sync.signed_in) {
|
||||
setDevices(null);
|
||||
return;
|
||||
}
|
||||
api.syncDevices().then(setDevices).catch(() => setDevices(null));
|
||||
}, [sync.signed_in, sync.account?.id]);
|
||||
|
||||
async function act(work: () => Promise<string | null>) {
|
||||
setBusy(true);
|
||||
setOutcome(null);
|
||||
try {
|
||||
const message = await work();
|
||||
if (message) setOutcome({ ok: true, message });
|
||||
} catch (err) {
|
||||
// Backing out of a sign-in is a choice, not a failure worth flagging.
|
||||
if (!String(err).includes("Sign-in cancelled")) {
|
||||
setOutcome({ ok: false, message: String(err) });
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
api.syncStatus().then(setSync).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
const signIn = () =>
|
||||
act(async () => {
|
||||
if (!serverUrl.trim()) throw new Error("Enter your sync server's address.");
|
||||
// Shows "finish signing in in your browser" while this waits.
|
||||
setSync({ ...sync, signing_in: true });
|
||||
const s = await api.syncSignIn(serverUrl.trim(), deviceName.trim() || undefined);
|
||||
setSync(s);
|
||||
onLibraryReplaced();
|
||||
return s.needs_choice ? null : `Signed in as ${s.account?.username}. Your library is syncing.`;
|
||||
});
|
||||
|
||||
const cancelSignIn = () => api.syncCancelSignIn().catch(() => {});
|
||||
|
||||
const signOut = () =>
|
||||
act(async () => {
|
||||
setSync(await api.syncSignOut());
|
||||
onLibraryReplaced();
|
||||
return "Signed out. Your library stays on this device and keeps working offline.";
|
||||
});
|
||||
|
||||
const syncNow = () =>
|
||||
act(async () => {
|
||||
const r = await runSync();
|
||||
return r ? describeSync(r) : "A sync was already running.";
|
||||
});
|
||||
|
||||
const connect = (choice: FirstSync) =>
|
||||
act(async () => {
|
||||
const r = await api.syncConnect(choice);
|
||||
onLibraryReplaced();
|
||||
return r ? describeSync(r) : null;
|
||||
});
|
||||
|
||||
const revoke = (device: SyncDevice) =>
|
||||
act(async () => {
|
||||
setDevices(await api.syncRevokeDevice(device.id));
|
||||
return `Signed out ${device.name}.`;
|
||||
});
|
||||
|
||||
const exportLibrary = () =>
|
||||
act(async () => {
|
||||
const path = await save({
|
||||
title: "Save a backup of your library",
|
||||
defaultPath: `mtgc-library-${new Date().toISOString().slice(0, 10)}.json`,
|
||||
filters: [{ name: "mtgc library", extensions: ["json"] }],
|
||||
});
|
||||
if (typeof path !== "string") return null;
|
||||
await api.exportLibraryDocument(path);
|
||||
return `Saved to ${path}.`;
|
||||
});
|
||||
|
||||
const importLibrary = (preset?: string) =>
|
||||
act(async () => {
|
||||
const path =
|
||||
preset ??
|
||||
(await open({
|
||||
title: "Import a library file",
|
||||
filters: [
|
||||
{ name: "mtgc library", extensions: ["json", "db"] },
|
||||
{ name: "All files", extensions: ["*"] },
|
||||
],
|
||||
}));
|
||||
if (typeof path !== "string") return null;
|
||||
const r = await api.importLibraryFile(path);
|
||||
onLibraryReplaced();
|
||||
return `Imported ${r.cards} card${r.cards === 1 ? "" : "s"} and ${r.decks} deck${
|
||||
r.decks === 1 ? "" : "s"
|
||||
}, combined with what was already here.`;
|
||||
});
|
||||
|
||||
const account = sync.account;
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2 style={{ margin: 0 }}>Your library</h2>
|
||||
|
||||
{!sync.signed_in ? (
|
||||
<>
|
||||
<div className="hint">
|
||||
Your library is kept on this device and works offline. Sign in to a sync server to
|
||||
share it with your other devices — edits made in two places are combined, not
|
||||
overwritten.
|
||||
</div>
|
||||
{sync.last_error === SIGNED_OUT_BY_SERVER && (
|
||||
<div className="sync-bad">
|
||||
✕ This device was signed out by the server (removed from another device, perhaps).
|
||||
Sign in again to resume syncing.
|
||||
</div>
|
||||
)}
|
||||
{sync.signing_in ? (
|
||||
<>
|
||||
<div className="sync-ok">Finish signing in in your browser…</div>
|
||||
<div className="row">
|
||||
<button onClick={cancelSignIn}>Cancel</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="field">
|
||||
<label htmlFor="sync-server">Sync server</label>
|
||||
<input
|
||||
id="sync-server"
|
||||
type="url"
|
||||
inputMode="url"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
placeholder="mtgc.example.com"
|
||||
value={serverUrl}
|
||||
onChange={(e) => setServerUrl(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && !busy && signIn()}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="sync-device">Name for this device (optional)</label>
|
||||
<input
|
||||
id="sync-device"
|
||||
placeholder="e.g. Desktop, Phone"
|
||||
value={deviceName}
|
||||
onChange={(e) => setDeviceName(e.target.value)}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
<div className="row">
|
||||
<button className="primary" onClick={signIn} disabled={busy || !serverUrl.trim()}>
|
||||
Sign in…
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="sync-folder">
|
||||
{account?.display_name && account.display_name !== account.username
|
||||
? `${account.display_name} (${account.username})`
|
||||
: account?.username}
|
||||
{account?.email && <span className="hint"> · {account.email}</span>}
|
||||
</div>
|
||||
<div className="hint" style={{ overflowWrap: "anywhere" }}>
|
||||
{sync.server_url}
|
||||
{account?.method === "oidc" ? " · single sign-on" : ""}
|
||||
</div>
|
||||
|
||||
{sync.needs_choice ? (
|
||||
<div className="sync-choice">
|
||||
<strong>This device and your account both have a library.</strong>
|
||||
<div className="hint">
|
||||
Combining keeps everything from both. Using the server's library replaces what's on
|
||||
this device.
|
||||
</div>
|
||||
<div className="row" style={{ flexWrap: "wrap" }}>
|
||||
<button className="primary" onClick={() => connect("combine")} disabled={busy}>
|
||||
Combine them
|
||||
</button>
|
||||
<button onClick={() => setConfirm({ kind: "use_server" })} disabled={busy}>
|
||||
Use the server's library
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={sync.last_error ? "sync-bad" : "sync-ok"}>
|
||||
{sync.last_error
|
||||
? `✕ Couldn't sync: ${sync.last_error}${
|
||||
sync.pending_changes ? " Your changes are safe here and will sync later." : ""
|
||||
}`
|
||||
: syncing
|
||||
? "Syncing…"
|
||||
: sync.pending_changes
|
||||
? "Changes on this device haven't synced yet."
|
||||
: sync.last_synced_at
|
||||
? `✓ Synced ${formatRelativeTime(sync.last_synced_at)}`
|
||||
: "✓ Synced"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="row" style={{ gap: "var(--space-3)", flexWrap: "wrap" }}>
|
||||
{!sync.needs_choice && (
|
||||
<button className="primary" onClick={syncNow} disabled={busy || syncing}>
|
||||
{busy || syncing ? "Syncing…" : "Sync now"}
|
||||
</button>
|
||||
)}
|
||||
<button onClick={signOut} disabled={busy}>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{outcome && (
|
||||
<div className={outcome.ok ? "sync-ok" : "sync-bad"}>
|
||||
{outcome.ok ? "✓ " : "✕ "}
|
||||
{outcome.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sync.signed_in && devices && devices.length > 0 && (
|
||||
<div className="field">
|
||||
<label>Signed-in devices</label>
|
||||
<ul className="sync-devices">
|
||||
{devices.map((d) => (
|
||||
<li key={d.id}>
|
||||
<span className="sync-device-name">
|
||||
{d.name}
|
||||
{d.current && <span className="badge">This device</span>}
|
||||
</span>
|
||||
<span className="hint">seen {formatRelativeTime(d.last_seen_at)}</span>
|
||||
{!d.current && (
|
||||
<button
|
||||
className="link-button"
|
||||
onClick={() => setConfirm({ kind: "revoke", device: d })}
|
||||
disabled={busy}
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="field">
|
||||
<label>Backup</label>
|
||||
<div className="row" style={{ gap: "var(--space-3)", flexWrap: "wrap" }}>
|
||||
<button onClick={exportLibrary} disabled={busy}>
|
||||
Export library…
|
||||
</button>
|
||||
<button onClick={() => importLibrary()} disabled={busy}>
|
||||
Import library file…
|
||||
</button>
|
||||
</div>
|
||||
<span className="hint">
|
||||
Import combines a backup, or a library.json from an older version, with this one.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Only shown while an old library.db is still waiting; the offer
|
||||
disappears for good once it's been imported. */}
|
||||
{sync.legacy_library && (
|
||||
<>
|
||||
<div className="hint">
|
||||
An older library from a previous version is still on this device. Import it to bring
|
||||
your collection across.
|
||||
</div>
|
||||
<div className="sync-folder">{sync.legacy_library}</div>
|
||||
<div className="row">
|
||||
<button
|
||||
className="primary"
|
||||
onClick={() => importLibrary(sync.legacy_library!)}
|
||||
disabled={busy}
|
||||
>
|
||||
Import old library
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{confirm?.kind === "use_server" && (
|
||||
<ConfirmDialog
|
||||
title="Use the server's library?"
|
||||
message="This device's library will be replaced by the one on the server. Anything only on this device will be lost — export a backup first if you might want it."
|
||||
confirmLabel="Replace this device's library"
|
||||
danger
|
||||
onCancel={() => setConfirm(null)}
|
||||
onConfirm={() => {
|
||||
setConfirm(null);
|
||||
connect("use_server");
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{confirm?.kind === "revoke" && (
|
||||
<ConfirmDialog
|
||||
title={`Sign out ${confirm.device.name}?`}
|
||||
message="It will stop syncing until someone signs in on it again. Its library stays on it."
|
||||
confirmLabel="Sign out device"
|
||||
onCancel={() => setConfirm(null)}
|
||||
onConfirm={() => {
|
||||
const d = confirm.device;
|
||||
setConfirm(null);
|
||||
revoke(d);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user