---
slug: 41b-pwa
title: Tu app en el teléfono (PWA)
description: Hacé instalable un sitio Synsema en Android, iOS y escritorio — manifest, service worker, íconos, offline honesto — y notificá a tus usuarios con Web Push nativo (push_send / push_vapid_keys), todo desde un `synsema init --pwa`.
example_ids: [pwa]
---

# Tu app en el teléfono (PWA)

Un sitio Synsema **se instala** en un teléfono o en un escritorio como una app: un ícono en la pantalla de inicio, pantalla completa sin el chrome del navegador, una shell que abre sin red, y notificaciones push — con nada más que la web que el servidor ya renderiza. Sin cliente nativo, sin tienda, sin framework. El cliente es el navegador que el usuario ya tiene; el backend, los agentes y los secretos se quedan en el servidor, que es donde van.

```sh
synsema init --pwa miapp && cd miapp
synsema serve app.syn            # http://localhost:8080 — Chrome y Edge la instalan desde localhost
```

Apretá **Run** para la parte doctesteada — el par VAPID nace sellado, la clave privada sólo se acepta como secret, y el envío se rechaza antes de abrir ningún socket si no declaraste el push service:

```synsema
-- Doc example: Web Push (installable apps). The VAPID pair is born SEALED, the private
-- key is accepted only as a secret, and delivering is refused before any socket opens
-- unless the push service's host was declared (deny-by-default). Runs anywhere: no
-- network is touched.
intent: "doc example: web push — VAPID keys + the gates"
require random                     -- push_vapid_keys() creates new secret material

let keys be push_vapid_keys()      -- {public: text, private: secret}
print("public key: " + text(length(keys["public"])) + " chars · private: " + text(keys["private"]))

task subscription()
    -- the shape the browser gives you: PushSubscription.toJSON() (keys from RFC 8291 here)
    give {"endpoint": "https://web.push.apple.com/QAbc123", "keys": {"p256dh": "BP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27mlmlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A8", "auth": "BTBZMqHH6r4Tts7J_aSIgg"}}

task send_without_net()
    -- no `require net("web.push.apple.com")` → refused at the capability check
    give push_send(subscription(), {"title": "hi"}, {"vapid": {"public": keys["public"], "private": keys["private"], "subject": "mailto:ops@example.com"}})

task send_with_a_plain_text_key()
    -- a private key as plain text is never accepted (doctrine: private keys are secrets)
    give push_send(subscription(), "hi", {"vapid": {"public": keys["public"], "private": "not-a-secret", "subject": "mailto:ops@example.com"}})

test "the pair: a base64url public key and a SEALED private key"
    assert_eq(length(keys["public"]), 87)                        -- 65-byte uncompressed P-256 point
    assert_eq(text(keys["private"]), "secret(vapid_private)")    -- redacted wherever it goes

test "delivering needs net(<push service host>) — refused before any socket opens"
    assert_error(send_without_net)

test "the VAPID private key is only accepted as a secret"
    assert_error(send_with_a_plain_text_key)
```

## Qué escribe `synsema init --pwa` (engine v0.6.15+)

| Archivo | Qué es |
|---|---|
| `app.syn` | el server: `static "/" from "./public"`, la página y `mount api.api` (engine v0.6.19+; antes las rutas iban inline) |
| `api.syn` | (engine v0.6.19+) el API como grupo `export routes api` — `/api/ping` y las rutas de push (`/api/push/config`, `/api/push/subscribe` a 10/min, `/api/push/test` a 2/min), así las mismas rutas sirven también a la [entrada de escritorio](/es/0.6.x/41c-desktop). Un módulo no lleva `require`: las capabilities las declara la entrada |
| `push_keys.syn` | una sola vez: imprime `VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY` / `VAPID_SUBJECT` para `.env` |
| `index.html` | la página — `<link rel="manifest">`, `theme-color`, `apple-touch-icon`, los metas de pantalla completa, `<script src="/app.js">` |
| `public/manifest.webmanifest` | `id: "/"`, nombre, `start_url: "/"`, `scope: "/"`, `display: "standalone"`, íconos 192 + 512 (`any`) y un 512 `maskable` aparte (Lighthouse rechaza un `any maskable` combinado). Sumá `screenshots` (y una `description` más larga) para la hoja de instalación rica y las tiendas — son tuyas, `init` no las inventa |
| `public/sw.js` | el service worker: shell cacheada **stale-while-revalidate** (responde del caché, refresca por detrás — un cambio se ve en la *siguiente* apertura; subí `CACHE` para forzarlo ya), `/api/` jamás cacheado, push → notificación con **badge** + `tag`/`renotify`, toque → **enfoca la ventana abierta** (y le avisa por `postMessage`) en vez de abrir otra |
| `public/app.js` | registra el worker, el botón **Install** (`beforeinstallprompt`), el aviso para iPhone, un estado honesto (`navigator.onLine` **y** una prueba `no-store` de `/api/ping` — un server caído no es "online"), **Notify me** (espera al worker *activado*, reusa la suscripción existente, reintenta el primer `subscribe` con backoff, muestra `nombre: mensaje` si falla) y el demo **Send a test push** |
| `public/icon.svg`, `public/icon-maskable.svg`, `public/badge.svg` | los íconos fuente: `icon-192.png` / `icon-512.png` / `apple-touch-icon.png` del primero, `icon-maskable-512.png` (fondo a lienzo completo, dibujo en el 80% seguro) del segundo, `badge-96.png` (el ícono monocromo de la barra de estado que Android necesita — sin él sale uno genérico) del tercero. Todos **generados** por `init`: editá un SVG, volvé a correr `init --pwa`, sus PNG siguen; los otros no se tocan |

