Repository navigation
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.
-
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.tsholds 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.
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
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,abrirwaits 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 withEstadoReceptor.distancia = falseand 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 setsdistancia. 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_PROPIOis 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 sharedEndpointthat 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 ispasar::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 aMulticastLockis held, so the app takes it while «Recibir» or the search is open (Entorno::multicast→android::multicast, theandroid-extraplugin) and releases it afterwards. The plugin has a single lock with no counter, so Rust counts the uses (UsoMulticast::BuscarandRecibir): 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::nuevofails 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 whenHola.pasematches, compared in constant time. Otherwise it is rejected withno_admitebefore 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 uses100.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_nuevoreturns 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::llamarkeeps 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.
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
-
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 withoferta_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. Atamanolarger than the disk is refused before a single byte is written. Either way the receiver ends withInvalidInput,motivo: sin_espacio, and closes withCIERRE_SIN_ESPACIO(42), which the sender reads asotro_sin_espacio; running out of disk while writing (StorageFull) gives the samesin_espacio. -
A hostile peer is assumed (the module's first rule).
validar_ofertarejects 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 bypackage::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 exactlytamanobytes, computes the SHA-256 while writing and compares it withhuella; a mismatch answersHecho {ok: false, motivo: "huella"}and the exchange is cut. Only thenpackage::import_conruns, 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 forListootherwise). While a package arrives, every read has 2 minutes to bring at least one byte (protocolo::plazo_sin_bytes, inrecibir_bytes): a sender that stalls halfway does not keep «Recibir» busy forever, and the receiver ends withNetwork,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(differentVERSION; 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 codeCIERRE_NO_ESPERADO(40) without reading anything; the sender turns that intono_espera.CIERRE_CANCELADO(41) is a cancel andCIERRE_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 withNetwork,motivo: cortado, because a tunnel or lost coverage is not somebody pressing «Cancelar». -
A
motivofrom the other computer is never trusted as is. It is foreign text, so the sender maps it through a closed list: aRechazokeepsversion,codigo,no_admite,caducado,rechazadoorocupadoand anything else becomesrechazado; a failedHechokeepshuellaand anything else becomesimportar(motivo_de_hecho, testel_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 oferror::red:Networkwith itsmotivoand, when there is an underlying error, its whole chain of causes written toproxy-builder.logand carried indetalle(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.
🛑 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 returnedGuardiawhile it lives. Those loops senddeck/1toMesa::atender_deck, which spawnspasar::atender. Any other ALPN is theirs. - The rest of the time,
pasar/runs its own loop (bucle, started byregistrarwhen «Recibir» opens), which accepts onlydeck/1. 🛑 It lives only while «Recibir» is open:recibir::cerrarandrecibir::cancelarcallpasar::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 isRed::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 (cederstops 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 byregistrarand cleared byretirar). Taking aGuardiaaborts the own loop; dropping the last one restarts it only if «Recibir» is still open (reanudar, which needsarrancar).registrardoes not restart a loop that already exists. With «Recibir» closed, a game just leaves a temporary note inREGISTROthat disappears with itsGuardia, and there is nothing to restart. -
atendercloses at once (without reading) anydeck/1connection 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.
«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).
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::saveinserts 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). -
actualizadois the time of the version's last save, on the computer where it was made (decks::saveanddecks::renamerefresh it, nothing else touches it). It travels inBiblioteca.linaje/actualizadoof 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 (testun_linaje_raro_de_fuera_se_ignora). A package from before lineage imports as a new deck.
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"]
- A deck that matches must be resolved before the answer is sent (
falta_resolucionotherwise; the screen only enables the button when every accepted existing deck has a resolution). The receiver checks again in Rust at import time (paquete_importarcallsconflicto_enitself) 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.pbdeckwith «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 (
partidaspoints 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 andactualizadobecomes 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_enoffers the most recent one. - The import is all or nothing per deck: if a later step fails, the new deck is deleted (
import_concloses with that rule), and a replaced deck goes back to how it was: before touching itimport_conkeeps aCopia(the deck, its play lists, its cover and its lineage) and, if anything after saving the new content fails,Copia::restaurarputs 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).
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_infofirst, 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 acontent://). 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_leerreads the manifest and cover without importing (package::resumen), shows the same choices (with or without proxies, replace or keep both) andpaquete_importarimports with them.
«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.
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.
-
Sending to yourself (a code of the same node) fails with
uno_mismo; a computer that left the nearby list fails withno_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_abrirreturns the new state of the receiver). So the panel sets the last event aside (apartadoinenviar.tsxandrecibir.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_conreads, 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 byandroid::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 withcortado. 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. SettingRELE_PROPIOto 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.
abrirmay 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_recibirgoes up on each open and close, and anabrirthat finds it changed returnsCancelled, 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
tamanois 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) andtamanoOriginaleswhat it would weigh without proxies.
-
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--ignoredandPB_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) andvelocidad_por_el_rele(nodes with no IP transport at all, so everything goes through n0's relay;PB_MBsets the size). -
pasar/mod.rs: thecederloop test.red/pruebas.rs: receiving a deck with a game open.red/nodo.rs: two nodes connect overdeck/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_vuelvefor ZIP64),store/linaje.rs,proxy-core::compartir(a deck of each card type keeps ids, play list and marks) andsrc/features/pasar/__tests__/.
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.
- A new field in the offer:
DeckOfrecido(tipos.rs),oferta_de(enviar.rs), its check invalidar_oferta, andpnpm 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, bumpVERSIONand the ALPN (proxy-builder/deck/2, keepingdeck/1oncrearuntil 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.
Use and build · Uso y desarrollo
- User-Guide · Guía de usuario
- Development-Setup · Preparación del entorno de desarrollo
- Testing-and-CI · Tests y CI
- Releases-and-Auto-Updates · Versiones y actualizaciones automáticas
- Conventions · Convenciones
- Rename-to-Tapete · Cambio de nombre a Tapete
Architecture · Arquitectura
- Architecture · Arquitectura
- IPC-and-Error-Handling · IPC y manejo de errores
- Rust-App-Layer · Capa de aplicación Rust
Data · Datos
- Deck-Model · Modelo del mazo
- Storage-and-Blobs · Almacenamiento y blobs
- Deck-Package-Format · Formato del paquete de mazo
- Deck-Library · Biblioteca de mazos
Getting cards · Conseguir cartas
- Decklist-Parsing · Parseo de la lista del mazo
- Scryfall-Integration · Integración con Scryfall
- External-Sources · Fuentes externas
- Card-Identification-and-OCR · Identificación de cartas y OCR
Layout, images, PDF · Maquetación, imagen y PDF
- Layout-Engine · Motor de maquetación
- Imaging-and-PDF · Imagen y PDF
- Export · Exportación
- The-Card-Stamp · El sello de las cartas
- Live-Preview · Vista previa en vivo
- Statistics · Estadísticas
Card designer · Diseñador de cartas
- Designer-Overview · Visión general del diseñador
- Designer-Engine · Motor del diseñador
- Designer-Composition · Composición del diseñador
- Frame-Packs · Packs de marcos
- Designer-App-Layer · Capa de aplicación del diseñador
- Designer-UI · Interfaz del diseñador
- Card-Editor-Dialog · Diálogo Editar carta
Game table · Mesa de juego
- Game-Engine · Motor de la mesa
- Game-Table · Mesa de juego
- Online-Play · Juego en línea
- Friends-and-Messages · Amigos y mensajes
Mobile · Móvil
Frontend · Frontend
- Frontend-Architecture · Arquitectura del frontend
- State-Stores · Stores de estado
- Internationalization · Internacionalización
- Themes-and-Skins · Temas y skins
- Icons · Iconos
- Settings-and-Shell · Ajustes y shell
- Tutorials · Tutoriales
- Decklist-Panel · Panel de la lista del mazo
- Add-Card-and-Variants · Añadir carta y variantes
- Card-Search · Búsqueda de cartas
- Set-Browser · Explorador de sets
Reference · Referencia