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

@@ -489,6 +489,9 @@ input[type="checkbox"] {
.status-dot.off {
background: var(--faint);
}
.status-dot.warn {
background: var(--warn);
}
/* Sidebar "your library" summary panel. */
.lib-stats-head {
@@ -784,7 +787,57 @@ input[type="checkbox"] {
gap: var(--space-3);
max-width: 480px;
}
/* The currently chosen synced folder. */
/* Top-bar sync state; opens the Info tab. Quiet when all is well, so it
only draws the eye when something needs attention. */
.sync-indicator {
flex-shrink: 0;
font-size: var(--fs-caption);
padding: 5px 10px;
white-space: nowrap;
}
.sync-indicator-warn {
border-color: var(--warn);
color: var(--warn);
}
/* First sync with an account that already has a library: a decision only the
user can make, so it's set apart from the surrounding status text. */
.sync-choice {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding: var(--space-3);
border: 1px solid var(--warn);
border-radius: var(--radius);
}
.sync-devices {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.sync-devices li {
display: flex;
align-items: center;
gap: var(--space-2);
flex-wrap: wrap;
}
.sync-device-name {
display: inline-flex;
align-items: center;
gap: var(--space-2);
font-weight: 600;
color: var(--text-strong);
}
.sync-devices .link-button {
margin-left: auto;
background: none;
border: none;
padding: 2px 4px;
color: var(--danger);
}
/* The signed-in account. */
.sync-folder {
font-size: var(--fs-body);
font-weight: 600;