Skip to content
Pablo Martínez López edited this page Nov 4, 2026 · 4 revisions

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.

Qué es

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.

Los tres formatos

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
Loading
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 sw600dp de Android. La mesa se juega en horizontal, así que sin ella nunca tendría su disposición de móvil.
  • tactil va aparte del tamaño. Una ventana de escritorio estrecha con ratón es tableta pero no tactil; un portátil con pantalla táctil es escritorio y tactil. Lo que depende del dedo (los paneles de tableta, Exportar en una hoja) se decide con tamano === "tableta" && tactil; lo que depende solo del sitio (una ventana estrecha) usa tamano.
  • plataforma() / esMovil() (src/lib/plataforma.ts) contestan otra pregunta, «¿es de verdad Android o iOS?», con el comando app_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 con import.meta.env.DEV), porque la ventana de escritorio tiene minWidth: 960 y 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 es tableta, conserva la disposición de Diseñar de siempre (la lista de cartas solo pasa a una hoja con tamano === "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-xs pasa de 12 a unos 13 px; todo lo que va en rem crece con él), touch-action: manipulation quita 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 un WithTooltip pierde su propio data-slot="button" (el disparador del tooltip pone el suyo), así que el mínimo cubre también button[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 de index.css pesa más que una clase suelta.
  • Hojas y diálogos se desplazan siempre con el dedo. Dos reglas de index.css: todo ScrollArea dentro de un dialog-content pasa a ser rejilla (grid-template-columns: minmax(0, 1fr), min-height: 0), porque un ScrollArea cuya raíz no es rejilla no se desplaza dentro de un diálogo (su viewport pide height: 100% contra un alto «auto») y la columna minmax(0, 1fr) impide que uno horizontal ensanche el diálogo; y el propio dialog-content lleva overflow-y: auto con overscroll-behavior: contain, la red para un diálogo sin DialogCuerpo o 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 del ScrollArea es 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 lleva min-w-0, que la regla respeta solo en el ancho. (2) El ScrollArea de Radix mete el contenido en un div con display: table, que toma el ancho de lo que lleva dentro, así que truncate y los grids minmax(0, 1fr) no encogían: en un diálogo táctil ese div se fuerza a display: block. (3) Un <button> con flex-col trae align-items: flex-start del navegador, así que sus hijos no se estiran y truncate nunca corta: lleva items-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 una Fila baja 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: landscape bajo data-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 scrollWidth mayor que el ancho, elementos fuera de la pantalla, contenedores con overflow-x que 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).

Reglas táctiles

Nada solo con el ratón

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 «⋯».

Pulsación larga y un gesto por elemento

  • «Dedo» es solo pointerType === "touch". El lápiz va como el ratón: tiene «encima» de verdad y doble clic nativo, así que hayEncima (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" (umbralDe en features/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_PULSACION en src/lib/pulsacion-larga.ts). crearDetector es la parte pura; usePulsacionLarga da 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. Un ContextMenu de 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 keydown global debe tratar e.target como posible document. El Escape sintético que manda «atrás» (más abajo) se lanza sobre el elemento con el foco, y sobre document si nada tiene el foco, así que e.target puede no ser un Element; los manejadores comprueban instanceof Element antes de llamar a closest o matches.

Siempre a pantalla completa, y zonas seguras

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] #root se rellena con env(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 es min(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() en barra-mesa.tsx; en escritorio es el setFullscreen de la ventana). El comando app_pantalla_completa y android::pantalla_completa siguen 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. Es clip y no hidden para que no sea un contenedor de scroll al que el navegador pueda mover al enfocar algo. Cada pantalla del móvil se comprueba con scrollWidth === innerWidth.

El teclado de la pantalla

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.

El botón «atrás»

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:

  1. Una capa de Radix abierta ([role="dialog"], alertdialog, menu, listbox, cada una con data-state="open"): se cierra la de arriba mandando un Escape sintético, que es lo que Radix entiende. Solo cuentan las capas de Radix (llevan data-state): un role="dialog" propio donde Escape decide algo (la reacción del fantasma del sparring) no lo es, porque «atrás» nunca toma una decisión de juego. El Escape va al elemento con el foco (document.activeElement, que Radix deja dentro de la capa), no a document, 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).
  2. El último manejador registrado (registrarAtras, el más reciente primero; uno que no pueda hacer nada devuelve false y se prueba el siguiente): los paneles de la mesa, la propia mesa, Diseñar.
  3. El de por defecto (fijarAtrasPorDefecto, lo pone AppShell con atrasPorDefecto de shell/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 a app_salir. Con un trabajo largo en marcha o una actualización bajando (pendientes() de lib/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.

Ciclo de vida

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.ts guarda el deck y la carta abierta en Diseñar con visibilitychange a hidden (la primera señal, antes de que el JS pueda quedarse parado) y con el evento ciclo://pausa. El campo del nombre del deck confirma antes su texto (un oyente en fase de captura en AppShell), porque conserva el foco cuando la app se va.
  • src-tauri/src/ciclo.rs (on_window_event) reacciona a WindowEvent::Suspended: guarda la partida abierta de la mesa (guardar_si_toca) y emite ciclo://pausa. Con WindowEvent::Resumed emite ciclo://vuelta y 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 a network_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).

Diálogos y hojas

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.

Piezas comunes del móvil

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í.

Tooltips sin ratón

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 es display: contents, no cambia la maqueta); con el dedo añade una «i» (AyudaTactil, solo visible con [data-tactil]) que abre un Popover con el mismo texto; claseAyuda le da a esa «i» sus propias clases (Diseñar la agranda junto a un botón grande, AYUDA_GRANDE). Fila de components/campos.tsx lo usa para la ayuda de un ajuste; con apilarEnMovil la 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 escritorio apilarEnMovil no 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), un Popover al tocar.
  • Botones que solo llevan un icono: su nombre va en aria-label (tooltips-nativos.tsx ya pasa un title a aria-label en 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 en main.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).

