);
}
/** Percent-fill (0..100) of one segment, given overall cumulative percent. */
function segmentFill(stages: IngestStageInfo[], index: number, overallPercent: number): number {
let start = 0;
for (let i = 0; i < index; i++) start += stages[i].weight;
const end = start + stages[index].weight;
const [startPct, endPct] = [start * 100, end * 100];
if (overallPercent <= startPct) return 0;
if (overallPercent >= endPct) return 100;
if (endPct === startPct) return 100;
return ((overallPercent - startPct) / (endPct - startPct)) * 100;
}
// How long the floating "Undo" snackbar stays up before auto-hiding itself.
// This only controls the snackbar's visibility — the underlying undo action
// stays available in the command palette well past this (see `undo` below),
// 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 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() {
const [status, setStatus] = useState(null);
const [libStats, setLibStats] = useState(null);
const [tab, setTab] = usePersistedState("active-tab", "add");
const [progress, setProgress] = useState(null);
const [busy, setBusy] = useState(false);
// 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(null);
// A sync is in flight, for the top-bar indicator.
const [syncing, setSyncing] = useState(false);
const lastProgress = useRef(null);
// Set by the ingest-progress listener below; reset before each
// updateCardData() call so it can detect a stalled invoke — see there.
const gotProgress = useRef(false);
// Lives here (not in LibraryBrowser) so it's reachable from the command
// palette, which is mounted at the app level and outlives any one tab's
// view across tab switches. Unlike the snackbar, this has no timeout —
// it's only replaced by a newer undo offer, run, or explicitly dismissed —
// so an undo that's missed in the snackbar is still reachable via Alt+/.
const [undo, setUndo] = useState<{ message: string; run: () => void | Promise } | null>(
null,
);
const [snackbarVisible, setSnackbarVisible] = useState(false);
const snackbarTimer = useRef | null>(null);
// Bumped once an undo actually runs, so views mounted elsewhere than where
// the undo was offered (e.g. the Decks tab, when the undo came from a
// Library bulk add-to-deck) know to refetch instead of showing stale data
// until the user leaves and comes back.
const [deckRefreshSignal, setDeckRefreshSignal] = useState(0);
function offerUndo(message: string, run: () => void | Promise) {
if (snackbarTimer.current) clearTimeout(snackbarTimer.current);
setUndo({ message, run });
setSnackbarVisible(true);
snackbarTimer.current = setTimeout(() => setSnackbarVisible(false), UNDO_SNACKBAR_MS);
}
// Explicit "×" on the snackbar: reject the whole offer, not just hide it.
function dismissUndo() {
if (snackbarTimer.current) clearTimeout(snackbarTimer.current);
setSnackbarVisible(false);
setUndo(null);
}
function runUndo() {
if (!undo) return;
const { run } = undo;
if (snackbarTimer.current) clearTimeout(snackbarTimer.current);
setSnackbarVisible(false);
setUndo(null);
Promise.resolve(run()).then(() => setDeckRefreshSignal((v) => v + 1));
}
const refreshStatus = useCallback(async () => {
// This is the *only* place status gets fetched — nothing else re-polls it.
// The Android IPC bridge has been observed to silently drop an invoke()
// outright: the promise never resolves *or* rejects (confirmed
// separately for update_card_data — same symptom, no backend log line at
// all, so the call never even reached Rust). A try/catch alone doesn't
// help against that, since nothing ever throws; the race against a
// per-attempt timeout below is what actually turns a hang into a
// retryable failure. Without it, this ran once, hung forever, and the
// app was permanently stuck on "no card data yet" every launch — even
// with a fully loaded reference.db on the backend, since nothing else
// ever asks it again.
for (let attempt = 0; ; attempt++) {
try {
const s = await Promise.race([
api.getStatus(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error("get_status timed out")), 5000),
),
]);
setStatus(s);
return;
} catch (err) {
if (attempt >= 4) {
console.error("getStatus failed after retries:", err);
return;
}
await new Promise((r) => setTimeout(r, 300 * (attempt + 1)));
}
}
}, []);
useEffect(() => {
refreshStatus();
const un = listen("ingest-progress", (e) => {
gotProgress.current = true;
lastProgress.current = e.payload;
setProgress(e.payload);
});
return () => {
un.then((f) => f());
};
}, [refreshStatus]);
// Force a re-render every minute so the "Updated Xh ago" stamp stays fresh
// over a long session without needing an actual status refetch.
const [, tick] = useState(0);
useEffect(() => {
const interval = setInterval(() => tick((n) => n + 1), 60000);
return () => clearInterval(interval);
}, []);
const ready = status?.reference_ready ?? false;
// On Android the very first IPC call after launch has been observed taking
// several seconds (likely one-time JIT/class-verification warm-up on the
// native bridge, not a hang — it does complete). `!ready` was previously
// used both for "we don't know yet" and "confirmed no card data", so during
// that warm-up the app showed the full "Download card data" prompt — which
// invited exactly what happened: a redundant download click before the
// real status had even loaded. Distinguish the two explicitly.
const statusLoaded = status !== null;
// Card data built by an older version lacks columns this one reads, so
// some features quietly stay off until it's updated. "Not now" is
// remembered against exactly what's missing: the notice comes back once a
// later version adds something more.
const missingColumns = status?.reference_missing_columns ?? [];
const outdatedKey = missingColumns.join(",");
const [dismissedOutdated, setDismissedOutdated] = usePersistedState("card-data-outdated-dismissed", "");
const showOutdated = ready && missingColumns.length > 0 && !progress && dismissedOutdated !== outdatedKey;
// Mirrors `ready` for the automatic-sync callbacks, which must stay stable
// across renders (re-creating them would re-arm the listeners below and fire
// a fresh sync every time card data finished loading).
const readyRef = useRef(ready);
useEffect(() => {
readyRef.current = ready;
}, [ready]);
// Keep the sidebar's library summary fresh: fetch once card data is ready,
// then poll (cards/decks can change from any tab without a shared event bus).
useEffect(() => {
if (!ready) {
setLibStats(null);
return;
}
let cancelled = false;
const refresh = () => api.getLibraryStats().then((s) => !cancelled && setLibStats(s));
refresh();
const interval = setInterval(refresh, 5000);
return () => {
cancelled = true;
clearInterval(interval);
};
}, [ready]);
async function updateCardData() {
setBusy(true);
gotProgress.current = false;
let stallTimer: ReturnType | undefined;
try {
// Android's Tauri IPC bridge has been observed to silently drop an
// invoke() during a dev-mode webview reload: the promise never settles,
// no ingest-progress event ever arrives, and the button was stuck
// "Downloading…" forever with no way to retry (the backend never even
// logged that it started). A real ingest always emits its first
// progress event within milliseconds — it happens before any network
// I/O — so treat "no progress after a generous window" as a dropped
// call rather than hang indefinitely.
const s = await Promise.race([
api.updateCardData(),
new Promise((_, reject) => {
stallTimer = setTimeout(() => {
if (!gotProgress.current) {
reject(new Error("Couldn't reach the app. Try again — if this keeps happening, restart the app."));
}
}, 8000);
}),
]);
setStatus(s);
setProgress({
stage: "done",
message: `Loaded ${s.card_count.toLocaleString()} cards.`,
overall_percent: 100,
stages: lastProgress.current?.stages ?? [],
});
setTimeout(() => setProgress((p) => (p?.stage === "done" ? null : p)), 4000);
} catch (err) {
const msg = String(err);
if (msg === "cancelled") {
setProgress({
stage: "cancelled",
message: "Cancelled.",
overall_percent: lastProgress.current?.overall_percent ?? 0,
stages: lastProgress.current?.stages ?? [],
});
setTimeout(() => setProgress((p) => (p?.stage === "cancelled" ? null : p)), 3000);
} else {
setProgress({
stage: "error",
message: `Failed: ${msg}`,
overall_percent: lastProgress.current?.overall_percent ?? 0,
stages: lastProgress.current?.stages ?? [],
});
}
} finally {
clearTimeout(stallTimer);
setBusy(false);
}
}
async function cancelUpdate() {
await api.cancelUpdateCardData();
}
/** 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 () => {
try {
setSync(await api.syncStatus());
} catch (err) {
console.error("syncStatus failed:", err);
}
}, []);
// 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, so "synced 5m ago" is current there.
useEffect(() => {
if (tab === "info" && statusLoaded) refreshSync();
}, [tab, statusLoaded, refreshSync]);
/**
* 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.
*/
const refreshEverything = useCallback(() => {
api.getStatus().then(setStatus).catch(() => {});
if (readyRef.current) api.getLibraryStats().then(setLibStats).catch(() => {});
setDeckRefreshSignal((v) => v + 1);
}, []);
/** 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 (!syncActive) return;
let cancelled = false;
// 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(() => {});
};
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
// hidden — the transition back handles that case.
const timer = setInterval(() => {
if (!document.hidden) run();
}, AUTO_SYNC_MS);
return () => {
cancelled = true;
clearInterval(timer);
document.removeEventListener("visibilitychange", run);
window.removeEventListener("online", run);
};
}, [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. */
async function importReferenceDb() {
const path = await open({
title: "Import a card database (reference.db)",
filters: [{ name: "Card database", extensions: ["db", "sqlite"] }],
});
if (typeof path !== "string") return;
try {
setStatus(await api.importReferenceDb(path));
setProgress(null);
} catch (err) {
setProgress({
stage: "error",
message: `Couldn't import that card database: ${String(err)}`,
overall_percent: 0,
stages: lastProgress.current?.stages ?? [],
});
}
}
return (
{status?.library_label ?? ""}
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 && (
)}
{progress && (
Your card data was built by an older version of mtgc, so some newer features stay off until you
update it.
{/* A full download is heavy on a phone; copying a fresh
reference.db over from a desktop is the cheaper route. */}
{status?.is_mobile && (
)}
)}
{!statusLoaded ? (
Loading…
) : !ready ? (
Welcome to mtgc
No card database yet. Download card data 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 is synced.
{status?.is_mobile && (
Already have it on your computer? Copying that{" "}
reference.db over is much faster — it's about 73MB
versus the ~740MB the download has to stream.
)}
) : tab === "add" ? (
) : /* 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" ? (
) : tab === "search" ? (
) : tab === "decks" ? (
) : tab === "game" ? (
// Like News, independent of the library: the game in progress
// is this device's scratch state, not synced data.
) : tab === "news" ? (
// Library-independent, so unlike the others it isn't keyed on the
// account — switching libraries doesn't change the news.
) : (
// The per-install status page: card data, library sync, and the
// collection summary. On mobile it's the only home for the
// card-data buttons (no topbar room) and the summary (the nav
// rail it normally sits in is replaced by the bottom nav);
// on desktop those also appear in the topbar and rail, but sync
// lives only here.
Card database
{sync === null ? (
<>
Your library
Checking…
>
) : (
)}
)}
{/* Android-customary bottom tab bar — replaces the rail above on
narrow viewports (see the ≤720px CSS breakpoint), rather than the
rail collapsing to an icon strip in place. Hidden while the
initial status fetch is still pending: there's nothing to
navigate to yet, and showing it makes the loading screen look
further along than it is. Carries an extra "Info" tab beyond
`NAV` — card-database management, which lives in the topbar on
desktop instead (see the button below). */}
{statusLoaded && (
)}