Skip to content

Deck Sharing

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

Deck Sharing

English · Español

Moving decks between computers and phones, or to a friend, with no account and no server of our own: from the library, «Enviar / Recibir» sends one or several decks to another copy of the app, and «Compartir…» hands a .pbdeck file to any other app. Both honour the same two questions: with or without proxies, and what to do if the receiver already has that deck.

Source: 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 and red/nodo.rs, and on the screen src/features/pasar/ and src/lib/escaner.ts.

What it is (and is not)

  • Peer to peer over iroh, the same piece the Game Table uses. The sender dials the receiver's public key; the data is a .pbdeck (Deck Package Format) sent over a QUIC stream. There is no account, no upload and no server: nothing is stored anywhere but on the two computers.
  • Rust does everything. Finding each other, the protocol, building and checking the package, importing it. The screen (features/pasar/) only paints the state Rust emits (pasar://receptor, pasar://emisor, pasar://cerca) and sends the person's choices; cuentas.ts holds the pure rules of what must be chosen before continuing.
  • Bluetooth was ruled out on purpose. WebKitGTK has no Web Bluetooth, the Rust Bluetooth libraries behave badly on desktop (and a computer must be able to be the sender), and Bluetooth is very slow for several MB of images. iroh is also already in the app. The system's own Bluetooth stays available by hand through «Compartir…» (a file handed to another app).
  • Not a sync. A transfer is a one-off copy. «Is it the same deck?» is answered by the lineage, but nothing is merged: the receiver replaces or keeps both.

How the two computers find each other

flowchart TD
    R["Receiver opens «Recibir»<br/>(15 minutes)"] --> C["Code PBD1-… + QR<br/>(address with local IPs and relay + one-use secret)"]
    R --> M["Announces its nickname by mDNS<br/>(only if the network allows it)"]
    S["Sender opens «Enviar»"] --> L["Nearby computers list"]
    S --> P["Pastes the code or scans the QR"]
    M --> L
    L -->|"tap a computer: no secret"| A["Receiver compares the 6 digits<br/>and accepts"]
    P -->|"secret in the code"| Z["Straight in"]
    A --> X["Exchange"]
    Z --> X
Loading

There are three ways, all ending in the same protocol:

Way Where it works What proves it is the right computer
Nearby computers (encontrar.rs::Anuncio / Busqueda) Same local network Two things, both needed: the sender brings the pass from the receiver's mDNS announcement (Hola.pase, see below), and the person accepts the 8-digit code both screens show (encontrar::cifras: taken from the SHA-256 of the two public keys, sorted). The code is not a secret (keys are public): it lets two people check they reached the computer they meant, like Bluetooth pairing. It is eight digits so that an attacker on the same network cannot manufacture a key that gives the same number during the minute a request lasts.
QR (qr_svg, generated in Rust as an SVG) Same network or far away The one-use secret inside the code. The sender scans it with the camera (mobile only, src/lib/escaner.ts, the only door to the scanner plugin) or pastes the text (on a computer it is always pasted).
Text code PBD1-… Same network or far away The same secret, copied by hand.
  • The code (red/codigo.rs) is the game's direct-code format (EndpointAddr + a 16-byte secret, base64url of the JSON) with another prefix: PBD1- for decks, PB1- for games. Why two prefixes: pasting a game code where a deck code goes (or the other way round) gives a clear error (motivo: codigo_de_partida / codigo_de_deck) and not a strange connection. The address carries up to six useful local IPs, the most likely first (encontrar::recortar: no loopback, link-local or unspecified ones; private IPv4, then other IPv4, then IPv6) plus the relay URL; more IPs would make a QR that cannot be read. When there is a relay, abrir waits up to 5 s for the node to be online so the code already carries it. If it is not online yet (a phone that has just found coverage) the receiver opens anyway with EstadoReceptor.distancia = false and the screen says so; a background task then waits for the node, and when it arrives updates the code and the QR with the same secret and sets distancia. A code therefore never promises it works from afar when it does not yet.
  • Two forms of the code. The QR (EstadoReceptor.codigo) carries the IPs: whoever scans it is standing there. What the screen shows, copies and shares (codigoMensaje) carries only the relay (encontrar::sin_directas), about half as long, so a code pasted into a group chat does not spread anyone's public IP. It hides the IP from the chat, not from the peer: if the two manage a direct path, each one then sees the other's address, which is what makes the path direct. With no relay configured the message form keeps the IPs, the only way to arrive.
  • Far away. With the relay in the address, iroh goes direct on the same Wi-Fi and through the relay otherwise. The relay (n0's public ones today, the project's own plus n0's when RELE_PROPIO is set) only forwards bytes that are already encrypted between the two keys: QUIC with TLS 1.3 and ed25519 identities, so end to end. It cannot read or change a deck, and nobody needs an account. Inferred: a relay can see that two keys talk and how much, as any relay does.
  • Nearby computers use their own mDNS service (pbdeck, iroh-mdns-address-lookup), not iroh's. Why: only Tapete copies that are waiting for a deck see each other, not every iroh app nor the nodes of a game, and nothing is added to the shared Endpoint that could not be removed (the announcement and the search are independent of it). The announcement carries the device's name as user data and is republished every 3 s in case an address changes, which is also how a new name reaches others without restarting anything (Anuncio::cambiar_apodo). That name is pasar::nombre_equipo: the one typed in «Recibir» (red.apodo, pasar_nombre_set) or, while empty, the system's (the computer's name without its domain; on Android the name given to the device or else make and model, android::nombre_equipo). A list full of «Equipo sin nombre» could not tell two computers apart, and only a copy older than this feature still shows it. On Android the Wi-Fi drops multicast unless a MulticastLock is held, so the app takes it while «Recibir» or the search is open (Entorno::multicast → android::multicast, the android-extra plugin) and releases it afterwards. The plugin has a single lock with no counter, so Rust counts the uses (UsoMulticast::Buscar and Recibir): the lock is taken with the first and released with the last, and closing «Recibir» does not leave a running search without it (Mobile). If mDNS cannot start, Anuncio::nuevo fails quietly (the screen says «No visible en la red local: hace falta el QR o el código.») and the code still works.
  • Why a pass when there is no secret. The announcement says «someone here is waiting», but the receiver's public key also travels in any old code, and iroh can dial a key from anywhere through the relay; with nothing more, anyone holding that key could pop up a 8-digit request. So each time «Recibir» opens it draws a random pass (32 hex characters, from the same generator as the secret), publishes it in the mDNS user data (<pase>:<apodo>, encontrar::datos_del_anuncio) and only asks the person when Hola.pase matches, compared in constant time. Otherwise it is rejected with no_admite before anyone is asked, whatever path the connection came by. The pass replaced a check on the peer's IP (camino_local), which failed both ways: it refused a computer right next to you when the connection began through the relay or over IPv6 and iroh did not find a local path in its 3 s (the person saw «Ese código no es válido», about a code they never typed), and it counted as local anyone on the same mobile carrier, because carrier NAT uses 100.64.0.0/10, the same range as mesh VPNs. A copy from before the pass announces only the name and sends no pass: as a sender it is refused by a newer receiver, as a receiver it still applies its old check.
  • The code is a password. Its secret is 128 bits from the operating system's generator; if the system cannot provide them, secreto_nuevo returns an error rather than a guessable value. It expires after 15 minutes (recibir::CADUCIDAD, long enough to send it by message and have it pasted) and the secret is used once: the first connection that brings it burns it (r.secreto = None), compared in constant time (mismo_secreto). It also contains the receiver's IPs, so share it like the game's.
  • With a code, the sender insists. enviar::llamar keeps dialling for up to 3 minutes (INSISTIR_CON_CODIGO, attempts of 30 s, 3 s apart). Why: whoever sent the code by message may still have the messaging app in front, and Android cuts our app's network meanwhile. As soon as they come back the two meet, without anyone having to press anything. A computer from the nearby list gets a single attempt: if it does not answer, it has gone.

The protocol (pasar/protocolo.rs)

ALPN proxy-builder/deck/1 (the version is in the ALPN) on the same iroh node as the game (see Online Play). One bidirectional stream per exchange. Frames are a u32 big-endian length plus JSON, as in the game protocol; after a Paquete frame come exactly tamano raw bytes of the .pbdeck, with no frame.

sequenceDiagram
    participant E as Sender
    participant R as Receiver
    E->>R: Hola {version, secreto?, apodo}
    Note over R: secret valid, or a person accepts the 6 digits
    R-->>E: Listo {apodo} | Rechazo {motivo}
    E->>R: Oferta {decks: nombre, cartas, proxies, tamano, tamanoOriginales, linaje, actualizado, portada}
    Note over R: validates the offer, looks for decks of the same lineage, the person chooses
    R-->>E: Respuesta {decisiones: No | ConProxies | SoloOriginales, one per deck}
    loop each accepted deck
        E->>R: Paquete {indice, tamano, huella}
        E->>R: tamano bytes of the .pbdeck
        Note over R: checks size and SHA-256, then package::import_con
        R-->>E: Hecho {indice, ok, motivo?}
    end
    E->>R: Fin
    R-->>E: Adios
Loading
  • Limits, all checked before reading or trusting (protocolo.rs): a frame is at most 64 KiB (TOPE_TRAMA, the offer included), 32 decks per offer, a deck name 120 characters, a nickname 32, a cover 6 KiB of base64. If the offer does not fit with covers, the sender drops them (OFERTA_SIN_PORTADAS); if it still does not fit it fails with oferta_grande. A package has no fixed cap. What limits it is the receiver's free disk (mod.rs::cabe_al_recibir: twice the package, because the file in the cache and its images in the store coexist until the file is deleted, plus 200 MB that are always left free, store/blobs.rs::RESERVA_DISCO).
  • Free space is checked twice. When the person answers, against what the offer says they accepted, so a deck that does not fit is refused at once and not after a long transfer; and again at each Paquete, against its real size, because the offer's figure comes from the other computer and can be only an estimate. A tamano larger than the disk is refused before a single byte is written. Either way the receiver ends with InvalidInput, motivo: sin_espacio, and closes with CIERRE_SIN_ESPACIO (42), which the sender reads as otro_sin_espacio; running out of disk while writing (StorageFull) gives the same sin_espacio.
  • A hostile peer is assumed (the module's first rule). validar_oferta rejects an empty or oversized offer, names that are empty, too long or contain control characters, absurd card counts, a lineage that is not a short id ([A-Za-z0-9_-], ≤ 64) and a too-big cover. Nicknames are cleaned (apodo_limpio: no control characters, collapsed spaces). Covers are re-made locally (recibir::sanear_portada: base64 decoded, size read from the header before decoding anything, ≤ 512 px, re-encoded to a small JPEG by package::miniatura) so what the screen paints is always an image this app produced, never another computer's bytes. The receiver's decisions are checked too (plan_de: exactly one per deck, no repeats, a resolution for every accepted deck that already exists).
  • The package. The receiver streams it to a file in app_cache_dir/pasar/<uuid>/ (deleted when dropped, also if the exchange is cancelled and the task aborted), counts exactly tamano bytes, computes the SHA-256 while writing and compares it with huella; a mismatch answers Hecho {ok: false, motivo: "huella"} and the exchange is cut. Only then package::import_con runs, which distrusts everything again (zip-slip, per-image hash, falta_imagen...; see Deck Package Format).
  • The sender builds each package just before sending it (package::export_en_fichero, into the cache, an image at a time rather than the whole ZIP in memory) and deletes it after.
  • Timeouts (a stalled peer cannot hold the other forever): 10 s for the Hola, 60 s for the offer, 1 hour to wait for a package (ESPERA_PAQUETE: the sender builds each package just before sending it, and compressing a big deck can take a long time on a phone; if the sender goes away the connection closes sooner), 1 hour for the receiver to import it (ESPERA_HECHO), 20 s for the end; the person has 15 minutes to decide, and 60 s to accept a 8-digit request (the sender waits up to 90 s for that, and 30 s for Listo otherwise). While a package arrives, every read has 2 minutes to bring at least one byte (protocolo::plazo_sin_bytes, in recibir_bytes): a sender that stalls halfway does not keep «Recibir» busy forever, and the receiver ends with Network, motivo: tiempo, not «cancelled». It is generous because mobile data and a rate-limited relay do have pauses of many seconds; a connection that has really died is reported sooner by QUIC.
  • Rejections (Rechazo.motivo): version (different VERSION; both apps must be updated), codigo (wrong secret), no_admite (no secret and no valid pass from the announcement), caducado, rechazado (the person said no, or did not answer), ocupado (another exchange is in progress). A connection the receiver does not expect is closed with code CIERRE_NO_ESPERADO (40) without reading anything; the sender turns that into no_espera. CIERRE_CANCELADO (41) is a cancel and CIERRE_FIN (0) a normal end, CIERRE_SIN_ESPACIO (42) «it does not fit». A cut stream is read as «the other side cancelled», unless the connection itself says it was lost (motivo_del_corte: timed out, reset or a transport error): then both sides end with Network, motivo: cortado, because a tunnel or lost coverage is not somebody pressing «Cancelar».
  • A motivo from the other computer is never trusted as is. It is foreign text, so the sender maps it through a closed list: a Rechazo keeps version, codigo, no_admite, caducado, rechazado or ocupado and anything else becomes rechazado; a failed Hecho keeps huella and anything else becomes importar (motivo_de_hecho, test el_motivo_de_fuera_va_por_una_lista_cerrada). The screen therefore only ever translates a reason it knows.
  • Network failures that are not HTTP (iroh, mDNS, a timeout, a rejection) are built with error::red_sin_http(motivo, causa), the sibling of error::red: Network with its motivo and, when there is an underlying error, its whole chain of causes written to proxy-builder.log and carried in detalle (IPC and Error Handling).
  • One exchange at a time on a receiver (Receptor.ocupado; reservar). A rejected attempt (Salida::Rechazo: bad code, wrong version) leaves the receiver waiting for whoever comes next, so a wrong code does not end the receiver's wait.

One node, one accept loop (pasar::ceder)

🛑 There is one iroh node per installation (Red::nodo()), the one the game uses, with ALPN_DECK among its ALPNs. Two nodes with the same key would step on each other at the relay, and a different key would show the other computer two identities. The node is created on first use and not closed when the exchange ends (the same as after a game); it only goes down with the app or when the network identity is replaced.

The problem: a node can only be accepted from one place, and two loops reading from the same endpoint would each get an arbitrary share of the connections. While an online game is open, its own loop already accepts (red/anfitrion.rs::atender). So the rule:

  • Whoever accepts connections on the node calls pasar::ceder(ep.id()) and keeps the returned Guardia while it lives. Those loops send deck/1 to Mesa::atender_deck, which spawns pasar::atender. Any other ALPN is theirs.
  • The rest of the time, pasar/ runs its own loop (bucle, started by registrar when «Recibir» opens), which accepts only deck/1. 🛑 It lives only while «Recibir» is open: recibir::cerrar and recibir::cancelar call pasar::retirar, which aborts it and forgets how to start it. Why: while that loop lives nobody else accepts on the node, so a game connection arriving then would be cut.
  • Another ALPN in the own loop. With no online game, a connection of another ALPN is closed at once (CIERRE_NO_ESPERADO). With an online game open (Entorno::partida_en_red, which is Red::ocupada), it is instead held for 10 s without an answer (RETENER): it may be another player looking for a new host (red/sucesion.rs), and closing it would tell them nobody is here. Held, it looks like a node that has not answered yet; the other side gives up that attempt and tries again, by then with the host's loop if this node became the host (ceder stops the own one).
  • REGISTRO (a static map by node id, Entrada) counts how many game loops hold the node (cedido, a counter, so that two guards alive at once release the node correctly) and whether «Recibir» wants its own loop (arrancar, set by registrar and cleared by retirar). Taking a Guardia aborts the own loop; dropping the last one restarts it only if «Recibir» is still open (reanudar, which needs arrancar). registrar does not restart a loop that already exists. With «Recibir» closed, a game just leaves a temporary note in REGISTRO that disappears with its Guardia, and there is nothing to restart.
  • atender closes at once (without reading) any deck/1 connection when «Recibir» is not open and waiting, so a stale code fails immediately («the other computer is not waiting for a deck») and not after a timeout.

A test (el_bucle_de_pasar_cede_el_nodo_a_la_partida_y_lo_recupera) checks there are never two loops, and pasar_un_deck_con_la_partida_abierta_no_la_corta (red/pruebas.rs) that receiving a deck during a game does not disturb it.

How a deck travels: optimized, original or without proxies

«Proxies» here means whatever the person made rather than Scryfall's (compartir::resumen_proxies: own images, designed cards, own backs, own default back). The sender chooses a package::Modo: «Optimizado» (the default), «Calidad original» or «Sin proxies»; the receiver then chooses per deck «Recibirlo» (as sent), «Solo cartas originales» (only if the deck carries proxies) or «No». The sender's choice is the ceiling: a receiver can ask for fewer proxies, never for more (SoloOriginales turns the deck's mode into SinProxies, never the other way round).

«Optimizado» exists because own images weigh far more than the deck needs: a fully designed deck of 110 cards was 520 MB and is about 136 MB optimized, which on mobile data or through a relay is minutes rather than a quarter of an hour. PNGs without transparency travel as JPEG at the same size; see Deck Package Format. The sender's own images do not change. The package is made in the cache during the phase FaseEmisor::Preparando, which shows progress by images and can be cancelled. The offer says DeckOfrecido.optimizado (absent from an older sender, hence #[serde(default)]), and choosing «Reemplazar el de aquí» for such a deck warns that the local originals may be lost: the deck that comes back from a friend must not silently downgrade the author's copy.

proxy_core::compartir::sin_proxies (pure, idempotent) rewrites a deck so the package is a fraction of the size:

Card Without proxies
Scryfall card or token Unchanged (a hand-picked own back becomes the deck's back).
Own card linked to Scryfall (and a designed one with a link) Becomes that Scryfall card, with no image and no design: the receiver downloads it.
Card from another source (Cockatrice tokens) Keeps its reference, no image; the receiver downloads it from there.
Own or designed card without a link A blank slot that keeps its name (meta.name), so the receiver knows which card it was.
Collections, own images, own default back Not in the package; the receiver's app back is used.

🛑 Every card keeps its id. The play lists (which point to card ids, the commander too), the chosen cover and the marks (DeckCard.marca) stay valid. The receiver's import also calls sin_proxies on what arrives, so «Solo cartas originales» works the same on a package that did bring proxies.

After importing «solo originales», the cards without an image (compartir::pendientes) are fetched by a job with progress and cancellation (pasar_completar, pasar/imagenes.rs): Scryfall ones by id with traer_carta, other sources with their own fetcher, completing each card in place (compartir::completar, which keeps id, mark and place) and saving the deck every 10 cards so a cancel loses nothing. If that deck is the one open, the screen lends it with prestarDeck(id, { conservar: true }) (features/pasar/imagenes.ts): what the person changes meanwhile is kept, and at the end it is mixed with what Rust downloaded (mezclarCartas: Rust's card where the person did not touch it, theirs where they did). The screen reports downloaded and failed images (pasar://imagenes-progress, pasar://imagenes-done). A failed image stays blank until looked up again. Known: if the deck is almost all Scryfall cards, «solo originales» saves little (in one trial, 92 MB became 89 MB).

Lineage: is it the same deck?

Every import creates new ids on purpose (package::import_con), so comparing ids does not work, and names do not identify a deck. Decks therefore carry a lineage (deck_linaje, see Storage and Blobs; store/linaje.rs), kept apart from the deck like the tags and play lists:

  • A new deck takes its own id as lineage (decks::save inserts it). A duplicate is another deck, so another lineage.
  • One that is imported or received keeps the lineage it came with (Destino::Nuevo).
  • A deck with no row (from before the migration) counts as having its own id as lineage (linaje::de).
  • actualizado is the time of the version's last save, on the computer where it was made (decks::save and decks::rename refresh it, nothing else touches it). It travels in Biblioteca.linaje/actualizado of the .pbdeck, so the offer can say which copy is newer. Inferred / known limit: it compares clocks of two different computers, which may not be in sync, and an autosave counts as an update, so «newer» is a hint, not proof.
  • It never changes updated_at, so organising or receiving does not reorder the library.
  • A lineage from outside is accepted only if it is a short id (linaje_sano), so a crafted package cannot smuggle odd text (test un_linaje_raro_de_fuera_se_ignora). A package from before lineage imports as a new deck.

When the deck already exists

flowchart TD
    O["Offered deck"] --> Q{"Same lineage as a deck here?<br/>(conflicto_en: the most recent of them)"}
    Q -- "no" --> N["New deck<br/>(keeps the lineage it came with)"]
    Q -- "yes" --> W["Dialog shows which is newer:<br/>Aqui · Llega · Igual · Desconocido"]
    W --> D{"Person chooses"}
    D -- "Reemplazar el de aquí" --> RE["Same deck here receives the content<br/>(Destino::Reemplazar)"]
    D -- "Guardar los dos" --> GD["A new, separate deck<br/>(own lineage, like a duplicate)"]
    D -- "No" --> NO["Nothing is received"]
Loading
  • A deck that matches must be resolved before the answer is sent (falta_resolucion otherwise; the screen only enables the button when every accepted existing deck has a resolution). The receiver checks again in Rust at import time (paquete_importar calls conflicto_en itself) and does not trust what the screen showed.
  • Replacing the deck that is open. Rust writes the deck in the database while the screen holds it in memory, and the autosave would write the old content over the new. So before answering, the screen lends each replaced deck to Rust with prestarDeck(id, { conservar: false }) (src/stores/deck-store.ts, see State Stores): it saves what is pending, the autosave waits while the loan lasts, and when the exchange ends (well or badly) the deck is read again from Rust, without history. Importing a .pbdeck with «Reemplazar el de aquí» does the same (importar-dialog.tsx).
  • «Reemplazar el de aquí» keeps what belongs to this computer: the deck's id, its name, its tags, favourite, notes, language override and game history (partidas points to the deck id). It takes from the incoming deck its cards (with new card ids), its settings and default back, the play lists in the package (the old ones pointed to card ids that no longer exist, so they are deleted) and the cover if it brings one (otherwise none). The lineage stays and actualizado becomes the incoming one, so both now read «same version». A game saved halfway should keep working: the saved game carries its own prepared decks (Storage and Blobs). Inferred: not retested with a replace.
  • «Guardar los dos» creates another deck (new id, its own lineage, renamed «… (importado)» if the name exists). If there were several local decks with the lineage, conflicto_en offers the most recent one.
  • The import is all or nothing per deck: if a later step fails, the new deck is deleted (import_con closes with that rule), and a replaced deck goes back to how it was: before touching it import_con keeps a Copia (the deck, its play lists, its cover and its lineage) and, if anything after saving the new content fails, Copia::restaurar puts them back. Why a copy and not a transaction: each step asks for the database connection on its own, so they do not fit in a single one. Cutting a transfer halfway leaves nothing half-written in the library; decks that had already arrived whole stay (each is complete).

Sharing and opening the .pbdeck

The reserve for when there is no common network or relay: the file through any app.

  • «Compartir…» in a deck's menu (compartir-dialog.tsx, deck_paquete_info first, to show the weight with and without proxies). On Android it writes the package to the cache and offers it through the system sheet (deck_paquete_compartir → compartir_fichero); on a computer it is «Guardar como…» (deck_paquete_guardar, which also accepts a content://). With several decks selected the dialog only offers sending to another computer.
  • Opening one (the library's «Importar .pbdeck», or on Android «Abrir con…» from Files, Downloads or a message, see Deck Package Format) always opens the same import dialog as receiving (importar-dialog.tsx): paquete_leer reads the manifest and cover without importing (package::resumen), shows the same choices (with or without proxies, replace or keep both) and paquete_importar imports with them.

The screen (src/features/pasar/)

«Enviar / Recibir» in the library bar (icon only on a phone) opens pasar-dialog.tsx, a dialog that cancels what is running when it closes. Tab «Enviar» (enviar.tsx): which decks, with or without proxies, and to whom (nearby list, scan a QR, or paste the code; the code field validates the prefix with pasar_codigo_comprobar). Tab «Recibir» (recibir.tsx): the QR and code with a countdown, buttons to copy it and, on a phone, to send it with another app, a line saying whether the code already works from afar (distancia) and another whether this computer shows up on the local network, «Código nuevo» when it expires, the accept request, the offer (oferta.tsx), the progress and a result per deck (piezas.tsx) with «Abrir». The stores and events are in estado.ts and store.ts; no decision lives in the screen. Errors are translated by motivo (cuentas.ts::MOTIVOS) to texts of the pasar area of the 15 dictionaries.

Commands (commands/pasar.rs)

pasar_recibir_abrir (Cancelled if it is closed before it finishes opening), pasar_recibir_estado, pasar_recibir_cerrar, pasar_recibir_cancelar, pasar_compartir_codigo (the system's share sheet with the code; Android only), pasar_peticion (accept or refuse the 8-digit request), pasar_responder (the offer decisions), pasar_cerca_buscar / pasar_cerca_parar, pasar_codigo_comprobar, pasar_enviar, pasar_enviar_cancelar, pasar_emisor_estado, pasar_emisor_olvidar, pasar_completar (missing images), deck_paquete_info, deck_paquete_guardar, deck_paquete_compartir, paquete_leer and paquete_importar. States use ts-rs types; those with generic names carry #[ts(rename = "…Pasar")] (DecisionPasar, DestinoPasar, EleccionPasar, ProgresoPasar, RemotoPasar, ResolucionPasar, ResultadoPasar, ConflictoPasar, EquipoCerca) because ts-rs writes one file per name and would overwrite the game's Destino, Decision and Resultado.

The code is written against a small trait (pasar::Entorno, implemented by AppHandle) so the exchanges can be tested with two nodes in one process and no Tauri.

Edge cases and decisions

  • Sending to yourself (a code of the same node) fails with uno_mismo; a computer that left the nearby list fails with no_esta.
  • Only one send at a time (Busy, motivo: enviando); an offer takes 1–32 decks.
  • «Send more» and «Receive another». The screen follows Rust's events (pasar://emisor, pasar://receptor) with a hook that keeps the last event it received, and that last event is the "finished" one. Once an exchange is over, pressing «Send more» or «Receive another» has to go back to the start, but the hook would keep returning the old "finished" state and it would win over whatever Rust answers next (pasar_recibir_abrir returns the new state of the receiver). So the panel sets the last event aside (apartado in enviar.tsx and recibir.tsx): it ignores that exact event until a newer one arrives. «Send more» also tells Rust to forget the last send (pasar_emisor_olvidar); «Receive another» opens «Receive» again and drops the previous first state. Without this, both buttons looked dead.
  • Cancelling. Either side can cancel; the other sees it. What had already arrived whole is kept and shown in the results; the deck that was in flight does not exist.
  • Memory. Neither side holds a package in memory, because a package has no cap: sending reads and writes a few images at a time, and package::import_con reads, checks and stores one image at a time (Deck Package Format). The peak on the receiver is the largest image, not the deck.
  • Background (mobile). During an exchange the screen stays on (Entorno::pantalla_encendida, counted with the game table by android::UsoPantalla, so finishing one does not switch the screen off for the other), and the screen asks to keep the app open. There is still no foreground service: if the person leaves the app during a long transfer, Android can cut its network, and the exchange ends with cortado. A dropped transfer starts that deck again from the beginning; Inferred: resuming would be the next thing to add for very large decks over poor coverage.
  • Speed through a relay. Two phones behind carriers that do not allow a direct path go through the relay the whole way. Today that is n0's public relays, whose rate limit is not published and which n0 does not guarantee for production use. Measured with velocidad_por_el_rele (no direct path possible, both ends on the same machine): about 1 MB/s, so 136 MB takes a little over 2 minutes and 1 GB about 17. Setting RELE_PROPIO to the project's own relay removes that limit; iroh's own relay has no rate limit by default.
  • A closed «Recibir» cannot come back to life. abrir may wait up to 5 s for the relay, and meanwhile the person may close the screen or change tab. Without care the wait ended by leaving a receiver nobody could see, announcing itself and holding the node. Pasar::pedido_recibir goes up on each open and close, and an abrir that finds it changed returns Cancelled, which the screen ignores.
  • Android emulator. Its network does not pass multicast and the computer cannot call it, so between a computer and the emulator the emulator calls, or the relay is used; «nearby computers» between a computer and a phone is only really testable on a real Wi-Fi.
  • The offer's tamano is what the package would weigh as sent (package::tamano_estimado, the sum of the images, without building it; when optimized, an estimate by pixels, peso_jpeg, because knowing the real figure means compressing, and the receiver checks its free space again with the real package) and tamanoOriginales what it would weigh without proxies.

Tests

  • pasar/pruebas.rs (real nodes in one process, no relay unless noted): three decks (one with proxies, one «solo originales», one refused); a one-use code and a bad one do not let anyone in; a node that does not expect a deck closes without reading; an existing deck asks, and «Reemplazar» keeps what is local; cutting halfway leaves nothing half-done; cancelling from the receiver stops the send; a thousand-deck offer is cut and the nickname arrives clean; a package that does not fit on the disk is cut (and the sender learns why), an offer that does not fit is refused before anything is sent, and a package with another hash is not imported; without the announcement's pass nobody is even asked; closing «Recibir» while it opens leaves nothing behind; with a code the sender waits for the other to come back; frames, codes and covers damaged at random never panic (lo_que_llega_estropeado_al_azar_nunca_revienta), and a sender of garbage leaves «Recibir» in a final state (un_emisor_que_manda_basura_no_deja_recibir_colgado); an optimized deck arrives in JPEG and the sender's stays as it was; the 8-digit code accepts someone from the local network; the code works through the relay with no direct addresses (el_codigo_vale_por_el_rele_sin_direcciones_directas, against n0's real relays, so it needs internet). By hand, with --ignored and PB_PLAZO_REAL=1: un_envio_de_mas_de_500_mb_llega_entero (720 MB over real nodes, and the memory stays well below the package) and velocidad_por_el_rele (nodes with no IP transport at all, so everything goes through n0's relay; PB_MB sets the size).
  • pasar/mod.rs: the ceder loop test. red/pruebas.rs: receiving a deck with a game open. red/nodo.rs: two nodes connect over deck/1.
  • package.rs (lineage round trip, «Guardar los dos», a strange lineage, replacing, a 3.2.3 package without lineage still imports, «solo originales» weighs less, an oversized manifest is not read; by hand with --ignored, un_paquete_de_mas_de_4_gb_va_y_vuelve for ZIP64), store/linaje.rs, proxy-core::compartir (a deck of each card type keeps ids, play list and marks) and src/features/pasar/__tests__/.

Not verified

Tested on the Android emulator against a computer, across the emulator's NAT: a 60 MB deck by code, the progress in MB, the screen staying on during the exchange, a 100 GB offer refused with sin_espacio, the share sheet, closing «Recibir», and the screens upright and sideways. Written from the code and its tests, and not exercised in the field at the time of writing: two real phones on mobile data, scanning a QR with a real camera, «nearby computers» on a real phone, and pasting a long code by hand in the GUI.

Where to start when extending

  • A new field in the offer: DeckOfrecido (tipos.rs), oferta_de (enviar.rs), its check in validar_oferta, and pnpm bindings. Anything that the other computer sends is untrusted: cap it and clean it.
  • A new protocol message: add it to AlReceptor/AlEmisor; if old and new apps cannot talk, bump VERSION and the ALPN (proxy-builder/deck/2, keeping deck/1 on crear until it can go).
  • Never accept connections on the node from a new place without calling pasar::ceder.
  • A new rule for «which is the same deck» belongs in store/linaje.rs, not in the screen.

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