Todo lo demás que escribe `init` (`.env.example`, `.gitignore`, `.mcp.json`) viene también; `hello.syn` no — el starter es `app.syn`. Volver a correrlo es seguro: un archivo que editaste se conserva y la versión de fábrica queda al lado como `<archivo>.new`. `--pwa` y `--synfide` son starters distintos — elegí uno (exit 2 con los dos).

## Las piezas, para sumarlas a una app que ya existe

1. **Montá `public/` en la raíz** — el service worker tiene que servirse desde `/sw.js` para controlar todo el origen:

```synsema
require serve(8080)
require file.read("index.html")            -- render() lee la página del disco (engine v0.6.14+)

serve on 8080
    static "/" from "./public" cache "1h"
    route "GET /"
        give render("index.html", {"title": "Mi app"})
```

   `.webmanifest` se sirve como `application/manifest+json` (pinneado — el navegador rechaza el manifest con cualquier otro tipo). Las rutas declaradas ganan sobre el estático, así que `/` sigue siendo tuya.

2. **El `<head>`** de tu layout necesita las líneas del scaffold: `<link rel="manifest" href="/manifest.webmanifest">`, `<meta name="theme-color">`, `<link rel="apple-touch-icon" href="/apple-touch-icon.png">`, `<meta name="mobile-web-app-capable" content="yes">` y su gemelo `apple-` (sin ellos iOS no abre a pantalla completa ni usa tu ícono), `viewport-fit=cover`.

3. **HTTPS** en producción: `synsema serve app.syn --domain app.example.com --tls-auto vos@example.com` ([Deploy](/es/0.6.x/71-deploy)). `localhost` y `127.0.0.1` cuentan como seguros, así que desarrollar no necesita nada. iOS exige un certificado **confiable** para el service worker y la instalación.

4. **Push** es opcional — la app funciona sin claves; la página sólo dice que push no está configurado.

## Dónde se instala, con honestidad

| | Android (Chrome, Edge, Samsung) | iPhone / iPad (Safari 16.4+) | Escritorio (Edge, Chrome) |
|---|---|---|---|
| Instalar | el botón **Install** (`beforeinstallprompt`) o el menú del navegador | *Compartir → Añadir a pantalla de inicio* (sin API; la página muestra el aviso) | menú → Instalar: ventana propia, entrada en Menú Inicio / Dock |
| Ícono, pantalla completa | manifest | `apple-touch-icon` + los metas `apple-` | manifest |
| Shell offline | service worker | service worker | service worker |
| Push | sí | **sólo desde la app instalada**, permiso con un toque | sí (Windows entrega por `*.notify.windows.com`) |
| Origen | HTTPS o localhost | HTTPS con certificado confiable | HTTPS o localhost |
| Tienda | opcional: Trusted Web Activity con PWABuilder — fuera de Synsema | opcional: PWABuilder → proyecto Xcode, caso a caso — fuera de Synsema | — |

Firefox de escritorio no tiene instalación ni modo app: el sitio funciona como sitio.

## Un offline que dice la verdad

El `sw.js` del scaffold cachea la **shell** (`/`, `/app.js`, el manifest, el ícono) y deja el API en red: sin conectividad `/api/*` responde `503 {"error": "offline"}` y la página lo dice. Nunca cachea una respuesta que no sea OK, así que una falla jamás se repite como dato. Si tu `/` muestra datos del usuario logueado, sacala de la lista de la shell y cacheá una página pública de *"estás sin conexión"* — el caché es del navegador, no de un usuario.

