Repository navigation
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), the layer that makes the same crate run on Android, and a module map of the crate.
| 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.
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: mesa::historial::instalar (save finished games to the history, then copy their replay images)
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
Details and reasons:
-
The window starts hidden and dark.
tauri.conf.jsonsets"visible": falseand"backgroundColor": "#1d1b19". This avoids a white flash while the WebView loads. -
The window-state plugin must not make it visible.
tauri_plugin_window_state::Builderis configured withStateFlags::all() & !StateFlags::VISIBLE, so a saved "visible" flag cannot show the window early. The same mask is used byactualizar.rs→guardar_ventanabefore an update restart. The plugin is only added#[cfg(desktop)]and is a desktop-only dependency inCargo.toml(Android has no window to remember). -
Three independent ways to show the window, so it can never stay hidden:
on_page_load(Rust shows it atPageLoadEvent::Finished),ensenarVentana()at the end ofsrc/main.tsx(callsgetCurrentWindow().show(), allowed by capabilitycore:window:allow-show), and a fallback thread insetupthat shows it after 3 s ifis_visible()is false. Why not wait for the first painted frame: WebKitGTK gives norequestAnimationFrameframes 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. -
setupfailures abort the app.AppState::openerrors are mapped toStringand returned fromsetup;run()then printsProxy Builder no ha podido arrancar: …to stderr and exits with status 1 (no window to show an error in). -
Plugin order: on desktop, single-instance goes first (the plugin requires it, and not with
PB_PERFIL); then opener, dialog,fs(used only from Rust, to read and write acontent://), updater;.manage(actualizar::Actualizador::default())registers the updater's own state; on mobileandroid-extraandbarcode-scannerfollow; deep-link comes last. The main window is not created by Tauri from the configuration:tauri.conf.jsonhas it withcreate: falseandcrear_ventanainlib.rsbuilds it from that config insetup, so that aPB_PERFILprofile can give it its own WebView data folder and title. -
After the shell mounts,
src/features/shell/use-deck-inicial.tsstarts autosave (iniciarAutoguardado, 800 ms) and the plan store (iniciarPlan, 100 ms), thenabrirDeckInicialopens the last deck from settinglast_deck_idor 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).
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–9; 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). |
pasar |
pasar::Pasar |
What is in progress of moving decks between devices: the search for nearby devices, the receiving side and the sending side (Deck Sharing). |
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.
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.
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 passstore::blobs::es_huella(exactly 64 lowercase hex characters), which also blocks path traversal. -
<width>must be one ofANCHOS = [146, 244, 488, 672, 745, 1000](mirrored byANCHOSinimage-url.ts);<side>one ofLADOS_ARTE = [1000, 2000](diseno/vista.rs). -
<params>is the base64url (no padding) of the JSONPreviewParams(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 ofANCHOS_MESA(the six ofANCHOSplus 1920, for the mat);<scryfall id>must passmesa::imagenes::es_id_scryfall(36 characters, hex digits and dashes) and<face>is 0 or 1 (testlas_rutas_de_la_mesa). Files come fromapp_cache_dir/mesa/; widths of 745 or more are served as stored, smaller ones are reduced once intorenders/m/(mesa/imagenes.rs::servir). See Game Table. - Status codes: unparsable-but-recognised shape is
400, unknown shape404, missing blob/design404, other render failure500, failed remote fetch for/t/502. - Every response sets
X-Content-Type-Options: nosniffandAccess-Control-Allow-Origin: *; successful ones addCache-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.
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.
- Look for
app_cache_dir/renders/t/<hash>.jpg; serve it if present. - Else look the hash up in
AppState.miniaturas; unknown hash is404. - Download: URLs starting with
https://cards.scryfall.io/usescryfall.download; anything else goes throughexterno.bajar(url, MAX_IMAGEN)andfuentes::limpiar_imagen(decode and re-encode), so only allow-listed sources are ever fetched. - 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:.
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.
| 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, create: false (built by crear_ventana) |
tauri.android.conf.json |
A JSON merge patch applied only for Android builds. A merge patch replaces arrays whole, so it restates app.windows entirely (the same window without the desktop minimum size) and sets bundle.android.minSdkVersion to 26. |
plugins.deep-link |
desktop.schemes and, for phones, mobile with the scheme proxybuilder (appLink: false: a custom scheme, no verified web links). On Android the matching intent-filter is in the AndroidManifest.xml. |
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.
Three files, all for window main. default.json (every platform) grants only: core:default, core:window:allow-show, core:window:allow-destroy, core:window:allow-set-fullscreen, core:window:allow-minimize, core:window:allow-close, opener:default, dialog:default. escritorio.json (platforms: linux, macOS, windows) adds window-state:default: it moved out of default.json because the permission does not exist on Android, where the plugin is not there, and the build validates every identifier. movil.json (android, iOS) adds barcode-scanner:default, for the camera that reads the QR of "Receive a deck" (src/lib/escaner.ts, the only entry to that plugin). 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 F11 and the full-screen buttons (src/lib/ventana.ts, Settings and Shell); core:window:allow-minimize and core:window:allow-close are for the window controls the top bar shows in full screen (close() goes through the close guard, not destroy()). 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).
| Plugin (crate) | Used from | For |
|---|---|---|
tauri-plugin-dialog |
JS (src/lib/dialogos.ts) |
Open/save/folder dialogs (on Android they return a content://, see IPC and Error Handling) |
tauri-plugin-fs |
Rust only (fichero.rs) |
Opens a content:// and gives a real File; no fs: permission is granted to the WebView |
tauri-plugin-android-extra (own, plugins/android-extra/, mobile only) |
Rust only (android.rs) |
Share, install an APK, screen on and full screen, Wi-Fi multicast lock, file name of a content://, files opened from another app (see below) |
tauri-plugin-barcode-scanner (mobile) |
JS (src/lib/escaner.ts) |
The camera that reads a QR |
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 |
Rust only (enlace.rs, set up in lib.rs) |
proxybuilder:// invitation links, also on Android (mobile section of the configuration); 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).
-
[profile.dev] debug = "line-tables-only",split-debuginfo = "packed", dependencies withdebug = false: keepstarget/small. -
[profile.dev.package."*"] opt-level = 2: dependencies (image, resize, PDF) compile optimised even in dev because unoptimised image processing makes the preview unusable underpnpm tauri dev. -
reqwestusesrustlswithdefault-features = false; CI fails if OpenSSL appears incargo tree. - Target-specific dependencies: single-instance and window-state only outside Android and iOS;
gtkon Linux andarboardon Windows and macOS (clipboard image: a phone has none);webpki-root-certs,webpki-rootsandbarcode-scanneronly on mobile. -
actualizar-localis the only Cargo feature, there is no default feature and it is never turned on in CI or in a release (a test inactualizar.rsreadsCargo.toml, both workflows andpackage.jsonto be sure): see Releases and Auto Updates. - Game table dependencies:
getrandom(the seed of each game, from system entropy),regex(the fixed «+N/+N» of auras and equipment) andflate2with thezlib-rsbackend (the saved game; the same zlib-rs thatzipalready brings).
| 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, listas, partidas, linaje, migrations/) |
SQLite library and blob store | Storage and Blobs |
package.rs |
.pbdeck ZIP packages |
Deck Package Format |
optimizar.rs |
Optimizing deck images in place | Storage and Blobs |
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 |
iconos.rs |
Iconify icons for the icon picker (catalogue cached in app_cache_dir/iconos/, chosen icons in app_data_dir/iconos/elegidos.json, SVG sanitising) |
Icons |
actualizar.rs |
Updater wrapper (desktop installers and the Android APK) | Releases and Auto Updates |
tls.rs |
The TLS of each platform, the only place that creates an HTTP client or a verifier | this page, "Android and the mobile layer" |
ciclo.rs |
Android lifecycle: save when leaving, reconnect when back | this page |
android.rs and plugins/android-extra/
|
The contract with the Kotlin plugin: share, install an APK, screen, multicast, names, files opened from outside | this page |
fichero.rs, abrir.rs, commands/fichero.rs
|
Chosen files as paths or content://; opening a .pbdeck from another app; sharing |
IPC and Error Handling, Export |
pasar/ (encontrar, recibir, enviar, imagenes, protocolo, tipos), store/linaje.rs and commands/pasar.rs
|
Moving decks between devices over the shared iroh node; the deck lineage table | Deck Sharing |
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 |
The same crate builds for Android (pnpm tauri android build, project in src-tauri/gen/android/, which is versioned: minSdk 26, package com.estigio.proxybuilder, intent-filters for proxybuilder:// and for .pbdeck files, a FileProvider over the cache). There is one app crate and one Rust code base; what differs sits behind cfg(mobile) / cfg(target_os = "android") and in the files below. Desktop behaviour must not change (the lifecycle, TLS and Kotlin hooks are no-ops there). See Mobile for the whole picture, including the screen side.
flowchart LR
S["screen (React)"] -- "call()" --> C["commands/*"]
C --> F["fichero.rs: path or content://"]
C --> A["android.rs"]
A -- "register_android_plugin" --> K["plugins/android-extra (Kotlin)"]
K -- "Channel: file opened from outside" --> AB["abrir.rs"]
W["WindowEvent::Suspended / Resumed"] --> CI["ciclo.rs"]
CI -- "ciclo://pausa, ciclo://vuelta" --> S
CI --> M["save game / network_change()"]
T["tls.rs"] --> H["HTTP clients, updater, rooms WebSocket, iroh"]
There are three consumers of TLS: the HTTP clients (reqwest, the updater among them), the WebSocket of the room server (rustls) and the iroh node. src-tauri/src/tls.rs gives each its configuration (reqwest(builder), rustls(), iroh()), and a test (nadie_crea_un_cliente_ni_un_verificador_fuera_de_tls_rs) fails if any other file builds a client or a verifier by hand.
-
Desktop: the system verifier (
rustls-platform-verifier,CaTlsConfig::system()): the certificates the system already trusts, with their revocation. -
Android (and iOS, until tried there): the Mozilla root certificates embedded in the app (
webpki-root-certsforreqwest,webpki-rootsforrustls,CaTlsConfig::embedded()for iroh).
Why: the platform verifier panics on Android unless it is initialised with its own Kotlin side, and initialising it needs a JNI context at start-up. Embedded roots need neither Kotlin nor JNI at boot. The price is that an authority a user installed by hand on the phone is not trusted, which this app does not need (it only talks to Scryfall, GitHub, the sources of External Sources, the room server and iroh relays). The updater gets its client through UpdaterBuilder::configure_client(tls::reqwest).
A phone does not say it is about to freeze or kill the process (on Android 14 and later a cached app is frozen about ten seconds after it leaves the screen, and its TCP sockets are closed), so saving cannot wait for a close that never comes. ciclo::evento is the on_window_event of the app:
-
WindowEvent::Suspended: saves the open game if anything is unsaved (commands::mesa::guardar_si_toca) and emitsciclo://pausa; the screen (src/lib/ciclo-de-vida.ts) saves the deck and the card being designed. -
WindowEvent::Resumed: emitsciclo://vueltaand tells the iroh node the network may have changed (red.cambio_de_red(), i.e.Endpoint::network_change()), because Android does not tell native code about network changes. An online game reconnects as it would after a cut. - Never call
iroh::dns::install_android_jni_context:taoalready initialises the Android context, and initialising it twice panics.
plugins/android-extra/ is the project's own Tauri plugin (a workspace member; its first Kotlin). AndroidExtraPlugin.kt does what Tauri and its official plugins do not: compartir (ACTION_SEND of a file in the cache through the FileProvider, behind the system chooser), pantallaEncendida (FLAG_KEEP_SCREEN_ON), pantallaCompleta (hides the system bars; a swipe from the edge shows them for a moment), multicast (a Wi-Fi MulticastLock: without it Android drops the mDNS packets that find nearby devices; released when the activity is destroyed; the plugin keeps one lock with no counter, so android::multicast(app, uso, si) counts the users in Rust, UsoMulticast::Buscar and Recibir, and only takes it with the first and releases it with the last), instalarApk, nombreDeUri (the DISPLAY_NAME of a content://, which carries the extension that the URI may not) and escucharAbiertos (files that another app opens with this one, ACTION_VIEW, delivered through a Channel; one that arrives before Rust listens is kept and delivered when it does; the start intent is ignored when it carries FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY, because reopening from Recents after the process died delivers the original intent again and that file was already imported).
The rule: only Rust calls the plugin, through src-tauri/src/android.rs, one function per command. The plugin declares no permissions for the WebView, so the screen cannot reach it; it asks "is this a phone?" with src/lib/plataforma.ts and does everything else through the commands of IPC and Error Handling. Outside Android each function either does nothing and returns Ok (screen on, multicast, listening for opened files, nothing to tweak) or returns Unsupported (detalle: android) where it makes no sense without Android (share, install, full screen). A command that calls a function of the second kind must decide whether Unsupported is a failure; app_pantalla_completa treats it as "nothing to do".
instalar_apk has an extra outcome: if the system does not let the app install packages (canRequestPackageInstalls() is false) the plugin opens the "install unknown apps" setting of this app and answers lanzado: false; Rust turns that into Unsupported with motivo: instalar_permiso, and the person comes back and presses Install again (Releases and Auto Updates).
fichero.rs (paths and content://), commands/fichero.rs (fichero_temporal, compartir_fichero, which only shares a content:// or a file inside app_cache_dir and otherwise fails with InvalidInput, motivo: compartir_fuera, so the webview cannot get the database or the node key shared) and abrir.rs (a .pbdeck opened from another app: pbdeck://abierto, pbdeck_pendiente) are described in IPC and Error Handling. abrir.rs is only started on Android (abrir::escuchar in setup). Two intent-filters in the manifest bring files here: one for content/file URIs whose name ends in .pbdeck (several MIME types, because Android gives a .pbdeck any of zip, x-zip-compressed or octet-stream; four pathPatterns with zero to three extra dots, because Android's matcher does not backtrack and my.deck.pbdeck would not match one), and a second, limited to the Downloads provider, because the URIs it hands out (.../document/msf:1234) have no name to match. Side effect of the second: the app is offered for any zip or binary opened from Downloads, and Rust rejects what is not a package when it reads it (package::import does not trust its input).
| What | Desktop | Android |
|---|---|---|
| Window state |
tauri-plugin-window-state (+ capability escritorio) |
none |
Single instance, proxybuilder:// registration at run time |
yes | the OS and the manifest do it |
| Updater | installer of the platform | the APK (below, and Releases and Auto Updates) |
| Clipboard image |
gtk / arboard
|
none |
| Dialogs | paths | content:// |
| TLS | system verifier | embedded roots |
Inferred: iOS is prepared (Plataforma::Ios, mobile-only dependencies under cfg(any(android, ios))) but nothing has been built for it; see Mobile.
-
Never block the async runtime with CPU work. Use
spawn_blocking(asexport_pdf, the protocol handlers anden_hiloincommands/diseno.rsdo). -
Hold
state.trabajo()for every long job orblob_gcandupdate_installmay run under it. An open game counts as busy by itself (hay_partida()). -
cache_clearmust only touchrenders/. A new cache location for deck-critical data would break this guarantee; put it inapp_data_dirand the blob store instead. -
Add new protocol routes with a test in
protocol.rsfor both acceptance and rejection (traversal, bad hash length, uppercase hex, bad widths are already covered). -
Do not edit shipped migrations; append the next number (
0010_*.sql) and add it toMIGRACIONESinstore/db.rs. -
Blob store versus cache: the blob store is in
app_data_dirbecause decks depend on it; GC keeps images younger than 24 h (GRACIA_MS) and anything referenced bydeck_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_dirto debug network behaviour.
| 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 |
src-tauri/src/tls.rs |
nadie_crea_un_cliente_ni_un_verificador_fuera_de_tls_rs: no file other than tls.rs contains Client::builder() (unless wrapped in tls::reqwest(), with_platform_verifier or CaTlsConfig::
|
src-tauri/src/fichero.rs, abrir.rs, commands/sistema.rs
|
content:// handling against a fake Acceso; only content:// of a sane size count as opened files and a pending one is delivered once; full screen without Android is not a failure |
- New command or job: IPC and Error Handling.
- New shared resource: add a field to
AppStateand initialise it inAppState::open; keep tests constructing it throughPathson temp directories. - Something only Android can do: a
@Commandfunction inplugins/android-extra/android/src/main/java/.../AndroidExtraPlugin.kt, its wrapper insrc-tauri/plugins/android-extra/src/movil.rs, and a function inandroid.rswith a non-Android twin (Unsupportedwhere it makes no sense without Android,Okwhere it only tweaks the window). Never call the plugin from the screen. - New plugin: add the crate,
.plugin(...)inlib.rs, a permission incapabilities/default.jsononly if the WebView uses it, and update the CSP only if it needs new origins (avoid; prefer a Rust command).
Use and build · Uso y desarrollo
- User-Guide · Guía de usuario
- Development-Setup · Preparación del entorno de desarrollo
- Testing-and-CI · Tests y CI
- Releases-and-Auto-Updates · Versiones y actualizaciones automáticas
- Conventions · Convenciones
Architecture · Arquitectura
- Architecture · Arquitectura
- IPC-and-Error-Handling · IPC y manejo de errores
- Rust-App-Layer · Capa de aplicación Rust
Data · Datos
- Deck-Model · Modelo del mazo
- Storage-and-Blobs · Almacenamiento y blobs
- Deck-Package-Format · Formato del paquete de mazo
- Deck-Library · Biblioteca de mazos
Getting cards · Conseguir cartas
- Decklist-Parsing · Parseo de la lista del mazo
- Scryfall-Integration · Integración con Scryfall
- External-Sources · Fuentes externas
- Card-Identification-and-OCR · Identificación de cartas y OCR
Layout, images, PDF · Maquetación, imagen y PDF
- Layout-Engine · Motor de maquetación
- Imaging-and-PDF · Imagen y PDF
- Export · Exportación
- Live-Preview · Vista previa en vivo
- Statistics · Estadísticas
Card designer · Diseñador de cartas
- Designer-Overview · Visión general del diseñador
- Designer-Engine · Motor del diseñador
- Designer-Composition · Composición del diseñador
- Frame-Packs · Packs de marcos
- Designer-App-Layer · Capa de aplicación del diseñador
- Designer-UI · Interfaz del diseñador
- Card-Editor-Dialog · Diálogo Editar carta
Game table · Mesa de juego
Mobile · Móvil
Frontend · Frontend
- Frontend-Architecture · Arquitectura del frontend
- State-Stores · Stores de estado
- Internationalization · Internacionalización
- Themes-and-Skins · Temas y skins
- Icons · Iconos
- Settings-and-Shell · Ajustes y shell
- Tutorials · Tutoriales
- Decklist-Panel · Panel de la lista del mazo
- Add-Card-and-Variants · Añadir carta y variantes
- Card-Search · Búsqueda de cartas
- Set-Browser · Explorador de sets
Reference · Referencia