Garrett Mills 348a950489 fdroid-publish: require a /repo URL and recover from a failed update
fdroidserver refuses a repo_url that doesn't end in /repo, but only at
`fdroid update`, after the APK was already copied in. The leftover APK
then made every retry think that version was published. Check the URL
up front, and take the APK back out if the update fails.

FDROID_REPO_URL now also updates repo_url in an existing repo, instead
of being read only when the repo is created.
2026-10-13 21:29:19 -05:00
2026-08-02 13:16:34 -05:00

mtgc — Personal Magic: The Gathering Library Catalog

A desktop and Android app (Tauri v2 + React) for cataloging your MTG collection: rapid fuzzy card entry, rich criteria search (colors, commander color-identity compatibility, mana value, type/subtype/keyword, price, format legality), and deck organization with per-card deck membership. All logic lives in a platform-agnostic Rust core, so both platforms share it.

Architecture

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, sync
crates/mtgc-server/  Optional sync server: one library per user, OIDC and/or passwords

Two kinds of data, both in the OS app-data dir (e.g. ~/.local/share/com.garrettmills.mtgc/):

  • 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). 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.

First-time setup

./scripts/devel-setup.sh     # Tauri system libs (webkit2gtk/gtk3) + Tauri CLI
npm install                  # frontend deps

Why GTK3, not GTK4? Tauri v2's Linux backend (wry/tao) hard-depends on GTK3 + webkit2gtk-4.1; there's no GTK4/webkitgtk-6.0 option yet. GTK4 support is tracked upstream but unmerged with no ETA: tauri#7335, tao#1104, wry#1228. Revisit once Tauri ships GTK4 support (likely Tauri v3).

Run

cargo tauri dev              # launches the desktop app (dev mode)

On first launch, click Download card data — it fetches the Scryfall Oracle Cards + Default Cards bulk (~a few hundred MB) and builds reference.db. After that the app is fully offline.

You can also build the card database from the command line:

cargo run -p mtgc-core --bin mtgc-ingest -- /path/to/reference.db

Production build

Build an optimized app with the frontend embedded into the executable:

./scripts/devel-setup.sh   # once, if not already done
cargo tauri build

