Repository navigation
Friends and Messages
English · Español
«Amigos» lets two Tapete installations become friends, write to each other with end-to-end encryption and send each other invitations to an online game. It is the only feature that talks to the Tapete server. The game itself never goes through that server: it stays peer to peer over iroh with the direct code (see Online Play). The server stores public keys, who is friends with whom, and message bytes it cannot read.
Where it lives:
| Part | Files |
|---|---|
| Rust logic |
src-tauri/src/amigos/ (mod.rs, firma.rs, cifrado.rs, cliente.rs, sincronizar.rs, avisar.rs, fondo.rs, pruebas_servidor.rs, vectores/*.json) |
| Android, app closed |
src-tauri/plugins/android-extra/android/src/main/java/com/estigio/proxybuilder/extra/AvisosAmigos.kt (WorkManager Worker and Nativo), android::avisos_amigos, android::pedir_permiso_avisos
|
| Local storage |
src-tauri/src/store/amigos.rs, migration src-tauri/src/store/migrations/0010_amigos.sql
|
| Commands |
src-tauri/src/commands/amigos.rs (amigos_*), plus enlace_invitacion, enlace_amigo in src-tauri/src/enlace.rs
|
| Screen |
src/features/amigos/ (amigos-dialog.tsx, consentimiento.tsx, lista.tsx, conversacion.tsx, seguridad.tsx, boton-amigos.tsx, enlaces.ts, visto.ts), src/stores/amigos-store.ts
|
| Server | A separate Next.js + SurrealDB project. Its contract is its docs/api.md (API v1, routes under /api/v1/). |
Nothing is sent to the server until the person opens «Amigos» and accepts. consentimiento.tsx lists what is sent (nickname, the device's public key, platform and app version, the friendship graph and «last seen», the encrypted messages) and what is not (message text, invitations in clear, decks, cards, games, real name, e-mail, contacts), with a link to the server's privacy policy (amigos_privacidad opens <server>/privacy in the system browser, because the webview never goes online).
Accepting calls amigos_activar, which writes the setting amigos.activo = "1" (app_settings), starts the sync loop and runs a first sync. Every other command that touches the server checks exigir_activo and fails with InvalidInput, motivo: amigos_apagado while it is off. Why: the rest of the app (decks, PDF, the game table, even online play) works with no account and no central server; a friends list is opt-in, not a precondition.
There is no account and no password. The identity is the ed25519 secret key of the iroh node (AppState.red.clave(), stored in app_data_dir/identidad/clave, sealed with the Android Keystore on Android). The same key:
- signs every request to the server, and
- derives the encryption key for messages.
Why one key: exporting and importing the identity (red_identidad_exportar/red_identidad_importar) carries everything, and on Android the existing Keystore sealing already protects the encryption key; there is no second secret to store, back up or seal.
Every route except GET /healthz carries four headers (firma.rs):
| Header | Value |
|---|---|
X-Tapete-Key |
ed25519 public key, 32 bytes, lowercase hex |
X-Tapete-Timestamp |
Unix seconds, decimal |
X-Tapete-Nonce |
16 random bytes, lowercase hex, new for each request |
X-Tapete-Signature |
ed25519 signature (64 bytes), lowercase hex |
The signed string is UTF-8, lines joined by \n, with no trailing newline (firma::cadena):
TAPETE-v1
<METHOD in upper case>
<path?query exactly as it travels, e.g. /api/v1/sync?since=…>
<timestamp>
<nonce>
<sha256 hex of the raw body (of the empty string when there is no body)>
The server accepts a timestamp within ±300 s (clock_skew otherwise, which the screen explains as «set the clock right») and remembers each nonce for 24 h (replayed_nonce). If the system has no randomness, firmar still sends the request with a zero nonce: a server refusal is better than silently reusing a nonce.
cifrado::secreto derives an X25519 secret from the ed25519 seed with HKDF-SHA256 (empty salt, info = "tapete/cifrado/v1", 32 bytes). Its public half is published in POST /hello together with an ed25519 signature over
TAPETE-v1-cifrado
<x25519 public key, lowercase hex>
The server checks that signature before storing it, and the app checks it again (cifrado::clave_de_amigo) before encrypting to a friend or decrypting from them: a server that swapped the key would need the friend's identity key to sign the swap. A friend whose key does not verify is shown with claveValida = false and cannot be written to (bad_encryption_key).
Each message is crypto_box (X25519 + XSalsa20-Poly1305, libsodium's box) between my derived secret and the friend's X25519 key, with a fresh random 24-byte nonce (cifrado::cerrar). The bytes are:
[version = 1][nonce: 24 bytes][box]
sent as standard base64 in ciphertext. Inside the box is a small JSON Carta: { "tipo": "texto", "texto": … } or { "tipo": "invitacion", "codigo": … }, plus enviado (milliseconds, sender's clock). Text is limited to 2000 characters (MAX_TEXTO, the same MAX_MENSAJE on screen) and the encrypted message to 4096 bytes (MAX_CIFRADO, the server's maxMessageBytes). cifrado::abrir returns None for a wrong version, a tampered box or a message from another key; the sync loop logs and skips it.
Stated plainly, as the code comment does:
- No forward secrecy. The box key is static per pair. Whoever steals a device's identity can decrypt any old message they recorded.
- Metadata is visible to the server: who is friends with whom, who writes to whom and when, message sizes, and «last seen». Never what is said.
Messages are deleted from the server as soon as the recipient acknowledges them, and expire there after 30 days. A recipient can have at most 500 uncollected messages (recipient_inbox_full).
cifrado::numero_de_seguridad gives both sides the same 12 digits (1234 5678 9012): SHA-256 of tapete/seguridad/v1\n<identity A>\n<encryption key A>\n<identity B>\n<encryption key B> with the two pairs sorted by identity, first 8 bytes as a big-endian integer modulo 10¹². If the numbers match when compared in person or over another trusted channel, nobody is in the middle; the person marks the friend as verified (amigos_verificar, seguridad.tsx).
The app remembers each friend's encryption key as last seen (amigos_claves.clave_cifrado, store::amigos::anotar_clave). If a later sync brings a different key, the row gets cambio = 1 and loses verificado, and the friend shows claveCambio until the person looks at it. Why: a changed key is either a legitimate identity import on the friend's side or an attack; only the person can tell, so the app flags it instead of deciding.
sincronizar::arrancar runs one background task (guarded by en_marcha); sincronizar::sincronizar is one round, serialized by Amigos.turno (the server allows only 3 concurrent requests per key).
Each round:
-
POST /helloonly if the nickname, the app version, the encryption key or the server base URL changed since the last one (a fingerprint stored asamigos.hola) or there is no friend code yet. The answer gives the friend code (TAP-XXXX-XXXX, stored asamigos.codigo). The nickname is the device name used for online play (pasar::nombre_equipo, up to 32 characters,Tapeteif empty). - Retry my messages still
pendiente(sent while offline), with the sameclientIdso the server does not duplicate them. -
GET /sync?since=<cursor>: friends (with their keys,online= seen under 150 s,lastSeen), requests in and out, blocked keys, server notices, new messages and delivery receipts. Each message is verified, decrypted and stored, then all ids are acknowledged withPOST /messages/ack(the server deletes them). If the server saysmoreMessages, the round asks again, up to 10 pages. - Publish the whole state to the screen (
amigos://estado) and, if anyone wrote,amigos://mensajeswith their keys and a system notification.
Timing:
| Situation | Next round |
|---|---|
Something changed on the server (cambio over /events, see below) |
Right away |
| Normal | 60 s (NORMAL) |
A conversation is open (amigos_ritmo(true)) and /events is not connected |
10 s (CON_CHAT), and one round right away when it opens |
App in the background (ciclo.rs calls segundo_plano(true)) |
No calls at all; the loop just waits. Coming back triggers a round |
| Server or network failure | 60 s, 120 s, 240 s, 480 s, then 600 s (MAX_ESPERA) for every further failure; the error is shown in the state and cleared by the next good round |
Actions such as sending, accepting a request or opening the dialog call Amigos::ya() to wake the loop immediately instead of waiting.
sequenceDiagram
participant A as App A (Rust)
participant S as Tapete server
participant B as App B (Rust)
Note over A: amigos_mandar(clave, texto)
A->>A: verify B's key signature, crypto_box, store as «pendiente»
A->>S: POST /messages {to, ciphertext, clientId} (signed)
S-->>A: 201 {id}
A->>A: mark «enviado»
S-->>B: event: cambio (over /events)
loop right away, and every 60 s as a safety net
B->>S: GET /sync?since=cursor (signed)
S-->>B: friends, requests, messages, receipts, cursor
B->>B: verify A's key, open box, store, emit amigos://mensajes
B->>S: POST /messages/ack {ids}
Note over S: messages deleted, receipt recorded for A
end
A->>S: GET /sync (next round)
S-->>A: deliveries [clientId]
A->>A: mark «entregado»
Message states (amigos_mensajes.estado): mine go pendiente → enviado → entregado, or fallido when the server refuses (not friends, inbox full…; network errors leave them pendiente). amigos_reintentar resends a failed one with the same clientId. Received ones are recibido; the unique index on id_servidor stops duplicates when an ack was lost.
While the app is in front it keeps one connection open to the server: GET /events, a Server-Sent Events stream (amigos/directo.rs). It is signed like every other request (the signature only opens it) and carries no data of its own:
| Event | What the app does |
|---|---|
hola (on connect) |
Wakes the sync loop to catch up (Amigos::ya()) |
cambio |
Wakes the sync loop: something of mine changed |
escribiendo {from}
|
Emits amigos://escribiendo with that friend's key |
: latido comment every 20 s |
Nothing; it keeps proxies (Cloudflare cuts after 100 s of silence) and the app's 60 s read timeout happy |
The server sends cambio on a new message, a delivery receipt, a friend request (new, accepted, declined or withdrawn), a friend removing or blocking me or deleting their account, a friend's hello (new nickname or encryption key), and a friend connecting (so they show «online» at once: online is also true while their /events is open). Why no data in the event: everything still goes through sync, with its signatures, permissions and encryption; the stream only says when to look. That keeps the server's push path trivial and the app's logic in one place.
Details:
- One reader (
LectorSse, incliente.rs) cuts the stream into events, tolerating chunks split anywhere and\r\n. -
en_directois true while connected: then an open chat no longer speeds the loop up to 10 s. The 60 s round stays as a safety net (and refreshes «last seen»). - Reconnection: 2 s, doubling up to 2 min; a connection that lasted over a minute resets the wait. A 404 that is not a JSON error from the API (an old server without the route) stops the listener, and only the periodic sync remains. A 404 with
{code}(for examplenot_registered, when the stream races the firsthello) is just retried. -
Amigos.cortar(notify_waiters, so no stale notice cuts the next connection) closes it when the app goes to the background on a phone, when friends are switched off and when the identity changes; the connection also checks on every chunk that it should still be open (app in front, friends on, same key). - On the server the listeners live in the process memory (
lib/dominio/eventos.ts); the server runs as a single PM2 process. A client not connected gets nothing and finds out in its nextsync.
While I write in a conversation, amigos_escribiendo(clave) sends POST /typing {to} at most every 3 s (only with text in the field; sending a message resets the timer). The server forwards it as escribiendo only if we are friends with no block, and stores nothing; its answer is the same either way. The screen keeps «Escribiendo…» for 6 s after the last notice (ESCRIBIENDO_MS, useEscribiendo in amigos-store.ts), and clears it as soon as a message from that friend arrives. It shows under the name in the conversation and in the friend's row of the list.
-
My name («Tu nombre», top of the list,
TuNombreinnombres.tsx) is the same device name used to pass decks (pasar_nombre/pasar_nombre_set, settingred.apodo; empty = the device's own name). Saving it wakes the loop; thehellofingerprint changed, so the next round re-introduces itself and the server tells my friends at once. -
A friend's name and note («Nombre y nota» in the friend's ⋯ menu,
NombreYNota): a name only I see (up to 32 characters,alias) and a private note (up to 500,nota), like the friend notes of some online games. Stored only on this device (amigos_alias, migration 0011) throughamigos_personal(clave, alias, nota); empty clears each. The friend never sees them and the server does not know them. The name I gave is shown first everywhere (nombreAmigo: list, conversation, report dialog, system notifications), with their own name underneath; the note shows as a dashed bar at the top of the conversation (tap to edit) and as an icon in the list. Removing or blocking the friend, or leaving friends with local deletion, forgets them too.
avisar::mensajes_nuevos shows a system notification only when the main window does not have focus (on Android pulling down the notification shade is enough), and its body is only «Message from » or « invites you to a game», never the message text. Rust writes no prose: the screen sends the translated templates with amigos_textos at startup and when the language changes (TextosAvisosAmigos in amigos-dialog.tsx), and Rust only replaces {apodo}. Without those texts nothing is shown.
On Android the sync loop stops while the app is in the background, so that path only fires on desktop (window open but not focused).
sequenceDiagram
participant W as WorkManager
participant K as AvisosAmigos.kt
participant R as amigos::fondo (Rust, JNI)
participant S as Tapete server
W->>K: every ~15 min, with network
K->>K: app in front? no permission? → stop
K->>K: read identidad/clave or unseal clave.sellada (Cofre)
K->>R: Nativo.comprobar(datos, clave)
R->>S: GET /sync (no cursor, no ack)
S-->>R: pending messages + friends
R->>R: decrypt only to tell text from invitation; drop ids already in avisados.json
R-->>K: [{ amigo, titulo, cuerpo }]
K->>K: one notification per friend
-
It only looks.
fondo::comprobarcallsGET /syncwithoutsinceand never sendsacknor touches the database. The messages stay on the server until the app opens and its own loop collects them as always, so a notification from here can never lose or duplicate a message in the conversation. It decrypts only to know whether the last new one from each friend is a message or an invitation; the body is the same «Message from » / « invites you to a game», never the text. -
Texts. On Android
amigos_textosalso writes the templates toapp_data_dir/amigos/textos-avisos.json(fondo::guardar_textos): the worker has no screen. Without that file nothing is shown. -
No repeats.
app_data_dir/amigos/avisados.jsonkeeps the ids already notified that are still on the server (fondo::avisos_dekeeps only those, so the list never grows beyond what is pending). A message nobody collected does not notify again every 15 minutes. -
The key. The worker reads the identity like
red::identidad::cargar_o_crearchooses it: the clearidentidad/claveif present, otherwiseidentidad/clave.selladaopened with the same Keystore key (Cofreobject inAndroidExtraPlugin.kt, aliasproxy-builder-identidad, shared withcofreSellar/cofreAbrir). 🛑 It never writes, renames or deletes identity files: sealing what is in clear is the app's job at start-up. The 32 bytes go to Rust through JNI and are zeroed on both sides afterwards. -
Scheduling.
sincronizar::arrancar(when friends are on) callsandroid::avisos_amigos(Some(app_data_dir)), which hands the data folder to Kotlin (SharedPreferences) and enqueues the unique periodic worktapete-avisos-amigoswithKEEP(scheduling it again does not duplicate it);amigos_desactivarcancels it (None). The folder is passed rather than guessed so thatPB_PERFILprofiles work too. -
Permission. Android 13+ needs
POST_NOTIFICATIONS.amigos_activarasks for it (android::pedir_permiso_avisos, only while the state is still «prompt») at the moment the person accepts using the server, not before. Without it, or with notifications off, the worker does nothing. - Skipped when in front. If the app process is in the foreground the worker returns at once: the open app already syncs every 60 s.
-
One look for every friends notification on Android.
AvisosAmigos.avisar(companion, also reachable from Rust asandroid::aviso_amigo→avisoAmigoin the plugin) is used both by the worker and byavisar.rswith the app open:tauri-plugin-notificationwould show its generic «ⓘ» icon and, on layers such as MIUI that show the notification expanded, an empty body. Ours setsBigTextStyleso the text is there expanded too,CATEGORY_MESSAGEand the green accent. On desktop the plugin is still used. - Channel
default(the same astauri-plugin-notification, so both share the system settings), small monochrome iconres/drawable/aviso_tapete.xmlin the plugin, one notification per friend (id = hash of the key, the next replaces the previous), tapping opens the app.consumer-rules.prokeepsNativoandAvisosAmigosfrom R8: Rust exportsJava_com_estigio_proxybuilder_extra_Nativo_comprobar(thejnicrate, Android-only dependency, same version tao already uses).
Why WorkManager and not a push service: push (FCM) would need Google Play Services and a Firebase project, and would give a third party a token for every device. A 15-minute check is the minimum Android allows for periodic work and is enough for messages between friends; it costs one small signed request with network available and nothing otherwise.
Inferred: Android may delay the work further in battery saver or Doze, and on some manufacturers' builds a swiped-away app is treated as force-stopped (WorkManager then reschedules at the next start).
Inviting works like on Steam: no code to copy.
-
From the waiting room (host only), «Invitar amigos» (
features/mesa/red/invitar-amigos.tsx) lists every friend whose key is valid, online ones first, each with «Invitar» (then «Invitado»). -
From a conversation, «Invitar a una partida» is always there unless this app is already in a started game or is a guest in someone else's. With a waiting room of mine open it sends the invitation right away («Invitar a tu partida»). Without one it sets
useAmigos.invitarA, closes «Amigos» and goes to «Jugar»: the entry opens «Jugar en red», which creates the game by itself with the deck chosen there as soon as it is ready, andInvitarAmigossends the invitation once the code exists (it reads and clearsinvitarAfrom the store in one step, so an effect that runs twice does not send it twice). With no deck chosen, a notice asks for one.
Either way the message is of type invitacion with the game's direct code. amigos_invitar passes the code through red::codigo::solo_rele, which keeps only the relay address and drops the host's IP addresses (and fails with motivo: sin_rele if there is no relay). Why: the server and the friend's device should not learn the host's network addresses; the relay is enough to connect and iroh upgrades to a direct path on its own. The recipient's «Unirse» (useAmigos.pedirUnirse) opens «Jugar en red» and joins at once as a player with the deck and list chosen in «Jugar»: pressing «Unirse» on the invitation is already the person's own decision, so no second tap is asked for. Without a chosen deck the code stays in «Unirse con un código» with a notice to pick one.
| Link | Use |
|---|---|
https://tapete.zodhub.app/friend/<TAP-…> (enlace_amigo) |
Shared to be added as a friend. The QR of my own code (amigos_qr) encodes this link, so any phone camera opens it |
https://tapete.zodhub.app/join/<code> (enlace_invitacion) |
Shared to invite to a game; built with solo_rele, so without IPs. «Copiar enlace» and «Compartir» in the online-play dialog use it |
tapete://amigo/<TAP-…> |
Deep link that opens «Amigos» with the code in «Añadir» |
tapete://unirse/<code> (and proxybuilder://unirse/…) |
Deep link that opens «Jugar en red» with the code filled in |
The shared links are the web ones because they work for everyone: the website opens the app with whichever scheme the installed version understands, or sends the person to download it. The tapete:// links are what the website (or the OS) uses to open the app. A deep link never acts on its own: it only fills a field. «Añadir» accepts a bare code, a web link or a tapete://amigo/ link (enlace::codigo_amigo_pegado, which also normalises Crockford look-alikes such as O→0); on mobile it can scan the QR with the camera (lib/escaner.ts). The join field (features/mesa/red/codigo.ts) also accepts the web /join/ link.
«Last seen» is shown as relative time in the interface language (visto.ts, Intl.RelativeTimeFormat: «5 minutes ago»).
A friend's secondary actions (safety number, remove, block, report) are a dropdown on desktop and a sheet that slides up on a phone (MenuAmigo in conversacion.tsx), like every other phone menu. The dialog has a fixed height only with the list or a conversation (so it does not jump between them); the consent screen takes the height of its text. On a phone on its side with the keyboard open, the dialog title hides itself so the conversation keeps the ~150 dp that remain (see Mobile).
Importing another identity (red_identidad_importar) makes this device a different person for the server: different key, code, friends and cursor. sincronizar::otra_clave waits for any running round, clears amigos.codigo, amigos.cursor and amigos.hola, forgets the cached friend list, and triggers a new hello. Stored conversations stay on disk.
amigos_desactivar(borrarServidor, borrarLocal) (the «leave» section at the bottom of lista.tsx):
-
borrarServidor:DELETE /mefirst; the server deletes the key with its friendships, blocks, messages and receipts (not_registeredis accepted as already done). The local code, cursor and fingerprint are cleared. -
borrarLocal:store::amigos::borrar_tododeletes the conversations and key records on this device. - Always:
amigos.activo = "0", and the loop stops at its next wake-up.
Removing or blocking a friend (amigos_quitar, amigos_bloquear) also forgets that conversation locally (store::amigos::olvidar). A block is invisible to the blocked person: for them your code «does not exist». amigos_denunciar sends a report with a reason and an optional note, never messages. amigos_nuevo_codigo replaces the friend code; the old one stops working.
Migration 0010_amigos.sql:
-
amigos_mensajes: the decrypted conversation (amigo= friend's identity hex,mio,tipo,texto,enviado= sender's clock,orden= this device's clock, which orders the conversation,estado,id_cliente,id_servidor). -
amigos_claves: per friend, the last seen encryption key,verificado,cambioandleido_hasta(unread counts).
Migration 0011_amigos_alias.sql: amigos_alias (amigo, alias, nota), what I note about a friend, only for me. A separate table because an amigos_claves row is born with the friend's encryption key, and these can be set before; a row with neither is deleted.
Settings in app_settings: amigos.activo, amigos.cursor, amigos.hola, amigos.codigo.
Server errors ({ code, params }) reach the screen as ErrorCode::Server with params.code set to the server's code; src/lib/errores.ts has a sentence for each (clock_skew, friend_code_not_found, not_friends, recipient_inbox_full, app_outdated, maintenance…). Network failures go through error::red/error::estado_http like every other HTTP client, and the client comes from tls.rs.
cliente::PRODUCCION is https://tapete.zodhub.app. In debug builds PB_TAPETE overrides it (for example a local server on port 1530), read at start-up or, since an Android app never sees the variables of whoever launches it, at compile time (PB_TAPETE=http://10.0.2.2:1530 pnpm tauri android dev reaches the host's server from the emulator).
-
firma.rsandcifrado.rscheck the shared vectorssrc-tauri/src/amigos/vectores/firma-v1.jsonandclave-cifrado-v1.json. They are an exact copy of the server'stests/vectores/: if either side changes them, both must change, or the app and the server stop understanding each other. -
pruebas_servidor.rs(#[ignore]) runs two fresh identities against a real server: register, befriend, exchange an encrypted message and an invitation, acknowledge, and see the delivery receipt. Run withPB_TAPETE=http://127.0.0.1:1530 cargo test --lib amigos::pruebas_servidor -- --ignored. -
pruebas_servidor.rsalso checks the background path against the real server: one notification with the nickname, none on the next round, and the message still pending for the app. -
fondo.rschecks one notification per friend without the text, no repeats, ids collected by the app dropped, undecryptable messages ignored, and nothing without the screen's texts. -
cliente.rsreads an SSE stream split anywhere;store/amigos.rssets, clears and forgets a friend's name and note;fondo.rsuses the name I gave in the notification. - On the server,
lib/dominio/eventos.test.ts(who gets each notice) andtests/api/directo.test.ts(request, acceptance, message and typing arrive over/events; typing only from a friend; online while connected). -
avisar.rschecks that a notification carries the nickname and never the text;src/features/amigos/__tests__/visto.test.tscovers the relative time.
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
- License · Licencia
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