Skip to content

Rust App Layer

Claude edited this page Oct 26, 2026 · 10 revisions

Rust App Layer

English · Español

The app crate (proxy-builder, library proxy_builder_lib, in src-tauri/) wires Tauri to the pure proxy-core crate: it builds the window and plugins, owns AppState (database, blob store, HTTP clients, jobs), serves the pbimg:// image protocol, and registers every command. This page covers the boot sequence, shared state, the protocol and render cache, Tauri configuration (window, CSP, capabilities, plugins) and a module map of the crate.

Entry points

File Role
src-tauri/src/main.rs Six lines: fn main() { proxy_builder_lib::run() }. On Windows release builds #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] suppresses the console.
src-tauri/src/lib.rs pub fn run() (marked #[cfg_attr(mobile, tauri::mobile_entry_point)]): builder, plugins, protocol, setup, generate_handler!. Also declares all modules and #![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used))].
src-tauri/build.rs tauri_build::build().
src-tauri/Cargo.toml Library crate types staticlib, cdylib, rlib; the lib name has the _lib suffix to avoid a Windows name clash with the binary. Workspace members: the app and crates/proxy-core. Dev profile tweaks (see below).

lib.rs is intentionally small: it only boots and registers. If it grows, move code into a module under commands/ or elsewhere.

Startup sequence

sequenceDiagram
    participant OS
    participant RUN as lib.rs run()
    participant TAU as Tauri runtime
    participant ST as setup closure
    participant WV as WebView (main window, hidden)
    participant FE as main.tsx

    OS->>RUN: start proxy_builder_lib::run
    RUN->>TAU: Builder: plugins opener, dialog, updater, window-state
    RUN->>TAU: manage(Actualizador)
    RUN->>TAU: on_page_load(show window when Finished)
    RUN->>TAU: register_asynchronous_uri_scheme_protocol("pbimg", protocol::manejador)
    TAU->>ST: setup(app)
    ST->>ST: Paths { data: app_data_dir, cache: app_cache_dir }
    ST->>ST: registro::iniciar(app_log_dir)
    ST->>ST: AppState::open(paths): dirs, library.db + migrations, rellenar_refs, BlobStore, Recursos, Scryfall, Externo
    ST->>TAU: manage(AppState)
    ST->>ST: spawn task: original::completar (missing Original frames)
    ST->>ST: spawn thread: after 3 s show window if still hidden
    TAU->>WV: create window (visible false, background 1d1b19)
    WV->>FE: load index.html and JS
    FE->>FE: prepararTemasInstalados, ponerTemaAntesDeMontar
    FE->>WV: render React tree
    FE->>TAU: getCurrentWindow().show()
    TAU-->>WV: window shown (dark background, theme already applied)
    Note over RUN,TAU: Rust also shows it on PageLoadEvent::Finished
Loading

Details and reasons:

  1. The window starts hidden and dark. tauri.conf.json sets "visible": false and "backgroundColor": "#1d1b19". This avoids a white flash while the WebView loads.
  2. The window-state plugin must not make it visible. tauri_plugin_window_state::Builder is configured with StateFlags::all() & !StateFlags::VISIBLE, so a saved "visible" flag cannot show the window early. The same mask is used by actualizar.rs → guardar_ventana before an update restart. The plugin is only added #[cfg(desktop)] and is a desktop-only dependency in Cargo.toml.
  3. Three independent ways to show the window, so it can never stay hidden: on_page_load (Rust shows it at PageLoadEvent::Finished), ensenarVentana() at the end of src/main.tsx (calls getCurrentWindow().show(), allowed by capability core:window:allow-show), and a fallback thread in setup that shows it after 3 s if is_visible() is false. Why not wait for the first painted frame: WebKitGTK gives no requestAnimationFrame frames to a hidden window, which previously delayed the window by the 3 s fallback. The theme is applied to <html> before React mounts (ponerTemaAntesDeMontar), so showing early is flash-free.
  4. setup failures abort the app. AppState::open errors are mapped to String and returned from setup; run() then prints Proxy Builder no ha podido arrancar: … to stderr and exits with status 1 (no window to show an error in).
  5. Plugin order: opener, dialog, updater are registered first; .manage(actualizar::Actualizador::default()) registers the updater's own state.
  6. After the shell mounts, src/features/shell/use-deck-inicial.ts starts autosave (iniciarAutoguardado, 800 ms) and the plan store (iniciarPlan, 100 ms), then abrirDeckInicial opens the last deck from setting last_deck_id or creates one.

Logging is initialised in setup by registro::iniciar(&app_log_dir): appends to proxy-builder.log, tracing_subscriber::fmt to that file (no ANSI, no target, level INFO, try_init so a second init is harmless). Its stated purpose is proving that a saved deck downloads nothing: every Scryfall request is logged as a line with method, host, path and status (scryfall/client.rs). Failures to create the directory or file are ignored silently. Other modules log with tracing::info!/warn! (updater, themes, symbols, OCR models, external sources).

AppState

src-tauri/src/state.rs. Registered with app.manage(...), injected into commands as State<'_, AppState>, and fetched from AppHandle in spawned tasks (app.state::<AppState>()).

Field Type Meaning
db store::db::Db SQLite connection behind a Mutex; WAL, foreign_keys=ON, synchronous=NORMAL; migrations applied on open (MIGRACIONES 1–5; only append, never edit a shipped one). Long jobs release the lock between steps.
blobs store::blobs::BlobStore Content-addressed image store under paths.data/blobs.
paths Paths { data, cache } app_data_dir (library, images, resources) and app_cache_dir (deletable derivatives).
scryfall Arc<Scryfall> Rate-limited, retrying client for api.scryfall.com and image downloads.
externo Arc<fuentes::Externo> Allow-listed client for every non-Scryfall site.
jobs jobs::Jobs Cancel flags of running jobs.
miniaturas Mutex<HashMap<String,String>> Search-result thumbnails: SHA-256 of the URL to the URL. Filled by searches (commands/cards.rs → registrar_miniatura, resumir; cleared when it passes 5000 entries), read by the /t/ route.
diseno diseno::recursos::Recursos Fonts, frame packs and symbols of the designer, loaded at startup from paths.data.
vistas Mutex<diseno::vista::Vistas> Designs registered for live preview (/d/), capped at the 64 most recent.
imagenes CacheImagenes Decoded artwork for design rendering.
detalles biblioteca::CacheDetalles Library details per deck, in memory keyed by updated_at.
renders tokio::sync::Semaphore Bounds concurrent thumbnail renders to available_parallelism() (fallback 4).
mesa Mutex<Option<mesa::sesion::Sesion>> The game table session, if a game is open (Game Table).
red red::Red The network: the installation key, the iroh node (started when needed), the P2P role (Sala), the room-server client and its room (Salon) (Online Play).
mirada Mutex<Option<red::mirada::Mirada>> What a spectator sees (no session, only the view and its patches).
trabajos (private) AtomicUsize Count of running long jobs; see trabajo() / ocupado(). ocupado() is true while a job runs or a game is open (hay_partida()), so neither blob GC nor an update (which restarts the app) runs then.

AppState::open(paths) creates both directories, opens library.db, runs store::decks::rellenar_refs once (backfills blob_refs so designed images are protected from GC), builds the blob store and the designer resources, and the two HTTP clients. Tests build an AppState over tempfile directories (see the estado() helpers in render.rs, commands/cards.rs, diseno/*.rs).

Concurrency notes: db.conn() returns a MutexGuard or Internal if poisoned; hold it briefly. trabajo() returns a guard whose Drop decrements the counter, so early returns and panics-unwinding release it.

The pbimg:// image protocol

Registered with register_asynchronous_uri_scheme_protocol(protocol::ESQUEMA, protocol::manejador). The handler copies the AppHandle, spawns an async task per request, and answers via UriSchemeResponder. Only the URL path is used (peticion.uri().path()).

On the WebView side the base is convertFileSrc("", "pbimg"): pbimg://localhost/ on Linux/macOS and http://pbimg.localhost/ on Windows (src/lib/image-url.ts → raiz()), which is why the CSP lists both.

URL shapes

protocol.rs → leer_ruta parses the path into Ruta (and nothing else is accepted):

Path Ruta Returns Produced by (src/lib/image-url.ts)
/r/<sha256>/<params>/<width> Render { blob, params, ancho } JPEG thumbnail of a stored blob, processed with the deck's edits urlMiniatura(blob, PreviewParams, ancho)
/t/<sha256 of URL> Miniatura { huella } Image of a search result (PNG or JPEG, sniffed by magic bytes) urlBusqueda(huella)
/d/<huella>/<width> Diseno { huella, ancho } PNG preview of a design registered with design_register urlDiseno
/c/<huella>/<width> Capa { huella, ancho } PNG of the card without its artwork (design_overlay) urlCapa
/b/<sha256>/<side> Arte { blob, lado } Artwork from the blob store, reduced so its long side is 1000 or 2000 px (JPEG, or PNG if it has transparency) urlArte(blob, 1000 | 2000)
/x/<pack>/<n> Extra { pack, indice } PNG extra image of a downloadable frame pack urlExtra(pack, n)
/f/<sha256>.woff2 Letra { huella } Font file of an installed tweakcn theme raizProtocolo() used by src/lib/temas-instalados.ts
/m/<sha256>/<width> Mesa { imagen: ImagenMesa::Propia, ancho } JPEG of a game table face prepared by the app (or a mat) urlMesa({ tipo: "propia" }, ancho)
/m/s/<scryfall id>/<face>/<width> Mesa { imagen: ImagenMesa::Scryfall, ancho } JPEG of an untouched Scryfall print used on the game table urlMesa({ tipo: "scryfall" }, ancho)

Validation, enforced and unit-tested (protocol.rs tests las_rutas_raras_se_rechazan and others):

  • <sha256> / <huella> must pass store::blobs::es_huella (exactly 64 lowercase hex characters), which also blocks path traversal.
  • <width> must be one of ANCHOS = [146, 244, 488, 672, 745, 1000] (mirrored by ANCHOS in image-url.ts); <side> one of LADOS_ARTE = [1000, 2000] (diseno/vista.rs).
  • <params> is the base64url (no padding) of the JSON PreviewParams (render.rs): edits, edge (edge darkening), back, bleedMm, bleedMode, fillCorners, card (CardSize). Maximum 2048 characters (MAX_PARAMS), must decode and deserialize.
  • <pack> is 1–40 characters of [A-Za-z0-9_-]; <n> a non-negative integer.
  • /m/ widths must be one of ANCHOS_MESA (the six of ANCHOS plus 1920, for the mat); <scryfall id> must pass mesa::imagenes::es_id_scryfall (36 characters, hex digits and dashes) and <face> is 0 or 1 (test las_rutas_de_la_mesa). Files come from app_cache_dir/mesa/; widths of 745 or more are served as stored, smaller ones are reduced once into renders/m/ (mesa/imagenes.rs::servir). See Game Table.
  • Status codes: unparsable-but-recognised shape is 400, unknown shape 404, missing blob/design 404, other render failure 500, failed remote fetch for /t/ 502.
  • Every response sets X-Content-Type-Options: nosniff and Access-Control-Allow-Origin: *; successful ones add Cache-Control: max-age=31536000, immutable, because the URL contains everything that changes the image.

Why URLs instead of IPC bytes: no image bytes cross the IPC, and WebKit caches by URL. The URL carries all render parameters, so changing an edit or a setting simply produces a new URL.

Concurrency

Routes /r/, /d/, /c/, /b/ first await estado.renders.acquire() (semaphore sized to the core count), then run the CPU work in tauri::async_runtime::spawn_blocking. /x/ and /f/ are small file reads and skip the semaphore. /m/ runs in spawn_blocking without the semaphore: game images are prepared beforehand, so a request is a file read or, once per width, a resize. Because the front end only mounts <img> for visible pages (useVisible, see Live Preview), the semaphore queue stays short.

The /t/ route

  1. Look for app_cache_dir/renders/t/<hash>.jpg; serve it if present.
  2. Else look the hash up in AppState.miniaturas; unknown hash is 404.
  3. Download: URLs starting with https://cards.scryfall.io/ use scryfall.download; anything else goes through externo.bajar(url, MAX_IMAGEN) and fuentes::limpiar_imagen (decode and re-encode), so only allow-listed sources are ever fetched.
  4. Write the file atomically into the cache and return it.

The WebView therefore never contacts any image host; the img-src CSP allows only 'self' pbimg: http://pbimg.localhost data: blob:.

render.rs: thumbnails and cache

src-tauri/src/render.rs turns a blob plus settings into pixels, for both the screen and the PDF.

Item Behaviour
PreviewParams (#[derive(TS)]) What the screen puts in the URL. Rust decides whether edge darkening applies to this image from its origin and whether it is a back (proxy_core::imaging::pipeline::edge_for), the same rule as at export.
spec_de_miniatura(blob, img, params, ancho) RenderSpec with target: SpecTarget::Width(ancho), JPEG quality 85.
spec_de_casilla(slot, img, edits, settings) RenderSpec for a PagePlan slot at print resolution: card size is the slot's trim (so big cards work), bleed_mm is the slot's bleed_full_mm, SpecTarget::Dpi(quality.dpi()), encoding from the print settings; edits apply only if slot.apply_edits.
AppState::carpeta_renders() paths.cache/renders.
AppState::leer_blob(hash) Reads the file located by the blob store, probes size, reads origin from the blobs table; NotFound (param blob) if missing.
AppState::miniatura(blob, params, ancho) Cache file renders/<RenderSpec::key()>.jpg; on a miss renders, then escribir_atomicos it (errors writing the cache are ignored).
render_para_pdf(state, spec) Renders for print with no disk cache ("it weighs too much").
From<ImagingError> for AppError See IPC and Error Handling.

RenderSpec::key() is the SHA-256 of (PIPELINE_VERSION, spec) serialized as JSON (proxy-core/src/imaging/pipeline.rs, PIPELINE_VERSION = 5). Any change in the spec or a bump of the pipeline version yields a different filename, which invalidates old cache files without deleting them (they are removed by cache_clear). The designer's PNG cache keys include its own engine version (proxy_core::diseno::VERSION_MOTOR).

Cache layout under app_cache_dir/renders/:

Path Content
<spec key>.jpg Thumbnails of blobs
t/<url hash>.jpg Search-result images (may be PNG bytes despite the extension; the protocol sniffs the type)
d/<huella>-<width>.png Live design previews
c/<huella>-<width>.png, c/<huella>.json Design overlay and its artwork-window info
b/<blob>-<side>.jpg or .png Reduced artwork

cache_stats and cache_clear (commands/library.rs) measure and remove_dir_all exactly this folder. Rule: only derivatives live here; deck images are never in this tree. A unit test in commands/library.rs asserts that the renders folder is under paths.cache and not under paths.data.

The generic atomic writer is store/blobs.rs → escribir_atomico(destino, bytes): create parent dirs, write <name>.<ext>.part, fsync, rename. A crash never leaves a half-written file under its final name. PDFs, package files, caches and blobs all use it.

Tauri configuration

src-tauri/tauri.conf.json

Key Value / meaning
productName, identifier, version Proxy Builder, a reverse-DNS bundle identifier (read it in the file), 2.5.3 (kept in step with Cargo.toml and package.json; see Releases and Auto Updates)
build beforeDevCommand: pnpm dev, devUrl: http://localhost:1420, beforeBuildCommand: pnpm build, frontendDist: ../dist
app.windows[0] label main, 1440x900, minimum 960x640, visible: false, backgroundColor: #1d1b19
app.security.csp default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; img-src 'self' pbimg: http://pbimg.localhost data: blob:; style-src 'self' 'unsafe-inline'; font-src 'self' data: pbimg: http://pbimg.localhost; connect-src ipc: http://ipc.localhost; object-src 'none'; base-uri 'self'; form-action 'none'. devCsp is null (Vite HMR works in dev).
bundle targets: all, createUpdaterArtifacts: true, category Utility; Debian package depends: [] (WebKitGTK is not forced as a dependency here)
plugins.updater Minisign public key, endpoint https://github.com/PoolFlamingo/proxy-builder/releases/latest/download/latest.json, requireSignedVersion: true, Windows installMode: passive

Consequences of the CSP: the WebView cannot fetch anything outside IPC, cannot load remote scripts or images, and cannot submit forms. 'wasm-unsafe-eval' lets the WebView compile WebAssembly (the physics of the 3D dice, see Game Table); it does not allow eval of JavaScript. style-src 'unsafe-inline' is needed by React inline styles and the injected <style> for installed themes. Fonts for installed themes come from pbimg://…/f/…. The test la_configuracion_del_actualizador_es_valida in src-tauri/src/actualizar.rs reads tauri.conf.json and fails if updater signing, the endpoint shape or the public key header change.

src-tauri/capabilities/default.json

Applies to window main and grants only: core:default, core:window:allow-show, core:window:allow-destroy, core:window:allow-set-fullscreen, opener:default, dialog:default, window-state:default. The description states the rule: no fs permission; all disk access goes through Rust commands. core:window:allow-show lets main.tsx reveal the window; core:window:allow-destroy lets the close guard (ConfirmarCierre, Settings and Shell) close it: with a JavaScript onCloseRequested listener Tauri stops closing the window by itself and destroy() does it, and that permission is not included in core:default / core:window:default, so without the explicit entry the window could not be closed at all (the build validates the identifier). core:window:allow-set-fullscreen is for the full-screen button of the game table (src/features/mesa/barra-mesa.tsx). The updater plugin has no capability entry because it is used only from Rust (actualizar.rs, commands/actualizar.rs). Custom commands registered with generate_handler! are not gated by a per-command permission list in this configuration (the app does not define a custom permission set; Inferred from the capability file containing only plugin and core permissions).

Plugins

Plugin (crate) Used from For
tauri-plugin-dialog JS (src/lib/dialogos.ts) Open/save/folder dialogs
tauri-plugin-opener Rust (OpenerExt in commands/export.rs, commands/temas.rs) Open exported files, reveal in folder, open the tweakcn community page
tauri-plugin-updater Rust only (actualizar.rs) Check, download, verify signature, install
tauri-plugin-deep-link (desktop) Rust only (enlace.rs, set up in lib.rs) proxybuilder:// invitation links; no capability entry because the screen never calls it
tauri-plugin-single-instance (desktop, feature deep-link) Rust only (lib.rs), not with PB_PERFIL A second launch hands its arguments (the link) to the open app
tauri-plugin-window-state (desktop) Plugin + Rust save_window_state Remember size, position, maximised; never visibility

Other platform dependencies: gtk on Linux and arboard elsewhere, both for reading the clipboard image from Rust (diseno/portapapeles.rs; WebKitGTK refuses navigator.clipboard.read() for the web view).

Cargo profile notes (src-tauri/Cargo.toml)

  • [profile.dev] debug = "line-tables-only", split-debuginfo = "packed", dependencies with debug = false: keeps target/ small.
  • [profile.dev.package."*"] opt-level = 2: dependencies (image, resize, PDF) compile optimised even in dev because unoptimised image processing makes the preview unusable under pnpm tauri dev.
  • reqwest uses rustls with default-features = false; CI fails if OpenSSL appears in cargo tree.
  • Game table dependencies: getrandom (the seed of each game, from system entropy), regex (the fixed «+N/+N» of auras and equipment) and flate2 with the zlib-rs backend (the saved game; the same zlib-rs that zip already brings).

Module map of the app crate

Module Responsibility Deeper page
commands/* Tauri commands, one file per area IPC and Error Handling
error.rs AppError, ErrorCode, From impls IPC and Error Handling
state.rs, jobs.rs, registro.rs Shared state, job flags, log file this page
protocol.rs, render.rs pbimg:// and renders this page, Live Preview
store/ (db, blobs, decks, presets, settings, colecciones, etiquetas, migrations/) SQLite library and blob store Storage and Blobs
package.rs .pbdeck ZIP packages Deck Package Format
biblioteca.rs Per-deck details for the library (colors, class, price, languages), cached by updated_at Deck Library
scryfall/ (client, rate, cache, models, resolve, busquedas, fichas, fichas_auto, nombres) Scryfall access, name catalogue, list resolution Scryfall Integration
archidekt.rs Import from an Archidekt URL (exact Scryfall ids) Scryfall Integration
fuentes/ (cockatrice, mtgio, mtgjson, mod.rs with Externo) Non-Scryfall sources behind an allow-list External Sources
ocr.rs, originales.rs Optional OCR and original-name detection Card Identification and OCR
diseno/ App side of the card designer (drafts, rendering with resources, live preview, artwork, PNG export, batch, packs and fonts download, clipboard) Designer App Layer
temas.rs tweakcn themes and fonts Themes and Skins
actualizar.rs Updater wrapper Releases and Auto Updates
enlace.rs Invitation links proxybuilder://unirse/<code>: validation, enlace_pendiente, enlace_invitacion Online Play
mesa/ (sesion, preparar, imagenes, guardada, tapete, ejemplo) The game table session around the mesa crate, deck and image preparation, the saved game, the mat Game Table
red/ (perfil, identidad, nodo, protocolo, codigo, anfitrion, invitado, mirada, imagenes, latencia, mesa, colores, servidor/) and commands/{red,salas}.rs Online play: debug profiles, identity, iroh node, game protocol, direct code, host and guest, spectators, images over the network, the room-server client and the room in the app Online Play

Invariants and gotchas

  • Never block the async runtime with CPU work. Use spawn_blocking (as export_pdf, the protocol handlers and en_hilo in commands/diseno.rs do).
  • Hold state.trabajo() for every long job or blob_gc and update_install may run under it. An open game counts as busy by itself (hay_partida()).
  • cache_clear must only touch renders/. A new cache location for deck-critical data would break this guarantee; put it in app_data_dir and the blob store instead.
  • Add new protocol routes with a test in protocol.rs for both acceptance and rejection (traversal, bad hash length, uppercase hex, bad widths are already covered).
  • Do not edit shipped migrations; append 0006_*.sql and add it to MIGRACIONES in store/db.rs.
  • Blob store versus cache: the blob store is in app_data_dir because decks depend on it; GC keeps images younger than 24 h (GRACIA_MS) and anything referenced by deck_cards, decks.default_back_blob, blob_refs, or the generic back setting.
  • Release builds hide the console on Windows; use the log file in app_log_dir to debug network behaviour.

Tests that guard this layer

Where What
src-tauri/src/protocol.rs (mod tests) Valid paths parse; malformed, traversal, wrong widths/sides, bad pack names, .ttf instead of .woff2 are rejected; game table routes (las_rutas_de_la_mesa)
src-tauri/src/mesa/ (sesion.rs, guardada.rs, preparar.rs) Patches equal the full view, rejections carry their reason, restart, an open game makes the app busy, save and resume keep the fingerprint, the second preparation paints nothing
src-tauri/src/render.rs (mod tests) RenderSpec building from slots, error conversion
src-tauri/src/jobs.rs alta_cancelar_y_baja
src-tauri/src/error.rs Error serialisation
src-tauri/src/actualizar.rs Updater config in tauri.conf.json is valid
src-tauri/src/commands/library.rs Cache folder is under cache, not data
src-tauri/src/commands/carpetas.rs la_carpeta_de_una_ruta

Where to start when extending

  • New command or job: IPC and Error Handling.
  • New shared resource: add a field to AppState and initialise it in AppState::open; keep tests constructing it through Paths on temp directories.
  • New plugin: add the crate, .plugin(...) in lib.rs, a permission in capabilities/default.json only if the WebView uses it, and update the CSP only if it needs new origins (avoid; prefer a Rust command).

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