Skip to content

Instalar una PWA sin beforeinstallprompt: el elemento install y navigator.install()

Instalar una PWA sin beforeinstallprompt: el elemento install y navigator.install()
14 min read

Si alguna vez pusiste un botón de "Instalar la app" en una PWA, ya conocés el ritual. Escuchás beforeinstallprompt, le hacés preventDefault(), guardás el evento en una variable global, mostrás tu botón, y cuando el usuario hace click llamás a prompt(). Si el evento nunca llega (porque el navegador decidió que no, porque el manifest cargó tarde, porque es Safari), tu botón no aparece y no tenés forma de saber por qué.

beforeinstallprompt nunca fue un estándar. Estuvo en la spec de Web App Manifest y se sacó en 2020 porque solo lo implementaba Chromium, que lo siguió manteniendo por su cuenta. Hoy hay un intento de volver a estandarizarlo, y en esa discusión es donde WebKit fijó su posición (más abajo).

Chrome 158, que hoy está en beta y llega a estable el 17 de noviembre, trae el reemplazo: el elemento <install> y el método navigator.install(). Los dos hacen lo mismo, pero piden permiso de forma distinta, y esa diferencia es lo más interesante de la feature.

Lo mínimo

Para ofrecer la instalación de tu propia app:

<link rel="manifest" href="/manifest.webmanifest">

<install>
  <a href="/como-instalar">Cómo instalar la app</a>
</install>

El navegador renderiza un botón propio. Lo que pongas adentro es fallback y solo se ve en navegadores que no conocen el elemento. En Chrome los hijos no se renderizan ni forman parte del árbol de accesibilidad, y el nombre accesible del botón lo pone el navegador, según el idioma de la página.

Si querés probarlo antes de que llegue la 158, en Chrome 157 se puede habilitar con --enable-blink-features=InstallElement,WebAppInstallation. Yo lo probé así: el elemento renderiza su botón, el link de adentro queda con ancho cero y navigator.install() sin un click del usuario tira NotAllowedError. El diálogo de instalación en sí no lo pude probar de esa forma.

La versión imperativa:

installButton.addEventListener('click', async () => {
  try {
    await navigator.install();
  } catch (err) {
    console.log(err.name); // AbortError, DataError, NotAllowedError, SecurityError…
  }
});

Sin argumentos, las dos instalan la app del documento actual. Hay una diferencia con beforeinstallprompt incluso acá: tu manifest tiene que declarar id y start_url, o falla con DataError. Pero lo realmente nuevo es poder instalar otra app.

Instalar apps de otro origen

<install manifest="https://mail.suite.example/manifest.webmanifest">
  <a href="https://mail.suite.example/">Abrir Mail</a>
</install>
await navigator.install({
  manifest: 'https://mail.suite.example/manifest.webmanifest',
});

Esto habilita casos que hasta ahora no tenían solución: un catálogo de apps internas de una empresa, una suite que ofrece instalar la app hermana desde la principal, o un buscador o directorio de PWAs.

Si probaste el origin trial, la forma cambió. La versión de prueba recibía la URL de una página (navigator.install(install_url, manifest_id), y <install installurl> en el elemento). El navegador cargaba esa página en una pestaña invisible para descubrir el manifest. El feedback del trial fue malo por varios lados: los redirects de login o de idioma rompían la URL, cargar un documento entero era caro en dispositivos chicos, y la página destino recibía cookies antes de que el usuario aceptara nada. Así que se rediseñó para recibir la URL del manifest. Muchos artículos de principios de este año todavía muestran la forma vieja.

Dos modelos de confianza

El elemento y la API hacen lo mismo, pero no piden permiso de la misma manera:

Diálogos que ve el usuario
navigator.install() sin argumentos Confirmación de instalación
navigator.install({ manifest }) Permiso "este sitio quiere instalar apps" + confirmación
<install> con o sin manifest Confirmación de instalación

navigator.install({ manifest }) pide un permiso nuevo, web-app-installation, asociado al origen que instala. Y lo pide siempre que pasás un manifest, incluso si es del mismo origen. El usuario ve dos diálogos: primero el permiso, después la confirmación.

<install> no lo pide nunca. Según la spec, funciona incluso si el usuario había denegado ese permiso antes. La razón es que un click sobre un botón que dibuja el navegador (con estilo controlado, sin posibilidad de taparlo o disfrazarlo) es una señal de intención mucho más fuerte que un click cualquiera en la página seguido de una llamada a una API.