Dos cosas que el scaffold decide por vos, y por qué: la shell es **stale-while-revalidate** (la copia cacheada responde al instante, la red la refresca para la próxima apertura — así un cambio en `app.js` o `/` aparece una apertura después; subí `CACHE` en `sw.js` cuando lo necesites ya, el `activate` borra el caché viejo). Y una notificación tocada **enfoca la ventana que ya está abierta** y le avisa (`postMessage({type: "notificationclick", url})`) en vez de abrir una segunda instancia sin estado; sólo si no hay ninguna abre una. Si el SO mató la app, esa ventana nueva arranca de cero — lo que tenga que sobrevivir (un borrador, un filtro, la vista actual) va a `localStorage`/IndexedDB, no a variables.

## Push, de punta a punta

**1. Claves, una vez.** `synsema run push_keys.syn` imprime las tres líneas para `.env`. El par sale de `push_vapid_keys()` (necesita `require random`: crea material secreto). La privada nace **sellada** — se imprime redactada, por eso el script declara `require reveal("vapid_private")` y la revela a propósito (auditado). Mismos formatos que `web-push generate-vapid-keys`.

**2. Suscribirse.** `app.js` lee la clave pública de `GET /api/push/config`, pide permiso, llama `pushManager.subscribe({userVisibleOnly: true, applicationServerKey})` y manda `PushSubscription.toJSON()` — `{"endpoint", "keys": {"p256dh", "auth"}}` — con POST a `/api/push/subscribe`. Guardala en una tabla, atada al usuario logueado (`requires auth`); el scaffold guarda hasta 500 en `state_*` como demo.

**3. Enviar.** Una línea por push service que atiendas — deny-by-default, el push service es un host como cualquier otro:

```synsema
require secret("VAPID_PRIVATE_KEY")
require env("VAPID_PUBLIC_KEY")
require net("fcm.googleapis.com")                  -- Chrome, Android, Brave, Opera …
require net("jmt17.google.com")                    -- … Chrome entrega cualquiera de los dos dominios de FCM
require net("*.notify.windows.com")                -- Edge en Windows
require net("updates.push.services.mozilla.com")   -- Firefox
require net("web.push.apple.com")                  -- Safari, iPhone, iPad, Mac

let vapid be {"public": env("VAPID_PUBLIC_KEY"), "private": secret("VAPID_PRIVATE_KEY"), "subject": "mailto:ops@example.com"}
let r be push_send(sub, {"title": "Pedido enviado", "body": "El #1042 va en camino", "url": "/orders/1042"},
    {"vapid": vapid, "ttl": 3600, "urgency": "high", "topic": "order-1042"})
when r["gone"]                                     -- 404 / 410: el navegador se desuscribió
    sql_exec("DELETE FROM push_subs WHERE endpoint = ?", [sub["endpoint"]])
```

`push_send(subscription, payload, opts)` → `{status, ok, gone, retry_after, body}`:

- `payload`: texto tal cual · un map/lista → JSON (lo que `sw.js` lee como `{title, body, url}`) · `bytes` · `nothing` para un "algo cambió" sin cuerpo. **Como máximo 3993 bytes** — el cuerpo cifrado tiene que quedar bajo los 4096 de los servicios. Un payload `secret` es error; un secret dentro de un map viaja redactado.
- `opts.vapid` (obligatorio): `public` (texto base64url), `private` (**sólo un `secret`** — de `secret("VAPID_PRIVATE_KEY")`, `as_secret(...)` o `push_vapid_keys()`; un texto plano se rechaza, exactamente como la clave privada de `sign`: quien la tiene manda push a todos los usuarios), `subject` (`mailto:` o `https://`). Una pública que no corresponde a la privada falla acá, no como un 401 opaco del servicio.
- `opts.ttl` segundos que el servicio guarda un mensaje no entregado (default 86400) · `opts.urgency` `very-low | low | normal | high` · `opts.topic` 1–32 caracteres `[A-Za-z0-9_-]` (un mensaje más nuevo con el mismo topic reemplaza al pendiente) · `opts.timeout` segundos (default 30).
- `status` 201 = aceptado; **`gone`** con 404/410 → borrá esa suscripción; `retry_after` (texto o `nothing`) con 429/503. El endpoint tiene que ser `https://` (`http://` sólo en loopback, para mocks). Servicio inalcanzable → error.
- **No confundas tus errores con suscripciones muertas.** Un error de capability (`Capability not granted: net("jmt17.google.com")` — un navegador cuyo push service no declaraste) o un servicio inalcanzable es *tu* configuración o tu red: conservá la suscripción, agregá el host que nombra el error, mandá de nuevo. Descartá una suscripción sólo con `gone`, o cuando el error nombra `subscription` (claves o endpoint rotos). El `POST /api/push/test` del scaffold hace exactamente eso en su `recover`. Y mantené los hosts exactos: `net("*.google.com")` abriría egress mucho más allá del push.
- Lo de adentro: RFC 8291/8188 `aes128gcm` — ECDH P-256 con clave efímera + sal de 16 bytes del CSPRNG del sistema por mensaje, HKDF, AES-128-GCM, un registro de 4096 bytes — y un JWT VAPID ES256 (RFC 8292) con `exp` de 12 horas. La aleatoriedad interna del protocolo no necesita la capability `random`, como el handshake TLS. No disponible en el perfil wasm/puro (necesita sockets); `push_vapid_keys` sí.

