Garrett Mills ce2e556c52 Add a News tab: set releases and links to Wizards' articles
The app knew nothing about the game outside your own collection — no way to
see that a set drops next week, or that a Secret Lair was announced.

Two sources with very different reliability, kept apart so one failing can't
take the page down.

Releases come from Scryfall's documented /sets endpoint. Clicking a set fetches
its cards live rather than reading reference.db, so a set released or spoiled
since the last card-data download still works; ownership is joined locally by
oracle_id, and the same add-to-library stepper works there as anywhere else.

Articles have no supported source at all: Wizards publishes no RSS or Atom feed
and no public API, and their page is a Nuxt app whose CSS classes are per-build
hashes. So the app links out rather than reproducing anyone's writing, which
keeps the scraped surface to titles and hrefs. parse_articles keys on nothing
cosmetic — anchors pointing at /en/news/<category>/<slug>, taking the anchor's
own text as the title. A restyle can't break that; only a change to their URL
scheme could, and the fixture tests fail loudly when it does rather than the
page quietly going empty. Verified against a live capture: 15 articles, correct
titles and categories.

Two things the fixtures caught. Where an article is linked several times per
card, the first link is a "Learn More" button, so the longest text wins rather
than the first. And a *successful* fetch yielding zero articles is treated as a
failure, since that is the signature of their markup changing.

get_set_cards returns a full CardRef rather than the handful of fields the grid
needs, so the tab renders the same CardTile as the rest of the app and gets
click-to-preview instead of a lookalike that does nothing when clicked.

Both feeds cache to news-cache.json for six hours and fall back to whatever was
last seen, so opening the tab offline shows stale content with its age rather
than an error.

Also promotes the HTTP agent out of ingest.rs into a shared http module, so
timeouts and User-Agent can't drift between the ingester and anything else that
reaches the network. Adds tauri-plugin-opener for opening articles externally.
2026-08-07 23:35:50 -05:00
2026-08-02 13:16:34 -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

Two files:

  • your library .json — owned cards, decks, deck contents. Small, readable, and the only file you name or move. Put it somewhere synced; see Sync.
  • 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/).

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 document defaults to that directory too; move it into a synced folder with New Library (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 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.

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.

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.

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.

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.

Develop / test

cargo test -p mtgc-core      # core: db, search, filters, decks, ingest, sync merge
cargo test -p mtgc           # shell: document read/write, conflict-copy matching
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.

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.

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

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

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%