Repository navigation
Móvil
English · Español
Proxy Builder también funciona en móviles y tabletas Android. Es la misma aplicación, no una segunda: una sola interfaz de React que se reorganiza según el formato de la pantalla, el mismo núcleo de Rust, los mismos mazos y los mismos ficheros .pbdeck. Esta página es la referencia de todo lo que existe solo por la pantalla táctil o por el sistema Android: los tres formatos, las reglas táctiles, el botón «atrás», el ciclo de vida de la app, los ficheros, el plugin extra de Android, el TLS, los límites de memoria, lo que falta y cómo probarlo. Cada área tiene su propia página con el detalle; esta cuenta cómo encajan las piezas y por qué son como son.
| Dato | Valor | Dónde |
|---|---|---|
| Plataforma | Android 8.0 o más nuevo (minSdk 26) |
src-tauri/tauri.android.conf.json (bundle.android.minSdkVersion), src-tauri/gen/android/app/build.gradle.kts
|
| Id de la aplicación | com.estigio.proxybuilder |
src-tauri/gen/android/ |
| Arquitecturas | el APK de la release es solo aarch64 (arm64); las compilaciones de depuración para el emulador son x86_64
|
Versiones y actualizaciones automáticas |
| Distribución | un .apk adjunto a cada release de GitHub; sin tiendas de aplicaciones
|
Versiones y actualizaciones automáticas |
| Actualizaciones | la app busca, descarga y entrega el APK al instalador del sistema | Ajustes y shell |
| Icono del lanzador | sacado del icono propio de la app (el de escritorio, src-tauri/icons/), como icono adaptable (mipmap-anydpi-v26/ic_launcher.xml) con el primer plano sobre #1D1B19 (values/ic_launcher_background.xml), y el PNG de cada densidad para los lanzadores antiguos |
src-tauri/gen/android/app/src/main/res/ |
| iOS | no se compila (véase «Lo que no hay») |
El proyecto de Android (src-tauri/gen/android/) está en git, al contrario de la carpeta generada habitual de Tauri: lleva el manifiesto con los intent-filter de la app (enlaces proxybuilder:// y ficheros .pbdeck), el FileProvider y la configuración de firma. Lo propio de Android va en src-tauri/tauri.android.conf.json, que Tauri funde sobre tauri.conf.json como un parche de JSON (las listas, como app.windows, se reemplazan enteras, por eso ese fichero repite la ventana). Las capacidades se reparten por plataforma: capabilities/default.json es común, escritorio.json lleva lo que solo existe en escritorio (el plugin de estado de la ventana) y movil.json lo que solo existe en el teléfono (la cámara para el lector de QR).
Por qué una sola interfaz (y no pantallas aparte para el móvil): duplicar pantallas doblaría cada cambio y los 15 diccionarios. El precio es que todo componente tiene que contestar «¿cómo se ve a 400 px y con un dedo?», cosa que las reglas de abajo vuelven mecánica.
useFormato() (src/lib/formato.ts) devuelve { tamano, tactil, horizontal } y escribe data-formato y data-tactil en <html> (el CSS los lee). El hook escucha resize, orientationchange y la media query (pointer: coarse), así que girar una tableta cambia el formato al instante.
flowchart TD
A["innerWidth / innerHeight (px de CSS = dp en Android)"] --> B{"¿VITE_PB_FORMATO puesto (solo desarrollo)?"}
B -->|sí| F["tamano = ese valor, tactil = true"]
B -->|no| C{"¿táctil? (pointer: coarse, Android/iOS)"}
C -->|no| D["por ancho: menos de 640 movil, menos de 1024 tableta, si no escritorio"]
C -->|sí| E{"¿lado corto menor de 600 dp?"}
E -->|sí| M["movil"]
E -->|no| D
tamano |
Ancho | Aparato típico | Disposición |
|---|---|---|---|
movil |
menos de 640 (o táctil con el lado corto menor de 600) | teléfono | un panel a la vez, barra de pestañas abajo, diálogos a pantalla completa |
tableta |
de 640 a 1023 | tableta, ventana de escritorio estrecha | dos paneles, hojas laterales |
escritorio |
1024 o más | ordenador, tableta ancha | la disposición original de tres paneles, sin cambios |
-
La regla del lado corto. Con dedo, un lado corto de menos de 600 dp es un teléfono aunque esté de lado: un móvil en horizontal mide unos 914 x 411 dp, más de 640 de ancho, y solo por el ancho saldría como tableta. Es la regla
sw600dpde Android. La mesa se juega en horizontal, así que sin ella nunca tendría su disposición de móvil. -
tactilva aparte del tamaño. Una ventana de escritorio estrecha con ratón establetapero notactil; un portátil con pantalla táctil esescritorioytactil. Lo que depende del dedo (los paneles de tableta, Exportar en una hoja) se decide contamano === "tableta" && tactil; lo que depende solo del sitio (una ventana estrecha) usatamano. -
plataforma()/esMovil()(src/lib/plataforma.ts) contestan otra pregunta, «¿es de verdad Android o iOS?», con el comandoapp_plataforma. Se usan para lo que solo existe en un teléfono (Compartir, el selector de ficheros del sistema), nunca una ventana estrecha ni una pantalla táctil. -
Forzar un formato en desarrollo.
VITE_PB_FORMATO=movil|tableta(solo se lee conimport.meta.env.DEV), porque la ventana de escritorio tieneminWidth: 960y nunca podría enseñar la disposición del móvil. -
El escritorio con ratón no cambia. Lo añadido para el dedo depende de
tactil(o del puntero del evento), nunca solo del ancho. Así, una ventana de escritorio de 960 a 1023 px, que por ancho estableta, conserva la disposición de Diseñar de siempre (la lista de cartas solo pasa a una hoja contamano === "tableta" && tactil,disenar-screen.tsx), y en la mesa los rótulos de zona, la vista grande de la carta y el diálogo Recortar se comportan con el ratón como antes (véase Mesa de juego e Interfaz del diseñador). -
Lo táctil cambia el tamaño base. Con
html[data-tactil](src/index.css) el tamaño de letra de la raíz es el 108 % (text-xspasa de 12 a unos 13 px; todo lo que va enremcrece con él),touch-action: manipulationquita la espera de 300 ms del toque, el resalte del toque y el rebote del scroll están apagados, y los botones, campos, listas desplegables y filas de menú miden al menos 2,5 rem (unos 43 px al 108 %). Por qué no más: al 116 % y con 2,75 rem la pantalla de un teléfono se llenaba de botones enormes y la biblioteca se salía por el lado. Un botón dentro de unWithTooltippierde su propiodata-slot="button"(el disparador del tooltip pone el suyo), así que el mínimo cubre tambiénbutton[data-slot="tooltip-trigger"]; sin ello esos botones se quedaban en 24 px. Una barra que tiene que ser más compacta a propósito (la del móvil en la mesa) se salta el mínimo con clases con!, porque la regla deindex.csspesa más que una clase suelta. -
Hojas y diálogos se desplazan siempre con el dedo. Dos reglas de
index.css: todoScrollAreadentro de undialog-contentpasa a ser rejilla (grid-template-columns: minmax(0, 1fr),min-height: 0), porque unScrollAreacuya raíz no es rejilla no se desplaza dentro de un diálogo (su viewport pideheight: 100%contra un alto «auto») y la columnaminmax(0, 1fr)impide que uno horizontal ensanche el diálogo; y el propiodialog-contentllevaoverflow-y: autoconoverscroll-behavior: contain, la red para un diálogo sinDialogCuerpoo un teléfono en horizontal. Con el dedo el scroll nativo es el bueno (su barra es flotante y no se tiñe; la regla delScrollAreaes por la barra de escritorio de WebKitGTK). El resultado: una hoja o un diálogo nunca corta lo que no cabe. -
Trampas de desborde encontradas probando todas las pantallas (cada una hacía que el contenido se saliera de un diálogo del móvil; el método de prueba, abajo). (1) El tamaño mínimo táctil (
html[data-tactil] button[data-slot="tooltip-trigger"], 2,5 rem) no dejaba encoger las barras de un gráfico, porque son disparadores de tooltip en fila: un botón con tooltip que tiene que encoger llevamin-w-0, que la regla respeta solo en el ancho. (2) ElScrollAreade Radix mete el contenido en undivcondisplay: table, que toma el ancho de lo que lleva dentro, así quetruncatey los gridsminmax(0, 1fr)no encogían: en un diálogo táctil esedivse fuerza adisplay: block. (3) Un<button>conflex-coltraealign-items: flex-startdel navegador, así que sus hijos no se estiran ytruncatenunca corta: llevaitems-stretch. (4) El ajuste de zoom de las hojas se hacía una vez por sesión, así que al girar el teléfono la hoja quedaba cortada: se reajusta cada vez que cambia el ancho salvo que la persona haya pellizcado. (5) Con el dedo, la etiqueta de unaFilabaja de línea en vez de cortarse con «…». -
El teléfono de lado. Unos 360 dp de alto. Además del carril y las barras de una fila (véase Shell), la descripción de un diálogo se queda solo para el lector de pantalla y su pie va en una fila (
index.css,orientation: landscapebajodata-formato="movil"); Estadísticas pone la barra de sección en la fila del selector y enseña las secciones en dos columnas. -
Cómo se verifica una pantalla del móvil. Nunca solo a ojo, y en todos los casos: cada pantalla y cada diálogo, de pie (411 × 914) y de lado (914 × 411), con datos (resultados, listas largas), con el teclado abierto y tras girar. En el emulador, por la depuración remota de Chrome, un script recorre el diálogo abierto y avisa de
scrollWidthmayor que el ancho, elementos fuera de la pantalla, contenedores conoverflow-xque de verdad se desplazan y botones cortados por un ancestro que recorta; luego se miran las capturas a ojo para lo que un script no ve (texto cortado con «…», solapes, un pie que se come el alto).
Pasar el ratón por encima y el clic derecho no existen en una pantalla táctil, así que lo que solo se alcanzara por ahí quedaría inalcanzable. pnpm check:tactil (scripts/sin-hover-solo.mjs, parte de pnpm check) falla si algún .ts/.tsx de src/ usa onMouseEnter, onPointerEnter, onMouseLeave, onPointerLeave, onMouseOver, onPointerOver u onContextMenu (como prop) o addEventListener de mouseenter, pointerenter, mouseover, pointerover o contextmenu, salvo que esa línea o una de las tres anteriores lleve un comentario // táctil: <cómo se hace con el dedo>. components/ui/ y components/tooltips-nativos.tsx son de solo lectura y se saltan. El comentario es el registro del diseño: pulsación larga, un toque o un botón «⋯».
-
«Dedo» es solo
pointerType === "touch". El lápiz va como el ratón: tiene «encima» de verdad y doble clic nativo, así quehayEncima(features/mesa/leer-en-grande.ts) trata"mouse"y"pen"como «algo puede estar encima», y el umbral de arrastre es de 8 px solo con"touch"(umbralDeenfeatures/mesa/gestos.ts; 5 px con ratón y lápiz). Por qué: una tableta con lápiz fino perdería si no la lectura al pasar por encima y tendría las tolerancias gruesas del dedo. -
Pulsación larga = clic derecho. 500 ms con el dedo quieto, tolerancia de 8 px (
MS_PULSACION,TOLERANCIA_PULSACIONensrc/lib/pulsacion-larga.ts).crearDetectores la parte pura;usePulsacionLargada los manejadores de puntero (solo para dedo y lápiz, el ratón conserva el clic derecho) y se traga el clic que llega al soltar para que no haga además lo de un toque.PulsacionLarga(src/components/pulsacion-larga.tsx) envuelve un contenedor. UnContextMenude Radix no necesita nada de esto: ya se abre a los 700 ms. - Un gesto por elemento. Un elemento nunca mezcla «mantener para arrastrar» con «mantener para el menú»: se disputarían el mismo dedo. Las cartas de las hojas del deck se arrastran por un asa y abren su menú con un toque; las cartas de la mesa se arrastran al moverse más de 8 px y abren su menú con la pulsación larga; los mazos de la biblioteca usan la pulsación larga para entrar en el modo de selección y el botón «⋯» para su menú.
-
Un
keydownglobal debe tratare.targetcomo posibledocument. ElEscapesintético que manda «atrás» (más abajo) se lanza sobre el elemento con el foco, y sobredocumentsi nada tiene el foco, así quee.targetpuede no ser unElement; los manejadores compruebaninstanceof Elementantes de llamar aclosestomatches.
En Android la app va siempre a pantalla completa: sin barra de estado ni de navegación (un gesto desde el borde las enseña un momento). Lo hace la actividad, no la web: MainActivity.kt (src-tauri/gen/android/app/src/main/java/com/estigio/proxybuilder/) esconde systemBars() con BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE en onCreate y lo vuelve a pedir en onResume y cada vez que la ventana recupera el foco (onWindowFocusChanged), porque un diálogo del sistema, el teclado o volver de otra app devuelven las barras. El fondo de la ventana es negro, para que el momento antes de que pinte el WebView no destelle.
La muesca de la cámara tampoco se resuelve en CSS. Un oyente de WindowInsets en la vista del contenido (android.R.id.content) le pone a esa vista un margen del tamaño de displayCutout() y le pasa al WebView los márgenes con la muesca y las barras del sistema a cero. Así, dentro del WebView env(safe-area-inset-*) vale 0 y la muesca no se cuenta dos veces. El teclado (ime) sí le llega: con él encoge el visualViewport, que es lo que mide lib/teclado.ts (más abajo).
Lo que eso supone en la parte web:
-
html[data-tactil] #rootse rellena conenv(safe-area-inset-*)a secas; los mínimos de antes (24 px arriba y 14 px abajo, para los WebView que no contaban las barras) ya no están, porque las barras van escondidas. La regla se queda para otras plataformas. El alto del shell esmin(100%, var(--alto-visible, 100dvh) - env(safe-area-inset-top)). Las hojas y la tarjeta del tutorial suman los mismos márgenes (Hoja,Recorrido), que en Android valen 0. -
No hay botón de pantalla completa en el móvil. El botón «Pantalla completa» de la barra de la mesa y su entrada de menú solo existen fuera de Android (
useEsMovil()enbarra-mesa.tsx; en escritorio es elsetFullscreende la ventana). El comandoapp_pantalla_completayandroid::pantalla_completasiguen registrados, pero la pantalla ya no los llama. -
Ninguna pantalla del móvil se desplaza en horizontal. El shell del móvil es
w-full overflow-clip(app-shell.tsx): nada de lo de dentro (una barra que no cabe, una rejilla) puede ensanchar el documento. Esclipy nohiddenpara que no sea un contenedor de scroll al que el navegador pueda mover al enfocar algo. Cada pantalla del móvil se comprueba conscrollWidth === innerWidth.
De borde a borde (obligatorio desde Android 15) el sistema ya no encoge la ventana con el teclado, así que MainActivity.kt suma el alto del teclado al margen de la vista que contiene el WebView, igual que con la muesca, y al WebView le pasa el ime a cero; sin esto el teclado tapaba lo que se escribía y el WebView no se enteraba. En un teléfono de lado con el teclado solo quedan unos 150 dp, así que lo que no es el campo se esconde con landscape:[[data-formato=movil][data-teclado]_&]:hidden (las pestañas y el pie de Diseñar, la barra y el pie de la lista de Deck, el título y el pie del editor de listas, la barra y el pie del editor de colecciones). engancharTeclado() (src/lib/teclado.ts, lo arranca AppShell cuando el formato es táctil) mide el visor y deja dos cosas en <html>: --alto-visible (el alto visible en px, que usa el shell) y data-teclado mientras el teclado está abierto (la barra de pestañas de abajo se esconde para no quitar sitio justo cuando más falta hace). Se considera abierto cuando el visor ha encogido más de 120 px (UMBRAL_TECLADO) respecto al mayor alto visto con ese ancho y hay un campo de texto (input, textarea, select o contenteditable) con el foco (editando): sin campo no puede haber teclado, así que una pantalla dividida o una ventana flotante que encoge la vista no se toma por él (y, sin nada que escribir, un cambio del alto de la ventana reinicia la medida). Girar también lo reinicia. Al abrirse, el input enfocado se trae a la vista (un textarea no: el campo de la lista de cartas crece con su texto y el navegador ya sigue el cursor). La pantalla de Diseñar tiene su propia variante para su disposición de móvil (véase Interfaz del diseñador). crearVigilaTeclado es pura y tiene tests.
src/lib/atras.ts atiende el botón y el gesto «atrás» de Android (enlazarAtras, que main.tsx llama solo en Android y que usa onBackButtonPress, por lo que quita el comportamiento de fábrica de Tauri). Una llamada a atras() hace lo primero que valga:
-
Una capa de Radix abierta (
[role="dialog"],alertdialog,menu,listbox, cada una condata-state="open"): se cierra la de arriba mandando unEscapesintético, que es lo que Radix entiende. Solo cuentan las capas de Radix (llevandata-state): unrole="dialog"propio dondeEscapedecide algo (la reacción del fantasma del sparring) no lo es, porque «atrás» nunca toma una decisión de juego. ElEscapeva al elemento con el foco (document.activeElement, que Radix deja dentro de la capa), no adocument, para que los atajos de la pantalla, que miran si la tecla viene de un diálogo o un menú, no hagan además lo suyo (por ejemplo, soltar la selección). -
El último manejador registrado (
registrarAtras, el más reciente primero; uno que no pueda hacer nada devuelvefalsey se prueba el siguiente): los paneles de la mesa, la propia mesa, Diseñar. -
El de por defecto (
fijarAtrasPorDefecto, lo poneAppShellconatrasPorDefectodeshell/atras-shell.ts): «Pulsa otra vez para salir»; una segunda pulsación en 2 s guarda el deck y la carta que se diseña y llama aapp_salir. Con un trabajo largo en marcha o una actualización bajando (pendientes()delib/cierre.ts) no sale: avisa «Hay un trabajo en marcha; espera a que termine.», igual que cerrar la ventana en escritorio pregunta antes. Desde Diseñar, «atrás» vuelve primero a Deck (destinoDeAtras).
Por qué un diálogo con secciones no puede usar registrarAtras: con un diálogo abierto, el paso 1 consume la pulsación y nunca llega a los manejadores registrados. Un diálogo que enseña una lista de secciones (Ajustes, Estadísticas) intercepta entonces el Escape por su cuenta con onEscapeKeyDown={volverConEscape(enSeccion, volver)} (src/lib/seccion-movil.ts): dentro de una sección vuelve a la lista, no sale del diálogo.
En Android no hay «cerrar». El sistema congela el proceso unos 10 s después de irse a segundo plano (Android 14 y posteriores) y puede matarlo después, sin avisar antes. Así que todo se guarda al irse:
-
src/lib/ciclo-de-vida.tsguarda el deck y la carta abierta en Diseñar convisibilitychangeahidden(la primera señal, antes de que el JS pueda quedarse parado) y con el eventociclo://pausa. El campo del nombre del deck confirma antes su texto (un oyente en fase de captura enAppShell), porque conserva el foco cuando la app se va. -
src-tauri/src/ciclo.rs(on_window_event) reacciona aWindowEvent::Suspended: guarda la partida abierta de la mesa (guardar_si_toca) y emiteciclo://pausa. ConWindowEvent::Resumedemiteciclo://vueltay avisa al nodo de iroh de que la red puede haber cambiado (network_change()), de modo que una partida en línea reconecta sola como tras un corte de red. -
Por qué no
install_android_jni_context: iroh lo ofrece para que su monitor de red vea la conectividad de Android, pero tao (la capa de ventanas de Tauri) ya instala ese contexto y una segunda llamada entra en pánico. Solo se llama anetwork_change(). - En escritorio nada de esto se ejecuta (los manejadores son
#[cfg(mobile)]).
El shell del móvil tampoco hace la pregunta «¿descartar los cambios?» que escritorio y tableta hacen al salir de Diseñar con una carta sin guardar: en el móvil la carta se guarda en el acto y luego cambia la pantalla (Ajustes y shell).
| Pieza | Uso |
|---|---|
DialogoAdaptable (src/components/dialogo-adaptable.tsx) |
El DialogContent de siempre en escritorio y tableta; a pantalla completa en un móvil, con zonas seguras, una X de cerrar de 44 px, el título sin pisarla y los botones del pie apilados abajo con el principal el último. compacto deja un aviso corto como tarjeta centrada. La pantalla completa anula también los sm:max-w-* … 2xl:max-w-* que traiga el diálogo (sm:max-w-none md:max-w-none …): un teléfono en horizontal ya tiene el ancho de sm:, y el sm:max-w-lg propio de un diálogo dejaría la «pantalla completa» en una caja. Un diálogo nuevo lo usa, nunca un DialogContent suelto (Confirmar es la excepción: se queda como tarjeta centrada). |
Hoja (src/components/hoja.tsx) |
Un panel que sube desde abajo (lado="abajo", móvil) o entra por la derecha (lado="derecha", tableta). Es un DialogContent con otra colocación, así que hereda foco, Escape, el botón «atrás» y la pregunta de salir con cambios. En el móvil se usa a través de HojaMenu (más abajo); suelta, en la lista de cartas de Diseñar y en Exportar en la tableta. |
ListaSecciones + BarraSeccion (src/components/lista-secciones.tsx) |
Sustituye las pestañas o las secciones apiladas de un diálogo en el móvil: una lista de filas grandes, cada una entra en una sección, con una barra para volver. Ajustes y Estadísticas la usan. |
cambiosSinGuardar={false} |
Un diálogo u hoja hecho de interruptores y casillas lo pasa para que cerrarlo nunca pregunte «¿descartar los cambios?». |
Las variantes arbitrarias de Tailwind se escriben como clases enteras ([[data-tactil]_&]:min-h-11), porque el escáner de clases no ve cadenas montadas a trozos.
Para que todas las pantallas del móvil se vean igual, la barra de arriba, los menús y los paneles que suben desde abajo son siempre los mismos componentes:
| Pieza | Uso |
|---|---|
BarraSuperior (src/components/barra-superior.tsx) |
La barra de arriba de una pantalla del móvil: 3 rem de alto, el título a la izquierda (se recorta si no cabe), las acciones a la derecha como botones de icono (size="icon-sm" con aria-label), la principal con texto y las secundarias en un «⋯» (BotonMenu). Nunca se sale por el lado: el título encoge y las acciones no. La usan la Biblioteca, Deck → Lista y la cabecera del shell en Deck; la Biblioteca de lado y «Jugar» la dejan fuera y se quedan con una fila junto al buscador (enUnaFila). (La cabecera del shell es la misma barra de 3 rem, con el nombre del deck como campo editable y el indicador de guardado como icono, SaveIndicator compacto.) |
HojaMenu (src/components/hoja-menu.tsx) |
El patrón de todas las hojas del móvil: sube desde abajo (es una Hoja, que no se toca), con una cabecera de 3 rem (el título a la izquierda y la X siempre en el mismo sitio, a la derecha) y debajo un cuerpo que se desplaza con el dedo (DialogCuerpo). Opcionales: pie (botones fijos abajo, fuera del scroll) y sinRelleno (una lista de filas que pone su propio relleno). No da el foco a nada al abrirse, porque el primer botón enseñaría su tooltip. Un menú es una HojaMenu de FilaMenu; un panel de opciones, una HojaMenu con lo que lleve dentro. |
CabeceraHoja, CLASES_HOJA_MENU
|
La misma cabecera y la misma X para una hoja que no usa HojaMenu entera (un cuerpo con su propio scroll: la lista de cartas en Diseñar). |
FilaMenu |
Una fila de menú dentro de una HojaMenu: icono, texto y, a la derecha, lo que haga falta (una cuenta, un interruptor, una marca como el puntito de la actualización). Al pulsarla, la hoja se cierra salvo con mantener. |
BotonMenu |
El «⋯» de una barra del móvil: un botón de icono que abre una HojaMenu con las acciones secundarias. |
ItemHoja, SeparadorHoja, SubHoja, SubHojaFila, SubHojaContenido, CasillaHoja
|
Las piezas de un menú de Radix (Item, Separator, Sub, SubTrigger, SubContent, CheckboxItem) pintadas como filas de HojaMenu, para que una sola definición de menú se abra como desplegable con el ratón y como hoja en el móvil. Un submenú se despliega debajo de su fila; onSelect recibe un evento y preventDefault() deja la hoja abierta, como en Radix. |
BotonMenuDeck (features/library/menu-deck.tsx) |
El «⋯» de un deck: un desplegable con el ratón y, en el móvil, una HojaMenu con el nombre del deck de título, hecha con los mismos Items a través de esas piezas. |
useNombreDeck() (src/lib/use-nombre-deck.ts, src/lib/nombre-deck.ts) |
El nombre de un deck tal como se enseña. Un deck nuevo nace con «Deck sin título» en el idioma de la interfaz de ese momento; la app arranca en inglés la primera vez, así que tras cambiar de idioma seguiría diciendo «Untitled deck». Mientras no se renombre, se enseña en el idioma de ahora: nombreVisible reconoce el nombre de fábrica en cualquiera de los 15 idiomas (NOMBRES_SIN_TITULO, que un test compara con los diccionarios). Solo es de pantalla, lo guardado no cambia; el nombre de fichero (nombreSeguro en features/export/acciones.ts) y el nombre de un duplicado también lo usan. |
Por qué componentes y no solo convenciones: las pantallas del móvil se iban separando (cabeceras de alturas distintas, menús como diálogos, hojas como desplegables, la X en sitios distintos). Con las piezas, una pantalla nueva sale coherente de por sí.
El Tooltip de Radix no sale con un toque. Tres sustitutos, según lo que diga el tooltip:
-
Información de verdad (una explicación, un aviso):
ConAyuda(src/components/con-ayuda.tsx). En escritorio es el tooltip de siempre (la envoltura esdisplay: contents, no cambia la maqueta); con el dedo añade una «i» (AyudaTactil, solo visible con[data-tactil]) que abre unPopovercon el mismo texto;claseAyudale da a esa «i» sus propias clases (Diseñar la agranda junto a un botón grande,AYUDA_GRANDE).Filadecomponents/campos.tsxlo usa para la ayuda de un ajuste; conapilarEnMovilla etiqueta va encima de su control en el móvil (en 411 px no caben los dos en una línea) y, con el dedo, una etiqueta larga baja de línea en vez de cortarse. En escritorioapilarEnMovilno cambia nada. -
Marcas e iconos sin acción propia que llevan información (de quién es el daño de comandante, quién mira la partida):
AyudaAlTocar(src/components/ayuda-al-tocar.tsx), unPopoveral tocar. -
Botones que solo llevan un icono: su nombre va en
aria-label(tooltips-nativos.tsxya pasa untitleaaria-labelen ellos) y, al mantener pulsado el botón, sale una etiqueta pequeña encima, como hace Android (src/components/etiquetas-tactiles.tsx, montada una vez enmain.tsx). Los atajos de los menús (ContextMenuShortcut,DropdownMenuShortcut) los oculta el CSS con el dedo: no hay teclado.
Un paso del tutorial que nombre el ratón o el teclado lleva tactil: { texto } (Tutoriales).


-
Shell. Móvil: una cabecera de 3 rem con el nombre del deck y el indicador de guardado como icono (solo en Deck; la barra propia de Diseñar dice las dos cosas), barra de abajo con Deck, Diseñar, Biblioteca, Jugar y «Más» (una
HojaMenucon Ajustes, el tutorial de la pantalla y el botón de actualizar), y Deck en tres pestañas (Lista, Hojas, Exportar). De lado la barra pasa a un carril a la izquierda (lado) y las pestañas de Deck van dentro de la cabecera (enCabecera): unos 360 dp de alto no aguantan filas apiladas. Tableta con dedo: dos paneles y Exportar en una hoja lateral. Escritorio: sin cambios. Véase Ajustes y shell. -
Biblioteca. Una
BarraSuperiorcon «Nuevo deck» y un «⋯» («Enviar / Recibir», «Importar .pbdeck», «Liberar espacio»); debajo, el buscador con Etiquetas y «Filtros y orden», las dosHojaMenu. Dos columnas (una por debajo de 360 px; tarjetas de unos 170 px con el teléfono en horizontal), el menú del deck como hoja (BotonMenuDeck), selección por pulsación larga. Véase Biblioteca de mazos. -
Deck → Lista. Una
BarraSuperior«Lista de cartas» con el explorador de sets y un «⋯» (lista de ejemplo, vaciar el cuadro), y todo lo demás debajo en una sola columna que se desplaza (sin el asa: con el dedo no se reparte el alto). Véase Panel de la lista del mazo. - Deck → Hojas. La barra de las hojas es una sola fila de 3 rem (deshacer, rehacer, «Opciones de la vista» y «Añadir carta»); el zoom y el resto de acciones de la barra van en la hoja «Opciones de la vista». Zoom con pellizco en las hojas (la primera vez que salen hojas en un móvil, el zoom ajusta la hoja al ancho de la pantalla), un asa para arrastrar una carta, un toque en una carta seleccionada abre su menú. Véase Vista previa en vivo.
- Deck → Exportar. El mismo panel sin su título «Exportar» (ya lo dice la pestaña de arriba). Véase Exportación.
-
Diseñar. El mismo
Formulario, las seis pestañas y las opciones del escritorio. De pie, la carta arriba con sus controles flotando a los lados y el formulario debajo; de lado, la carta a toda la altura y el formulario a su derecha. Las herramientas de la barra (renumerar, diseñar el deck, exportar PNG, colecciones, letras y packs) están en la hoja del «⋯». Tableta: la lista de cartas pasa a una hoja. La ilustración se mueve con los dedos: uno arrastra, dos pellizcan. Véase Interfaz del diseñador. -
La mesa de juego en el móvil (
useMesaMovil(), lado corto menor de 600 dp): un tablero a la vez con una barra de asientos, «Mano» y pantalla de vida a pantalla completa, botón redondo de «Pasar turno», la mesa ocupando toda la pantalla (barras del shell escondidas,useMesaAPantallaCompleta) y un aviso de «Gira el teléfono» en vertical (no durante el mulligan: la mano inicial se ve bien de pie). La barra de la mesa es compacta a propósito (BOTON_MOVIL, botones de 36 px) y de pie va en una sola fila hasta «Pasar turno», con la mano, la vida, el registro y el chat en su «⋯»; una carta leída en grande esLecturaMovil(la carta entera, la X fuera de ella); el mulligan en horizontal va en una fila (cartaEnFila); «Jugar» deja a la vista nombre, formato y lista y lo demás en la hoja «Opciones de la partida» (PanelEmpezarMovil,entrada-movil.tsx). Gestos de carta: moverse más de 8 px arrastra, 500 ms abre el menú, un toque la lee en grande (useLeerEnGrande, nunca unonPointerEntersuelto), un doble toque actúa. Dos dedos hacen zoom y desplazan el campo (solo táctil; el ratón nunca lo tuvo). Las cartas van en una capa con zoom, así que nunca se convierten posiciones a mano condisp.campo;touch-action: nonesolo va en el tablero. Con zoom, las cartas y lo que se pinta sobre el campo (bolitas, flechas, los fantasmas de los demás) se recortan a la parte visible del campo (Recortadoentablero.tsx), así que nunca tapan las zonas. Con vida compartida (Two-Headed Giant) un equipo es un solo objetivo de vida en la pantalla de vida (objetivoQueCuentaenfeatures/mesa/movil/cuentas-vida.ts): elegir a los dos compañeros no la cambia dos veces. Valores de fábrica táctiles: sombras apagadas y dados en CSS 3D (mesaDeFabricaPara), y la pantalla se queda encendida mientras hay una partida abierta (pantalla_encendida, la marca de ventanaFLAG_KEEP_SCREEN_ON). El detalle, en Mesa de juego y Juego en línea. - Los tutoriales funcionan en todos los formatos (Tutoriales).
Los selectores de ficheros de Android no dan rutas sino URI content://: un permiso que el sistema concede a ese fichero, sin carpeta y a menudo sin extensión. La regla es que todo comando que lea o escriba un fichero que elige la persona pasa por src-tauri/src/fichero.rs, nunca std::fs sobre una ruta de diálogo.
-
Leer un
content://lo copia a la caché (app_cache_dir/fichero/) y se trabaja con la copia, que se borra al soltar laCopia. Por qué una copia: un proveedor en la nube puede dar una tubería sinSeek, y el ZIP de dentro de un.pbdecklo necesita. -
Escribir escribe en la caché y copia al destino, porque un
content://no se puede renombrar (la escritura atómica de escritorio no vale). El destino se abre contauri-plugin-fssolo desde Rust (app.fs().open), así que a la vista web no se le expone ningún permisofs:. -
Nombre y formato llegan como parámetro: un
content://no tiene extensión, así que el nombre visible se le pide a Android (android::nombre_de_uri) y los comandos que dependen del formato (imagen, letra, lista de texto) lo sacan de ese nombre. Los diálogos de ficheros en Android usan tipos MIME en vez de extensiones (Android no conoce.pbdeckni.ttf) y no recuerdan carpeta (src/lib/dialogos.ts). -
Las carpetas no existen. El selector no puede elegir una carpeta, así que un PDF partido (cada N hojas) y la exportación de PNG salen en un solo ZIP (
export_pdfcon su parámetrozip,fichero::zip_de_lista). -
Compartir sustituye a «Mostrar en la carpeta» y «Abrir»: tras guardar, el aviso ofrece Compartir, que abre la hoja del sistema (
compartir_fichero). Ese comando solo comparte una URIcontent://(antes copiada a la caché) o un fichero dentro deapp_cache_dir(uno de otra carpeta de la caché se copia a la de compartir); cualquier otra ruta, también una que salga de la caché por..o un enlace (las dos se canonicalizan), falla conInvalidInput,motivo: compartir_fuera(fichero.rs::para_compartir_con). Por qué: la vista web puede llamar al comando, y sin esa comprobación podría dar a otra app cualquier fichero de la app (la clave de la identidad de red, la base de datos);export_opentambién comparte en Android porqueACTION_VIEWcon una ruta de fichero lanzaFileUriExposedException. El menú de exportar tiene «Compartir el PDF», «Compartir la lista (texto)» y «Compartir el deck (.pbdeck)» (features/export/menu-exportar.tsx). El código de invitación a una partida se comparte como un pequeño fichero de texto (app_compartir_texto); algunas apps de mensajería lo adjuntan en vez de pegarlo. Deducido: un comando propio del plugin para compartir texto lo pegaría; no está hecho. -
Abrir un
.pbdeckdesde otra app (Archivos, Drive, un mensaje): unintent-filterdeACTION_VIEWen el manifiesto..pbdeckno tiene tipo MIME propio, así que el filtro acepta los tipos zip y binario genérico con una ruta que acabe en.pbdeck. Lleva cuatropathPattern, con cero a tres puntos más antes de.pbdeck(.*\\.pbdeck,.*\\..*\\.pbdeck…): elPatternMatcherde Android no reintenta,.*se para en el primer punto y un nombre comomi.deck.pbdeckno casaría con un solo patrón. Un segundo filtro, limitado al proveedor de Descargas (com.android.providers.downloads.documents), cubre las URI como.../document/msf:1234, que no llevan nombre. Efecto secundario: la app sale en «Abrir con» de cualquier binario de Descargas, y Rust rechaza lo que no sea un paquete al leerlo. El plugin recoge la URI ysrc-tauri/src/abrir.rsla guarda y emitepbdeck://abierto; si la app arrancó con ese fichero, la pantalla aún no escucha y lo pide conpbdeck_pendiente, que lo devuelve una sola vez. El plugin ignora el intent de arranque si llevaFLAG_ACTIVITY_LAUNCHED_FROM_HISTORY(loadenAndroidExtraPlugin.kt): al volver a la app desde Recientes después de que el sistema matara el proceso, Android entrega otra vez el intent con el que se abrió, y ese.pbdeckya se importó. Nada se importa sin preguntar: el diálogo de importar (el mismo que se usa al recibir un deck de otro equipo) enseña lo que trae el paquete y pregunta cómo importarlo (Pasar decks, Formato del paquete de mazo). -
Soltar ficheros sobre la vista previa no existe en un móvil (
useArrastrarFicheroscompruebaesMovil()); «Añadir imágenes» abre el selector.
Lo que solo hace Android está escrito en Kotlin en un único plugin propio y pequeño, src-tauri/plugins/android-extra/ (AndroidExtraPlugin.kt), y se llama solo desde Rust por src-tauri/src/android.rs. La pantalla nunca llama al plugin: pide un comando de Rust, que llama a crate::android::*.
Función (android.rs) |
Qué hace |
|---|---|
compartir(ruta, tipo) |
ACTION_SEND de un fichero ya escrito en la caché, por el FileProvider de la app |
pantalla_encendida(si) |
mantiene la pantalla encendida mientras hay una partida de la mesa abierta |
pantalla_completa(si) |
esconde o enseña las barras del sistema. Se conserva, pero la pantalla ya no la llama: MainActivity.kt tiene la app a pantalla completa todo el rato |
multicast(uso, si) |
mantiene un MulticastLock de Wi-Fi (sin él Android filtra los paquetes multicast que necesita el descubrimiento de «equipos cerca») mientras la búsqueda de equipos cerca o «Recibir» están abiertos. El Kotlin tiene un solo lock sin contador, así que Rust cuenta quién lo usa (UsoMulticast::Buscar / Recibir, un conjunto: pedirlo dos veces para el mismo uso no cuenta doble) y solo lo coge con el primer uso y lo suelta con el último, así cerrar «Recibir» no deja sin él a la búsqueda |
instalar_apk(ruta) |
entrega al instalador del sistema el APK descargado; si la app no tiene permiso para instalar paquetes abre ese ajuste y falla con Unsupported, motivo: instalar_permiso
|
nombre_de_uri(uri) |
el nombre visible de un content://
|
nombre_equipo() |
el nombre del dispositivo o, si no, marca y modelo, para presentarse a otros equipos |
(escucharAbiertos, onNewIntent) |
recogen la URI de un fichero abierto desde otra app |
En escritorio cada una no hace nada (Ok) cuando no hay nada que hacer (pantalla, barras, multicast) o devuelve Unsupported cuando no tiene sentido sin Android; un comando que las usa trata Unsupported como «nada que hacer» cuando es el caso. Por qué un plugin propio y no tauri-plugin-sharekit ni tauri-plugin-android-installer: estaban en versión previa o eran muy nuevos, y esto son tres intents cortos; el propio no depende de terceros y es el sitio donde viviría el equivalente de iOS (una carpeta ios/ en Swift).
src-tauri/src/tls.rs es el único sitio que monta configuración TLS: ningún reqwest::Client::builder(), with_platform_verifier ni CaTlsConfig:: en otro lado (lo vigila un test). Sus consumidores son los clientes HTTP (el actualizador incluido), el WebSocket del servidor de salas y el nodo de iroh. En escritorio es el verificador del sistema (rustls-platform-verifier). En móvil son los certificados raíz de Mozilla incrustados en la app (webpki-root-certs, y CaTlsConfig::embedded() para iroh). Por qué: el verificador del sistema entra en pánico en Android si no se inicializa con su propia parte de Kotlin; incrustar las raíces no necesita Kotlin ni JNI al arrancar, y con tres consumidores un fallo de inicialización los habría tumbado a los tres. El precio es que no se confía en las autoridades de certificación que alguien instale a mano en el teléfono, cosa que esta app no necesita.
Un teléfono tiene mucha menos memoria que un ordenador, y el sistema mata la app en vez de usar el disco como memoria.
-
Exportar.
export_estimatedevuelve, además del tamaño y el número de imágenes,files(cuántos PDF saldrán),memory(más o menos lo que hará falta al componer) ylowMemory.lowMemoryes verdadero cuando lo necesario pasa del 60 % de la memoria que el equipo dice tener libre (sysinfo), porque el sistema y las demás apps usan el resto. El aviso «El PDF va a ser muy grande» añade entonces que al equipo le queda poca memoria libre y la app podría cerrarse, y ofrece «Bajar a JPEG 90». La calidad de exportación no se baja de fábrica en el móvil (600 PPP es el valor de fábrica en todas partes): el aviso es la protección. -
Valores de fábrica táctiles (
src/stores/ui-store.ts, solo el punto de partida; lo que la persona haya guardado manda): la miniatura de la vista previa de 244 en vez de 488 en un móvil o tableta con dedo (calidadVistaDeFabrica; un portátil táctil es escritorio y se queda en 488), y la mesa con sombras apagadas y dados en CSS 3D (mesaDeFabricaPara), porque three.js y Rapier pesan en un teléfono modesto. - El OCR conserva su aviso de descarga de modelos como en escritorio.
-
Pegar imágenes del portapapeles. El plugin de portapapeles de Tauri solo da texto en Android, así que leer una imagen falla con
Unsupported(diseno/portapapeles.rs;arboardsolo se compila en Windows y macOS). En su lugar se elige la imagen con el selector de ficheros. - Soltar ficheros sobre la app: no se arrastra desde otras apps; «Añadir imágenes» abre el selector.
- Elegir una carpeta: el selector de Android no lo ofrece; las exportaciones salen en ZIP.
- Mostrar en la carpeta: lo sustituye Compartir.
-
El estado de la ventana y la instancia única son plugins solo de escritorio (Android no tiene posición de ventana que recordar y el sistema ya mantiene una sola instancia). Los enlaces
proxybuilder://sí funcionan en Android: el plugin de enlaces se compila en ambos y su configuración móvil (plugins.deep-link.mobileentauri.conf.json, esquema propio, sin enlaces de aplicación) genera elintent-filterdel manifiesto. - Bluetooth para pasar decks: se descartó a propósito. WebKitGTK no tiene Web Bluetooth, las librerías de Rust para Bluetooth van mal en escritorio (y el ordenador tiene que poder hacer de emisor), y Bluetooth es muy lento para varios MB de imágenes. Los decks viajan por iroh (equipos cerca, QR o código), que es la misma pieza que usa la mesa; el Bluetooth del sistema sigue al alcance a mano mediante Compartir. Véase Pasar decks.
-
iOS. No se compila. El código se mantiene portable (
#[cfg(mobile)]y"platforms": ["android", "iOS"]donde nada es propio de Android; el Kotlin aislado en el plugin;esMovil()es verdadero también en iOS), pero no se ha compilado ni ejecutado allí. Para publicarlo falta: un Mac o un runner de macOS en el CI; una cuenta de desarrollador de Apple (de pago, anual); la firma del código y los perfiles de aprovisionamiento; el plugin en Swift equivalente aandroid-extra; y no hay actualizador dentro de la app fuera de la App Store o TestFlight. Además, la revisión de la App Store podría poner pegas a una app para imprimir proxies. Deducido: una primera compilación para iOS sacaría más huecos en código que nunca se ha construido para él.
-
Entorno.
. scripts/android-env.shpone el SDK de Android, el JDK 21, el NDK 28.2 y los compiladores cruzados de Rust (CC_*, enlazadores paraaarch64yx86_64). Gradle para Android necesita el JDK 21 (el lint se cae con el 17) y un NDK estable. -
Comprobaciones.
pnpm checkincluyecheck:tactil;pnpm check:clippy-androidpasa clippy a la librería paraaarch64-linux-androidy así se revisan también las ramascfg(mobile); las comprobaciones de escritorio conservan su sentido de siempre. Véase Tests y CI. -
Compilar.
pnpm tauri android build --apk --target aarch64da el APK de release;pnpm tauri android devlanza una compilación de depuración en un aparato o emulador conectado. Un emulador esx86_64, así que las compilaciones de depuración para él usan ese objetivo. -
Emulador. Se crean dos aparatos virtuales definidos en dp, porque los cortes de formato son px de CSS, que en Android son dp: un móvil de unos 411 x 914 dp y una tableta de 1280 x 800 dp. Una tableta en horizontal a 1280 dp es
escritoriopor los cortes, a propósito; se prueba también en vertical (800 dp,tableta). Se arranca sin ventana conemulator -avd <nombre> -no-window -no-audio -no-snapshot -gpu swiftshader_indirect, se espera aadb shell getprop sys.boot_completed= 1, se instala conadb instally se hacen capturas conadb exec-out screencap -p. La depuración remota de Chrome permite inspeccionar el WebView. -
La disposición sin Android.
VITE_PB_FORMATO=movil pnpm tauri devenseña la disposición del móvil en escritorio (sin zonas seguras, sin teclado y sin botón «atrás» del sistema). - Dos cosas que un emulador no enseña: el descubrimiento entre aparatos por multicast (la red del emulador no lo deja pasar y un ordenador no puede llamar al emulador, así que entre un escritorio y un emulador siempre llama el emulador) y la lectura real de un QR con la cámara; para eso hace falta un teléfono físico.
- Firma de la release. Un APK firmado con otra clave no se instala encima de uno instalado, y desinstalar borra la biblioteca; la clave de firma es tan insustituible como la del actualizador. Véase Versiones y actualizaciones automáticas.
- Nunca se decide «móvil» por el ancho de la ventana:
useFormato()para la disposición yesMovil()para lo propio de la plataforma. - Un manejador de pasar el ratón o de clic derecho sin comentario
// táctil:rompepnpm check. - Un diálogo nuevo usa
DialogoAdaptable; un panel,Hoja; un diálogo con secciones,ListaSeccionesyvolverConEscape. - La barra de arriba de una pantalla del móvil es
BarraSuperior; un menú o panel que sube,HojaMenu(filasFilaMenu, lo secundario en unBotonMenu); el menú de un deck,BotonMenuDeck; el nombre de un deck en pantalla pasa poruseNombreDeck(). - No se añade un interruptor de pantalla completa para Android ni mínimos de zona segura en el CSS: la actividad esconde las barras y deja fuera la muesca. Una pantalla nueva del móvil se comprueba sin scroll horizontal (
scrollWidth === innerWidth). - Todo fichero que elige la persona pasa por
fichero.rs; toda función solo de Android pasa por el plugin yandroid.rs, llamada desde Rust. - No se llama a
install_android_jni_context; no se añade un cliente TLS fuera detls.rs. - El scroll hay que seguir probándolo en el WebView de destino: el de Android es Chromium, no WebKitGTK, y su scroll y su teclado se comportan distinto.
- Arquitectura del frontend
- Ajustes y shell
- Biblioteca de mazos
- Vista previa en vivo
- Panel de la lista del mazo
- Interfaz del diseñador
- Tutoriales
- Mesa de juego
- Juego en línea
- Pasar decks
- Formato del paquete de mazo
- Exportación
- Capa de aplicación Rust
- Versiones y actualizaciones automáticas
- Tests y CI
- Preparación del entorno de desarrollo
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
- Set-Browser · Explorador de sets
Reference · Referencia