Repository navigation
Deck Package Format
English · Español
A .pbdeck file is a ZIP that carries one deck together with every image it uses, so it can be moved to another computer (or backed up) without losing anything. Code: src-tauri/src/package.rs (export, import, blobs_del_deck), commands deck_export_package / deck_import_package in src-tauri/src/commands/library.rs, and, for the import dialog, sharing and passing decks, paquete_leer, paquete_importar, deck_paquete_info, deck_paquete_guardar and deck_paquete_compartir in src-tauri/src/commands/pasar.rs. On Android the paths are content:// URIs and go through src-tauri/src/fichero.rs.
The format is deliberately boring: a JSON manifest plus the original image files named by their SHA-256. Import distrusts everything in the file.
x.pbdeck
├── manifest.json (Deflate-compressed)
└── blobs/
├── <sha256>.jpg|png|webp (stored, not recompressed)
└── ... (one entry per distinct image)
Serialized from the private Manifiesto struct (pretty-printed JSON):
| Field | Type | Meaning |
|---|---|---|
format |
string | Must equal FORMATO = "proxy-builder-deck". |
version |
u32 | Package version, currently VERSION = 2. |
deck |
Deck |
The whole Deck-Model value, as JSON (cards, settings, default back, decklistText, marks, designs...). |
colecciones |
Vec<Collection> |
The designer collections referenced by the designs in the deck (design.collection). #[serde(default)]. |
biblioteca |
optional | Library data: etiquetas ([{nombre, color, icono?}]; icono, an Iconify id, is written only when the tag has one), notas (string), idioma (optional language override), linaje, actualizado and portada (see «Import options, lineage and «without proxies»»), listas (the game table's game lists, [ListaDeJuego] with copy ids of this deck; omitted when empty). #[serde(default)]; every inner field also defaults. |
Short example of the shape (abridged):
{ "format": "proxy-builder-deck", "version": 2,
"deck": { "id": "...", "name": "...", "cards": [ ... ], "settings": { ... } },
"colecciones": [],
"biblioteca": { "etiquetas": [{ "nombre": "Tournament", "color": "red" }],
"notas": "", "idioma": "es" } }blobs_del_deck(deck) returns the set (a BTreeSet, so order is stable) of hashes of: the default back, every card front, every Dfc/Custom back, and every designer image (CardDesign::blobs(): art, "crop" source blobs, own emblem, "before" front and back). escribir adds the emblem of each exported collection. Each is written once as blobs/<hash>.<ext> where ext comes from the stored file.
| Not included | Consequence on the other computer |
|---|---|
Imported fonts and frame packs (app_data_dir/fuentes, marcos/) |
Designs fall back to the built-in ones ("the house" fonts and packs). |
| Favourite flag | Not part of Biblioteca. (The chosen cover card does travel, as portada.) |
| Card ids | Regenerated on import. |
| Scryfall metadata cache | Not needed: CardMeta is inside each card. |
Package version
|
What it adds |
|---|---|
| 1 | Deck + images. |
| 2 | Designer data (DeckCard.design) and the collections they use (colecciones). |
biblioteca (tags, notes, and later the language override, the game lists, the lineage, its time and the cover) was added without bumping version: it is optional, and serde ignores unknown fields in older apps, which simply do not see it. The same pattern is used for later Deck fields (see Deck-Model).
Import rules, in order (package::import):
- A ZIP that cannot be read is
InvalidInput(motivo = zip); an IO error isIo. - No
manifest.json, or one that does not deserialize intoManifiesto:InvalidInput,motivo = manifiesto. Note the parse happens while scanning entries, before the version check, so a package from a future app whosedeckJSON no longer fits the current types reportsmanifiesto, not "unsupported". Inferred from the order in the code. -
format != "proxy-builder-deck":InvalidInput,motivo = formato. -
version > VERSION:Unsupportedwithparams.version. This is how a 1.x app rejects a v2 package (it would otherwise lose the designs on its next save). - A package with a lower version imports normally; missing newer fields take their serde defaults.
The package version is independent of DECK_SCHEMA_VERSION (both happen to be 2 now). The deck's own schema_version inside the manifest is not checked on import; decks::save stamps the current value.
flowchart TD
A["menu-exportar.tsx / library-screen.tsx: guardarAhora()"] --> B["guardarParaExportar(): pick .pbdeck path"]
B --> C["deck_export_package(id, path)"]
C --> D["decks::open: deck from the database"]
D --> E["package::export"]
E --> F["escribir: manifest + collections + tags/notes/language"]
F --> G["for each hash in blobs_del_deck + emblems: BlobStore::locate, read file"]
G --> H["ZIP written to a temporary file next to the destination, a few images at a time"]
H --> I["sync, then rename over the destination"]
I --> J["returns size in bytes to the UI"]
Details:
- The screen first flushes pending edits (
guardarAhora()), then asks for a destination through the shared dialog helperguardarParaExportar(src/lib/dialogos.ts, which remembers the last export folder), then calls the command. Two entry points: the "..." menu next to "Export PDF" (src/features/export/menu-exportar.tsx) for the open deck, and the library screen's per-deck action (src/features/library/library-screen.tsx). - The export reads the deck from the database (
decks::open), not from the screen, which is why the flush comes first. -
A package has no size limit, so it is never held whole in memory. The ZIP is written to a temporary file beside the destination, a few images at a time, then synced and renamed; a crash never leaves a partial
.pbdeck. Past 4 GiB it becomes ZIP64 by itself (un_paquete_de_mas_de_4_gb_va_y_vuelve). A missing blob file isNotFound(params.blob). - Deterministic output: entries use a fixed timestamp (
zip::DateTime::default()), blobs come in sorted order, so exporting the same deck twice yields byte-identical files (testel_mismo_deck_da_el_mismo_fichero). - Images are
Stored, the manifest isDeflated: images are already compressed.
flowchart TD
A["library-screen.tsx: abrirParaImportar(.pbdeck)"] --> B["deck_import_package(path)"]
B --> C["package::import(reader)"]
C --> D["for each ZIP entry: leer_entrada"]
D --> E{"name"}
E -- "manifest.json" --> F["parse Manifiesto"]
E -- "blobs/xxxx.ext" --> G["validate name, SHA-256 must match"]
E -- other --> H["ignored"]
F --> I["check format and version"]
G --> I
I --> J["every image the deck needs is in the zip or already local?"]
J --> K["BlobStore::put_bytes for each image (origin taken from the deck)"]
K --> L["collections: add those whose id does not exist locally"]
L --> M["new deck id + new card ids, rename if the name exists"]
M --> N["decks::save"]
N --> O["tags, notes, language, game lists"]
O --> P["returns the new Deck, library reloads"]
Step-by-step behaviour:
-
Entries.
leer_paquetefirst walks the ZIP's index without decompressing anything, and reads only the manifest (at most 128 MiB,MAX_MANIFIESTO). The images are read afterwards, one at a time, and only those the deck uses and this computer lacks: what is in memory is the largest image, not the deck. Names pass throughZipFile::enclosed_name, which rejects..and absolute paths (zip-slip), then backslashes are normalised. Unsafe names fail withInvalidInput(motivo = ruta). Entries other thanmanifest.jsonandblobs/...are silently ignored. -
Blob entries. The name must be
blobs/<hash>.<ext>with a valid 64-hex<hash>and no extra slash (es_huella); otherwiseruta. The bytes are hashed and must equal the name's hash or the whole import fails (motivo = huella,params.blob). Images are stored as they pass, so one failing halfway leaves earlier ones in the store with nothing using them; the deck does not exist, andBlobStore::gccollects them. The extension in the file name is not trusted:BlobStore::put_bytesprobes the real format from the bytes. -
Completeness. Every hash in
blobs_del_deck(&deck)must either be in the ZIP or already exist locally (blobs.locate), elseInvalidInput(motivo = falta_imagen). This check does not cover collection emblems; those are verified later bycolecciones::save(blob_missing). -
Images.
put_bytesstores each image (rejecting non-images withUnsupported/Imageand sides over 12000 px). The origin is looked up in the deck (deck_origen), so a Scryfall image keepsScryfallPng/ScryfallLarge(and therefore edge-darkening behaviour); unknown ones becomeUpload. Existing blobs are deduplicated for free. -
Collections. A collection whose
idalready exists locally is not replaced: this computer's version wins. Others are saved throughcolecciones::save, which also writes the emblem intoblob_refs. -
Deck identity. The imported deck always gets a new uuid and every card a new uuid (so importing the same file twice yields two independent decks). If a deck with exactly the same
nameexists, it is renamed to"<name> (importado)"(this Spanish suffix is hard-coded inpackage.rs, which departs from the rule that Rust never writes user-facing prose). WithDestino::Reemplazarno deck is created: the existing one is rewritten and keeps its id and name (see «Import options, lineage and «without proxies»»). -
Library data. Tags: each name is created or matched case-insensitively to an existing local tag; an existing tag keeps its local colour and its local icon; the package's icon is only set on a tag that has none. Notes are applied if non-empty. The language override is applied only if it is one of the supported codes (
etiquetas::IDIOMAS), otherwise ignored. Game lists are saved again with a new id and their copy ids translated to the new card ids (nuevos, the map built while renumbering the cards); ids that are not in the deck are dropped (Game Table). -
Result. The new
Deckis returned and the library reloads. The command opens the file withBufReader.
Since lineage and passing decks (Deck Sharing), importing is package::import_con(reader, blobs, db, &Opciones); import is the same with the defaults. The screen no longer calls a bare import: the library's «Importar .pbdeck», a file opened from another app and a package received from another computer all open one dialog (features/pasar/importar-dialog.tsx) that first reads the file without importing (paquete_leer → package::resumen: name, card count, whether it carries proxies, what it would weigh without them, lineage, time, and a small cover) and then imports with what the person chose (paquete_importar). deck_import_package is still registered (Inferred: nothing on the screen calls it now).
| Option | Effect |
|---|---|
solo_originales |
The deck goes through proxy_core::compartir::sin_proxies and the collections are dropped; if the deck has no default back, the app's generic one is used. Only the images that remain are stored. |
destino: Nuevo |
A new deck (new ids). It keeps the lineage and time the package carries (a lineage from outside is only accepted if it is a short id, linaje_sano). |
destino: GuardarLosDos |
A new, separate deck: its own lineage, as a duplicate. |
destino: Reemplazar(id) |
The deck with that id is rewritten (see below). Its existence is checked before anything is touched. |
New optional fields of Biblioteca (all #[serde(default)], written only when present, so VERSION stays at 2 and an older app simply ignores them): linaje (the deck's lineage, shared by every copy of the same deck), actualizado (the time in ms of the version's last save, on the computer where it was made) and portada (the chosen cover card, by its id in the package). A package without them (from before) imports as a new deck whose lineage is its own id. Which lineage and what «newer» means are explained in Deck Sharing; the table is deck_linaje (Storage and Blobs).
Replacing (Destino::Reemplazar) keeps what belongs to this computer and takes the rest from the package:
- Kept: the deck's id and name, its tags, favourite, notes, language override and game history.
- Taken: the cards (with new card ids), the deck's settings and default back, the game lists of the package (the local ones pointed at card ids that no longer exist, so they are deleted first) and the cover if the package has one (otherwise none). The lineage stays and
actualizadobecomes the package's. - The tags, favourite, notes and language of a package are only applied to a new deck; a replaced one is not touched.
-
A failed replace undoes itself. Before rewriting,
import_conkeeps aCopiaof the local deck (the deck, its play lists, its cover and its lineage); if any step after saving the new content fails,Copia::restaurarwrites them back and the error is returned. Why not a transaction: each step (decks,listas,etiquetas,linaje) asks for the database connection on its own. A new deck that fails halfway is simply deleted. -
If the replaced deck is the one open, the screen first lends it to Rust (
prestarDeck(id, { conservar: false }), State Stores): pending changes are saved, the autosave does not write over what Rust is writing, and afterwards the deck is reloaded from Rust.
Exporting has matching variants: export_con(…, modo) (deck_paquete_guardar, deck_paquete_compartir), export_en_fichero (writes straight to a file a few images at a time, to send it without holding the ZIP in memory) and tamano_estimado (what it would weigh in a given Modo, without building anything, for the offer). miniatura makes the small JPEG cover. The export of a deck now also writes its lineage, time and cover.
calidades_de lists the images worth trying: only upload PNGs (Scryfall's carry transparent corners), at quality 92, and the «before» images of a design (before.front, before.back), which only serve to remove the design, at 85; an image also used elsewhere keeps the higher one. proxy_core::imaging::compactar::png_a_jpeg then decides from the bytes: it refuses anything under 64 KiB, any real transparency (corners print in the paper's colour), a changed size from an EXIF orientation, and a result that saves under 10 %. A JPEG is never recompressed, so nothing loses quality twice.
The manifest is written after the images because it needs their new hashes (entries are read by name, so order does not matter to any version). cambiar_huellas replaces each hash across the whole JSON rather than field by field, so a reference added to the model later cannot be missed. Two PNGs with the same pixels give one JPEG, and a ZIP cannot hold the same name twice, so each hash is written once. biblioteca.optimizado records it. format and version do not change: to an older app this is an ordinary package with JPEGs in it. The same deck gives the same file. Images are processed in small parallel batches (LOTE_MAXIMO), and avance returning false stops with Cancelled.
The .pbdeck has no MIME type of its own, so the manifest (src-tauri/gen/android/app/src/main/AndroidManifest.xml) has two VIEW intent-filters on the main activity:
-
contentandfileURIs withapplication/zip,application/x-zip-compressedorapplication/octet-streamand a path ending in.pbdeck(pathPatternonly works with ahost, hencehost="*"). There are four patterns, with zero to three more dots before.pbdeck: Android'sPatternMatcherdoes not backtrack, its.*stops at the first dot, and a name such asmy.deck.pbdeckwould not match.*\\.pbdeckalone. - The same types for
contentfrom the Downloads provider only (com.android.providers.downloads.documents): its URIs are numeric (…/document/msf:1234) with no file name, so the path pattern cannot match. Side effect: the app appears in «Abrir con» for any binary in Downloads, and Rust refuses anything that is not a package when reading it.
The android-extra plugin collects the URI of the intent and abrir.rs stores it and emits pbdeck://abierto; if the app was started by that file, the screen was not listening yet and asks for it once with pbdeck_pendiente. When the activity is recreated from Recents after the process died, Android hands over the original intent again; the plugin ignores an intent with FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY, so the same file does not open the import dialog a second time. Nothing is imported at that point: it opens the import dialog above. The URI is a content://, which fichero.rs copies to the cache before reading (a ZIP needs Seek, and a cloud provider may give a pipe); the copy is deleted afterwards.
| Threat | Defence |
|---|---|
Zip-slip (../x, /etc/passwd) |
enclosed_name; rejected with ruta (test una_ruta_con_puntos_se_rechaza). |
| Tampered or swapped image | Hash must match the name (test una_imagen_alterada_se_rechaza). Also protects against mislabelled files. |
| Zip bomb on a single entry |
MAX_ENTRADA = 512 MiB per image and MAX_MANIFIESTO = 128 MiB checked against the declared size, and a read never goes past that declared size; tamano error. |
| Zip bomb across many entries, or a package that does not fit | The declared sizes of the new images are added up and compared with the free disk before storing anything (BlobStore::cabe, leaving 200 MB free); sin_espacio error. Memory stays at one image. |
| A manifest asking for too much |
validar_manifiesto, before anything is done with it: at most 100,000 cards, a 500-character name, 1 MiB of notes, 1,000 play lists, tags and collections; otherwise manifiesto. Control characters are removed from the deck's name. |
| A card id that tries to be another path |
Scryfall::card only asks for ids shaped like a Scryfall id: a deck cannot make the app call another API route. |
Non-image payload under blobs/
|
probe in put_bytes rejects it. |
| Oversized images | 12000 px side cap in put_bytes. |
| Wrong file type |
format check; test sin_manifiesto_o_de_otro_formato_no_entra. |
| Package from a newer app |
Unsupported. |
| Deck referencing a missing image | Rejected up front (falta_imagen) and again by decks::save (blob_missing). |
Known gaps to be aware of (read from the code; Inferred where noted as risk):
- There is no fixed cap on the number of entries. A crafted package can make the import work hard (it decompresses up to the free disk), but it can no longer exhaust memory. Inferred: the import cannot be cancelled while this runs.
- Import is not transactional across steps. Images and collections are written before the deck is saved, and tags are applied after. If a later step fails, earlier images stay in the blob store as orphans (garbage collection removes them after the 24 h grace period, see Storage-and-Blobs) and a newly saved collection stays.
- The user sees a generic error text, because the screen translates by error code only (
src/lib/errores.ts, for exampleInvalidInputbecomes "Los datos no son válidos."); the specificmotivois not shown. See IPC-and-Error-Handling.
| Test | Guards |
|---|---|
ida_y_vuelta_en_un_equipo_limpio |
Round trip into an empty library, new ids, same blob set, second import renamed "(importado)". |
las_etiquetas_y_las_notas_viajan |
Tags merge with existing ones by name, notes and language. |
las_listas_para_jugar_viajan_con_los_ids_nuevos |
A game list travels, follows the new card ids and loses an id that was not in the deck. |
un_deck_disenado_viaja_con_su_arte_y_su_coleccion |
Designs, art, emblem and collection survive, and blob_refs protects the art from GC on the new machine. |
el_mismo_deck_da_el_mismo_fichero |
Deterministic output. |
el_linaje_hace_ida_y_vuelta_entre_equipos |
Export, import elsewhere, export and import back: same lineage and time. |
guardar_los_dos_es_otro_linaje_y_un_duplicado_tambien |
«Guardar los dos» and a duplicate get their own lineage. |
un_linaje_raro_de_fuera_se_ignora |
A crafted lineage is not accepted. |
reemplazar_conserva_lo_de_aqui_y_toma_las_cartas_las_listas_y_la_portada |
What replacing keeps and takes. |
solo_originales_no_guarda_las_imagenes_propias_y_pesa_menos |
Import without proxies. |
| a package from 3.2.3 without lineage | Still imports, as a new deck. |
una_imagen_alterada_se_rechaza |
Hash validation. |
un_paquete_estropeado_al_azar_…, un_manifiesto_estropeado_al_azar_…, una_imagen_estropeada_al_azar_…
|
Mutation tests with a fixed seed: a damaged package never panics, an error leaves no deck, and what does get in is whole. 300 rounds in CI, PB_ITERACIONES for more. |
una_ruta_con_puntos_se_rechaza |
Zip-slip. |
sin_manifiesto_o_de_otro_formato_no_entra |
Manifest and format checks. |
- Adding data to the package: make it an optional field with
#[serde(default)]inManifiestoorBiblioteca, fill it inescribir, apply it at the end ofimport. BumpVERSIONonly when an older app would lose or misread data (as designs did); otherwise keep it unchanged so older apps keep importing. - Adding a new place that holds image hashes: include it in
blobs_del_deck, otherwise the package will be incomplete and import will fail withfalta_imagen. - Hardening import: add total-size and entry-count caps in the loop of
import.
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
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
- 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
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