hotwire-native-shell: un shell de Hotwire Native configurado desde Rails

Escrito por
💻 Código
08 de octubre de 2026 • 12 min de lectura

hotwire-native-shell es un repo open source (MIT) con dos apps nativas delgadas, una para Android y otra para iOS, construidas sobre Hotwire Native. Lo Hice para llevar mis apps Rails a iOS y Android sin escribir un cliente móvil por cada una.

Resuelve el armado que se repite en cada app de Hotwire Native: la URL base, las tabs, los bridges registrados, cómo reconfigurar la app sin publicar otra build. Aquí esas decisiones no viven en Swift ni en Kotlin; las toma Rails, con un endpoint JSON y una gema que lo sirve.

Hotwire Native en breve

Hotwire Native es el framework de 37signals para envolver una app web en un shell nativo. Su documentación lo resume así: el contenido es web y la navegación es nativa. La app intercepta los taps en links, empuja una pantalla nativa y carga ahí el HTML de tu servidor. Cuando despliegas Rails, la app cambia sin pasar por la tienda.

Dos piezas más:

  • Path configuration. Un JSON con settings y rules; cada regla asocia patrones de URL con propiedades como "context": "modal". Se recomienda una copia en la app y otra en el servidor, versionadas por plataforma (/configurations/ios_v1.json, /configurations/android_v1.json).
  • Bridge components. Un controller de Stimulus más un componente en Swift o Kotlin, para que la página declare elementos que se dibujan de forma nativa: un botón en la barra, un menú, la hoja de compartir.

Arquitectura del shell

El shell usa Hotwire Native Android 1.3.1 (Kotlin, minSdk 28, target API 36) y Hotwire Native iOS 1.3.1 (Swift, iOS 15.6). Las dos apps leen el mismo contrato.

GET /native/config

Al arrancar, la app pide {base_url}/native/config. Un ejemplo con la forma del contrato (recortado a una tab):

{
"name": "ejemplo",
"base_url": "https://app.ejemplo.com",
"start_path": "/",
"tabs": [
{ "id": "home", "title": "Inicio", "titles": { "es": "Inicio", "en": "Home" }, "path": "/", "icon": "home" }
],
"bridges": {
"notification_token": true, "share": true, "haptic": true,
"camera": false, "biometric": false, "clipboard": false, "file_download": false
},
"push": { "enabled": true, "topics": ["novedades"] }
}

name también es el prefijo del user agent (ejemplo;). El shell ignora llaves desconocidas, así que se pueden agregar campos sin romper versiones publicadas.

El JSON empaquetado

Una copia de ese documento va dentro de la app (flavors/<app>/assets/native/config.json). El primer arranque la usa sin esperar al servidor. Cuando /native/config responde, la respuesta se guarda y se usa en el siguiente arranque en frío; si el sitio aún no sirve la ruta, sigue mandando la copia empaquetada.

Path configuration

Va aparte del contrato. Por default hay dos reglas: todas las rutas en contexto default con pull to refresh, y /new$ y /edit$ como modal. La app trae su copia y luego intenta la remota.

El bridge de tabs

Las tabs de /native/config son solo la lista de arranque. Después, cada página manda la suya con el bridge tabs:

<nav data-controller="bridge--tabs">
<a href="/" data-bridge--tabs-target="tab" data-bridge-id="home"
data-bridge-title="Inicio" data-bridge-icon="home" data-bridge-active="true">Inicio</a>
<a href="/acerca" data-bridge--tabs-target="tab" data-bridge-id="about"
data-bridge-title="Acerca" data-bridge-icon="info" data-bridge-sf-symbol="info.circle">Acerca</a>
</nav>

Con dos o más tabs válidas, el shell dibuja una barra inferior nativa con un navigator por tab; con cero o una, usa un solo navigator. Guarda máximo cinco y salta las entradas inválidas. Cambiar las tabs es cambiar HTML, no publicar una build (salvo que necesites un ícono de Android que no esté en el APK).

Así se resuelve el login: sin sesión, la página manda tabs: [] y el sign-in vive en un solo navigator. Al entrar, llegan las tabs de la cuenta y el shell arma un navigator nuevo por tab, sin dejar el sign-in en el historial.