Si leíste el post sobre capability elements, es la misma idea llevada un paso más allá. Ahí los elementos mejoraban el timing del prompt de permisos y te entregaban el dato. Acá el elemento directamente reemplaza al permiso, porque el navegador confía más en un botón que dibuja él que en cualquier código que escribas vos.

En la práctica, para un catálogo, el elemento da una experiencia bastante mejor. Y como el evento de resultado burbujea, alcanza con un listener en el contenedor:

<ul id="catalog">
  <li data-app="Mail">
    Mail
    <install manifest="https://mail.suite.example/manifest.webmanifest">
      <a href="https://mail.suite.example/">Abrir Mail</a>
    </install>
  </li>
  <li data-app="Calendario">
    Calendario
    <install manifest="https://cal.suite.example/manifest.webmanifest"
             manifestid="https://cal.suite.example/?source=pwa">
      <a href="https://cal.suite.example/">Abrir Calendario</a>
    </install>
  </li>
</ul>
<p id="status" role="status"></p>

<script>
  const status = document.getElementById('status');

  document.getElementById('catalog').addEventListener('installresult', (e) => {
    const app = e.target.closest('li').dataset.app;
    if (e.result === 'success') status.textContent = `${app}: listo.`;
    if (e.result === 'invalid-data') console.error(`${app}: revisá el manifest`);
    // 'aborted': el usuario canceló o el navegador no pudo. No insistas.
  });
</script>

El role="status" está a propósito: la spec aclara que el evento no anuncia nada a los lectores de pantalla, así que el feedback lo tenés que dar vos.

Con la API hay otro tema con el permiso. Consultar el estado con navigator.permissions.query({ name: 'web-app-installation' }) dentro del handler del click funciona (lo probé y la query tarda unos milisegundos), pero install() necesita transient activation, que dura unos pocos segundos, y si en el medio metés un fetch o algo lento te podés pasar. Prefiero consultarlo al renderizar y esconder el botón si está en denied.

Tu manifest ahora es una API pública

Con la instalación por URL de manifest, un archivo que hasta ahora solo leía el navegador cuando alguien entraba a tu sitio pasa a ser un endpoint que otros sitios linkean. Y tiene reglas estrictas (las dos primeras aplican también a instalar tu propia app):

  • id y start_url tienen que estar declarados explícitamente. No alcanza con los valores que el navegador calcula por default. Según el explainer, en 2024 solo un 4% de los manifests declaraba id. Si el manifest de otra app no lo tiene, podés pasar el id calculado con manifestid (lo ves en DevTools, en Application > Manifest).
  • Nada de redirects. Cualquier redirect hace fallar la instalación. Revisá trailing slashes, redirects de http a https, el cleanUrls de tu hosting y CDNs que redirigen según el idioma.
  • El Content-Type tiene que ser JSON (application/manifest+json o application/json). Con text/plain falla en 158; si probás en 157 con flags, ese chequeo todavía no está.
  • start_url tiene que ser del mismo origen que el manifest. No podés servir el manifest desde un CDN en otro dominio.
  • Cuenta la CSP del que instala. El fetch del manifest respeta el manifest-src del sitio que tiene el botón. Un catálogo con default-src 'self' no puede instalar nada de afuera hasta que agregues esos orígenes.
  • No uses hash en el nombre del manifest. Si tu build genera manifest.3f2a.json, cada deploy rompe todos los links de instalación de terceros.

Del lado de la privacidad, el fetch del manifest va sin cookies, sin referrer y sin pasar por el service worker del destino. No necesita CORS, porque el destino no expone nada que no sea público.

El resto de la validación es razonable: tiene que haber un name o short_name, y al menos un ícono con purpose que incluya any. No se exige service worker ni soporte offline.

{
  "id": "/",
  "start_url": "/",
  "name": "Mail",
  "icons": [{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }],
  "display": "standalone",
  "related_applications": [{ "platform": "webapp", "id": "/" }]
}

Aunque no pienses usar ninguna de estas APIs, agregar id y start_url explícitos a tu manifest hoy es gratis y te ahorra problemas el día que alguien quiera instalar tu app desde otro lado.

