Skip to content

Architecture

Claude edited this page Oct 26, 2026 · 8 revisions

Architecture

English · Español

Proxy Builder is a Tauri 2 desktop app: a React/TypeScript front end that only draws screens and holds screen state, an app crate (proxy-builder) that owns I/O (SQLite, disk, network, Tauri), and a pure crate (proxy-core) that owns every deterministic computation (parsing, page geometry, image processing, PDF, statistics, card-design engine). This page is the whole-system view; each box links to the page that goes deep.

The three layers

Layer Where Owns Must not
React front end src/ Rendering, screen state (Zustand stores), i18n, themes Compute geometry, talk to the network, call invoke directly
App crate proxy-builder (lib proxy_builder_lib) src-tauri/src/ Tauri wiring, commands, SQLite library, blob store, Scryfall and other sources, caches, jobs, pbimg:// protocol, updater Hold pure logic that could be tested without Tauri
Core crate proxy-core src-tauri/crates/proxy-core/src/ model, decklist, layout, imaging, pdf, stats, diseno (card designer engine), paper, units Know about Tauri, the network, the database, or AppError
Game engine crate mesa src-tauri/crates/mesa/src/ The game table: model, action catalogue, deterministic randomness, visibility, undo and replay (see Game Engine) Same as proxy-core, plus any clock or outside randomness
Room server (separate repository) server/ (ignored by the app) Identity without accounts, playgroups, invitations, rooms, signalling, host election and migration, the relay callout (see Online Play) Carry the game itself: games are P2P between the apps

The Rust crates are members of one Cargo workspace declared in src-tauri/Cargo.toml (members = [".", "crates/proxy-core", "crates/mesa"]). proxy-core has its own error enums (LayoutError, ImagingError, PdfError, ProbeError, DisenoError, PackError, LineError) and the app converts them to AppError with From impls (see IPC and Error Handling).

flowchart TB
    subgraph WV["WebView (React 19, no network)"]
        UI["features/* (decklist, preview, export, variants, library, disenar, ...)"]
        ST["stores/* (deck-store, plan-store, ui-store, trabajo-store, actualizacion-store, edicion-store)"]
        IPC["lib/ipc.ts: call() and on()"]
        IMG["lib/image-url.ts: pbimg:// URLs"]
        UI --> ST
        UI --> IPC
        UI --> IMG
    end

    subgraph APP["App crate proxy-builder (src-tauri/src)"]
        LIB["lib.rs: run(), plugins, setup, generate_handler!"]
        CMD["commands/*.rs (108 commands)"]
        PROTO["protocol.rs + render.rs (pbimg://)"]
        STATE["state.rs: AppState"]
        STORE["store/*: Db (SQLite), BlobStore, settings, presets"]
        SCRY["scryfall/*: client, cache, resolve, nombres"]
        FUE["fuentes/*: Externo allow-list, Cockatrice, MTGJSON, MTG.io"]
        DIS["diseno/*: designer app layer"]
        MISC["package.rs, ocr.rs, originales.rs, temas.rs, actualizar.rs, biblioteca.rs"]
        LIB --> CMD
        LIB --> PROTO
        CMD --> STATE
        PROTO --> STATE
        STATE --> STORE
        STATE --> SCRY
        STATE --> FUE
        STATE --> DIS
    end

    subgraph CORE["Core crate proxy-core (pure)"]
        MODEL["model, units, paper"]
        DL["decklist"]
        LAY["layout: PagePlan"]
        IM["imaging"]
        PDF["pdf"]
        STA["stats"]
        DSN["diseno: engine"]
    end

    IPC -- "invoke / events" --> CMD
    IMG -- "GET pbimg://localhost/..." --> PROTO
    CMD --> CORE
    PROTO --> CORE
    SCRY -- HTTPS --> NET1["api.scryfall.com, cards.scryfall.io"]
    FUE -- "HTTPS, allow-list only" --> NET2["Cockatrice, MTGJSON, MTG.io, tweakcn, GitHub"]
    STORE --> DISK1[("app_data_dir")]
    PROTO --> DISK2[("app_cache_dir")]
Loading

Architecture rules and their rationale

These come from CLAUDE.md; each is an invariant that code review and CI enforce or that the layout of the code depends on.