Bridges

menu, overflow-menu y tabs se registran siempre, porque son la navegación nativa. notification-token, share y haptic se registran solo si su flag está en true. camera, biometric, clipboard y file_download existen como flags reservados, pero todavía no tienen implementación.

Instalación del lado de Rails

La gema hotwire_native_shell-rails vive en rails/ del mismo repo y no está en RubyGems. Como el gemspec no está en la raíz, Bundler necesita el glob:

# Gemfile
gem "hotwire_native_shell-rails", github: "eserna27/hotwire-native-shell", glob: "rails/*.gemspec"
bundle install
bin/rails generate hotwire_native_shell:install
bin/rails db:migrate

El generador:

  • crea config/initializers/hotwire_native_shell.rb;
  • agrega hotwire_native_shell a config/routes.rb, que monta GET /native/config, GET /configurations/android_v1.json, GET /configurations/ios_v1.json y POST/DELETE /native/device_tokens;
  • copia los controllers de Stimulus a app/javascript/controllers/bridge/ (y un menu_controller.js si no existe);
  • crea app/views/shared/_native_tabs.html.erb;
  • fija @hotwired/hotwire-native-bridge 1.2.2 en el importmap, o lo agrega a package.json si usas jsbundling;
  • agrega al .gitignore los patrones de llaves de push;
  • genera la migración de hotwire_native_shell_device_tokens.

El initializer es toda la configuración. El arranque en frío no ve la sesión, así que /native/config publica la ruta y las tabs sin sesión; las de la cuenta llegan después desde la página:

HotwireNativeShell.configure do |config|
config.name = "ejemplo"
config.base_url = "https://app.ejemplo.com"
config.title_suffix = "Ejemplo"
config.signed_out_start_path = "/users/sign_in"
config.signed_in_start_path = "/dashboard"

config.tab :home, auth: :signed_in, title: "Inicio",
titles: { es: "Inicio", en: "Home" }, path: "/dashboard", icon: "home"
config.tab :posts, auth: :signed_in, title: "Posts",
path: "/dashboard/posts", icon: "posts"

config.menu_item "Sign out", "/users/sign_out", method: :delete, auth: :signed_in
end

Si necesitas otras reglas de navegación, config.path_rule reemplaza las de default (hay que declarar todas las que quieras conservar):

config.path_rule [ "/posts/new$" ], context: "modal", pull_to_refresh: false

En el layout:

<%= stylesheet_link_tag "hotwire_native_shell" %>
<%= render "shared/native_tabs" %>
<% if native_render_web_nav? %>
<nav class="navbar"><%# navbar HTML del sitio %></nav>
<% end %>
<title><%= native_document_title(page_title) %></title>

El partial de tabs va en todas las páginas, aunque la lista esté vacía; si falta, se queda la barra anterior. native_share, native_menu y native_notification_token van solo en las vistas que los usan. Ninguno imprime nada si el user agent no incluye Hotwire Native, así que el sitio no cambia.

Para el flujo con login, este concern redirige solo las visitas nativas: sin sesión, al signed_out_start_path; con sesión y en /, al signed_in_start_path.

class ApplicationController < ActionController::Base
include HotwireNativeShell::NativeEntry
end

La gema también envía push con HotwireNativeShell::Push.deliver (APNs con llave .p8 o FCM HTTP v1), deliver_to_owner y deliver_topic (solo FCM); las credenciales van en Rails credentials o en variables HOTWIRE_NATIVE_SHELL_*. Y trae dos helpers para App Review: native_oauth_allowed? oculta el login con Google o GitHub en la app mientras config.sign_in_with_apple sea false (guía 4.8), y native_account_deletion_link muestra el link para borrar la cuenta cuando defines config.account_deletion_path (guía 5.1.1(v)).

Crear una app nueva con bin/new-app

Del lado nativo, una app nueva es un flavor. bin/new-app pregunta nombre visible, bundle id, origen, colores, ícono, tabs, bridges y push, o lee las respuestas de un app.yml. Un app.yml de ejemplo:

display_name: Ejemplo
slug: ejemplo
application_id: com.ejemplo.app
base_url: https://app.ejemplo.com
dev_base_url: http://localhost:3000
android_dev_base_url: http://10.0.2.2:3000
colors:
primary: "#6EE7B7"
secondary: "#3B82F6"
splash_background: "#FFFFFF"
icon: https://app.ejemplo.com/icon.svg
start_path: /
bridges:
notification_token: true
share: true
haptic: true
push:
enabled: true
topics:
- novedades
bin/new-app --file path/to/app.yml --config-only   # solo imprime native/config.json
bin/new-app --file path/to/app.yml --dry-run # valida y lista lo que haría
bin/new-app --file path/to/app.yml # escribe el flavor

Una corrida completa escribe flavors/<slug>/, el product flavor de Android con launcher y splash, ajusta el target de iOS (bundle id, nombre, carpeta native, splash) y genera store/brand.yml para las capturas de tienda. Al final imprime lo que no puede hacer: firmar en Xcode, subir la llave de APNs a la app Rails y crear el proyecto de Firebase. Requiere PyYAML, más Pillow y rsvg-convert para el ícono.

Después se compila solo ese flavor:

cd android
./gradlew :app:assembleEjemploDebug

python3 script/check_contract.py revisa el contrato sin el SDK de Android ni Xcode; con --flavor <slug> revisa el JSON y el cableado de Gradle de ese flavor.

Usar el shell con agentes de código

El flujo para crear una app nueva está pensado para que lo lleve un agente de código (Cursor, Claude Code, Codex u otro): todo está en archivos de texto y en comandos que corren sin preguntas.

Qué le da el repo al agente

AGENTS.md es el punto de entrada. El repo no trae CLAUDE.md ni reglas en .cursor/; si tu herramienta no lee AGENTS.md por su cuenta, pídele en el prompt que empiece por ahí. Ese archivo define:

  • Orden de lectura: README, los docs de contrato, bridges, UI nativa y apps nuevas, los README de android/, ios/ y rails/, y bin/new-app.
  • Qué no tocar: un cliente es un flavor, no un clon ni un fork; no se inventa otro contrato ni se renombran campos; no se suben keystores, google-services.json, .p12 ni perfiles; no se agregan FCM ni APNs a mano.
  • Cuándo está terminada una tarea: el flavor compila, su config.json apunta al origen correcto, check_contract.py y bundle exec rake test de la gema pasan, y no hay secretos en git. En Linux, el agente no debe decir que xcodebuild pasó si no corrió.

Por qué bin/new-app sirve para un agente

Sin --file, bin/new-app es un cuestionario interactivo. Con --file app.yml corre sin preguntas, y --dry-run y --config-only solo funcionan con --file. --dry-run valida el YAML y muestra lo que escribiría, el native/config.json resultante y el checklist, sin tocar archivos. Los errores salen por stderr con el prefijo new-app: y código 1 (por ejemplo, new-app: icon is required (image file or favicon URL)), así que el agente corrige el YAML y reintenta. --skip-ios y --keep-brand dejan intactos el target de iOS o store/brand.yml.

Un prompt para una app nueva, en el repo del shell:

Lee AGENTS.md y sigue su orden de lectura.
Crea un cliente para mi app Rails en https://app.ejemplo.com:
1. Escribe flavors/ejemplo/app.yml: display_name "Ejemplo", slug ejemplo,
application_id com.ejemplo.app, icon con la URL del sitio o del favicon,
tabs Inicio (/) y Cuenta (/cuenta), bridges share y haptic, push apagado.
2. Corre bin/new-app --file flavors/ejemplo/app.yml --dry-run y corrige hasta que pase.
3. Corre bin/new-app --file flavors/ejemplo/app.yml.
4. Corre python3 script/check_contract.py --flavor ejemplo.
5. Compila: cd android && ./gradlew :app:assembleEjemploDebug.
6. Termina con el checklist "Manual steps left" que imprimió bin/new-app.
No toques signing, Firebase ni APNs.

