Skip to content

Rust App Layer

Pablo Martínez López edited this page Nov 5, 2026 · 15 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), the layer that makes the same crate run on Android, 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: 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
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 (Android has no window to remember).
  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: 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 a content://), updater; .manage(actualizar::Actualizador::default()) registers the updater's own state; on mobile android-extra and barcode-scanner follow; deep-link comes last. The main window is not created by Tauri from the configuration: tauri.conf.json has it with create: false and crear_ventana in lib.rs builds it from that config in setup, so that a PB_PERFIL profile can give it its own WebView data folder and title.
  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–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.

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

src-tauri/capabilities/

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

Plugins

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

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.
  • Target-specific dependencies: single-instance and window-state only outside Android and iOS; gtk on Linux and arboard on Windows and macOS (clipboard image: a phone has none); webpki-root-certs, webpki-roots and barcode-scanner only on mobile.
  • actualizar-local is the only Cargo feature, there is no default feature and it is never turned on in CI or in a release (a test in actualizar.rs reads Cargo.toml, both workflows and package.json to 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) 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, 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

Android and the mobile layer

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"]
Loading

tls.rs: one place for TLS

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-certs for reqwest, webpki-roots for rustls, 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).

ciclo.rs: going to the background

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 emits ciclo://pausa; the screen (src/lib/ciclo-de-vida.ts) saves the deck and the card being designed.
  • WindowEvent::Resumed: emits ciclo://vuelta and 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: tao already initialises the Android context, and initialising it twice panics.

android.rs and plugins/android-extra/

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

Files, opening from outside and sharing

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

Two configurations of the same crate

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.

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 the next number (0010_*.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
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

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.
  • Something only Android can do: a @Command function in plugins/android-extra/android/src/main/java/.../AndroidExtraPlugin.kt, its wrapper in src-tauri/plugins/android-extra/src/movil.rs, and a function in android.rs with a non-Android twin (Unsupported where it makes no sense without Android, Ok where it only tweaks the window). Never call the plugin from the screen.
  • 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

Mobile · Móvil

Frontend · Frontend

Reference · Referencia

Clone this wiki locally