Decision Why Where it shows
All heavy work lives in Rust: list parsing, Scryfall, downloads, caches, page geometry, image processing, PDF, statistics. React only draws and holds screen state. One implementation, testable with cargo test, and the WebView never blocks on CPU work or touches the network. proxy-core modules; src/lib/ipc.ts as the only bridge
One geometry for screen and paper. PagePlan (millimetres) is computed once by proxy_core::layout::plan and consumed by both the preview and the PDF composer. The screen never recomputes positions. What you see is what prints. A second layout implementation would drift. commands/layout.rs → compute_page_plan; stores/plan-store.ts → usePlan; commands/export.rs → exportar calls plan(deck); proxy-core/src/pdf/mod.rs doc: "does not compute positions, it draws the PagePlan"
One image pipeline for screen and paper. Thumbnails and PDF images go through the same render() with a RenderSpec. Same reason; preview fidelity. src-tauri/src/render.rs → spec_de_miniatura, spec_de_casilla; proxy-core/src/imaging/pipeline.rs
Deck images live in the blob store (app_data_dir/blobs/, addressed by SHA-256), never in the cache. The cache (app_cache_dir/renders/) holds only derivatives and can be deleted at will. Decks depend on their images; the OS may clear caches. cache_clear must never lose user data. store/blobs.rs → BlobStore; render.rs → AppState::carpeta_renders; commands/library.rs → cache_clear (test asserts renders is under paths.cache and not under paths.data)
The preview unmounts images of pages that are not visible (IntersectionObserver whose root is the ScrollArea viewport). Only visible cards are rendered by Rust and held by WebKit. The document itself never scrolls, so a document-rooted observer would consider everything visible. src/features/preview/visibilidad.tsx → useVisible, RaizScroll
proxy-core is pure: no Tauri, no network, no database. Fast tests (cargo test -p proxy-core), clear boundary. proxy-core/src/lib.rs doc comment; Cargo.toml of the crate (no tauri, reqwest, rusqlite)
lib.rs only boots and registers; each area has commands/<area>.rs. An earlier project's lib.rs grew to ~12k lines; the file is kept small on purpose. src-tauri/src/lib.rs (about 200 lines, doc comment says so)
Commands return Result<T, AppError> ({ code, params }); the screen translates with useLang().mensaje(e). Rust never writes prose for the user. Language can change without Rust changes; one place for texts. src-tauri/src/error.rs; src/lib/errores.ts
Shared types come from Rust: #[derive(TS)] #[ts(export)] and pnpm bindings write src/bindings/. Never edited by hand. Front/back type drift is impossible to ship silently. .cargo/config.toml sets TS_RS_EXPORT_DIR
The screen talks to Rust only through call() in src/lib/ipc.ts. The Commands interface makes the compiler check command names and argument/return types. src/lib/ipc.ts
The WebView never goes to the internet; all network is done by Rust. Single place for rate limiting, allow-lists, redaction, logging; strict CSP. tauri.conf.json CSP (connect-src is only ipc:); scryfall/client.rs; fuentes::Externo
Everything outside Scryfall is downloaded through fuentes::Externo: closed allow-list of site and path, every redirect re-checked, size caps, images always decoded and re-encoded. Limits the blast radius of a compromised or changed third-party site. External Sources
Changes to the maintainer's tree arrive as uncommitted edits; the cloud workflow never pushes. Process rule, not code. See Conventions and Testing and CI

Process boundaries