Del lado de Rails, el prompt es la sección de instalación de arriba: la línea del Gemfile, el generador, db:migrate, el initializer con el mismo name, base_url y tabs, el partial en el layout, NativeEntry, y comparar GET /native/config con el config.json del flavor.

Lo que queda para ti

bin/new-app termina con "Manual steps left", lo que no puede hacer:

  • Elegir el Team en Signing & Capabilities de Xcode, sin subir team id, perfil ni .p12.
  • Servir /native/config y las dos path configurations desde Rails.
  • Mantener los orígenes de desarrollo fuera del JSON de producción.
  • Editar store/brand.yml para las capturas de tienda.
  • Con push: confirmar el proyecto de Firebase y agregar el plugin de Google services y FCM; crear la llave de APNs y subirla a la app Rails; pasar el entitlement a production al archivar.

Con push encendido, bin/new-app además exige push.google_services_json: ese archivo lo descargas tú de Firebase. Las cuentas de Apple Developer y Google Play Console no están en el checklist, pero también te tocan a ti.

Limitaciones

  • No hay builds en la nube. iOS necesita una Mac con Xcode 15 o más reciente; xcodebuild no corre en Linux. Android necesita JDK 17.
  • No hay material de firma en git. No hay certificados, perfiles de aprovisionamiento ni keystores, a propósito. La firma es manual.
  • El push en el dispositivo no está terminado. El bridge notification-token regresa placeholder-not-a-device-token hasta que agregues Firebase en Android o el entitlement de APNs en iOS. bin/new-app no aplica el plugin de Google services ni suscribe a los topics.
  • Cuatro bridges están reservados pero no implementados: cámara, biometría, portapapeles y descarga de archivos.
  • iOS v1 es un solo target. Cambiar de app es cambiar el bundle id y la carpeta del flavor en ese target.
  • La gema está en la versión 0.1.0 y se instala desde GitHub.
  • No funciona offline. Como cualquier app de Hotwire Native, necesita conexión para cargar pantallas.
  • Es una librería para mis apps, no un producto con soporte.

Cuándo usar Ruby Native

Si no quieres abrir Xcode ni Android Studio, este shell no es la mejor opción. Para eso está Ruby Native, de Joe Masilotti.

Joe es de quienes más han hecho para que los desarrolladores Rails lleguemos a Hotwire Native. Ayudó a construir la librería de iOS con el equipo de 37signals, escribió Hotwire Native for Rails Developers (Pragmatic Programmers) y publica guías, bridge components y un newsletter semanal en masilotti.com. Mucho de lo que entiendo de Hotwire Native lo aprendí de su material.

Ruby Native está hecho para no escribir Swift ni Kotlin. Instalas una gema y configuras colores, tabs y navegación en config/ruby_native.yml. Con ruby_native preview escaneas un QR y ves la app en tu teléfono. Las builds se compilan y firman en la nube, así que no necesitas Mac para iOS. Incluye push por APNs y Firebase, capturas de tienda automáticas y compras in-app, y funciona con Hotwire, Inertia, React, Vue o ERB.

El modelo de precio es por app. Es gratis hasta que publicas (puedes llegar a TestFlight y a las pruebas internas de Google Play sin suscripción). Después, Starter cuesta USD 299 al año, Business USD 999 al año (agrega suscripciones in-app y soporte prioritario) y Turnkey, donde Joe construye y publica la app por ti, empieza en USD 15,000 en un solo pago. La cuenta de Apple Developer (USD 99 al año) va aparte. Si cancelas, te quedas con el código nativo, que en modo avanzado corre sobre Hotwire Native.

En su FAQ, Joe explica bien el costo de la otra ruta: un shell propio te deja dos codebases nativos, certificados, App Review y lo que rompa cada versión de iOS. Mi resumen: si quieres ser dueño de la capa nativa y tienes varias apps con el mismo contrato, un shell como este tiene sentido. Si tu prioridad es publicar y seguir en Rails, Ruby Native es la opción más directa.

Código

El repo está en github.com/eserna27/hotwire-native-shell, con licencia MIT. Los detalles de cada campo están en docs/CONTRACT.md, docs/BRIDGES.md y docs/NATIVE_UI.md.