Outputs (under the workspace target/release/):

  • target/release/mtgc — the standalone executable. The web UI and all app logic are baked into this one file. It dynamically links the system WebKit/GTK libraries (webkit2gtk4.1, gtk3), so those must be present on the machine that runs it — they are on any modern desktop Linux and are installed by devel-setup.sh.
  • target/release/bundle/rpm/*.rpm and .../deb/*.deb — native installers (Fedora / Debian-based) that add a menu entry and icon.

Useful variants:

cargo tauri build --no-bundle          # only the bare target/release/mtgc binary

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 lives there too; to share it between devices, see Sync.
  • The [profile.release] settings in Cargo.toml (strip + LTO) make the release build take a few minutes but keep the binary small.

Using it

  • Add Cards — type a name (typos OK); ↑/↓ then Enter adds a copy.
  • Library — filter owned cards by name, colors, commander identity (color identity ⊆ chosen), mana value, power/toughness, price, format legality, type/subtype/keyword, or deck. Hover a row's thumbnail for a full-size preview that tracks the cursor. Rows show which decks a card is in and a warning (⚠) when it's assigned to more decks than you own. Toggle List / Tiles (top-right) to switch between the table and a grid of full card images; the choice is remembered.
  • Card Search — the same filters over every card, owned or not. Select rows (or tiles) and bulk-add them to a deck; one undo covers the whole batch.
  • Deck contents offer the same List / Tiles toggle and hover previews.
  • Decks — create decks and assign owned cards; set per-deck quantities.

Both filter panels share a row of quick toggles: hide Universes Beyond (cards that exist only as another property — Lord of the Rings, Warhammer 40,000, Marvel), hide digital-only (Arena/MTGO cards such as Alchemy rebalances), hide Un-set joke cards, or keep only cards that can be a commander. The three exclusions are derived as every printing, so an ordinary card reprinted into one of those sets — Lightning Bolt appears in Marvel Commander — isn't hidden.

Those three, plus the power/toughness filters, live in reference.db, which is rebuilt rather than migrated. After upgrading, click Update card data once to enable them; until then they're inert rather than broken. Price, format legality and can be a commander work immediately.

Sync

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 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. 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.

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.

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.

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, shipped as a container image. Inside the container it listens on port 8080 and keeps its database in /data; everything else is environment variables (below).

To run it locally, build the image, then run it with the port forwarded to localhost and a named volume for the data:

docker build -f crates/mtgc-server/Dockerfile -t mtgc-sync-server .

docker run -d --name mtgc-sync-server \
  -p 127.0.0.1:8080:8080 \
  -v mtgc-sync-data:/data \
  -e MTGC_PUBLIC_URL=http://localhost:8080 \
  mtgc-sync-server

docker exec -it mtgc-sync-server mtgc-server user add yourname   # prompts for a password

Then sign in from the desktop app with http://localhost:8080. For more settings, copy crates/mtgc-server/deploy/.env.example to .env beside it (git ignores it) and pass --env-file crates/mtgc-server/deploy/.env instead of the -e. Podman takes the same commands.

The container runs as UID 10001. A named volume, as above, picks up the right ownership by itself; if you bind-mount a host directory instead, chown 10001 it first.

In production, put it behind something that terminates HTTPS — the Android app refuses plain HTTP outside debug builds — and set MTGC_PUBLIC_URL to the https:// address users reach it at. On Kubernetes that means a Deployment with one replica (it's a single SQLite file, so replicas can't share it) and a Recreate strategy, a PersistentVolumeClaim mounted at /data, a Service on port 8080, and an Ingress for TLS. GET /healthz answers ok for probes. Keep MTGC_OIDC_CLIENT_SECRET in a Secret. Run the account commands with kubectl exec -it deploy/<name> -- mtgc-server user add <name>.

A freshly provisioned volume is usually owned by root, which the server (UID/GID 10001) can't write to — it fails at startup with "/data isn't writable". Have Kubernetes hand the volume to its group in the pod spec:

spec:
  securityContext:
    runAsNonRoot: true
    runAsUser: 10001
    runAsGroup: 10001
    fsGroup: 10001

A few volume types ignore fsGroup (hostPath, and some NFS and local-path provisioners). For those, chown 10001:10001 the backing directory once, or add an init container that does it.

To build the image yourself and push it to your own registry:

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:

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

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), 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):

cd src-tauri/gen/android && ./gradlew :app:compileUniversalDebugKotlin

Android

The Rust core and frontend are platform-agnostic, so the Android build shares all app logic with the desktop; only data provisioning and file access differ.

One-time setup

Install Android Studio and launch it once so its wizard downloads the SDK, then:

./scripts/android-setup.sh

That adds what Android Studio doesn't install by default: the SDK command-line tools, a pinned stable NDK, and the Rust Android targets. It prints the three environment variables to export (ANDROID_HOME, NDK_HOME, JAVA_HOME).

Why the NDK? mtgc-core builds rusqlite with the bundled feature (compiles SQLite from C) and rustls pulls in ring (C/asm), so a C cross-toolchain is required. The bundled SQLite can't be swapped for Android's system libsqlite3 either — the card schema needs FTS5 with the trigram tokenizer, which platform SQLite often lacks.

Why JAVA_HOME? Gradle/AGP reject very new JDKs. The script prefers Android Studio's bundled JetBrains Runtime (JDK 21), which needs no extra install.

Build and run

Every Android command needs ANDROID_HOME / NDK_HOME / JAVA_HOME. Source the helper once per shell — it auto-detects all three:

source scripts/android-env.sh      # bash / zsh
source scripts/android-env.fish    # fish

Only the env helper is shell-specific (it has to mutate the calling shell); android-setup.sh is executed rather than sourced, so it works from any shell.

Then, from the project root:

cargo tauri android dev --open   # dev server + open in Android Studio
cargo tauri android build --apk  # standalone release APK

src-tauri/gen/android is committed (unlike the rest of gen/) because AndroidManifest.xml is hand-edited — Android permissions have no tauri.conf.json equivalent.

Working in Android Studio

Use cargo tauri android dev --open rather than opening src-tauri/gen/android directly. Studio's Run button delegates the Rust build to cargo tauri android android-studio-script, which expects a running Tauri dev session — it reads the dev-server address from /tmp/<identifier>-server-addr. Open the Gradle project on its own and the build fails with failed to read missing addr file.

With the dev session running, Studio's device dropdown, Device Manager (emulator), and debugger all work normally.

JetBrains Toolbox users: --open finds Studio by scanning XDG data dirs for a .desktop entry whose Name is exactly Android Studio. Toolbox writes a versioned name instead (e.g. Android Studio Quail 2 2026.1.2 Patch 1), so the lookup misses and the launch fails with a confusing Cmd(["Android Studio"]) ... No such file or directory. android-setup.sh drops in a hidden shim entry (~/.local/share/applications/android-studio.desktop) to fix this; delete that file to undo it.

To build without Studio and without a dev server, use cargo tauri android build — that path needs no addr file.

Distributing to phones (F-Droid)

Releases go out through a small F-Droid repo you host yourself: add it to the F-Droid app once, and every new build then shows up there as an update. scripts/fdroid-publish.sh builds a signed arm64 APK and adds it to the repo in a directory you choose. It needs fdroidserver:

pipx install fdroidserver

The first run also makes the app's signing key and the repo. Give it the address the repo will be served at:

FDROID_REPO_URL=https://fdroid.example.com/repo ./scripts/fdroid-publish.sh --init-keystore ~/fdroid-mtgc

After that, publishing is just:

./scripts/fdroid-publish.sh ~/fdroid-mtgc     # or: npm run android:publish -- ~/fdroid-mtgc

It ends by printing the repo address with its fingerprint (https://…/repo?fingerprint=…); add that in F-Droid under Settings → Repositories.

  • Serve only ~/fdroid-mtgc/repo (and archive/, which holds older versions, if you like) — any static file server will do. Never serve the directory itself: it holds the repo signing key (keystore.p12) and its password (config.yml).
  • Back up both keys: ~/.local/share/mtgc/android-release.jks with src-tauri/gen/android/keystore.properties (the app key and its password), and the repo directory's keystore.p12 with config.yml. Android only installs an update signed with the same key as the installed app, so without the app key every phone would have to uninstall and reinstall.
  • Versions are automatic. The Android versionCode is the number of commits on HEAD, and the version shown is e.g. 0.1.0-44-gece21c1, so any commit can ship without a bump. That's also why pushed history shouldn't be rewritten. The script won't publish uncommitted changes without --allow-dirty, or a versionCode the repo already has.
  • A phone with a debug build, or anything signed with another key, has to uninstall it before F-Droid can install the release, and uninstalling deletes the app's data. Sync or Export library… first.

Without keystore.properties, cargo tauri android build still produces an unsigned APK as before.

Card data on a phone

Downloading card data streams ~740MB of JSON to build a ~73MB database, which is slow on mobile and may be killed if the app is backgrounded. Faster: copy reference.db from a desktop install onto the device and use Import card data…. The file is validated before it replaces anything.

Sync on Android

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

The logo is an original pirate-ship silhouette in the terracotta brand color (#C8553D) — a nod to the MTG card "Pirate Ship", not a reproduction of its art. Source: src-tauri/icons/logo-source.svg. To regenerate the full platform icon set after editing it:

cargo tauri icon src-tauri/icons/logo-source.svg
Description
No description provided
Readme 4.9 MiB
Languages
Rust 57.5%
TypeScript 29.4%
CSS 7.9%
Shell 4.4%
Kotlin 0.6%