There is one OS process (the Tauri app) with three execution contexts:

  1. Rust main process. Tauri event loop, plugin handlers, AppState. Sync #[tauri::command] functions run on the Tauri side; async commands run on Tauri's async runtime (tokio). Long work is moved to tauri::async_runtime::spawn (network-bound jobs: resolve_decklist, identify_batch) or spawn_blocking (CPU-bound: export_pdf). Within a job, rayon thread pools do per-image parallel work (the PDF export builds its own pool of at most 8 threads in commands/export.rs → exportar).
  2. The WebView (WebKitGTK on Linux, WebView2 on Windows, WKWebView on macOS). Loads dist/ (or the Vite dev server on port 1420). Its CSP forbids any network except IPC (connect-src: ipc: http://ipc.localhost). Images come from the custom pbimg: scheme.
  3. Worker threads/pools inside Rust (rayon, spawn_blocking, the renders semaphore that bounds concurrent thumbnail renders to the number of cores, AppState.renders).

The WebView and Rust communicate through exactly three channels:

Channel Direction Mechanism Page
Commands WebView to Rust, request/response invoke wrapped by call() IPC and Error Handling
Events Rust to WebView, fire-and-forget app.emit("<job>://progress" | "<job>://done", payload) listened by on() IPC and Error Handling
Image protocol WebView to Rust, GET pbimg:// (http://pbimg.localhost/ on Windows), served by protocol.rs Rust App Layer

End-to-end flow: paste list, resolve, plan, preview, export

sequenceDiagram
    actor U as User
    participant P as Decklist panel (acciones.ts)
    participant IPC as ipc.ts call/on
    participant R as Rust commands
    participant SC as Scryfall client + BlobStore
    participant DS as deck-store
    participant PS as plan-store
    participant PC as proxy-core
    participant PV as Preview (slot-view.tsx)
    participant PR as pbimg protocol

    U->>P: pastes list, presses Load
    P->>IPC: call parse_decklist {text}
    IPC->>R: invoke
    R->>PC: decklist::parse
    PC-->>P: ParsedDecklist
    P->>IPC: lanzarTrabajo(resolve://progress, resolve://done)
    IPC->>R: call resolve_decklist {list, options}
    R-->>P: jobId (immediately)
    R->>SC: /cards/collection batches, language, tokens
    SC->>SC: images to blobs/ (SHA-256)
    R-->>P: event resolve://progress {jobId, progress}
    R-->>P: event resolve://done {jobId, result, error}
    P->>DS: aplicar(cards)  (history entry, version++)
    DS->>PS: subscribe fires (100 ms debounce)
    PS->>IPC: call compute_page_plan {deck}
    IPC->>R: invoke
    R->>PC: layout::plan(deck)
    PC-->>PS: PagePlan (mm)
    PS-->>PV: pages and slots
    PV->>PV: useVisible(page) true
    PV->>PR: GET pbimg://localhost/r/{blob}/{params}/{width}
    PR->>PC: imaging::pipeline::render (cached in app_cache_dir/renders)
    PR-->>PV: JPEG
    DS->>IPC: call deck_save (800 ms after last change)
    U->>P: Export PDF, picks a path (dialog plugin)
    P->>IPC: lanzarTrabajo(export://progress, export://done)
    IPC->>R: call export_pdf {deck snapshot, path}
    R-->>P: jobId
    R->>PC: layout::plan, render every distinct image (rayon), pdf::compose
    R-->>P: export://progress {phase: prepare / compose / write}
    R-->>P: export://done {jobId, result, error}
Loading

Key points visible in the diagram:

  • resolve_decklist and export_pdf return a jobId at once and finish by event; the front end listens before launching (src/lib/trabajos.ts → lanzarTrabajo) so a fast job cannot finish unseen.
  • The export sends the deck snapshot from the store, not the one in SQLite, because autosave may lag by up to 800 ms (src/features/export/acciones.ts → exportarA).
  • The preview and the PDF run the same render(); the preview asks for a width from ANCHOS = [146, 244, 488, 672, 745, 1000], the PDF asks for the DPI of the chosen quality.

Deeper pages: Decklist Parsing, Scryfall Integration, Layout Engine, Live Preview, Imaging and PDF, Export.

Data locations

The app asks Tauri for directories in lib.rs setup and stores them in state::Paths { data, cache }; the log directory comes from app_log_dir().

Location Content Source Deletable without data loss?
app_data_dir/library.db SQLite (WAL): decks, deck_cards, blobs, blob_refs, presets, app_settings, scryfall_cards, tags, deck_tags; migrations 0001–0004 in src-tauri/src/store/migrations/ state.rs → AppState::open, store/db.rs → Db No
app_data_dir/blobs/<2 hex>/<sha256>.<ext> Every image used by a deck (Scryfall PNGs, uploads, generated, designed) store/blobs.rs → BlobStore No
app_data_dir/fuentes/, marcos/, simbolos/ Designer resources: imported fonts, frame packs (including downloaded packs and the "Original" frames), mana symbols diseno/recursos.rs → Recursos Re-downloadable, but user imports are lost
app_data_dir/letras-cjk/ Noto Sans JP/KR/SC/TC (<code>.ttf) and their OFL licence, downloaded the first time a deck uses Japanese, Korean or Chinese as design language diseno/letras_cjk.rs, read by diseno/recursos.rs Yes (re-downloaded and SHA-256 checked)
app_data_dir/modelos/ OCR models, downloaded the first time OCR is used ocr.rs Yes (re-downloaded)
app_data_dir/temas/ Installed tweakcn themes (<id>.json) and their fonts (fuentes/<hash>.woff2) temas.rs Themes uninstalled
app_cache_dir/renders/ Thumbnail JPEGs keyed by RenderSpec::key(); t/ search-result thumbnails; b/ reduced artwork render.rs, protocol.rs, diseno/vista.rs Yes (cache_clear)
app_cache_dir/scryfall-card-names.json, scryfall-set-codes.json Scryfall name and set catalogues, renewed weekly scryfall/nombres.rs Yes
app_cache_dir/cockatrice-tokens.xml; app_cache_dir/mtgjson/nombres.sqlite Token catalogue and the multi-language name index fuentes/cockatrice.rs, fuentes/mtgjson.rs Yes
app_log_dir/proxy-builder.log One line per Scryfall request (method, host, path, status) registro.rs, scryfall/client.rs Yes
Browser storage (localStorage) UI language key proxybuilder.lang, other per-viewer conveniences src/components/language-provider.tsx Yes

Note that the Scryfall card data cache is not in app_cache_dir: it is the scryfall_cards table inside library.db (scryfall/cache.rs, TTL one week), so statistics and the library work offline. Because cache_clear only empties renders/, that table survives it.

See Storage and Blobs for tables and garbage collection.

Technology stack

Versions are the declared ranges in the manifests; the resolved versions in src-tauri/Cargo.lock are given where they matter.

Area Technology Declared Resolved / note
Shell Tauri tauri = "2" 2.12.3
Plugins tauri-plugin-dialog, -opener, -updater, -window-state "2" 2.8.2, 2.7.1, 2.14.0, 2.5.0 (window-state desktop only)
Types ts-rs "12" 12.0.1
Database rusqlite with bundled SQLite "0.40" 0.40.2
HTTP reqwest (rustls, no OpenSSL; CI asserts cargo tree -i openssl is empty) "0.13" 0.13.5
Async tokio (rt, sync, time, macros) "1" 1.53.2
Parallelism rayon "1.12"
PDF krilla (raster-images, simple-text) "0.8.2" 0.8.2
Images image, fast_image_resize (Lanczos3, SIMD) 0.25.10, 6.1.0
Card rendering parley (text), tiny-skia, resvg, skrifa 0.12, 0.12, 0.48, 0.44 0.12.0, 0.12.0, 0.48.1
OCR ocrs + rten 0.13, 0.26 0.13.1, 0.26.0
Packages zip (deflate only) for .pbdeck "9"
Logging tracing, tracing-subscriber (fmt) 0.1, 0.3
Rust edition / MSRV 2021, rust-version = "1.92"
Front end React, React DOM ^19.1.0
State Zustand ^5.0.15
UI kit Radix UI (radix-ui ^1.5.0), lucide-react ^1.17.0, sonner ^2.0.7, react-resizable-panels ^4.11.2, @dnd-kit/core ^6.3.1
Styling Tailwind CSS 4 (@tailwindcss/vite), tw-animate-css ^4.3.0
Build Vite ^7.3.6, TypeScript ~5.8.3
Tests / lint Vitest ^5.0.1 (happy-dom), Biome 2.4.13, knip ^6.34.0
Package manager pnpm 12.14.0 (packageManager), minimumReleaseAge: 1440 in pnpm-workspace.yaml
Node >=22

How the pieces map to wiki pages

Concern Page
Bridge, commands, errors, bindings, jobs IPC and Error Handling
lib.rs, AppState, pbimg://, render.rs, capabilities, window, CSP Rust App Layer
Deck, DeckCard, PrintSettings and friends Deck Model
SQLite, blob store, GC Storage and Blobs
.pbdeck files Deck Package Format
Parsing the pasted list Decklist Parsing
Scryfall client, cache, resolve Scryfall Integration
Cockatrice, MTGJSON, MTG.io, Externo External Sources
OCR and linking own cards Card Identification and OCR
PagePlan Layout Engine
Image pipeline and PDF Imaging and PDF
Export button, presets, calibration Export
Preview panes and pbimg:// URL building Live Preview
Statistics dialog Statistics
Card designer Designer Overview, Designer Engine, Designer Composition, Frame Packs, Designer App Layer, Designer UI
Front end structure, stores, i18n, themes, settings Frontend Architecture, State Stores, Internationalization, Themes and Skins, Settings and Shell
Feature screens Decklist Panel, Add Card and Variants, Set Browser, Deck Library, Card Editor Dialog
Dev setup, CI, releases, conventions Development Setup, Testing and CI, Releases and Auto Updates, Conventions

Where to start when extending

  • A new backend capability: put the pure part in proxy-core (with its own error enum), the I/O part in the app crate, add commands/<area>.rs, register it in lib.rs's generate_handler!, add its signature to Commands in src/lib/ipc.ts, run pnpm bindings. Checklist in IPC and Error Handling.
  • A new long job: allocate with state.jobs.alta(), hold state.trabajo() for its duration, emit <name>://progress and <name>://done, add both to Events in ipc.ts, launch from the screen with lanzarTrabajo.
  • A new image URL shape: extend Ruta and leer_ruta in protocol.rs (with a rejection test) and add a builder in src/lib/image-url.ts.

See also

Documentation · Documentación

Language: English · Español

Use and build · Uso y desarrollo

Architecture · Arquitectura

Data · Datos

Getting cards · Conseguir cartas

Layout, images, PDF · Maquetación, imagen y PDF

Card designer · Diseñador de cartas

Game table · Mesa de juego

Frontend · Frontend

Reference · Referencia

Clone this wiki locally