Lo que la API no te va a decir, a propósito

Los resultados son poco precisos a propósito:

  • success no significa "se instaló". Si la app ya estaba instalada, el navegador puede ofrecer abrirla, y si el usuario acepta, el resultado también es success. No sirve para medir instalaciones nuevas.
  • AbortError cubre casi todo: el usuario canceló, denegó el permiso, hay una política enterprise que lo bloquea, está en una ventana de incógnito, ya hay otra instalación en curso en esa pestaña. La spec deja la taxonomía de errores como un tema abierto.
  • DataError no distingue la causa: red caída, 404, redirect, JSON inválido o id que no coincide dan todos el mismo error. Si el manifest es tuyo, DevTools te muestra el detalle en el panel Issues. Si es de otro origen, nada.
  • No hay forma de saber si una app de otro origen ya está instalada. getInstalledRelatedApps() en desktop solo detecta apps cuyo scope incluye la página.

Es incómodo para el que construye la UI, pero tiene sentido. Si el resultado distinguiera "instalada" de "ya estaba", cualquier sitio podría sondear qué apps tenés instaladas. Lo mismo con el incógnito: falla siempre con aborted, pero después de validar los datos, para no dar una diferencia de timing que permita detectarlo.

Además hay restricciones de contexto. Solo funciona en el documento principal: nada de iframes (ni con allow), sandbox ni fenced frames. Si querés poner el botón dentro de un widget embebido, no se puede. Y el <install> hereda las restricciones de los capability elements: el navegador controla cómo se ve, valida que esté visible y no tapado, y lo bloquea un instante cuando recién se insertó o se movió. Si algo no cumple, el botón queda deshabilitado en silencio. El motivo lo podés leer con isValid e invalidReason (y enterarte cuando cambia con el evento validationstatuschange); dentro de un iframe, por ejemplo, vas a ver illegal_subframe.

Progressive enhancement

Por ahora esto funciona solo en Chrome 158 desktop (Windows, macOS, Linux y ChromeOS). Android llega más adelante, y WebView no tiene planes. Así que beforeinstallprompt todavía tiene un rato de vida:

// beforeinstallprompt se dispara una sola vez y temprano: hay que capturarlo apenas carga el módulo
let deferredPrompt;
addEventListener('beforeinstallprompt', (e) => {
  e.preventDefault();
  deferredPrompt = e;
});

const INSTALLED = '(display-mode: standalone), (display-mode: minimal-ui), (display-mode: window-controls-overlay)';

async function setupInstall(container) {
  // Ya estamos adentro de la app instalada
  if (matchMedia(INSTALLED).matches) return;

  // Solo responde si el manifest se lista a sí mismo en related_applications
  const related = (await navigator.getInstalledRelatedApps?.()) ?? [];
  if (related.some((app) => app.platform === 'webapp')) return;

  if ('HTMLInstallElement' in window) {
    container.append(document.createElement('install'));
    return;
  }

  // Defensivo: en Chrome 158 la API y el elemento salen juntos
  if ('install' in navigator) {
    const button = Object.assign(document.createElement('button'), { textContent: 'Instalar' });
    button.onclick = () => navigator.install().catch(() => {});
    container.append(button);
    return;
  }

  // Chromium sin las APIs nuevas, por ejemplo Android
  if (deferredPrompt) {
    const button = Object.assign(document.createElement('button'), { textContent: 'Instalar' });
    button.onclick = async () => {
      button.remove(); // prompt() se puede llamar una sola vez
      await deferredPrompt.prompt();
      deferredPrompt = null;
    };
    container.append(button);
  }
}

El elemento va primero porque es el que el navegador dibuja y valida. Para la app actual, ni el elemento ni navigator.install() sin argumentos piden permiso, pero el <install> no depende de que tu botón pase los chequeos de activación. El beforeinstallprompt se captura afuera de la función porque se dispara una sola vez, temprano en la carga: si registrás el listener después de un await, en Android muchas veces ya pasó y el botón no aparece nunca. En Safari y Firefox no va a pasar nada, y lo razonable ahí es un link a instrucciones o directamente no mostrar nada.

Sobre getInstalledRelatedApps(): no adivina nada. Para que sirva, tu manifest se tiene que listar a sí mismo en related_applications ({ "platform": "webapp", "id": "<tu id>" }), y en desktop solo responde si la página está dentro del scope de la app.

