Skip to content

Pasar decks

Pablo Martínez López edited this page Nov 6, 2026 · 4 revisions

Pasar decks

English · Español

Llevar decks de un ordenador a un móvil, o a un amigo, sin cuenta y sin servidor propio: desde la biblioteca, «Enviar / Recibir» manda uno o varios decks a otra copia de la app, y «Compartir…» entrega un fichero .pbdeck a cualquier otra app. Los dos caminos hacen las mismas dos preguntas: con o sin proxies, y qué hacer si quien recibe ya tiene ese deck.

Código: src-tauri/src/pasar/ (protocolo.rs, encontrar.rs, enviar.rs, recibir.rs, imagenes.rs, tipos.rs, mod.rs), src-tauri/src/commands/pasar.rs, src-tauri/src/package.rs (import_con, export_en_fichero, resumen), src-tauri/src/store/linaje.rs, src-tauri/crates/proxy-core/src/compartir.rs, src-tauri/src/red/codigo.rs y red/nodo.rs, y en la pantalla src/features/pasar/ y src/lib/escaner.ts.

Qué es (y qué no)

  • Entre pares sobre iroh, la misma pieza que usa la Mesa de juego. Quien envía llama a la clave pública de quien recibe; los datos son un .pbdeck (Formato del paquete de mazo) que viaja por un stream QUIC. No hay cuenta, ni subida, ni servidor: nada se guarda en ningún sitio salvo en los dos ordenadores.
  • Rust lo hace todo. Encontrarse, el protocolo, construir y comprobar el paquete, importarlo. La pantalla (features/pasar/) solo pinta el estado que Rust emite (pasar://receptor, pasar://emisor, pasar://cerca) y manda lo que elige la persona; cuentas.ts guarda las reglas puras de qué hay que elegir antes de seguir.
  • El Bluetooth se descartó a propósito. WebKitGTK no tiene Web Bluetooth, las librerías de Bluetooth de Rust van mal en escritorio (y un ordenador tiene que poder ser quien envía), y el Bluetooth es muy lento para varios MB de imágenes. Además iroh ya está en la app. El Bluetooth del propio sistema sigue disponible a mano con «Compartir…» (un fichero que se entrega a otra app).
  • No es una sincronización. Pasar un deck es una copia puntual. «¿Es el mismo deck?» lo responde el linaje, pero no se mezcla nada: quien recibe reemplaza o se queda con los dos.

Cómo se encuentran los dos equipos

flowchart TD
    R["Quien recibe abre «Recibir»<br/>(15 minutos)"] --> C["Código PBD1-… + QR<br/>(dirección con IP locales y relé + secreto de un solo uso)"]
    R --> M["Se anuncia con su apodo por mDNS<br/>(si la red lo permite)"]
    S["Quien envía abre «Enviar»"] --> L["Lista de equipos cerca"]
    S --> P["Pega el código o escanea el QR"]
    M --> L
    L -->|"toca un equipo: sin secreto"| A["Quien recibe compara las 8 cifras<br/>y acepta"]
    P -->|"secreto en el código"| Z["Entra directamente"]
    A --> X["Intercambio"]
    Z --> X
Loading

Hay tres caminos, todos acaban en el mismo protocolo:

Camino Dónde sirve Qué prueba que es el equipo correcto
Equipos cerca (encontrar.rs::Anuncio / Busqueda) La misma red local Dos cosas, las dos necesarias: quien envía trae el pase del anuncio mDNS de quien recibe (Hola.pase, véase abajo) y la persona acepta el código de 8 cifras que enseñan las dos pantallas (encontrar::cifras: sacado del SHA-256 de las dos claves públicas, ordenadas). El código no es un secreto (las claves son públicas): sirve para que dos personas comprueben que han llegado al equipo que querían, como al emparejar por Bluetooth. Son ocho cifras para que un atacante en la misma red no pueda fabricarse una clave que dé el mismo número en el minuto que dura una petición.
QR (qr_svg, un SVG hecho en Rust) La misma red o a distancia El secreto de un solo uso que va en el código. Quien envía lo lee con la cámara (solo móvil, src/lib/escaner.ts, la única puerta al plugin de escanear) o pega el texto (en un ordenador siempre se pega).
Código en texto PBD1-… La misma red o a distancia El mismo secreto, copiado a mano.
  • El código (red/codigo.rs) tiene el formato del código directo de la partida (EndpointAddr + un secreto de 16 bytes, base64url del JSON) con otro prefijo: PBD1- para decks y PB1- para partidas. Por qué dos prefijos: pegar un código de partida donde va uno de deck (o al revés) da un error claro (motivo: codigo_de_partida / codigo_de_deck) y no una conexión rara. La dirección lleva hasta seis IP locales útiles, las más probables primero (encontrar::recortar: sin las de loopback, enlace local o sin especificar; primero IPv4 privadas, luego otras IPv4 y al final IPv6) más la URL del relé; con más IP el QR no se podría leer. Cuando hay relé, abrir espera hasta 5 s a que el nodo esté en línea para que el código ya lo lleve. Si aún no lo está (un móvil que acaba de coger cobertura), se abre igualmente con EstadoReceptor.distancia = false y la pantalla lo dice; una tarea en segundo plano espera al nodo y, cuando llega, pone al día el código y el QR con el mismo secreto y marca distancia. Así un código nunca promete que vale a distancia cuando todavía no.
  • Dos formas del código. El QR (EstadoReceptor.codigo) lleva las IP: quien lo lee está delante. Lo que la pantalla enseña, copia y comparte (codigoMensaje) lleva solo el relé (encontrar::sin_directas), mide cerca de la mitad, y así un código pegado en un grupo no va repartiendo la IP pública de nadie. Esconde la IP del chat, no del otro equipo: si los dos consiguen un camino directo, cada uno ve la dirección del otro, que es justo lo que hace directo al camino. Sin relé configurado, el código para mensajes conserva las IP, que es lo único con lo que se puede llegar.
  • A distancia. Con el relé en la dirección, iroh va directo en la misma Wi-Fi y por el relé en cualquier otro caso. El relé (hoy los públicos de n0; el propio más los de n0 cuando haya RELE_PROPIO) solo reenvía bytes que ya van cifrados entre las dos claves: QUIC con TLS 1.3 e identidades ed25519, o sea de punta a punta. No puede leer ni cambiar un deck, y nadie necesita cuenta. Deducido: un relé puede ver que dos claves hablan y cuánto, como cualquier relé.
  • Equipos cerca usa su propio servicio mDNS (pbdeck, iroh-mdns-address-lookup), no el de iroh. Por qué: solo se ven entre sí las copias de Proxy Builder que esperan un deck, no cada app hecha con iroh ni los nodos de una partida, y al Endpoint compartido no se le añade nada que no se pueda quitar (el anuncio y la búsqueda son independientes de él). El anuncio lleva el nombre del equipo como datos de usuario y se vuelve a publicar cada 3 s por si cambia una dirección, y así también llega un nombre nuevo a los demás sin reiniciar nada (Anuncio::cambiar_apodo). Ese nombre es pasar::nombre_equipo: el que se escribe en «Recibir» (red.apodo, pasar_nombre_set) o, mientras esté vacío, el del sistema (el del ordenador sin su dominio; en Android el que se le puso al dispositivo o, si no, marca y modelo, android::nombre_equipo). Una lista llena de «Equipo sin nombre» no distinguía dos equipos, y ahora solo lo enseña una copia anterior a esto. En Android la Wi-Fi tira el multicast si nadie sostiene un MulticastLock, así que la app lo toma mientras «Recibir» o la búsqueda están abiertos (Entorno::multicast → android::multicast, el plugin android-extra) y lo suelta después. El plugin tiene un solo lock sin contador, así que Rust cuenta los usos (UsoMulticast::Buscar y Recibir): se coge con el primero y se suelta con el último, y cerrar «Recibir» no deja sin él a una búsqueda en marcha (Móvil). Si el mDNS no arranca, Anuncio::nuevo falla sin ruido (la pantalla dice «No visible en la red local: hace falta el QR o el código.») y el código sigue valiendo.
  • Por qué un pase cuando no hay secreto. El anuncio dice «aquí hay alguien esperando», pero la clave pública de quien recibe viaja también en cualquier código viejo, e iroh puede llamar a una clave desde cualquier sitio por el relé; sin nada más, cualquiera con esa clave podría hacer saltar una petición de 8 cifras. Así que cada vez que se abre «Recibir» se saca un pase aleatorio (32 caracteres hexadecimales, del mismo generador que el secreto), se publica en los datos de usuario del mDNS (<pase>:<apodo>, encontrar::datos_del_anuncio) y solo se pregunta a la persona si Hola.pase coincide, comparado en tiempo constante. Si no, se rechaza con no_admite antes de preguntar a nadie, sea cual sea el camino de la conexión. El pase sustituyó a una comprobación de la IP del otro (camino_local), que fallaba en los dos sentidos: rechazaba a un equipo que estaba al lado si la conexión empezaba por el relé o por IPv6 e iroh no encontraba un camino local en sus 3 s (la persona veía «Ese código no es válido», de un código que no había escrito), y daba por local a cualquiera de la misma operadora móvil, porque el NAT de las operadoras usa 100.64.0.0/10, el mismo rango que las VPN de malla. Una copia anterior al pase solo anuncia el nombre y no manda pase: como emisora la rechaza un receptor nuevo, y como receptora sigue con su comprobación de antes.
  • El código es una contraseña. Su secreto son 128 bits del generador del sistema operativo; si el sistema no puede darlos, secreto_nuevo devuelve un error y nunca un valor adivinable. Caduca a los 15 minutos (recibir::CADUCIDAD, tiempo de sobra para mandarlo por mensaje y pegarlo) y el secreto se usa una sola vez: la primera conexión que lo trae lo quema (r.secreto = None), comparado en tiempo constante (mismo_secreto). Además contiene las IP de quien recibe, así que se comparte como el de la partida.
  • Con un código, quien envía insiste. enviar::llamar sigue llamando hasta 3 minutos (INSISTIR_CON_CODIGO, intentos de 30 s separados 3 s). Por qué: quien mandó el código por mensaje puede tener aún la app de mensajes delante, y Android corta mientras la red de la nuestra. En cuanto vuelve, se encuentran sin que nadie pulse nada. Un equipo de la lista de «cerca» tiene un solo intento: si no contesta, es que se ha ido.

El protocolo (pasar/protocolo.rs)

ALPN proxy-builder/deck/1 (la versión va en el ALPN) en el mismo nodo de iroh que la partida (ver Juego en línea). Un stream bidireccional por intercambio. Las tramas son un u32 big-endian de longitud más JSON, como en el protocolo de la mesa; después de una trama Paquete van exactamente tamano bytes del .pbdeck, sin trama.

sequenceDiagram
    participant E as Quien envía
    participant R as Quien recibe
    E->>R: Hola {version, secreto?, apodo}
    Note over R: secreto válido, o una persona acepta las 8 cifras
    R-->>E: Listo {apodo} | Rechazo {motivo}
    E->>R: Oferta {decks: nombre, cartas, proxies, tamano, tamanoOriginales, linaje, actualizado, portada}
    Note over R: valida la oferta, busca decks del mismo linaje, la persona elige
    R-->>E: Respuesta {decisiones: No | ConProxies | SoloOriginales, una por deck}
    loop cada deck aceptado
        E->>R: Paquete {indice, tamano, huella}
        E->>R: tamano bytes del .pbdeck
        Note over R: comprueba tamaño y SHA-256, luego package::import_con
        R-->>E: Hecho {indice, ok, motivo?}
    end
    E->>R: Fin
    R-->>E: Adios
Loading
  • Topes, todos comprobados antes de leer o fiarse (protocolo.rs): una trama mide como mucho 64 KiB (TOPE_TRAMA, la oferta incluida), 32 decks por oferta, el nombre de un deck 120 caracteres, un apodo 32, una portada 6 KiB de base64. Si la oferta no cabe con portadas, quien envía las quita (OFERTA_SIN_PORTADAS); si aun así no cabe falla con oferta_grande. Un paquete no tiene tope fijo. Lo que lo limita es el disco libre de quien recibe (mod.rs::cabe_al_recibir: el doble del paquete, porque el fichero en la caché y sus imágenes en el almacén conviven hasta que se borra el fichero, más 200 MB que se dejan siempre libres, store/blobs.rs::RESERVA_DISCO).
  • El espacio libre se mira dos veces. Al contestar la persona, contra lo que la oferta dice que ha aceptado, para que un deck que no cabe se rechace al momento y no tras una transferencia larga; y otra vez en cada Paquete, contra su tamaño real, porque la cifra de la oferta viene del otro equipo y puede ser solo una estimación. Un tamano mayor que el disco se rechaza antes de escribir un solo byte. En cualquier caso quien recibe acaba con InvalidInput, motivo: sin_espacio, y cierra con CIERRE_SIN_ESPACIO (42), que quien envía lee como otro_sin_espacio; quedarse sin disco mientras se escribe (StorageFull) da el mismo sin_espacio.
  • Se da por hecho que el otro equipo puede ser hostil (la primera regla del módulo). validar_oferta rechaza una oferta vacía o demasiado grande, nombres vacíos, largos o con caracteres de control, recuentos de cartas absurdos, un linaje que no sea un id corto ([A-Za-z0-9_-], ≤ 64) y una portada demasiado grande. Los apodos se limpian (apodo_limpio: sin caracteres de control y con los espacios juntos). Las portadas se vuelven a hacer aquí (recibir::sanear_portada: base64 decodificado, medidas leídas de la cabecera antes de decodificar nada, ≤ 512 px, vuelta a codificar como un JPEG pequeño con package::miniatura), así que lo que pinta la pantalla es siempre una imagen que ha generado esta app, nunca los bytes de otro equipo. Las decisiones de quien recibe también se comprueban (plan_de: exactamente una por deck, sin repetir, y una resolución por cada deck aceptado que ya existe).
  • El paquete. Quien recibe lo guarda por partes en un fichero de app_cache_dir/pasar/<uuid>/ (se borra al soltarlo, también si se cancela y la tarea se aborta), cuenta exactamente tamano bytes, calcula el SHA-256 mientras escribe y lo compara con huella; si no coincide contesta Hecho {ok: false, motivo: "huella"} y corta el intercambio. Solo entonces corre package::import_con, que vuelve a desconfiar de todo (zip-slip, huella de cada imagen, falta_imagen…; ver Formato del paquete de mazo).
  • Quien envía construye cada paquete justo antes de mandarlo (package::export_en_fichero, en la caché, una imagen cada vez en lugar del ZIP entero en memoria) y lo borra después.
  • Plazos (un equipo parado no puede retener al otro): 10 s para el Hola, 60 s para la oferta, 1 hora esperando un paquete (ESPERA_PAQUETE: quien envía hace cada paquete justo antes de mandarlo, y comprimir un deck grande puede llevar mucho en un móvil; si se va, la conexión se cierra antes), 1 hora para que quien recibe lo importe (ESPERA_HECHO), 20 s para el final; la persona tiene 15 minutos para decidir y 60 s para aceptar una petición de 8 cifras (quien envía espera hasta 90 s por eso, y si no, 30 s para el Listo). Mientras llega un paquete, cada lectura tiene 2 minutos para traer al menos un byte (protocolo::plazo_sin_bytes, en recibir_bytes): un emisor que se queda parado a medias no deja «Recibir» ocupado para siempre, y quien recibe acaba con Network, motivo: tiempo, no con «cancelado». Es holgado porque los datos móviles y un relé con tope de velocidad tienen parones de muchos segundos; una conexión muerta de verdad la avisa antes QUIC.
  • Rechazos (Rechazo.motivo): version (otra VERSION; hay que actualizar las dos apps), codigo (secreto equivocado), no_admite (sin secreto y sin un pase válido del anuncio), caducado, rechazado (la persona dijo que no, o no contestó), ocupado (hay otro intercambio en marcha). Una conexión que no se espera se cierra con el código CIERRE_NO_ESPERADO (40) sin leer nada; quien envía lo traduce a no_espera. CIERRE_CANCELADO (41) es una cancelación y CIERRE_FIN (0) un final normal y CIERRE_SIN_ESPACIO (42) «no cabe». Un stream cortado se lee como «el otro lado ha cancelado», salvo que la propia conexión diga que se ha perdido (motivo_del_corte: tiempo agotado, reinicio o error de transporte): entonces los dos lados acaban con Network, motivo: cortado, porque un túnel o quedarse sin cobertura no es que alguien pulse «Cancelar».
  • Un motivo del otro equipo nunca se cree tal cual. Es texto de fuera, así que quien envía lo pasa por una lista cerrada: un Rechazo conserva version, codigo, no_admite, caducado, rechazado u ocupado y cualquier otro pasa a rechazado; un Hecho fallido conserva huella y cualquier otro pasa a importar (motivo_de_hecho, test el_motivo_de_fuera_va_por_una_lista_cerrada). Así la pantalla solo traduce motivos que conoce.
  • Los fallos de red que no son de HTTP (iroh, mDNS, un plazo, un rechazo) se montan con error::red_sin_http(motivo, causa), el hermano de error::red: Network con su motivo y, si hay un error de fondo, su cadena entera de causas apuntada en proxy-builder.log y en detalle (IPC y manejo de errores).
  • Un intercambio a la vez en quien recibe (Receptor.ocupado; reservar). Un intento rechazado (Salida::Rechazo: código malo, otra versión) deja a quien recibe esperando al siguiente, así que un código equivocado no acaba con la espera.

Un nodo, un bucle de accept (pasar::ceder)

🛑 Hay un nodo de iroh por instalación (Red::nodo()), el que usa la partida, con ALPN_DECK entre sus ALPN. Dos nodos con la misma clave se pisarían en el relé, y con otra clave el otro equipo vería dos identidades. El nodo se crea al primer uso y no se cierra al acabar el intercambio (igual que tras una partida); solo cae con la app o cuando se sustituye la identidad de red.

El problema: a un nodo solo se le puede hacer accept desde un sitio, y dos bucles leyendo del mismo endpoint se repartirían las conexiones al azar. Mientras hay una partida o una sala abiertas, su propio bucle ya acepta (red/anfitrion.rs::atender, red/servidor/sala.rs::aceptar). Así que la regla es:

  • Quien acepta conexiones en el nodo llama a pasar::ceder(ep.id()) y guarda la Guardia que devuelve mientras viva. Esos bucles mandan deck/1 a Mesa::atender_deck, que lanza pasar::atender. Cualquier otro ALPN es suyo.
  • El resto del tiempo, pasar/ corre su propio bucle (bucle, que arranca registrar al abrir «Recibir»), que solo acepta deck/1. 🛑 Vive solo mientras «Recibir» está abierto: recibir::cerrar y recibir::cancelar llaman a pasar::retirar, que lo aborta y olvida cómo arrancarlo. Por qué: mientras ese bucle vive nadie más atiende el nodo, así que una conexión de la partida que llegara entonces se cortaría.
  • Otro ALPN en el bucle propio. Sin partida en red, una conexión de otro ALPN se cierra al momento (CIERRE_NO_ESPERADO). Con una partida en red o una sala abiertas (Entorno::partida_en_red, que es Red::ocupada), en cambio, se retiene 10 s sin contestar (RETENER): puede ser otro jugador que busca anfitrión nuevo (red/sucesion.rs), y cerrarla le haría creer que aquí no hay nadie. Retenida, parece un nodo que aún no ha contestado; el otro abandona ese intento y vuelve a probar, ya con el bucle del anfitrión si este nodo pasó a serlo (ceder para el propio).
  • REGISTRO (un mapa estático por id de nodo, Entrada) cuenta cuántos bucles de partida tienen el nodo (cedido: pueden estar a la vez un bucle de anfitrión y uno de sala) y si «Recibir» quiere su propio bucle (arrancar, que pone registrar y quita retirar). Coger una Guardia aborta el bucle propio; soltar la última lo vuelve a arrancar solo si «Recibir» sigue abierto (reanudar, que necesita arrancar). registrar no reinicia un bucle que ya existe. Con «Recibir» cerrado, una partida solo deja una nota temporal en REGISTRO que desaparece con su Guardia, y no hay nada que reanudar.
  • atender cierra al momento (sin leer) cualquier conexión deck/1 cuando «Recibir» no está abierto y esperando, así que un código viejo falla enseguida («el otro equipo no está esperando un deck») y no tras un plazo.

Un test (el_bucle_de_pasar_cede_el_nodo_a_la_partida_y_lo_recupera) comprueba que nunca hay dos bucles, y pasar_un_deck_con_la_partida_abierta_no_la_corta (red/pruebas.rs) que recibir un deck durante una partida no la molesta.

Cómo viaja un deck: optimizado, original o sin proxies

«Proxies» aquí es todo lo que ha hecho la persona en lugar de Scryfall (compartir::resumen_proxies: imágenes propias, cartas diseñadas, reversos propios, reverso propio del deck). Quien envía elige un package::Modo: «Optimizado» (el de fábrica), «Calidad original» o «Sin proxies»; quien recibe elige después por deck «Recibirlo» (tal como llega), «Solo cartas originales» (solo si el deck trae proxies) o «No». Lo que elige quien envía es el techo: quien recibe puede pedir menos proxies, nunca más (SoloOriginales convierte el modo del deck en SinProxies, nunca al revés).

«Optimizado» existe porque las imágenes propias pesan mucho más de lo que el deck necesita: un deck de 110 cartas, todo diseñado, ocupaba 520 MB y optimizado ronda los 136 MB, que con datos móviles o por un relé son minutos y no un cuarto de hora. Los PNG sin transparencia viajan como JPEG con las mismas medidas; véase Formato del paquete de mazo. Las imágenes de quien envía no cambian. El paquete se hace en la caché durante la fase FaseEmisor::Preparando, que enseña el avance por imágenes y se puede cancelar. La oferta lo dice en DeckOfrecido.optimizado (no lo trae un emisor anterior, de ahí #[serde(default)]), y elegir «Reemplazar el de aquí» en un deck así avisa de que se pueden perder los originales de aquí: un deck que vuelve de un amigo no debe rebajar en silencio la copia de quien lo hizo.

proxy_core::compartir::sin_proxies (pura e idempotente) reescribe un deck para que el paquete pese una fracción:

Carta Sin proxies
Carta o ficha de Scryfall Igual (un reverso elegido a mano pasa al reverso del deck).
Carta propia vinculada a Scryfall (y una diseñada con vínculo) Pasa a ser esa carta de Scryfall, sin imagen ni diseño: quien recibe la baja.
Carta de otra fuente (fichas de Cockatrice) Conserva su referencia, sin imagen; quien recibe la baja de allí.
Carta propia o diseñada sin vínculo Un hueco que conserva su nombre (meta.name), para que quien recibe sepa qué carta era.
Colecciones, imágenes propias, reverso propio del deck No van en el paquete; se usa el reverso de la app.

🛑 Cada carta conserva su id. Las listas para jugar (que apuntan a ids de carta, también el comandante), la portada elegida y las marcas (DeckCard.marca) siguen valiendo. La importación de quien recibe también llama a sin_proxies con lo que llega, así que «Solo cartas originales» funciona igual con un paquete que sí traía proxies.

Tras importar «solo originales», las cartas sin imagen (compartir::pendientes) las baja un trabajo con progreso y cancelable (pasar_completar, pasar/imagenes.rs): las de Scryfall por su id con traer_carta, las de otras fuentes con su propio descargador, completando cada carta en su sitio (compartir::completar, que conserva el id, la marca y el lugar) y guardando el deck cada 10 cartas para que cancelar no pierda nada. Si ese deck es el abierto, la pantalla lo presta con prestarDeck(id, { conservar: true }) (features/pasar/imagenes.ts): lo que la persona cambie entretanto se conserva y al final se mezcla con lo que bajó Rust (mezclarCartas: la carta de Rust donde la persona no la tocó, la suya donde sí). La pantalla cuenta las imágenes bajadas y las fallidas (pasar://imagenes-progress, pasar://imagenes-done). Una imagen fallida se queda en blanco hasta que se vuelva a buscar. Conocido: si el deck es casi todo de Scryfall, «solo originales» ahorra poco (en una prueba, 92 MB pasaron a 89 MB).

Linaje: ¿es el mismo deck?

Cada importación crea ids nuevos a propósito (package::import_con), así que comparar ids no sirve, y los nombres no identifican un deck. Por eso los decks llevan un linaje (deck_linaje, ver Almacenamiento y blobs; store/linaje.rs), guardado aparte del deck como las etiquetas y las listas para jugar:

  • Un deck nuevo toma su propio id como linaje (lo inserta decks::save). Un duplicado es otro deck, así que otro linaje.
  • Uno importado o recibido conserva el linaje con el que llegó (Destino::Nuevo).
  • Un deck sin fila (de antes de la migración) cuenta con su id como linaje (linaje::de).
  • actualizado es la hora del último guardado de esa versión, en el equipo donde se hizo (lo renuevan decks::save y decks::rename, nada más lo toca). Viaja en Biblioteca.linaje/actualizado del .pbdeck, para que la oferta pueda decir qué copia es más reciente. Deducido / límite conocido: compara relojes de dos equipos distintos, que pueden no estar sincronizados, y un autoguardado cuenta como actualización, así que «más reciente» es una pista, no una prueba.
  • Nunca cambia updated_at, así que organizar o recibir no reordena la biblioteca.
  • Un linaje de fuera solo vale si es un id corto (linaje_sano), de modo que un paquete manipulado no pueda colar texto raro (test un_linaje_raro_de_fuera_se_ignora). Un paquete de antes del linaje se importa como deck nuevo.

Cuando el deck ya existe

flowchart TD
    O["Deck ofrecido"] --> Q{"¿Mismo linaje que un deck de aquí?<br/>(conflicto_en: el más reciente de ellos)"}
    Q -- "no" --> N["Deck nuevo<br/>(conserva el linaje con el que llegó)"]
    Q -- "sí" --> W["El diálogo dice cuál es más reciente:<br/>Aqui · Llega · Igual · Desconocido"]
    W --> D{"La persona elige"}
    D -- "Reemplazar el de aquí" --> RE["El mismo deck de aquí recibe el contenido<br/>(Destino::Reemplazar)"]
    D -- "Guardar los dos" --> GD["Un deck nuevo y aparte<br/>(linaje propio, como un duplicado)"]
    D -- "No" --> NO["No se recibe nada"]
Loading
  • Un deck que coincide tiene que resolverse antes de mandar la respuesta (si no, falta_resolucion; la pantalla solo activa el botón cuando todo deck aceptado que ya existe tiene resolución). Quien recibe lo vuelve a mirar en Rust al importar (paquete_importar llama él mismo a conflicto_en) y no se fía de lo que enseñó la pantalla.
  • Reemplazar el deck abierto. Rust escribe el deck en la base mientras la pantalla lo tiene en memoria, y el autoguardado escribiría lo viejo encima de lo nuevo. Por eso, antes de contestar, la pantalla presta a Rust cada deck que se reemplaza con prestarDeck(id, { conservar: false }) (src/stores/deck-store.ts, véase Stores de estado): guarda lo pendiente, el autoguardado espera mientras dura el préstamo y, al acabar el intercambio (bien o mal), el deck se vuelve a leer de Rust, sin historial. Importar un .pbdeck con «Reemplazar el de aquí» hace lo mismo (importar-dialog.tsx).
  • «Reemplazar el de aquí» conserva lo que es de este equipo: el id del deck, su nombre, sus etiquetas, favorito, notas, idioma a mano e historial de partidas (partidas apunta al id del deck). Toma del deck que llega sus cartas (con ids de carta nuevos), sus ajustes y reverso por defecto, las listas para jugar del paquete (las de aquí apuntaban a ids de carta que ya no existen, así que se borran) y la portada si trae una (si no, ninguna). El linaje se queda y actualizado pasa a ser el del que llega, así que ahora las dos copias dicen «misma versión». Una partida guardada a medias debería seguir funcionando: la partida guardada lleva sus propios decks preparados (Almacenamiento y blobs). Deducido: no se ha probado de nuevo con un reemplazo.
  • «Guardar los dos» crea otro deck (id nuevo, linaje propio, con «… (importado)» si el nombre ya existe). Si había varios decks de aquí con ese linaje, conflicto_en ofrece el más reciente.
  • La importación es todo o nada por deck: si falla un paso posterior, el deck nuevo se borra (import_con cierra con esa regla), y un deck reemplazado vuelve a como estaba: antes de tocarlo, import_con guarda una Copia (el deck, sus listas para jugar, su portada y su linaje) y, si algo falla tras guardar el contenido nuevo, Copia::restaurar lo vuelve a poner. Por qué una copia y no una transacción: cada paso pide la conexión a la base por su cuenta, así que no caben en una sola. Cortar un envío a la mitad no deja nada a medias en la biblioteca; los decks que ya habían llegado enteros se quedan (cada uno está completo).

Compartir y abrir el .pbdeck

La reserva para cuando no hay red común ni relé: el fichero, por cualquier app.

  • «Compartir…» en el menú de un deck (compartir-dialog.tsx, primero deck_paquete_info, para enseñar el peso con y sin proxies). En Android escribe el paquete en la caché y lo ofrece por la hoja del sistema (deck_paquete_compartir → compartir_fichero); en un ordenador es «Guardar como…» (deck_paquete_guardar, que también acepta un content://). Con varios decks elegidos el diálogo solo ofrece enviarlos a otro equipo.
  • Abrir uno (el «Importar .pbdeck» de la biblioteca, o en Android «Abrir con…» desde Archivos, Descargas o un mensaje, ver Formato del paquete de mazo) abre siempre el mismo diálogo de importar que al recibir (importar-dialog.tsx): paquete_leer lee el manifiesto y la portada sin importar (package::resumen), enseña las mismas opciones (con o sin proxies, reemplazar o guardar los dos) y paquete_importar importa con ellas.

La pantalla (src/features/pasar/)

«Enviar / Recibir» en la barra de la biblioteca (solo el icono en un móvil) abre pasar-dialog.tsx, un diálogo que cancela lo que esté en marcha al cerrarse. Pestaña «Enviar» (enviar.tsx): qué decks, con o sin proxies, y a quién (lista de equipos cerca, escanear un QR o pegar el código; el campo del código valida el prefijo con pasar_codigo_comprobar). Pestaña «Recibir» (recibir.tsx): el QR y el código con su cuenta atrás, botones para copiarlo y, en un móvil, mandarlo con otra app, una línea que dice si el código ya vale a distancia (distancia) y otra si este equipo se ve en la red local, «Código nuevo» cuando caduca, la petición de aceptar, la oferta (oferta.tsx), el progreso y un resultado por deck (piezas.tsx) con «Abrir». El estado y los eventos están en estado.ts y store.ts; ninguna decisión vive en la pantalla. Los errores se traducen por motivo (cuentas.ts::MOTIVOS) a textos del área pasar de los 15 diccionarios.

Comandos (commands/pasar.rs)

pasar_recibir_abrir (Cancelled si se cierra antes de terminar de abrirse), pasar_recibir_estado, pasar_recibir_cerrar, pasar_recibir_cancelar, pasar_compartir_codigo (el selector del sistema con el código; solo en Android), pasar_peticion (aceptar o rechazar la petición de 8 cifras), pasar_responder (las decisiones de la oferta), pasar_cerca_buscar / pasar_cerca_parar, pasar_codigo_comprobar, pasar_enviar, pasar_enviar_cancelar, pasar_emisor_estado, pasar_emisor_olvidar, pasar_completar (imágenes que faltan), deck_paquete_info, deck_paquete_guardar, deck_paquete_compartir, paquete_leer y paquete_importar. Los estados usan tipos de ts-rs; los de nombre genérico llevan #[ts(rename = "…Pasar")] (DecisionPasar, DestinoPasar, EleccionPasar, ProgresoPasar, RemotoPasar, ResolucionPasar, ResultadoPasar, ConflictoPasar, EquipoCerca) porque ts-rs escribe un fichero por nombre y pisaría los Destino, Decision y Resultado de la mesa.

El código se escribe contra un trait pequeño (pasar::Entorno, que implementa AppHandle) para poder probar los intercambios con dos nodos en un solo proceso y sin Tauri.

Casos límite y decisiones

  • Enviarse a uno mismo (un código del mismo nodo) falla con uno_mismo; un equipo que ya salió de la lista de cerca falla con no_esta.
  • Un solo envío a la vez (Busy, motivo: enviando); una oferta lleva de 1 a 32 decks.
  • «Enviar más» y «Recibir otro». La pantalla sigue los eventos de Rust (pasar://emisor, pasar://receptor) con un hook que guarda el último evento recibido, y ese último es el de «terminado». Cuando un intercambio ha acabado, pulsar «Enviar más» o «Recibir otro» tiene que volver al principio, pero el hook seguiría devolviendo el estado viejo de «terminado» y mandaría sobre lo que Rust conteste después (pasar_recibir_abrir devuelve el estado nuevo del receptor). Por eso el panel aparta el último evento (apartado en enviar.tsx y recibir.tsx): ignora ese evento concreto hasta que llegue uno más nuevo. «Enviar más» además le dice a Rust que olvide el último envío (pasar_emisor_olvidar); «Recibir otro» vuelve a abrir «Recibir» y suelta el primer estado anterior. Sin esto, los dos botones parecían muertos.
  • Cancelar. Cualquiera de los dos lados puede cancelar; el otro lo ve. Lo que ya había llegado entero se queda y se enseña en los resultados; el deck que iba en camino no existe.
  • Memoria. Ningún lado tiene un paquete en memoria, porque un paquete no tiene tope: enviar lee y escribe unas pocas imágenes cada vez, y package::import_con lee, comprueba y guarda las imágenes de una en una (Formato del paquete de mazo). El pico de quien recibe es la imagen más grande, no el deck.
  • Segundo plano (móvil). Durante un intercambio la pantalla sigue encendida (Entorno::pantalla_encendida, contado con la mesa de juego por android::UsoPantalla, así que acabar uno no le apaga la pantalla al otro), y la pantalla pide dejar la app abierta. Sigue sin haber servicio en primer plano: si la persona sale de la app durante una transferencia larga, Android puede cortarle la red, y el intercambio acaba con cortado. Una transferencia cortada vuelve a empezar ese deck desde el principio; Deducido: reanudar sería lo siguiente que añadir para decks muy grandes con mala cobertura.
  • La velocidad por un relé. Dos móviles detrás de operadoras que no dejan un camino directo van por el relé todo el rato. Hoy son los relés públicos de n0, cuyo tope de velocidad no está publicado y que n0 no garantiza para producción. Medido con velocidad_por_el_rele (sin camino directo posible, los dos extremos en la misma máquina): cerca de 1 MB/s, así que 136 MB tardan algo más de 2 minutos y 1 GB unos 17. Poner en RELE_PROPIO el relé propio quita ese tope; el relé de iroh no limita la velocidad de fábrica.
  • Un «Recibir» cerrado no puede resucitar. abrir puede esperar hasta 5 s al relé, y mientras la persona puede cerrar la pantalla o cambiar de pestaña. Sin cuidado, esa espera acababa dejando un receptor que nadie veía, anunciándose y con el nodo cogido. Pasar::pedido_recibir sube en cada apertura y cierre, y un abrir que lo encuentra cambiado devuelve Cancelled, que la pantalla ignora.
  • Emulador de Android. Su red no deja pasar multicast y el ordenador no puede llamarle, así que entre un ordenador y el emulador llama el emulador, o se va por el relé; «equipos cerca» entre un ordenador y un móvil solo se puede probar de verdad en una Wi-Fi real.
  • El tamano de la oferta es lo que pesaría el paquete tal como se envía (package::tamano_estimado, la suma de las imágenes, sin construirlo; optimizado, una estimación por píxeles, peso_jpeg, porque saber la cifra real obliga a comprimir, y quien recibe vuelve a mirar el espacio libre con el paquete de verdad) y tamanoOriginales lo que pesaría sin proxies.

Tests

  • pasar/pruebas.rs (nodos reales en un solo proceso, sin relé salvo que se diga): tres decks (uno con proxies, uno «solo originales» y uno rechazado); un código de un solo uso y uno malo no dejan pasar a nadie; un nodo que no espera un deck cierra sin leer; un deck que ya está pregunta, y «Reemplazar» conserva lo de aquí; cortar a la mitad no deja nada a medias; cancelar desde quien recibe corta el envío; una oferta de mil decks se corta y el apodo llega limpio; un paquete que no cabe en el disco se corta (y quien envía sabe por qué), una oferta que no cabe se rechaza antes de mandar nada y un paquete con otra huella no se importa; sin el pase del anuncio no se pregunta a nadie; cerrar «Recibir» mientras se abre no deja nada detrás; con un código, quien envía espera a que el otro vuelva; tramas, códigos y portadas estropeados al azar nunca revientan (lo_que_llega_estropeado_al_azar_nunca_revienta), y un emisor de basura deja «Recibir» en un estado final (un_emisor_que_manda_basura_no_deja_recibir_colgado); un deck optimizado llega en JPEG y el de quien envía se queda como estaba; el código de 8 cifras acepta a alguien de la red local; el código vale por el relé sin direcciones directas (el_codigo_vale_por_el_rele_sin_direcciones_directas, contra los relés reales de n0, así que necesita internet). A mano, con --ignored y PB_PLAZO_REAL=1: un_envio_de_mas_de_500_mb_llega_entero (720 MB entre nodos reales, y la memoria queda muy por debajo del paquete) y velocidad_por_el_rele (nodos sin ningún transporte IP, así que todo pasa por el relé de n0; PB_MB cambia el tamaño).
  • pasar/mod.rs: el test del bucle de ceder. red/pruebas.rs: recibir un deck con una partida abierta. red/nodo.rs: dos nodos se conectan por deck/1.
  • package.rs (ida y vuelta del linaje, «Guardar los dos», un linaje raro, reemplazar, un paquete de la 3.2.3 sin linaje se sigue importando, «solo originales» pesa menos, un manifiesto enorme no se lee; a mano con --ignored, un_paquete_de_mas_de_4_gb_va_y_vuelve para ZIP64), store/linaje.rs, proxy-core::compartir (un deck con cada tipo de carta conserva ids, lista y marcas) y src/features/pasar/__tests__/.

Sin verificar

Probado en el emulador de Android contra un ordenador, a través del NAT del emulador: un deck de 60 MB por código, el avance en MB, la pantalla encendida durante el intercambio, una oferta de 100 GB rechazada con sin_espacio, el selector de compartir, cerrar «Recibir» y las pantallas de pie y de lado. Escrito a partir del código y sus tests, y sin probar en la práctica al escribirlo: dos móviles de verdad con datos móviles, escanear un QR con una cámara real, «equipos cerca» en un teléfono real y pegar a mano un código largo en la interfaz.

Por dónde empezar al ampliarlo

  • Un campo nuevo en la oferta: DeckOfrecido (tipos.rs), oferta_de (enviar.rs), su comprobación en validar_oferta y pnpm bindings. Todo lo que manda el otro equipo es entrada no confiable: se acota y se limpia.
  • Un mensaje nuevo del protocolo: añadirlo a AlReceptor/AlEmisor; si la app vieja y la nueva no pueden hablar, subir VERSION y el ALPN (proxy-builder/deck/2, dejando deck/1 en crear hasta que pueda irse).
  • Nunca aceptar conexiones en el nodo desde un sitio nuevo sin llamar a pasar::ceder.
  • Una regla nueva de «cuál es el mismo deck» va en store/linaje.rs, no en la pantalla.

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