**¿Ya usás un proveedor?** OneSignal, Firebase Cloud Messaging, Pusher Beams y similares siguen funcionando: `http_post` a su API REST bajo `require net(...)` con `bearer(secret("PROVIDER_KEY"))`. El push nativo es una opción, no una obligación.

## Lo que esto no es

Las APIs nativas que la web no tiene — Bluetooth, NFC, widgets en la pantalla de inicio, sincronización en segundo plano en iOS — viven en un cliente nativo (Swift, Kotlin, Flutter) que habla con el mismo servidor por el OpenAPI que deriva ([Construí un API](/es/0.6.x/43-build-api)). Synsema no compila UI nativa, y jamás manda tus secretos a un dispositivo.

## Un binario, la app entera (engine v0.6.16+)

```sh
synsema build app.syn -o app --serve --bind 0.0.0.0 --port 8080 --domain app.example.com --tls-auto ops@example.com
./app                                   # el runtime de serve con esos flags horneados; Ctrl-C = shutdown ordenado
```

`--serve` construye un **binario server**: los mounts estáticos del bloque serve (`public/`) y los templates se empaquetan solos y se sirven desde adentro del archivo, con ETag por contenido — nada que llevar al lado. El bind es obligatorio (un distribuible jamás adivina su interfaz): `--bind`, o la cláusula `bind "…"` del bloque (v0.6.18+); los otros flags son los que toma `synsema serve`, horneados. Mirá [CLI § `synsema build`](/es/0.6.x/70-cli).

El mismo layout es una **app de escritorio** (engine v0.6.18+): `bind "127.0.0.1"`, `synsema build … --serve --no-console --icon public/icon.svg --bundle`, el navegador como ventana de app, el service worker y el manifest haciéndola instalable desde Edge/Chrome en `127.0.0.1` — [Tu app en el escritorio](/es/0.6.x/41c-desktop).

## Probarla en un teléfono real

El teléfono necesita un **origen HTTPS** (localhost sólo es seguro en la propia máquina). Tres caminos, del más barato al real:

- **Un túnel a tu máquina:** `cloudflared tunnel --url http://localhost:8080` o `ngrok http 8080` te dan una URL HTTPS pública para el `synsema serve` local — instalar y push funcionan en Android y iOS como en producción. La URL cambia por corrida; las suscripciones mueren con ella.
- **Android por USB:** `adb reverse tcp:8080 tcp:8080` y abrí `http://localhost:8080` en el Chrome del teléfono — ahí `localhost` también es contexto seguro, sin certificado.
- **Un VPS con `--domain … --tls-auto`** — lo real; iOS exige un certificado *confiable*, uno autofirmado no sirve.

Si lo automatizás (Playwright/Puppeteer contra el server local): cerrá el navegador en un `finally` y matá el `synsema serve` hijo al terminar — un test que explota deja un Chrome headless huérfano (en Windows buscá `chrome.exe` con `ms-playwright` en la línea de comando).

## Tiendas, si las querés

- **Google Play:** empaquetá la PWA como Trusted Web Activity (PWABuilder o Bubblewrap). Play verifica que sos dueño del origen por `/.well-known/assetlinks.json` — ponelo bajo `public/.well-known/` y el mount estático lo sirve como JSON.
- **Microsoft Store:** acepta una PWA directa (PWABuilder genera el paquete).
- **App Store:** necesita un wrapper nativo (PWABuilder → Xcode); ojo que **Web Push no funciona dentro de WKWebView** — el push de iOS es para la instalación en pantalla de inicio, no para una app envuelta. Esa línea es de Apple, no de Synsema.

## Siguiente

- **[Frontend](/es/0.6.x/41-frontend)** — templates, layouts, estáticos.
- **[Deploy](/es/0.6.x/71-deploy)** — `--domain` + `--tls-auto`, el flag que hace que iOS la instale.
- **[Secretos](/es/0.6.x/21-secrets)** — por qué la privada VAPID es un `secret`, y la auditoría de `reveal()`.