Si no necesitás nada de esto, el <install> con un link de fallback adentro ya cubre el caso del navegador moderno y el del que no lo soporta, sin una línea de JavaScript.

El debate

La feature viene con pelea incluida.

WebKit se opone formalmente (la posición está en el issue sobre beforeinstallprompt, que cerró también el de Web Install). Su argumento va más por el modelo que por lo técnico: para ellos, instalar es una acción que tiene que iniciar el usuario desde el navegador. En sus palabras, lo que importa es "who determines when the installation flow begins": quién decide cuándo arranca el flujo de instalación. Que el diálogo sea del navegador no cambia que lo dispare la página. Y agregan que instalar una web app le da "poderes especiales" (en la práctica, en iOS: más almacenamiento, badges, la posibilidad de pedir notificaciones push), lo que le da a los sitios un incentivo para insistir. Su alternativa es que el sitio pueda detectar sin prompt si está corriendo como app instalada (un valor nuevo de display-mode), y dejar la instalación en manos del navegador.

En el mismo issue aparecen datos de Chrome, compartidos por Dan Murphy: el ícono de instalación en la barra de direcciones tiene un CTR de alrededor del 0,01%, y los prompts del navegador rondan el 1,2%. Su conclusión es que el navegador es mal juez de cuándo ofrecer la instalación, y que el sitio sabe más. La lectura de WebKit es otra: quizás la mayoría de la gente simplemente no quiere instalar, y darle al sitio el botón solo aumenta la insistencia. Esos números hay que leerlos con cuidado, porque son telemetría interna citada en una discusión y no un estudio publicado.

Mozilla no tiene posición todavía, pero alguien de su equipo dejó un comentario en otro issue: critica beforeinstallprompt porque prompt() se puede llamar en cualquier click, incluso desde un iframe, y dice que el elemento <install> no tiene ese problema porque solo cuenta un click sobre el botón del navegador. No es una posición oficial, pero sugiere que el elemento tiene más chances que la API.

El TAG del W3C declinó la propuesta y aclaró que su posición no cambió respecto de revisiones anteriores. Está conforme con la instalación same-origin, pero quiere verla desplegada antes de hablar de cross-origin. Y objeta algo que me parece razonable: usar nombres tan genéricos como navigator.install() y <install> para una API que trae incorporado el modelo de instalación de un solo motor, cuando otros navegadores instalan sin manifest ni id. Va a escribir un documento propio sobre instalación de web apps.

Conclusión

Yo lo trataría como una mejora progresiva solo para Chromium desktop, en la línea de Web Bluetooth. Que no vaya a estar en Safari no es motivo para ignorarlo, porque el fallback es trivial.

Lo que haría hoy:

  1. Agregar id y start_url explícitos al manifest y revisar que se sirva sin redirects y con Content-Type JSON. Es gratis.
  2. Reemplazar el banner casero basado en beforeinstallprompt por un <install> con el link actual adentro como fallback.
  3. Si tenés varias apps, armar la página de "nuestras apps" con un <install manifest> por cada una, y no con la API, para evitarle al usuario el diálogo de permiso extra.
  4. No usar success para analytics de instalaciones nuevas.

Lo que voy a seguir de cerca es qué hace Mozilla con el elemento. Si lo adopta, deja de ser una pelea entre Chrome y Safari, y lo que se discute pasa a ser si un botón del navegador dentro de la página es una buena forma de pedirle cosas al usuario. Con los capability elements y ahora con esto, Chrome ya está apostando a que sí.

Comentarios

Comentá con tu cuenta de GitHub.

Cargando comentarios…


Related Posts

Permisos declarativos con Capability Elements

Permisos declarativos con Capability Elements

11 min read

Un prompt de permisos que aparece fuera de contexto se bloquea por reflejo, y volver atrás implica bucear en la configuración del navegador. La apuesta de Chrome son cuatro elementos HTML que el navegador controla y que, además del permiso, te entregan el dato.

Responsive iframes

Responsive iframes

11 min read

Que un iframe se ajuste al alto de su contenido es uno de los pedidos más viejos de la plataforma, y siempre lo resolvimos con postMessage. Chrome 154 lo trae como un doble opt-in: una propiedad CSS del lado del padre y un meta del lado del hijo.