Pantallas según el formato

Hojas Biblioteca

Diseñar Mesa de juego

  • 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 HojaMenu con 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 BarraSuperior con «Nuevo deck» y un «⋯» («Enviar / Recibir», «Importar .pbdeck», «Liberar espacio»); debajo, el buscador con Etiquetas y «Filtros y orden», las dos HojaMenu. 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 es LecturaMovil (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 un onPointerEnter suelto), 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 con disp.campo; touch-action: none solo 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 (Recortado en tablero.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 (objetivoQueCuenta en features/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 ventana FLAG_KEEP_SCREEN_ON). El detalle, en Mesa de juego y Juego en línea.
  • Los tutoriales funcionan en todos los formatos (Tutoriales).

Ficheros en Android

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 la Copia. Por qué una copia: un proveedor en la nube puede dar una tubería sin Seek, y el ZIP de dentro de un .pbdeck lo 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 con tauri-plugin-fs solo desde Rust (app.fs().open), así que a la vista web no se le expone ningún permiso fs:.
  • 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 .pbdeck ni .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_pdf con su parámetro zip, 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 URI content:// (antes copiada a la caché) o un fichero dentro de app_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 con InvalidInput, 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_open también comparte en Android porque ACTION_VIEW con una ruta de fichero lanza FileUriExposedException. 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 .pbdeck desde otra app (Archivos, Drive, un mensaje): un intent-filter de ACTION_VIEW en el manifiesto. .pbdeck no tiene tipo MIME propio, así que el filtro acepta los tipos zip y binario genérico con una ruta que acabe en .pbdeck. Lleva cuatro pathPattern, con cero a tres puntos más antes de .pbdeck (.*\\.pbdeck, .*\\..*\\.pbdeck…): el PatternMatcher de Android no reintenta, .* se para en el primer punto y un nombre como mi.deck.pbdeck no 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 y src-tauri/src/abrir.rs la guarda y emite pbdeck://abierto; si la app arrancó con ese fichero, la pantalla aún no escucha y lo pide con pbdeck_pendiente, que lo devuelve una sola vez. El plugin ignora el intent de arranque si lleva FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY (load en AndroidExtraPlugin.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 .pbdeck ya 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 (useArrastrarFicheros comprueba esMovil()); «Añadir imágenes» abre el selector.

El plugin android-extra y android.rs

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).

TLS

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.

Límites de memoria

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_estimate devuelve, 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) y lowMemory. lowMemory es 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.

Lo que no hay

  • 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; arboard solo 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.mobile en tauri.conf.json, esquema propio, sin enlaces de aplicación) genera el intent-filter del 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 a android-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.

Cómo probarlo

  • Entorno. . scripts/android-env.sh pone el SDK de Android, el JDK 21, el NDK 28.2 y los compiladores cruzados de Rust (CC_*, enlazadores para aarch64 y x86_64). Gradle para Android necesita el JDK 21 (el lint se cae con el 17) y un NDK estable.
  • Comprobaciones. pnpm check incluye check:tactil; pnpm check:clippy-android pasa clippy a la librería para aarch64-linux-android y así se revisan también las ramas cfg(mobile); las comprobaciones de escritorio conservan su sentido de siempre. Véase Tests y CI.
  • Compilar. pnpm tauri android build --apk --target aarch64 da el APK de release; pnpm tauri android dev lanza una compilación de depuración en un aparato o emulador conectado. Un emulador es x86_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 escritorio por los cortes, a propósito; se prueba también en vertical (800 dp, tableta). Se arranca sin ventana con emulator -avd <nombre> -no-window -no-audio -no-snapshot -gpu swiftshader_indirect, se espera a adb shell getprop sys.boot_completed = 1, se instala con adb install y se hacen capturas con adb 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 dev enseñ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.

Trampas

  • Nunca se decide «móvil» por el ancho de la ventana: useFormato() para la disposición y esMovil() para lo propio de la plataforma.
  • Un manejador de pasar el ratón o de clic derecho sin comentario // táctil: rompe pnpm check.
  • Un diálogo nuevo usa DialogoAdaptable; un panel, Hoja; un diálogo con secciones, ListaSecciones y volverConEscape.
  • La barra de arriba de una pantalla del móvil es BarraSuperior; un menú o panel que sube, HojaMenu (filas FilaMenu, lo secundario en un BotonMenu); el menú de un deck, BotonMenuDeck; el nombre de un deck en pantalla pasa por useNombreDeck().
  • 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 y android.rs, llamada desde Rust.
  • No se llama a install_android_jni_context; no se añade un cliente TLS fuera de tls.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.

Véase también

Documentation · Documentación

Language: English · Español

Use and build · Uso y desarrollo

Architecture · Arquitectura

Data · Datos

Getting cards · Conseguir cartas

Layout, images, PDF · Maquetación, imagen y PDF

Card designer · Diseñador de cartas

Game table · Mesa de juego

Mobile · Móvil

Frontend · Frontend

Reference · Referencia

Clone this wiki locally