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:
156
README.md
156
README.md
@@ -11,17 +11,19 @@ in a platform-agnostic Rust core, so both platforms share it.
|
||||
```
|
||||
src/ React + TypeScript UI (swappable presentation)
|
||||
src-tauri/ Tauri v2 shell — exposes the core as typed commands
|
||||
crates/mtgc-core/ Pure Rust: db, fuzzy search, filters, decks, ingester
|
||||
crates/mtgc-core/ Pure Rust: db, fuzzy search, filters, decks, ingester, sync
|
||||
crates/mtgc-server/ Optional sync server: one library per user, OIDC and/or passwords
|
||||
```
|
||||
|
||||
Two files:
|
||||
Two kinds of data, both in the OS app-data dir (e.g.
|
||||
`~/.local/share/com.garrettmills.mtgc/`):
|
||||
|
||||
- **your library `.json`** — owned cards, decks, deck contents. Small,
|
||||
readable, and the only file you name or move. Put it somewhere synced; see
|
||||
[Sync](#sync).
|
||||
- **your library** — owned cards, decks, deck contents. Lives on each device
|
||||
and works fully offline; sign in to a sync server to share it between
|
||||
devices (see [Sync](#sync)). **Export library…** in the Info tab saves it as
|
||||
a JSON backup.
|
||||
- **`reference.db`** — read-only card data built from Scryfall bulk. Bulky and
|
||||
re-downloaded per device, so it's never synced. Lives in the OS app-data dir
|
||||
(e.g. `~/.local/share/com.garrettmills.mtgc/`).
|
||||
re-downloaded per device, so it's never synced.
|
||||
|
||||
## First-time setup
|
||||
|
||||
@@ -84,8 +86,8 @@ Notes:
|
||||
- The `mtgc` binary is a single file but relies on the system WebKit/GTK libs
|
||||
being present (installed by `devel-setup.sh`).
|
||||
- The binary does **not** embed card data — first run still downloads the Scryfall
|
||||
bulk into the OS app-data dir. Your library document defaults to that directory
|
||||
too; move it into a synced folder with **New Library** (see [Sync](#sync)).
|
||||
bulk into the OS app-data dir. Your library lives there too; to share it
|
||||
between devices, see [Sync](#sync).
|
||||
- The `[profile.release]` settings in `Cargo.toml` (strip + LTO) make the release
|
||||
build take a few minutes but keep the binary small.
|
||||
|
||||
@@ -119,42 +121,118 @@ Marvel Commander — isn't hidden.
|
||||
|
||||
## Sync
|
||||
|
||||
Your collection is a single JSON file — the only file you manage. Put it in a
|
||||
folder your sync tool watches (Syncthing, Dropbox, Nextcloud, Seafile, or a USB
|
||||
stick) and every device stays in step. Choose it with **Open Library** /
|
||||
**New Library**, or from the **Info** tab.
|
||||
Your library always lives on the device you're using, so the app works the
|
||||
same with or without a network. To keep several devices in step, run an
|
||||
[mtgc sync server](#running-the-sync-server) and sign in to it from each one:
|
||||
**Info → Sync server**, enter its address, and **Sign in…**. Sign-in happens
|
||||
in your browser, on the server's own page — with a username and password, or
|
||||
through your single sign-on provider, whichever the server offers — and the
|
||||
browser hands the app back a token for that device alone.
|
||||
|
||||
Editing in two places merges rather than one side overwriting the other. Sync
|
||||
is a three-way merge against the last version of the document that device read,
|
||||
which is what lets it tell "you changed this" from "they deleted it": edit
|
||||
different cards on a phone offline and on the desktop, and both survive. When
|
||||
both sides change the *same* field, the other copy's value wins and the change
|
||||
is reported so you can correct it.
|
||||
Editing in two places merges rather than one side overwriting the other. Each
|
||||
device remembers the last version of the library it knows the server had, and
|
||||
merges three ways against it — which is what lets it tell "you changed this"
|
||||
from "they deleted it": edit different cards on a phone offline and on the
|
||||
desktop, and both survive. When both sides change the *same* field, the
|
||||
server's value wins and the change is reported so you can correct it.
|
||||
|
||||
It runs by itself — at launch, on every switch away from and back to the app,
|
||||
and every few minutes while open. **Sync now** in the Info tab forces a pass.
|
||||
Sync runs by itself — at launch, on every switch away from and back to the app,
|
||||
on reconnecting, and every few minutes while open. The top bar shows where it
|
||||
stands (**Synced**, **Offline — changes will sync**, …); **Sync now** in the
|
||||
Info tab forces a pass. Offline, edits simply wait on the device: nothing is
|
||||
lost, and they go out with the next sync that reaches the server.
|
||||
|
||||
Nothing here depends on a particular sync tool. If two devices publish while
|
||||
both are offline, some tools leave a conflict copy behind; mtgc merges any it
|
||||
finds beside the document and clears it once the contents are safely published.
|
||||
The first time a device signs in to an account that already has a library,
|
||||
and the device has one too, it asks whether to **combine** them (nothing from
|
||||
either is lost) or **use the server's library** instead of this device's.
|
||||
|
||||
> **Upgrading from an older build?** Earlier versions had you manage
|
||||
> `library.db` yourself. The Info tab offers **Convert old library** once; the
|
||||
> original file is left untouched, so it doubles as a backup.
|
||||
The Info tab also lists the account's signed-in devices, so a lost phone can be
|
||||
signed out from another one.
|
||||
|
||||
> **Upgrading from folder sync?** Earlier versions kept the library as a JSON
|
||||
> file in a folder your sync tool watched. Your library is already on each
|
||||
> device, so signing in to a server picks up from there; the first device to
|
||||
> sign in uploads it. If one device's copy is out of date, use **Import library
|
||||
> file…** on it to combine in the `library.json` from the synced folder. Builds
|
||||
> older still had you manage `library.db` yourself; the Info tab offers
|
||||
> **Import old library** once for that.
|
||||
|
||||
## Running the sync server
|
||||
|
||||
`mtgc-server` is a single binary with a SQLite database. Run it behind a
|
||||
reverse proxy that provides HTTPS (the Android app refuses plain HTTP outside
|
||||
debug builds). `crates/mtgc-server/deploy/` has a Docker Compose example with
|
||||
Caddy:
|
||||
|
||||
```bash
|
||||
cd crates/mtgc-server/deploy
|
||||
cp .env.example .env # set MTGC_PUBLIC_URL, sign-in options
|
||||
docker compose up -d
|
||||
docker compose exec mtgc-server mtgc-server user add yourname
|
||||
```
|
||||
|
||||
To build the image yourself and push it to your own registry:
|
||||
|
||||
```bash
|
||||
DOCKER_REGISTRY=registry.example.com npm run sync-server:build # → registry.example.com/mtgc/sync-server:latest
|
||||
DOCKER_REGISTRY=registry.example.com npm run sync-server:push
|
||||
```
|
||||
|
||||
Both call `scripts/sync-server-image.sh`. `IMAGE_TAG` overrides the `latest`
|
||||
tag, and `CONTAINER_ENGINE=podman` picks podman when docker is also installed.
|
||||
|
||||
Configuration is all environment variables:
|
||||
|
||||
| Variable | Default | |
|
||||
|---|---|---|
|
||||
| `MTGC_PUBLIC_URL` | *(required)* | The `https://` address users reach the server at, exactly as the proxy serves it |
|
||||
| `MTGC_BIND` | `0.0.0.0:8080` | Listen address |
|
||||
| `MTGC_DB` | `mtgc-server.db` | SQLite database path |
|
||||
| `MTGC_LOCAL_AUTH` | `true` | Username/password accounts |
|
||||
| `MTGC_OIDC_ISSUER` | — | Turns on single sign-on, e.g. `https://auth.example.com/application/o/mtgc/` |
|
||||
| `MTGC_OIDC_CLIENT_ID`, `MTGC_OIDC_CLIENT_SECRET` | — | A confidential client, with redirect URI `${MTGC_PUBLIC_URL}/auth/callback` |
|
||||
| `MTGC_OIDC_NAME` | `single sign-on` | Shown on the button: "Sign in with …" |
|
||||
| `MTGC_OIDC_SCOPES` | `openid email profile` | |
|
||||
| `MTGC_ALLOWED_EMAILS` | — | Comma-separated allow list of verified emails; unset leaves it to the provider |
|
||||
|
||||
With both sign-in methods on, the sign-in page offers both; with only single
|
||||
sign-on, it goes straight to the provider. Each provider identity and each
|
||||
local account is its own user with its own library.
|
||||
|
||||
Local accounts are managed from the command line; there's no sign-up page, so
|
||||
a server on the internet can't be filled with accounts by strangers:
|
||||
|
||||
```bash
|
||||
mtgc-server user add <name> [--display-name <text>] # prompts for a password
|
||||
mtgc-server user passwd <name>
|
||||
mtgc-server user remove <name> --yes # also deletes their server-side library
|
||||
mtgc-server user list
|
||||
```
|
||||
|
||||
The server keeps the last 50 versions of each library in its database
|
||||
(`library_history`) as a safety net for recovering by hand.
|
||||
|
||||
## Develop / test
|
||||
|
||||
```bash
|
||||
cargo test -p mtgc-core # core: db, search, filters, decks, ingest, sync merge
|
||||
cargo test -p mtgc # shell: document read/write, conflict-copy matching
|
||||
cargo test -p mtgc-core # core: db, search, filters, decks, ingest, sync merge + cycle
|
||||
cargo test -p mtgc # shell: config, merge-base storage, sign-in listener
|
||||
cargo test -p mtgc-server # server: sign-in, compare-and-swap, devices, end-to-end sync
|
||||
npm run build # typecheck + build frontend
|
||||
cargo build # build everything incl. the desktop app
|
||||
```
|
||||
|
||||
The sync merge is a pure function over plain structs (`mtgc_core::sync::merge3`),
|
||||
so the cases that matter — concurrent edits, a delete racing an edit, repeated
|
||||
merges being a no-op — are unit tests rather than something you have to
|
||||
reproduce across two devices.
|
||||
and the sync cycle runs over a `Remote` trait (`mtgc_core::sync::remote`), so the
|
||||
cases that matter — concurrent edits, a delete racing an edit, a lost reply, a
|
||||
lost write race — are unit tests rather than something you have to reproduce
|
||||
across two devices. `crates/mtgc-server/tests/sync_e2e.rs` then runs two
|
||||
devices against the real server over HTTP.
|
||||
|
||||
To try sign-in by hand, run a server locally (`MTGC_PUBLIC_URL=http://127.0.0.1:8080
|
||||
cargo run -p mtgc-server`, after `cargo run -p mtgc-server -- user add me`) and
|
||||
sign in from the app with that address. From the Android emulator, the host is
|
||||
`http://10.0.2.2:8080`; debug builds allow plain HTTP.
|
||||
|
||||
To typecheck the Android Kotlin without building the APK (it doesn't invoke
|
||||
cargo, so it won't disturb a running dev session):
|
||||
@@ -251,15 +329,11 @@ data…**. The file is validated before it replaces anything.
|
||||
|
||||
### Sync on Android
|
||||
|
||||
Android can't hand out a filesystem path to a shared folder, so instead of
|
||||
naming a file you grant access to a *folder* through the system picker:
|
||||
**Info → Choose folder…**, then pick the one your sync tool watches. mtgc keeps
|
||||
`library.json` inside it and everything in [Sync](#sync) applies unchanged.
|
||||
|
||||
That grant is per-folder and persists across restarts, so the app declares **no
|
||||
storage permission at all** — no "All files access" prompt. The alternative,
|
||||
`MANAGE_EXTERNAL_STORAGE`, would hand the app the entire shared volume just to
|
||||
reach one directory.
|
||||
Signing in works as on desktop: the browser opens the server's sign-in page,
|
||||
and returns to the app through the `mtgc://` URL scheme (registered by the
|
||||
deep-link plugin from `plugins.deep-link` in `tauri.conf.json`). The app needs
|
||||
no storage permission — the library lives in its private storage, and syncing
|
||||
is network only.
|
||||
|
||||
## Branding
|
||||
|
||||
|
||||
Reference in New Issue
Block a user