Skip to content

Capa de aplicación Rust

Pablo Martínez López edited this page Nov 10, 2026 · 16 revisions

Capa de aplicación Rust

English · Español

El crate de la app (proxy-builder, librería proxy_builder_lib, en src-tauri/) conecta Tauri con el crate puro proxy-core: construye la ventana y los plugins, es dueño de AppState (base de datos, almacén de blobs, clientes HTTP, trabajos), sirve el protocolo de imagen pbimg:// y registra todos los comandos. Esta página cubre la secuencia de arranque, el estado compartido, el protocolo y la caché de renders, la configuración de Tauri (ventana, CSP, capabilities, plugins), la capa que hace que el mismo crate funcione en Android y un mapa de módulos del crate.

Puntos de entrada

Fichero Función
src-tauri/src/main.rs Seis líneas: fn main() { proxy_builder_lib::run() }. En las compilaciones release de Windows, #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] suprime la consola.
src-tauri/src/lib.rs pub fn run() (marcada con #[cfg_attr(mobile, tauri::mobile_entry_point)]): builder, plugins, protocolo, setup, generate_handler!. También declara todos los módulos y #![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used))].
src-tauri/build.rs tauri_build::build().
src-tauri/Cargo.toml Tipos de crate de la librería staticlib, cdylib, rlib; el nombre de la librería lleva el sufijo _lib para evitar un choque de nombres en Windows con el binario. Miembros del workspace: la app y crates/proxy-core. Ajustes del perfil de desarrollo (véase más abajo).

lib.rs es pequeño a propósito: solo arranca y registra. Si crece, mueve código a un módulo bajo commands/ o a otro sitio.

Secuencia de arranque

sequenceDiagram
    participant OS
    participant RUN as lib.rs run()
    participant TAU as Runtime de Tauri
    participant ST as closure de setup
    participant WV as WebView (ventana main, oculta)
    participant FE as main.tsx

    OS->>RUN: arranca proxy_builder_lib::run
    RUN->>TAU: Builder: plugins opener, dialog, updater, window-state
    RUN->>TAU: manage(Actualizador)
    RUN->>TAU: on_page_load(muestra la ventana al terminar, 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 + migraciones, rellenar_refs, BlobStore, Recursos, Scryfall, Externo
    ST->>TAU: manage(AppState)
    ST->>ST: mesa::historial::instalar (guarda las partidas terminadas en el historial y copia las imágenes de su repetición)
    ST->>ST: lanza una tarea: original::completar (marcos Original que falten)
    ST->>ST: lanza un hilo: tras 3 s muestra la ventana si sigue oculta
    TAU->>WV: crea la ventana (visible false, fondo 1d1b19)
    WV->>FE: carga index.html y JS
    FE->>FE: prepararTemasInstalados, ponerTemaAntesDeMontar
    FE->>WV: pinta el árbol de React
    FE->>TAU: getCurrentWindow().show()
    TAU-->>WV: ventana mostrada (fondo oscuro, tema ya aplicado)
    Note over RUN,TAU: Rust también la muestra en PageLoadEvent::Finished
Loading

Detalles y motivos:

  1. La ventana arranca oculta y oscura. tauri.conf.json fija "visible": false y "backgroundColor": "#1d1b19". Así se evita un destello blanco mientras carga el WebView.
  2. El plugin window-state no debe hacerla visible. tauri_plugin_window_state::Builder se configura con StateFlags::all() & !StateFlags::VISIBLE, de modo que un flag «visible» guardado no pueda mostrar la ventana antes de tiempo. La misma máscara la usa actualizar.rs → guardar_ventana antes de reiniciar para actualizar. El plugin solo se añade con #[cfg(desktop)] y es una dependencia solo de escritorio en Cargo.toml (Android no tiene ventana que recordar).
  3. Tres formas independientes de mostrar la ventana, para que nunca pueda quedarse oculta: on_page_load (Rust la muestra en PageLoadEvent::Finished), ensenarVentana() al final de src/main.tsx (llama a getCurrentWindow().show(), permitido por la capability core:window:allow-show) y un hilo de reserva en setup que la muestra a los 3 s si is_visible() es falso. Por qué no esperar al primer fotograma pintado: WebKitGTK no da fotogramas de requestAnimationFrame a una ventana oculta, lo que antes retrasaba la ventana hasta el respaldo de 3 s. El tema se aplica a <html> antes de que React monte (ponerTemaAntesDeMontar), así que mostrarla pronto no produce destellos.
  4. Los fallos de setup abortan la app. Los errores de AppState::open se convierten a String y se devuelven desde setup; run() entonces imprime Tapete no ha podido arrancar: … por stderr y sale con estado 1 (no hay ventana donde mostrar un error).
  5. Orden de los plugins: en escritorio va primero single-instance (el plugin lo exige, y no con PB_PERFIL); luego opener, dialog, fs (usado solo desde Rust, para leer y escribir un content://) y updater; .manage(actualizar::Actualizador::default()) registra el estado propio del actualizador; en móvil siguen android-extra y barcode-scanner; deep-link va el último. La ventana principal no la crea Tauri desde la configuración: tauri.conf.json la tiene con create: false y crear_ventana de lib.rs la construye con esa configuración en setup, para que un perfil de PB_PERFIL pueda darle su propia carpeta de datos del WebView y su título.
  6. Cuando el shell está montado, src/features/shell/use-deck-inicial.ts arranca el autoguardado (iniciarAutoguardado, 800 ms) y el store del plan (iniciarPlan, 100 ms), y después abrirDeckInicial abre el último mazo del ajuste last_deck_id o crea uno.

El registro se inicializa en setup con registro::iniciar(&app_log_dir): añade a proxy-builder.log, con tracing_subscriber::fmt hacia ese fichero (sin ANSI, sin target, nivel INFO, try_init para que un segundo inicio sea inofensivo). Su propósito declarado es demostrar que un mazo guardado no descarga nada: cada petición a Scryfall se registra como una línea con método, host, ruta y estado (scryfall/client.rs). Los fallos al crear el directorio o el fichero se ignoran en silencio. Otros módulos registran con tracing::info!/warn! (actualizador, temas, símbolos, modelos de OCR, fuentes externas).

AppState

src-tauri/src/state.rs. Se registra con app.manage(...), se inyecta en los comandos como State<'_, AppState> y se obtiene desde el AppHandle en las tareas lanzadas (app.state::<AppState>()).

Campo Tipo Significado
db store::db::Db Conexión SQLite tras un Mutex; WAL, foreign_keys=ON, synchronous=NORMAL; migraciones aplicadas al abrir (MIGRACIONES 1–9; solo se añaden, nunca se edita una ya publicada). Los trabajos largos sueltan el lock entre pasos.
blobs store::blobs::BlobStore Almacén de imágenes direccionado por contenido bajo paths.data/blobs.
paths Paths { data, cache } app_data_dir (biblioteca, imágenes, recursos) y app_cache_dir (derivados borrables).
scryfall Arc<Scryfall> Cliente con límite de frecuencia y reintentos para api.scryfall.com y las descargas de imágenes.
externo Arc<fuentes::Externo> Cliente con lista permitida para todo sitio que no sea Scryfall.
jobs jobs::Jobs Flags de cancelación de los trabajos en marcha.
miniaturas Mutex<HashMap<String,String>> Miniaturas de resultados de búsqueda: SHA-256 de la URL a la URL. Lo rellenan las búsquedas (commands/cards.rs → registrar_miniatura, resumir; se vacía al pasar de 5000 entradas) y lo lee la ruta /t/.
diseno diseno::recursos::Recursos Letras, packs de marcos y símbolos del diseñador, cargados al arrancar desde paths.data.
vistas Mutex<diseno::vista::Vistas> Diseños registrados para la vista previa en vivo (/d/), con tope de los 64 más recientes.
imagenes CacheImagenes Ilustraciones decodificadas para pintar diseños.
detalles biblioteca::CacheDetalles Detalles de la biblioteca por mazo, en memoria con clave updated_at.
renders tokio::sync::Semaphore Limita los renders de miniaturas simultáneos a available_parallelism() (4 si falla).
mesa Mutex<Option<mesa::sesion::Sesion>> La sesión de la mesa de juego, si hay una partida abierta (Mesa de juego).
red red::Red La red: la clave de la instalación, el nodo de iroh (se levanta al hacer falta), y el papel P2P (Sala) (Juego en línea).
mirada Mutex<Option<red::mirada::Mirada>> Lo que ve un espectador (sin sesión, solo la vista y sus parches).
pasar pasar::Pasar Lo que está en marcha al pasar decks entre equipos: la búsqueda de equipos cerca, el lado que recibe y el que envía (Pasar decks).
amigos amigos::Amigos Amigos: el cliente HTTP del servidor de Tapete (se crea al primer uso), el último estado enseñado a la pantalla, los amigos tal como los describió el servidor la última vez (las claves para cifrarles), si hay un chat abierto o la app está en segundo plano, y el cerrojo de una vuelta a la vez del bucle de sincronización (Amigos y mensajes).
trabajos (privado) AtomicUsize Número de trabajos largos en marcha; véanse trabajo() / ocupado(). ocupado() es verdadero mientras corre un trabajo o hay una partida abierta (hay_partida()), así no corren ni la limpieza de blobs ni una actualización (que reinicia la app).

AppState::open(paths) crea ambos directorios, abre library.db, ejecuta una vez store::decks::rellenar_refs (rellena blob_refs para que la GC proteja las imágenes diseñadas), construye el almacén de blobs y los recursos del diseñador, y los dos clientes HTTP. Los tests construyen un AppState sobre directorios de tempfile (véanse los ayudantes estado() en render.rs, commands/cards.rs, diseno/*.rs).

Notas de concurrencia: db.conn() devuelve un MutexGuard o Internal si está envenenado; mantenlo poco tiempo. trabajo() devuelve una guarda cuyo Drop decrementa el contador, de modo que los retornos tempranos y el desenrollado por pánico la liberan.

El protocolo de imagen pbimg://

Se registra con register_asynchronous_uri_scheme_protocol(protocol::ESQUEMA, protocol::manejador). El manejador copia el AppHandle, lanza una tarea asíncrona por petición y responde mediante UriSchemeResponder. Solo se usa la ruta de la URL (peticion.uri().path()).

En el lado del WebView la base es convertFileSrc("", "pbimg"): pbimg://localhost/ en Linux/macOS y http://pbimg.localhost/ en Windows (src/lib/image-url.ts → raiz()), por lo que la CSP lista ambas.

Formas de URL

protocol.rs → leer_ruta convierte la ruta en Ruta (y no se acepta nada más):

Ruta Ruta Devuelve Producido por (src/lib/image-url.ts)
/r/<sha256>/<params>/<width> Render { blob, params, ancho } Miniatura JPEG de un blob guardado, procesada con las ediciones del mazo urlMiniatura(blob, PreviewParams, ancho)
/t/<sha256 of URL> Miniatura { huella } Imagen de un resultado de búsqueda (PNG o JPEG, detectado por los bytes mágicos) urlBusqueda(huella)
/d/<huella>/<width> Diseno { huella, ancho } Vista previa PNG de un diseño registrado con design_register urlDiseno
/c/<huella>/<width> Capa { huella, ancho } PNG de la carta sin su ilustración (design_overlay) urlCapa
/b/<sha256>/<side> Arte { blob, lado } Ilustración del almacén de blobs, reducida para que su lado largo sea de 1000 o 2000 px (JPEG, o PNG si tiene transparencia) urlArte(blob, 1000 | 2000)
/x/<pack>/<n> Extra { pack, indice } Imagen PNG extra de un pack de marcos descargable urlExtra(pack, n)
/f/<sha256>.woff2 Letra { huella } Fichero de letra de un tema de tweakcn instalado raizProtocolo() usado por src/lib/temas-instalados.ts
/m/<sha256>/<ancho> Mesa { imagen: ImagenMesa::Propia, ancho } JPEG de una cara de la mesa de juego preparada por la app (o de un tapete) urlMesa({ tipo: "propia" }, ancho)
/m/s/<id de Scryfall>/<cara>/<ancho> Mesa { imagen: ImagenMesa::Scryfall, ancho } JPEG de una impresión de Scryfall sin retocar usada en la mesa urlMesa({ tipo: "scryfall" }, ancho)

Validación, aplicada y probada con tests unitarios (los tests de protocol.rs las_rutas_raras_se_rechazan y otros):

  • <sha256> / <huella> deben pasar store::blobs::es_huella (exactamente 64 caracteres hexadecimales en minúscula), lo que además bloquea el path traversal.
  • <width> debe ser uno de ANCHOS = [146, 244, 488, 672, 745, 1000] (reflejado por ANCHOS en image-url.ts); <side> uno de LADOS_ARTE = [1000, 2000] (diseno/vista.rs).
  • <params> es el base64url (sin relleno) del JSON de PreviewParams (render.rs): edits, edge (oscurecido de bordes), back, bleedMm, bleedMode, fillCorners, card (CardSize) y el sello opcional (DesignLang). Máximo 2048 caracteres (MAX_PARAMS); debe decodificarse y deserializarse.
  • <pack> son 1–40 caracteres de [A-Za-z0-9_-]; <n> un entero no negativo.
  • Los anchos de /m/ tienen que ser de ANCHOS_MESA (los seis de ANCHOS más 1920, para el tapete); <id de Scryfall> tiene que pasar mesa::imagenes::es_id_scryfall (36 caracteres, dígitos hexadecimales y guiones) y <cara> es 0 o 1 (test las_rutas_de_la_mesa). Los ficheros salen de app_cache_dir/mesa/; con 745 o más se sirven tal cual y los anchos menores se reducen una vez en renders/m/ (mesa/imagenes.rs::servir). Véase Mesa de juego.
  • Códigos de estado: una forma reconocida pero no parseable es 400, una forma desconocida 404, un blob/diseño que falta 404, otro fallo de renderizado 500, una descarga remota fallida para /t/ 502.
  • Toda respuesta pone X-Content-Type-Options: nosniff y Access-Control-Allow-Origin: *; las correctas añaden Cache-Control: max-age=31536000, immutable, porque la URL contiene todo lo que cambia la imagen.

Por qué URLs en lugar de bytes por IPC: ningún byte de imagen cruza el IPC y WebKit cachea por URL. La URL lleva todos los parámetros de renderizado, así que cambiar una edición o un ajuste simplemente produce una URL nueva.

Concurrencia

Las rutas /r/, /d/, /c/, /b/ primero hacen await estado.renders.acquire() (semáforo dimensionado al número de núcleos) y luego ejecutan el trabajo de CPU en tauri::async_runtime::spawn_blocking. /x/ y /f/ son lecturas pequeñas de fichero y se saltan el semáforo. /m/ corre en spawn_blocking sin el semáforo: las imágenes de la mesa se preparan antes, así una petición es leer un fichero o, una vez por ancho, reducirlo. Como el frontend solo monta <img> para las páginas visibles (useVisible, véase Vista previa en vivo), la cola del semáforo se mantiene corta.

La ruta /t/

  1. Busca app_cache_dir/renders/t/<hash>.jpg; lo sirve si existe.
  2. Si no, busca el hash en AppState.miniaturas; un hash desconocido es 404.
  3. Descarga: las URLs que empiezan por https://cards.scryfall.io/ usan scryfall.download; cualquier otra pasa por externo.bajar(url, MAX_IMAGEN) y fuentes::limpiar_imagen (decodificar y recodificar), de modo que solo se obtienen fuentes de la lista permitida.
  4. Escribe el fichero de forma atómica en la caché y lo devuelve.

Así, el WebView nunca contacta con ningún host de imágenes; la CSP de img-src solo permite 'self' pbimg: http://pbimg.localhost data: blob:.

render.rs: miniaturas y caché

src-tauri/src/render.rs convierte un blob más unos ajustes en píxeles, tanto para la pantalla como para el PDF.

Elemento Comportamiento
PreviewParams (#[derive(TS)]) Lo que la pantalla pone en la URL. Rust decide si el oscurecido de bordes se aplica a esta imagen a partir de su origen y de si es un reverso (proxy_core::imaging::pipeline::edge_for), la misma regla que al exportar. sello (opcional, #[ts(optional)]): pintar el sello de Tapete en este idioma; solo lo manda la vista previa de las hojas (Slot.sello), nunca el editor, las listas ni la mesa (El sello de las cartas).
spec_de_miniatura(blob, img, params, ancho) RenderSpec con target: SpecTarget::Width(ancho), calidad JPEG 85, sello de los parámetros.
spec_de_casilla(slot, img, edits, settings) RenderSpec para una casilla del PagePlan a resolución de impresión: el tamaño de carta es el recorte de la casilla (así funcionan las cartas grandes), bleed_mm es el bleed_full_mm de la casilla, SpecTarget::Dpi(quality.dpi()), codificación según los ajustes de impresión; las ediciones solo se aplican si slot.apply_edits; sello: None (en el PDF el sello es texto vectorial encima, así que el JPEG puede pasar tal cual).
AppState::carpeta_renders() paths.cache/renders.
AppState::leer_blob(hash) Lee el fichero localizado por el almacén de blobs, sondea el tamaño, lee origin de la tabla blobs; NotFound (parámetro blob) si falta.
AppState::miniatura(blob, params, ancho) Fichero de caché renders/<RenderSpec::key()>.jpg; si no está, renderiza y luego lo escribir_atomico; los errores al escribir la caché se ignoran.
render_para_pdf(state, spec) Renderiza para imprimir sin caché en disco («pesa demasiado»).
From<ImagingError> for AppError Véase IPC y manejo de errores.

RenderSpec::key() es el SHA-256 de (PIPELINE_VERSION, spec) serializado como JSON (proxy-core/src/imaging/pipeline.rs, PIPELINE_VERSION = 5). Cualquier cambio en el spec o un aumento de la versión de la canalización da un nombre de fichero distinto, lo que invalida los ficheros de caché antiguos sin borrarlos (los elimina cache_clear). Las claves de la caché PNG del diseñador incluyen su propia versión del motor (proxy_core::diseno::VERSION_MOTOR).

Organización de la caché bajo app_cache_dir/renders/:

Ruta Contenido
<spec key>.jpg Miniaturas de blobs
t/<url hash>.jpg Imágenes de resultados de búsqueda (pueden ser bytes PNG pese a la extensión; el protocolo detecta el tipo)
d/<huella>-<width>.png Vistas previas en vivo de diseños
c/<huella>-<width>.png, c/<huella>.json Capa del diseño y la información de su ventana de ilustración
b/<blob>-<side>.jpg o .png Ilustración reducida

cache_stats y cache_clear (commands/library.rs) miden y hacen remove_dir_all exactamente de esta carpeta. Regla: aquí solo viven derivados; las imágenes de los mazos nunca están en este árbol. Un test unitario de commands/library.rs comprueba que la carpeta de renders está bajo paths.cache y no bajo paths.data.

El escritor atómico genérico es store/blobs.rs → escribir_atomico(destino, bytes): crea los directorios padre, escribe <name>.<ext>.part, hace fsync y renombra. Un fallo nunca deja un fichero a medio escribir con su nombre definitivo. Los PDF, los ficheros de paquete, las cachés y los blobs lo usan todos.

Configuración de Tauri

src-tauri/tauri.conf.json

Clave Valor / significado
productName, identifier, version Tapete (el nombre visible; Proxy Builder antes de la 4.0.0), un identificador de paquete en DNS inverso que nunca cambia (léelo en el fichero; de él salen las carpetas de datos, el id de Android y las claves del registro de Windows, véase Versiones y actualizaciones automáticas), la versión actual (se mantiene a la par con Cargo.toml y package.json; véase Versiones y actualizaciones automáticas)
build beforeDevCommand: pnpm dev, devUrl: http://localhost:1520 (tiene que coincidir con el puerto de Vite de vite.config.ts), beforeBuildCommand: pnpm build, frontendDist: ../dist
app.windows[0] label main, 1440x900, mínimo 960x640, visible: false, backgroundColor: #1d1b19, create: false (la construye crear_ventana)
tauri.android.conf.json Un parche de fusión JSON que solo se aplica en las compilaciones de Android. Un parche de fusión reemplaza las listas enteras, así que repite app.windows completo (la misma ventana sin el tamaño mínimo de escritorio) y pone bundle.android.minSdkVersion a 26.
plugins.deep-link desktop.schemes y, para móviles, mobile con los esquemas proxybuilder y tapete (appLink: false: esquemas propios, sin enlaces web verificados; los dos tienen que coincidir con enlace::ESQUEMAS, lo comprueba un test). En Android el intent-filter correspondiente está en el 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 es null (el HMR de Vite funciona en desarrollo).
bundle targets: all, createUpdaterArtifacts: true, categoría Utility, shortDescription; el paquete Debian tiene depends: [] (aquí no se fuerza WebKitGTK como dependencia) y conflicts/replaces/provides: proxy-builder; el RPM, conflicts/obsoletes: proxy-builder; windows.nsis.installerHooks: ./windows/hooks.nsh. Estos tres últimos reemplazan una instalación hecha con el nombre viejo (véase Versiones y actualizaciones automáticas)
plugins.updater Clave pública de Minisign, dos endpoints en orden, https://github.com/PoolFlamingo/tapete/releases/latest/download/latest.json y después https://github.com/PoolFlamingo/proxy-builder/releases/latest/download/latest.json (véase Versiones y actualizaciones automáticas), requireSignedVersion: true, installMode: passive en Windows

Consecuencias de la CSP: el WebView no puede hacer fetch de nada fuera del IPC, no puede cargar scripts ni imágenes remotos y no puede enviar formularios. 'wasm-unsafe-eval' deja al WebView compilar WebAssembly (la física de los dados 3D, véase Mesa de juego); no permite eval de JavaScript. style-src 'unsafe-inline' hace falta por los estilos en línea de React y el <style> inyectado para los temas instalados. Las letras de los temas instalados vienen de pbimg://…/f/…. El test la_configuracion_del_actualizador_es_valida de src-tauri/src/actualizar.rs lee tauri.conf.json y falla si cambian la firma del actualizador, la forma del endpoint o la cabecera de la clave pública.

src-tauri/capabilities/

Tres ficheros, todos para la ventana main. default.json (todas las plataformas) concede únicamente: 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) añade window-state:default: salió de default.json porque ese permiso no existe en Android, donde el plugin no está, y la compilación valida cada identificador. movil.json (android, iOS) añade barcode-scanner:default, para la cámara que lee el QR de «Recibir un deck» (src/lib/escaner.ts, la única entrada a ese plugin). La descripción enuncia la regla: ningún permiso fs; todo el acceso a disco pasa por comandos de Rust. core:window:allow-show deja que main.tsx muestre la ventana; core:window:allow-destroy deja que la guarda de cierre (ConfirmarCierre, Ajustes y shell) la cierre: con un escuchante de JavaScript en onCloseRequested, Tauri deja de cerrar la ventana por su cuenta y lo hace destroy(), y ese permiso no está incluido en core:default ni en core:window:default, así que sin la entrada explícita la ventana no se podría cerrar (la compilación valida el identificador). core:window:allow-set-fullscreen es para F11 y los botones de pantalla completa (src/lib/ventana.ts, Ajustes y shell); core:window:allow-minimize y core:window:allow-close son para los controles de la ventana que la barra de arriba enseña a pantalla completa (close() pasa por la guarda de cierre, no por destroy()). El plugin del actualizador no tiene entrada de capability porque solo se usa desde Rust (actualizar.rs, commands/actualizar.rs). Los comandos propios registrados con generate_handler! no están limitados por una lista de permisos por comando en esta configuración (la app no define un conjunto de permisos propio; Deducido de que el fichero de capabilities solo contiene permisos de plugins y del core).

Plugins

Plugin (crate) Usado desde Para
tauri-plugin-dialog JS (src/lib/dialogos.ts) Diálogos de abrir/guardar/carpeta (en Android devuelven un content://, véase IPC y manejo de errores)
tauri-plugin-fs Solo Rust (fichero.rs) Abre un content:// y da un File de verdad; al WebView no se le concede ningún permiso fs:
tauri-plugin-android-extra (propio, plugins/android-extra/, solo móvil) Solo Rust (android.rs) Compartir, instalar un APK, pantalla encendida y completa, bloqueo de multicast de la Wi-Fi, nombre de un content://, ficheros abiertos desde otra app (véase abajo)
tauri-plugin-barcode-scanner (móvil) JS (src/lib/escaner.ts) La cámara que lee un QR
tauri-plugin-opener Rust (OpenerExt en commands/export.rs, commands/temas.rs) Abrir ficheros exportados, mostrarlos en su carpeta, abrir la página de la comunidad de tweakcn
tauri-plugin-updater Solo Rust (actualizar.rs) Comprobar, descargar, verificar la firma, instalar
tauri-plugin-deep-link Solo Rust (enlace.rs, montado en lib.rs) Enlaces de invitación proxybuilder:// y tapete://, también en Android (sección mobile de la configuración); sin entrada en las capacidades porque la pantalla nunca lo llama
tauri-plugin-single-instance (escritorio, feature deep-link) Solo Rust (lib.rs), no con PB_PERFIL Un segundo arranque pasa sus argumentos (el enlace) a la app abierta
tauri-plugin-window-state (escritorio) Plugin + save_window_state de Rust Recordar tamaño, posición, maximizada; nunca la visibilidad

Otras dependencias de plataforma: gtk en Linux y arboard en el resto, ambas para leer la imagen del portapapeles desde Rust (diseno/portapapeles.rs; WebKitGTK rechaza navigator.clipboard.read() en el webview).

Notas sobre los perfiles de Cargo (src-tauri/Cargo.toml)

  • [profile.dev] debug = "line-tables-only", split-debuginfo = "packed", dependencias con debug = false: mantiene target/ pequeño.
  • [profile.dev.package."*"] opt-level = 2: las dependencias (image, resize, PDF) se compilan optimizadas incluso en desarrollo, porque el procesado de imagen sin optimizar vuelve inutilizable la vista previa bajo pnpm tauri dev.
  • reqwest usa rustls con default-features = false; el CI falla si aparece OpenSSL en cargo tree.
  • Dependencias según el destino: single-instance y window-state solo fuera de Android e iOS; gtk en Linux y arboard en Windows y macOS (imagen del portapapeles: un móvil no la tiene); webpki-root-certs, webpki-roots y barcode-scanner solo en móvil.
  • actualizar-local es la única feature de Cargo, no hay ninguna por defecto y nunca se activa en el CI ni en una release (un test de actualizar.rs lee Cargo.toml, los dos workflows y package.json para asegurarlo): véase Versiones y actualizaciones automáticas.
  • Dependencias de la mesa de juego: getrandom (la semilla de cada partida, de la entropía del sistema), regex (el «+N/+N» fijo de auras y equipos) y flate2 con el motor zlib-rs (la partida guardada; el mismo zlib-rs que ya trae zip).

Mapa de módulos del crate de la app

Módulo Responsabilidad Página con más detalle
commands/* Comandos de Tauri, un fichero por área IPC y manejo de errores
error.rs AppError, ErrorCode, implementaciones de From IPC y manejo de errores
state.rs, jobs.rs, registro.rs Estado compartido, flags de trabajos, fichero de registro esta página
protocol.rs, render.rs pbimg:// y renders esta página, Vista previa en vivo
store/ (db, blobs, decks, presets, settings, colecciones, etiquetas, listas, partidas, linaje, migrations/) Biblioteca SQLite y almacén de blobs Almacenamiento y blobs
package.rs Paquetes ZIP .pbdeck Formato del paquete de mazo
optimizar.rs Optimizar las imágenes de los decks en el propio equipo Almacenamiento y blobs
biblioteca.rs Detalles por mazo para la biblioteca (colores, clase, precio, idiomas), en caché por updated_at Biblioteca de mazos
scryfall/ (client, rate, cache, models, resolve, busquedas, fichas, fichas_auto, nombres) Acceso a Scryfall, catálogo de nombres, resolución de listas Integración con Scryfall
archidekt.rs Importación desde una URL de Archidekt (ids exactos de Scryfall) Integración con Scryfall
fuentes/ (cockatrice, mtgio, mtgjson, mod.rs con Externo) Fuentes que no son Scryfall tras una lista permitida Fuentes externas
ocr.rs, originales.rs OCR opcional y detección del nombre original Identificación de cartas y OCR
diseno/ Parte de la app del diseñador de cartas (borradores, renderizado con recursos, vista previa en vivo, ilustración, exportación a PNG, lote, descarga de packs y letras, portapapeles) Capa de aplicación del diseñador
temas.rs Temas y letras de tweakcn Temas y skins
iconos.rs Iconos de Iconify del selector de iconos (catálogo en caché en app_cache_dir/iconos/, elegidos en app_data_dir/iconos/elegidos.json, limpieza del SVG) Iconos
actualizar.rs Envoltorio del actualizador (instaladores de escritorio y el APK de Android) Versiones y actualizaciones automáticas
tls.rs El TLS de cada plataforma, el único sitio que crea un cliente HTTP o un verificador esta página, «Android y la capa móvil»
ciclo.rs Ciclo de vida de Android: guardar al irse, reconectar al volver esta página
android.rs y plugins/android-extra/ El contrato con el plugin de Kotlin: compartir, instalar un APK, pantalla, multicast, nombres, ficheros abiertos desde fuera esta página
fichero.rs, abrir.rs, commands/fichero.rs Ficheros elegidos como ruta o content://; abrir un .pbdeck desde otra app; compartir IPC y manejo de errores, Exportación
pasar/ (encontrar, recibir, enviar, imagenes, protocolo, tipos), store/linaje.rs y commands/pasar.rs Pasar decks entre equipos por el nodo de iroh compartido; la tabla del linaje de los decks Pasar decks
enlace.rs Enlaces: acepta tapete:// y proxybuilder:// con unirse/<código PB1> o amigo/<código TAP>; monta los enlaces de la web que se comparten (enlace_invitacion → /join/<código> sin IP, enlace_amigo → /friend/<código>); enlace_pendiente, evento enlace://entrante Juego en línea, Amigos y mensajes
amigos/ (firma, cifrado, cliente, sincronizar, avisar), store/amigos.rs y commands/amigos.rs Amigos y mensajes: peticiones firmadas al servidor de Tapete, la clave de cifrado derivada de la identidad, mensajes cifrados de extremo a extremo, el bucle de sincronización, los avisos Amigos y mensajes
mesa/ (sesion, preparar, imagenes, guardada, tapete, ejemplo) La sesión de la mesa de juego alrededor del crate mesa, la preparación de decks e imágenes, la partida guardada y el tapete Mesa de juego
red/ (perfil, identidad, nodo, protocolo, codigo, anfitrion, invitado, mirada, imagenes, latencia, mesa, colores, sucesion, vuelta) y commands/red.rs El juego en red: perfiles de debug, identidad, nodo de iroh, protocolo de la partida, código directo, host e invitado, espectadores, imágenes por la red, el traspaso del anfitrión sin servidor, volver a una partida Juego en línea

Android y la capa móvil

El mismo crate compila para Android (pnpm tauri android build, proyecto en src-tauri/gen/android/, que está versionado: minSdk 26, paquete com.estigio.proxybuilder, intent-filter para proxybuilder://, tapete:// y los ficheros .pbdeck, un FileProvider sobre la caché). Hay un solo crate de la app y un solo código Rust; lo que cambia va tras cfg(mobile) / cfg(target_os = "android") y en los ficheros de abajo. El comportamiento de escritorio no debe cambiar (el ciclo de vida, el TLS y los ganchos de Kotlin no hacen nada allí). Véase Móvil para el conjunto, incluido el lado de la pantalla.

flowchart LR
    S["pantalla (React)"] -- "call()" --> C["commands/*"]
    C --> F["fichero.rs: ruta o content://"]
    C --> A["android.rs"]
    A -- "register_android_plugin" --> K["plugins/android-extra (Kotlin)"]
    K -- "Channel: fichero abierto desde fuera" --> AB["abrir.rs"]
    W["WindowEvent::Suspended / Resumed"] --> CI["ciclo.rs"]
    CI -- "ciclo://pausa, ciclo://vuelta" --> S
    CI --> M["guardar la partida / network_change()"]
    T["tls.rs"] --> H["clientes HTTP (actualizador, servidor de amigos…), iroh"]
Loading

tls.rs: un solo sitio para el TLS

Hay dos consumidores de TLS: los clientes HTTP (reqwest: Scryfall, el actualizador, el servidor de amigos de Tapete…) y el nodo de iroh. src-tauri/src/tls.rs da a cada uno su configuración (reqwest(builder), iroh()), y un test (nadie_crea_un_cliente_ni_un_verificador_fuera_de_tls_rs) falla si cualquier otro fichero construye a mano un cliente o un verificador.

  • Escritorio: el verificador del sistema (rustls-platform-verifier, CaTlsConfig::system()): los certificados en los que el sistema ya confía, con su revocación.
  • Android (e iOS, hasta que se pruebe allí): los certificados raíz de Mozilla incrustados en la app (webpki-root-certs para reqwest, webpki-roots para rustls, CaTlsConfig::embedded() para iroh).

Por qué: el verificador de la plataforma entra en pánico en Android si no se inicializa con su propia parte de Kotlin, y inicializarlo exige un contexto JNI al arrancar. Las raíces incrustadas no necesitan ni Kotlin ni JNI en el arranque. El precio es que una autoridad que alguien haya instalado a mano en el teléfono no es de fiar, cosa que esta app no necesita (solo habla con Scryfall, GitHub, las fuentes de Fuentes externas, el servidor de amigos de Tapete y los relés de iroh). El actualizador recibe su cliente con UpdaterBuilder::configure_client(tls::reqwest).

tls.rs guarda también AGENTE, el único User-Agent de las peticiones HTTP de la app: Tapete/<version> (+https://tapete.zodhub.app). Todo cliente de reqwest lo pone (.user_agent(tls::AGENTE)): Scryfall, commands/cards.rs (Archidekt), fuentes::Externo, los modelos del OCR, las letras libres y las CJK y los marcos Original. La excepción es el cliente del actualizador: configure_client(tls::reqwest) solo le pone el TLS (Deducido: el plugin mantiene su propio user agent). Por qué una sola constante: Scryfall pide a cada cliente un nombre y una forma de contacto, y GitHub, Google Fonts y los demás sitios ven el mismo nombre, así que el nombre y la versión no pueden desfasarse entre clientes. Los tests usan su propio Tapete-test.

ciclo.rs: ir a segundo plano

Un móvil no avisa de que va a congelar o matar el proceso (en Android 14 y posteriores una app en caché se congela unos diez segundos después de salir de la pantalla y se cierran sus sockets TCP), así que guardar no puede esperar a un cierre que no llega. ciclo::evento es el on_window_event de la app:

  • WindowEvent::Suspended: guarda la partida abierta si hay algo sin guardar (commands::mesa::guardar_si_toca) y emite ciclo://pausa; la pantalla (src/lib/ciclo-de-vida.ts) guarda el deck y la carta que se diseña.
  • WindowEvent::Resumed: emite ciclo://vuelta y avisa al nodo de iroh de que la red puede haber cambiado (red.cambio_de_red(), es decir, Endpoint::network_change()), porque Android no avisa al código nativo de los cambios de red. Una partida en red reconecta como tras un corte.
  • Nunca se llama a iroh::dns::install_android_jni_context: tao ya inicializa el contexto de Android y hacerlo dos veces entra en pánico.

android.rs y plugins/android-extra/

plugins/android-extra/ es el plugin de Tauri propio del proyecto (miembro del workspace; su primer Kotlin). AndroidExtraPlugin.kt hace lo que Tauri y sus plugins oficiales no hacen: compartir (ACTION_SEND de un fichero de la caché por el FileProvider, tras el selector del sistema), pantallaEncendida (FLAG_KEEP_SCREEN_ON), pantallaCompleta (esconde las barras del sistema; un gesto desde el borde las enseña un momento), multicast (un MulticastLock de la Wi-Fi: sin él Android tira los paquetes mDNS que encuentran los equipos cerca; se suelta al destruir la actividad; el plugin tiene un solo lock sin contador, así que android::multicast(app, uso, si) cuenta en Rust quién lo usa, UsoMulticast::Buscar y Recibir, y solo lo coge con el primero y lo suelta con el último), instalarApk, nombreDeUri (el DISPLAY_NAME de un content://, que lleva la extensión que la URI puede no tener) y escucharAbiertos (los ficheros que otra app abre con esta, ACTION_VIEW, entregados por un Channel; el que llega antes de que Rust escuche se guarda y se entrega cuando lo hace; el intent de arranque se ignora si lleva FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY, porque volver desde Recientes tras morir el proceso entrega otra vez el intent original y ese fichero ya se importó).

La regla: solo Rust llama al plugin, por src-tauri/src/android.rs, una función por comando. El plugin no declara permisos para el WebView, así que la pantalla no puede alcanzarlo; ella pregunta «¿es un móvil?» con src/lib/plataforma.ts y hace todo lo demás por los comandos de IPC y manejo de errores. Fuera de Android cada función no hace nada y devuelve Ok (pantalla encendida, multicast, escuchar ficheros abiertos, nada que retocar) o devuelve Unsupported (detalle: android) donde no tiene sentido sin Android (compartir, instalar, pantalla completa). Un comando que llama a una función del segundo tipo debe decidir si Unsupported es un fallo; app_pantalla_completa lo toma como «nada que hacer».

instalar_apk tiene un resultado más: si el sistema no deja a la app instalar paquetes (canRequestPackageInstalls() es falso) el plugin abre el ajuste de «instalar apps desconocidas» de esta app y contesta lanzado: false; Rust lo convierte en Unsupported con motivo: instalar_permiso, y la persona vuelve y pulsa Instalar otra vez (Versiones y actualizaciones automáticas).

Ficheros, abrir desde fuera y compartir

fichero.rs (rutas y content://), commands/fichero.rs (fichero_temporal, compartir_fichero, que solo comparte un content:// o un fichero dentro de app_cache_dir y si no falla con InvalidInput, motivo: compartir_fuera, para que la vista web no pueda hacer compartir la base de datos ni la clave del nodo) y abrir.rs (un .pbdeck abierto desde otra app: pbdeck://abierto, pbdeck_pendiente) se describen en IPC y manejo de errores. abrir.rs solo se arranca en Android (abrir::escuchar en setup). Dos intent-filter del manifiesto traen ficheros hasta aquí: uno para URI content/file cuyo nombre acaba en .pbdeck (varios tipos MIME, porque Android da a un .pbdeck cualquiera de zip, x-zip-compressed u octet-stream; cuatro pathPattern con cero a tres puntos más, porque el comparador de Android no reintenta y mi.deck.pbdeck no casaría con uno solo) y un segundo, limitado al proveedor de Descargas, porque las URI que reparte (.../document/msf:1234) no tienen nombre con el que casar. Efecto secundario del segundo: la app se ofrece para cualquier zip o binario abierto desde Descargas, y Rust rechaza lo que no sea un paquete al leerlo (package::import no se fía de su entrada).

Dos configuraciones del mismo crate

Qué Escritorio Android
Estado de la ventana tauri-plugin-window-state (+ capability escritorio) ninguno
Instancia única, registro de proxybuilder:// y tapete:// en ejecución sí lo hacen el sistema y el manifiesto
Actualizador el instalador de la plataforma el APK (abajo, y Versiones y actualizaciones automáticas)
Imagen del portapapeles gtk / arboard ninguna
Diálogos rutas content://
TLS verificador del sistema raíces incrustadas

Deducido: iOS está preparado (Plataforma::Ios, dependencias solo de móvil bajo cfg(any(android, ios))) pero no se ha compilado nada para él; véase Móvil.

Invariantes y trampas conocidas

  • Nunca bloquees el runtime asíncrono con trabajo de CPU. Usa spawn_blocking (como hacen export_pdf, los manejadores del protocolo y en_hilo en commands/diseno.rs).
  • Mantén state.trabajo() en cada trabajo largo o blob_gc y update_install podrían ejecutarse bajo él. Una partida abierta cuenta como ocupado por sí sola (hay_partida()).
  • cache_clear solo debe tocar renders/. Una nueva ubicación de caché para datos críticos del mazo rompería esta garantía; ponlos en app_data_dir y en el almacén de blobs.
  • Añade las rutas nuevas del protocolo con un test en protocol.rs tanto de aceptación como de rechazo (el path traversal, las longitudes de hash incorrectas, el hexadecimal en mayúsculas y los anchos incorrectos ya están cubiertos).
  • No edites migraciones ya publicadas; añade el número siguiente (0010_*.sql) y añádelo a MIGRACIONES en store/db.rs.
  • Almacén de blobs frente a caché: el almacén de blobs está en app_data_dir porque los mazos dependen de él; la GC conserva las imágenes de menos de 24 h (GRACIA_MS) y todo lo referenciado por deck_cards, decks.default_back_blob, blob_refs o el ajuste del reverso genérico.
  • Las compilaciones release ocultan la consola en Windows; usa el fichero de registro en app_log_dir para depurar el comportamiento de red.

Tests que vigilan esta capa

Dónde Qué
src-tauri/src/protocol.rs (mod tests) Las rutas válidas se parsean; se rechazan las malformadas, el traversal, los anchos/lados incorrectos, los nombres de pack incorrectos y .ttf en lugar de .woff2; las rutas de la mesa de juego (las_rutas_de_la_mesa)
src-tauri/src/mesa/ (sesion.rs, guardada.rs, preparar.rs) Los parches dan la vista entera, los rechazos llevan su motivo, reiniciar, una partida abierta deja la app ocupada, guardar y seguir conserva la huella, la segunda preparación no pinta nada
src-tauri/src/render.rs (mod tests) Construcción de RenderSpec a partir de casillas, conversión de errores
src-tauri/src/jobs.rs alta_cancelar_y_baja
src-tauri/src/error.rs Serialización de errores
src-tauri/src/actualizar.rs La configuración del actualizador en tauri.conf.json es válida
src-tauri/src/commands/library.rs La carpeta de la caché está bajo la caché, no bajo los datos
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: ningún fichero salvo tls.rs contiene Client::builder() (si no va envuelto en tls::reqwest(), with_platform_verifier ni CaTlsConfig::
src-tauri/src/fichero.rs, abrir.rs, commands/sistema.rs El manejo de content:// contra un Acceso simulado; solo cuentan como ficheros abiertos los content:// de tamaño razonable y uno pendiente se entrega una sola vez; la pantalla completa sin Android no es un fallo

Por dónde empezar para ampliarlo

  • Comando o trabajo nuevo: IPC y manejo de errores.
  • Recurso compartido nuevo: añade un campo a AppState e inicialízalo en AppState::open; haz que los tests lo construyan mediante Paths sobre directorios temporales.
  • Algo que solo Android puede hacer: una función @Command en plugins/android-extra/android/src/main/java/.../AndroidExtraPlugin.kt, su envoltorio en src-tauri/plugins/android-extra/src/movil.rs y una función en android.rs con su gemela fuera de Android (Unsupported donde no tiene sentido sin Android, Ok donde solo retoca la ventana). Nunca se llama al plugin desde la pantalla.
  • Plugin nuevo: añade el crate, .plugin(...) en lib.rs, un permiso en capabilities/default.json solo si lo usa el WebView, y actualiza la CSP solo si necesita orígenes nuevos (evítalo; es preferible un comando de Rust).

Véase también

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