DOCUMENTACIÓN PARA DESARROLLADORES
Puente JavaScript: funciones nativas desde tu sitio
Actualizado el 4 de noviembre de 2026
Prompt para Lovable, Bolt o Base44
Añade a mi proyecto la compatibilidad con el puente de app de SaasToStore.
Contexto: mi sitio también funciona dentro de una app móvil nativa creada con SaasToStore. Dentro de esa app, un objeto global `window.saastostore` da acceso a funciones nativas. En un navegador normal ese objeto no existe, y el sitio debe seguir funcionando exactamente como hoy. No cambies nada más.
1. Crea `src/lib/saastostore.ts` con:
- `isInApp()`: devuelve `typeof window !== "undefined" && navigator.userAgent.includes("SaasToStoreApp")`.
- `whenSaasToStore(key, timeoutMs = 3000)`: devuelve `window.saastostore?.[key]` en cuanto existe (comprobación cada 100 ms), o `null` tras el plazo. El puente solo se carga cuando la página ha terminado de cargar.
- Los tipos TypeScript de `window.saastostore`, todos los miembros opcionales:
- `biometric.isAvailable(): Promise<{ available: boolean; type: string } | null>`
- `biometric.authenticate(reason?: string): Promise<{ success: boolean; error: string | null } | null>`
- `setTabBadge(index: number, count: number): void`
- `billing: { isAvailable: boolean; identify(userId: string): void; purchase(productId: string): void; restore(): void; getStatus(): void }`
- `type PluginResult = { ok: boolean; error?: string } | null`
- `haptics: { impact(o?: { style?: "light" | "medium" | "heavy" }): Promise<PluginResult>; notification(o?: { type?: "success" | "warning" | "error" }): Promise<PluginResult>; selection(): Promise<PluginResult>; vibrate(o?: { duration?: number }): Promise<PluginResult> }`
- `appReview: { request(): Promise<{ requested: boolean; error?: string } | null> }`
- `share(o?: { title?: string; text?: string; url?: string }): Promise<{ shared: boolean; error?: string } | null>` (a function, not an object; with neither text nor url it shares the page shown; url must be http or https)
- `screen: { keepAwake(on?: boolean): Promise<PluginResult>; setBrightness(value?: number | null): Promise<PluginResult>; getBrightness(): Promise<{ brightness: number | null; error?: string } | null> }`
- `secureStorage: { get(key: string): Promise<{ value: string | null; error?: string } | null>; set(key: string, value: string): Promise<PluginResult>; remove(key: string): Promise<PluginResult>; keys(): Promise<{ keys: string[]; error?: string } | null>; clear(): Promise<PluginResult> }` (key: letters, digits, ".", "_", "-", 1 to 128 characters, e.g. "auth.refresh_token", never "auth:token"; value: a string of 64 KB at most, JSON.stringify objects; anything else resolves to { ok: false, error: "invalid_argument" }, nothing is converted)
- `appInfo: { get(): Promise<{ platform: "android" | "ios"; version: string; build: string; packageName: string; osVersion: string; language: string; model: string } | null> }`
- `browser: { open(url: string): Promise<PluginResult> }`
- `statusBar: { setStyle(style: "light" | "dark"): Promise<PluginResult>; setColor(hex: string): Promise<PluginResult> }` (setColor only works up to Android 14: call it with the page background together with setStyle, and ignore its not_supported)
- `localNotifications: { checkPermission(): Promise<{ status: "granted" | "denied" | "prompt" } | null>; requestPermission(): Promise<{ granted: boolean; status: "granted" | "denied" | "prompt"; error?: string } | null>; schedule(o: { id?: number; title: string; body?: string; at?: number | string; in?: number; data?: object }): Promise<{ scheduled: true; id: number; at: number } | { scheduled: false; error: string; status?: "denied" | "prompt" } | null>; cancel(o: { id: number }): Promise<PluginResult>; cancelAll(): Promise<PluginResult>; getPending(): Promise<{ notifications: { id: number; title: string; body: string; at: number; data: object }[] } | null>; getLastOpened(): Promise<{ notification: { id: number; title: string; body: string; at: number; data: object } | null } | null> }` (schedule() needs the permission: without it { scheduled: false, error: "not_allowed", status }; at: epoch ms or ISO 8601 with a time zone, or in: seconds, never both; title 1 to 200 characters, body 1,000 at most, data a JSON object of 4 KB at most; a tap dispatches a "s2s:localNotifications" CustomEvent on window with detail { type: "tap", id, title, body, at, data }; call getLastOpened() when the page starts: it returns the notification that opened the app, once)
- `barcode: { isAvailable(): Promise<{ available: boolean; reason?: "no_play_services" | "module_not_installed" | "no_camera" } | null>; checkPermission(): Promise<{ status: "granted" | "denied" | "prompt" } | null>; requestPermission(): Promise<{ granted: boolean; status: "granted" | "denied" | "prompt"; error?: string } | null>; scan(o?: { formats?: string[]; prompt?: string }): Promise<{ scanned: true; value: string; format: string } | { scanned: false; error: string; reason?: string } | null> }` (formats among qr, ean13, ean8, upca, upce, code128, code39, code93, itf, codabar, datamatrix, pdf417, aztec, all by default; prompt 200 characters at most, shown on iOS only; Android: Google Play services reader, no camera permission; iOS: the app's own reader after the camera permission)
- `downloads: { isAvailable(): Promise<{ available: boolean; error?: string } | null>; download(o: { url: string; filename?: string; open?: boolean }): Promise<{ downloaded: boolean; id?: string; filename?: string; mimeType?: string; size?: number; error?: string; reason?: string } | null>; open(o: { id: string }): Promise<{ opened: boolean; error?: string } | null> }` (normal download links need no code; the window event `s2s:downloads` carries `{ state, id, filename, source, error? }`)
- `calendar: { isAvailable(): Promise<{ available: boolean; error?: string } | null>; addEvent(o: { title: string; start: string | number | Date; end?: string | number | Date; allDay?: boolean; location?: string; notes?: string }): Promise<{ ok: true; saved: boolean | null } | { ok: false; error: string } | null> }` (opens the phone's calendar editor, filled in: the user saves the event, the site reads nothing; start and end: ISO 8601 with a time zone, epoch ms or a Date, end defaults to one hour after start; with allDay: true, "YYYY-MM-DD" days, end = the last day, included; title 1 to 500 characters, location 1,000 at most, notes 8,000; saved is null on Android, true or false on iOS)
- `documentScanner: { isAvailable(): Promise<{ available: boolean; error?: string; reason?: "no_play_services" | "unsupported_device" | "module_not_installed" } | null>; scan(o?: { pages?: number; format?: "jpeg" | "pdf" }): Promise<{ ok: true; format: "jpeg" | "pdf"; pageCount: number; truncated: boolean; files: File[] } | { ok: false; error: string; reason?: string } | null> }` (opens the phone's document scanner: edges found, page straightened; pages 1 to 20, 10 by default; jpeg: one File per page, pdf: a single File, ready for a FormData upload; 32 MB at most per scan, otherwise too_large; Android: Google Play services scanner, no camera permission; iOS: asks for the camera the first time, refused → not_allowed)
- `contacts: { isAvailable(): Promise<{ available: boolean; error?: string } | null>; pick(o?: { field?: "phone" | "email" | "name" }): Promise<{ ok: true; contact: { name: string | null; phones: string[]; emails: string[] } } | { ok: false; error: string } | null> }` (opens the phone's own contact picker: the user chooses one contact and, for "phone" or "email", one value, returned alone in phones or emails; field defaults to "phone"; the site never reads the address book, no permission)
Los resultados de las compras llegan como un evento de `window` llamado `"s2s:billing"`; `event.detail.type` es `identify_result`, `purchase_result`, `restore_result` o `status`.
2. Bloqueo biométrico: en la página de ajustes, muestra un interruptor «Desbloquear con huella / Face ID» solo si `isInApp()` y si `biometric.isAvailable()` devuelve `available: true`. Guarda la elección en localStorage. Si está activado, oculta el contenido de la sesión al abrir la app y al volver a primer plano (`visibilitychange`), llama a `biometric.authenticate("Desbloquear [NOMBRE DE LA APP]")` y vuelve a mostrar el contenido solo si `success` es true, con un botón «Reintentar» si no.
3. Insignia de pestaña: cada vez que cambie mi número de no leídos, llama a `window.saastostore?.setTabBadge?.([ÍNDICE DE LA PESTAÑA], count)` (0 oculta la insignia).
4. Suscripciones en la app Android: cuando `isInApp()` y `billing.isAvailable` sean true, llama a `billing.identify(user.id)` tras el inicio de sesión y a `billing.getStatus()` tras el evento `identify_result`. Ahí, mi botón de mejora llama a `billing.purchase("[ID DEL PRODUCTO]")` en lugar del pago web; escucha `s2s:billing` y desbloquea el plan de pago cuando un evento `purchase_result` o `status` tenga `isEntitled: true`. En la app iOS (`isInApp()` y `/iPhone|iPad/.test(navigator.userAgent)`), oculta ese botón. En un navegador normal, mantén el pago actual.
5. Plugins nativos: solo para los plugins que añadí en SaasToStore (borra las demás líneas). Llama a cada uno de forma opcional (`window.saastostore?.haptics?.…`); su promesa nunca se rechaza.
- Respuesta háptica: llama a `haptics.impact({ style: "light" })` al tocar un botón principal, y a `haptics.notification({ type: "success" })` o `({ type: "error" })` cuando un formulario sale bien o falla.
- Reseña en la app: llama a `appReview.request()` una vez, después de [UN MOMENTO DE ÉXITO], nunca desde un botón.
- Compartir nativo: dentro de la app, haz que mis botones de compartir llamen a `share({ title, text, url })` en lugar del código de compartir web.
- Control de pantalla: en [PÁGINA CON UN CÓDIGO QR O UNA ENTRADA], llama a `screen.keepAwake(true)` y `screen.setBrightness(1)` al abrirla, y a `screen.setBrightness(null)` y `screen.keepAwake(false)` al cerrarla.
- Almacenamiento seguro: dentro de la app, guarda también [VALOR A PROTEGER] con `secureStorage.set(key, value)` (clave: solo letras, cifras, `.`, `_`, `-`, por ejemplo `auth.refresh_token`; valor: una cadena, `JSON.stringify` para un objeto; nunca pases `undefined`), léelo con `secureStorage.get(key)` y llama a `secureStorage.clear()` al cerrar sesión.
- Información de la app: muestra la `version` y el `build` que devuelve `appInfo.get()` en la página de ajustes.
- Navegador integrado: dentro de la app, abre los enlaces a otros dominios (no los de inicio de sesión) con `browser.open(url)` en lugar de una pestaña nueva.
- Barra de estado: en las páginas oscuras llama a `statusBar.setColor(FONDO DE LA PÁGINA)` y luego a `statusBar.setStyle("dark")`, en las claras a `statusBar.setColor(FONDO DE LA PÁGINA)` y luego a `statusBar.setStyle("light")` (setColor solo funciona hasta Android 14; ignora su `not_supported`).
- Orientación de pantalla: nada que programar, es un ajuste de build.
- Escáner QR y de códigos de barras: en [EL BOTÓN QUE ESCANEA], llama a `barcode.scan({ formats: ["qr"], prompt: "Escanea tu código" })` y usa `value` cuando `scanned` sea true; ignora `error: "cancelled"`, muestra un mensaje para los demás errores; oculta el botón cuando `barcode.isAvailable()` responda `available: false`.
- Descarga de archivos: mis enlaces de descarga funcionan en la app sin código. Solo para [EL BOTÓN QUE EXPORTA UN ARCHIVO], dentro de la app, llama a `downloads.download({ url, filename })` y muestra un mensaje cuando `downloaded` sea false.
- Calendario: en [EL BOTÓN QUE AÑADE UN EVENTO], dentro de la app, llama a `calendar.addEvent({ title, start, end, location, notes })` con `start` y `end` como `date.toISOString()` (o `{ title, start: "YYYY-MM-DD", end: "YYYY-MM-DD", allDay: true }` para días completos, `end` incluido); muestra un mensaje cuando `ok` sea false; oculta el botón cuando `calendar.isAvailable()` responda `available: false`; fuera de la app, el botón sigue haciendo lo que hace hoy.
- Escáner de documentos: en [EL BOTÓN QUE ESCANEA UN DOCUMENTO], dentro de la app, llama a `documentScanner.scan({ format: "pdf" })` y sube `files` como un archivo elegido en mi formulario; no hagas nada cuando `error` sea `"cancelled"`, muestra un mensaje para los demás errores; oculta el botón cuando `documentScanner.isAvailable()` responda `available: false`; fuera de la app, mantén mi campo de archivo.
- Contactos: en [EL BOTÓN JUNTO AL CAMPO DE TELÉFONO], dentro de la app, llama a `contacts.pick({ field: "phone" })` (o `"email"`) y pon `contact.phones[0]` (o `contact.emails[0]`) en el campo cuando la lista no esté vacía; no hagas nada cuando `error` sea `"cancelled"`; fuera de la app, oculta ese botón y deja el campo como está.
- Notificaciones locales: en [ACCIÓN QUE MERECE UN RECORDATORIO], llama primero a `localNotifications.requestPermission()` (una vez, desde ese botón, nunca al abrir la app), luego a `localNotifications.schedule({ id, title, body, at })` con `at` como `Date.now() + …` o `date.toISOString()`; escucha `window.addEventListener("s2s:localNotifications", e => …)` y llama a `localNotifications.getLastOpened()` al arrancar la página, para abrir [LA PÁGINA DEL RECORDATORIO]; llama a `localNotifications.cancel({ id })` cuando el recordatorio ya no haga falta.Antes de pegarlo, borra los pasos que no necesites y sustituye los valores entre [CORCHETES]. Después, lanza un nuevo build en SaasToStore si activaste una nueva función nativa.
Detectar la app
Cada llamada de esta página solo tiene sentido dentro de la app. Comprueba primero, y tu sitio sigue funcionando igual en un navegador normal.
El user agent incluye «SaasToStoreApp»
Las apps Android creadas desde julio de 2026 y las apps iOS creadas desde el 19 de octubre de 2026 lo añaden a cada petición y a navigator.userAgent:
const inApp = navigator.userAgent.includes("SaasToStoreApp");Usa includes(), nunca una comparación exacta
La marca se añade tras el user agent del sistema, con un espaciado que cambia según la plataforma. Tu servidor también puede leerla, en la cabecera User-Agent.
Después comprueba la propia API
window.saastostore solo existe dentro de la app, y cada función solo cuando está activada. Comprueba el miembro que necesitas, por ejemplo window.saastostore?.biometric, antes de llamarlo.
Qué funciona dónde
Las insignias de pestañas, la navegación y las compras in-app llegan con Shell V2, la carcasa nativa (cabecera, barra de pestañas, menú lateral) que activas en el Shell Designer de tu proyecto; los plugins de la biblioteca funcionan con o sin ella (en iOS, la barra de estado solo en las apps a pantalla completa). La biometría también, salvo que en una app iOS sin Shell V2 necesita además uno de estos plugins: Respuesta háptica, Reseña en la app, Compartir nativo, Control de pantalla, Almacenamiento seguro, Información de la app, Navegador integrado, Barra de estado u Orientación de pantalla. Las funciones nativas se añaden al hacer el build: tras activar una, lanza un nuevo build. Los cambios de tu sitio aparecen en la app al momento; los nativos, no. Nada de esto existe en el Android Build (Web) (TWA), que abre tu sitio en Chrome: elige el Android Build (Native) o el build de Android TV.
| Función | App Android (build nativo) | App iOS |
|---|---|---|
| Detectar la app (user agent) | Builds desde julio de 2026 | Builds desde el 19/10/2026 |
| Desbloqueo biométrico (biometric) | Builds desde el 28/10/2026, con o sin Shell V2 | Con Shell V2; sin él, si se añade uno de estos plugins: haptics, appReview, share, screen, secureStorage, appInfo, browser, statusBar u orientation (nuevo build) |
| Plugins de la biblioteca (háptica, compartir, pantalla…) | Una vez añadidos, desde tu próximo build, con o sin Shell V2 | Una vez añadidos, desde tu próximo build, con o sin Shell V2 (barra de estado: solo apps a pantalla completa) |
| Insignias de pestañas (setTabBadge) | Shell V2 con barra de pestañas | Shell V2 con barra de pestañas |
| Pestaña activa y título de la cabecera | Shell V2 | Shell V2 |
| Compras in-app (billing) | Shell V2 + Suscripciones in-app (Google Play) | Aún no disponible |
Esperar al puente
El puente se carga cuando la página termina de cargar, así que puede faltar en las primeras líneas de tu código. Llámalo desde una acción del usuario o espéralo con esta función auxiliar. Devuelve la función pedida, o null fuera de la app. Los plugins de la biblioteca están desde la primera línea de tu página.
whenSaasToStore(key)
function whenSaasToStore(key, timeoutMs = 3000) { return new Promise((resolve) => { const start = Date.now(); (function check() { const api = window.saastostore?.[key]; if (api || Date.now() - start > timeoutMs) return resolve(api ?? null); setTimeout(check, 100); })(); }); }
Desbloqueo biométrico
Pide a quien tiene el teléfono que demuestre que es su dueño: huella, rostro o, si no, el PIN del teléfono. No inicia sesión a nadie: úsalo para desbloquear una sesión que tu sitio ya tiene o para confirmar una acción delicada. Activa Desbloqueo biométrico en la biblioteca de plugins de tu proyecto (activado por defecto) y lanza un nuevo build. En iOS también funciona sin Shell V2, siempre que añadas también uno de estos plugins: Respuesta háptica, Reseña en la app, Compartir nativo, Control de pantalla, Almacenamiento seguro, Información de la app, Navegador integrado, Barra de estado u Orientación de pantalla. La app lo recibe con su próximo build.
isAvailable()
¿Hay una huella o un rostro registrados en este teléfono? type es fingerprint, face, iris o biometric en Android, faceid o touchid en iOS.
await window.saastostore.biometric.isAvailable(); // → { available: true, type: "fingerprint" } // → { available: false, type: "none" }authenticate(reason)
Abre la ventana del sistema con tu motivo bajo el nombre de la app. error es el mensaje del sistema cuando la persona cancela. En Android también puede ser not_available (ni biometría ni bloqueo de pantalla) o busy (ya hay una ventana abierta).
await window.saastostore.biometric.authenticate("Unlock your account"); // → { success: true, error: null } // → { success: false, error: "Authentication canceled" }Ejemplo
const bio = await whenSaasToStore("biometric"); const check = await bio?.isAvailable(); if (check?.available) { const result = await bio.authenticate("Unlock your account"); if (result?.success) showAccount(); }
Plugins de la biblioteca
Los plugins siguientes están desactivados hasta que los añades en la biblioteca de plugins de tu proyecto; después, lanza un nuevo build. Funcionan en los builds Android nativos (Android Build (Native), Android TV) y en las apps iOS, con o sin Shell V2 (en iOS, la barra de estado solo en las apps a pantalla completa). No están en el Android Build (Web) (TWA): allí no existe window.saastostore.
Listos desde la primera línea
Su API se instala antes del primer script de tu página, en el dominio de la app y sus subdominios, solo en el marco principal: nunca en un iframe ni en otro sitio.
Promesas que nunca se rechazan
Cada método devuelve una Promise que nunca se rechaza. Si la llamada falla, vale un objeto con un campo error: not_available, not_supported, invalid_argument, busy, timeout o failed; y null si la app no puede responder. Fuera de la app, o en una app creada antes de añadir el plugin, el plugin simplemente no está: compruébalo antes de llamarlo.
const haptics = window.saastostore?.haptics; // undefined: normal browser, plugin not added, or app built before you added it if (haptics) { const r = await haptics.impact({ style: "light" }); if (r?.error) console.warn("haptics:", r.error); // never throws }
Respuesta háptica
Vibraciones breves que confirman un toque, un éxito o un error. Lo que siente la persona depende de su teléfono y de su ajuste de respuesta háptica.
impact, notification, selection, vibrate
impact recibe un style (light, medium por defecto, heavy), notification un type (success por defecto, warning, error); selection es un ligero tic, para un selector. vibrate recibe una duration de 1 a 1000 ms (300 por defecto); iOS no permite fijar la duración y reproduce un impacto fuerte en su lugar. Una duration que no sea un número de 1 a 1000 responde invalid_argument. En Android, impact, notification, selection y vibrate responden not_available cuando el dispositivo no reproduce nada (sin vibrador, como la mayoría de tablets y televisores, o con la respuesta háptica desactivada); iOS responde ok: true en ese caso.
const haptics = window.saastostore?.haptics; await haptics?.impact({ style: "medium" }); // light | medium | heavy await haptics?.notification({ type: "success" }); // success | warning | error await haptics?.selection(); // a light tick await haptics?.vibrate({ duration: 200 }); // ms, 1 to 1000 // → { ok: true } // → { ok: false, error: "invalid_argument" } e.g. duration: 5000 // → { ok: false, error: "not_available" } Android: the device plays nothing
Reseña en la app
Abre la ventana de valoración de la tienda sin salir de la app. La tienda decide si aparece y limita con qué frecuencia; tu código nunca sabe si se dejó una reseña. Solo aparece en una app instalada desde Google Play o la App Store, nunca en TestFlight.
request()
Llámala después de un éxito (un pedido entregado, un objetivo cumplido), no como única acción de un botón «Valóranos»: puede que la ventana no aparezca. error puede ser not_available (sin Google Play en el dispositivo), busy (ya hay una solicitud en curso), timeout o failed.requested: true llega en cuanto la solicitud pasa a la tienda, sin esperar a que se cierre la ventana. En Android, una segunda solicitud mientras la primera ventana sigue abierta responde busy.
// after a success, never as the only action of a button const r = await window.saastostore?.appReview?.request(); // → { requested: true } handed to the store, which decides whether to show it // → { requested: false, error: "not_available" } // → { requested: false, error: "busy" } Android: a review window is still open
Control de pantalla
Mantén la pantalla encendida y ajusta su brillo, para una receta, una tarjeta de embarque o un código QR en un control de acceso. Los ajustes duran mientras la app está abierta.
keepAwake(on)
true mantiene la pantalla encendida mientras la app está en primer plano; false devuelve el control al teléfono.
setBrightness(value), getBrightness()
value va de 0 a 1; null vuelve al brillo del sistema. En Android solo cambia la ventana de la app. En iOS cambia toda la pantalla: la app restablece el brillo original cuando pasa a segundo plano y vuelve a aplicar el tuyo al regresar. Los valores se comprueban, nunca se convierten: setBrightness recibe null o un número de 0 a 1, keepAwake un booleano (sin argumento: true); cualquier otra cosa responde invalid_argument. En Android, mientras la app no haya fijado un brillo, getBrightness devuelve una aproximación del ajuste del sistema.
const screen = window.saastostore?.screen; await screen?.keepAwake(true); // → { ok: true } await screen?.setBrightness(1); // 0 to 1, e.g. to show a QR code await screen?.getBrightness(); // → { brightness: 1 } await screen?.setBrightness(null); // back to the system brightness await screen?.keepAwake(false); await screen?.setBrightness("1"); // → { ok: false, error: "invalid_argument" }
Almacenamiento seguro
Un pequeño almacén clave-valor cifrado por el teléfono, para un token o un secreto que tu sitio necesita de un inicio a otro. En Android, los valores se cifran con AES-256-GCM con una clave del Android Keystore que nunca sale del dispositivo. En iOS, van al Keychain, legibles solo por esta app, solo en este dispositivo, tras su primer desbloqueo.
get, set, remove, keys, clear
Las claves usan letras, cifras, punto, guion bajo y guion (de 1 a 128 caracteres); los valores son cadenas de hasta 64 KB: guarda objetos con JSON.stringify. No se convierte nada por ti: una clave con otro formato (como auth:token), un valor que no sea una cadena (undefined, un número, un objeto) o uno demasiado largo responde invalid_argument, y no se guarda nada.
const vault = window.saastostore?.secureStorage; await vault?.set("refresh_token", token); // → { ok: true } const saved = await vault?.get("refresh_token"); // → { value: "…" }, or { value: null } when the key is missing await vault?.keys(); // → { keys: ["refresh_token"] } await vault?.remove("refresh_token"); // → { ok: true } await vault?.clear(); // e.g. at sign-out await vault?.set("session", JSON.stringify({ id, exp })); // objects: JSON.stringify await vault?.set("auth:token", undefined); // → { ok: false, error: "invalid_argument" } ":" in the key, value not a stringLímites que conviene conocer
En iOS, el Keychain sobrevive a la desinstalación de la app: una reinstalación encuentra los valores antiguos. Llama a clear() al cerrar sesión, y no tomes un almacén vacío por una primera instalación. En Android, todo se borra con la app y nunca entra en las copias de seguridad: una reinstalación, o un teléfono nuevo, empieza con el almacén vacío. Cada sitio tiene su propio almacén, como localStorage: www.example.com y example.com comparten uno, help.example.com tiene otro, y keys() y clear() solo ven las claves del sitio que llama. El JavaScript de tu página puede leer los valores: están protegidos en el teléfono, no frente a un script inyectado en tu sitio.
Información de la app
La versión y el número de build de la app, la plataforma, la versión del sistema y el idioma del teléfono: para tu página de ajustes o un mensaje al soporte.
get()
build es una cadena en ambas plataformas. model es un nombre de modelo genérico (el modelo en Android, «iPhone» en iOS): no se devuelve ningún identificador del dispositivo.
await window.saastostore?.appInfo?.get(); // → { platform: "android", version: "1.4.0", build: "12", // packageName: "com.example.app", osVersion: "14", // language: "fr-FR", model: "Pixel 8" }
Navegador integrado
Abre una página en una pestaña de navegador sobre la app: Custom Tabs en Android (o el navegador predeterminado), Safari View Controller en iOS. Para páginas fuera de tu dominio: condiciones, ayuda, el sitio de un socio.
open(url)
Solo http y https. La página se ejecuta fuera de tu app: no tiene window.saastostore, ni el user agent SaasToStoreApp, ni la sesión de tu app. Deja el inicio de sesión con Google o Apple a la app, que ya se encarga. error puede ser invalid_argument, busy (iOS: hay otra hoja abierta), not_available o failed. En Android, un enlace a un sitio que tiene su propia app en el teléfono (un sitio de vídeos o una red social, o tu propio dominio cuando los deep links están activos) puede abrir esa app en lugar de una pestaña.
const r = await window.saastostore?.browser?.open("https://example.com/terms"); // → { ok: true } // → { ok: false, error: "invalid_argument" } not http or https
Barra de estado
Adapta la barra de estado a tu página: iconos oscuros en una página clara, iconos claros en una página oscura. Hasta Android 14, la barra de estado es una franja encima de la página, no la página misma: llama a setColor con el color de fondo de tu página junto con setStyle. Desde Android 15 y en iOS, la página pasa por debajo de la barra de estado: basta con setStyle, y la respuesta not_supported de setColor se puede ignorar.
setStyle(style)
"light" para una página clara (iconos oscuros), "dark" para una página oscura (iconos claros). En iOS con Shell V2 responde not_supported: la barra de estado sigue la cabecera nativa.
setColor(hex)
Color de fondo en formato #RRGGBB, solo en Android 14 y anteriores. Android 15 y posteriores dibujan las apps de borde a borde, e iOS no tiene color de barra de estado: ahí la llamada responde not_supported.
const bar = window.saastostore?.statusBar; // a light page: dark icons, and (Android 14 and earlier) a bar of the page color await bar?.setColor("#ffffff"); // → { ok: true } up to Android 14 await bar?.setStyle("light"); // → { ok: true } // a dark page: light icons await bar?.setColor("#101827"); await bar?.setStyle("dark"); // setColor → { ok: false, error: "not_supported" } Android 15+ and iOS: ignore it // setStyle → { ok: false, error: "not_supported" } iOS with Shell V2
Orientación de pantalla
Un ajuste de build, sin JavaScript: añade Orientación de pantalla en la biblioteca de plugins, elige Vertical u Horizontal y lanza un nuevo build. La app se queda en esa orientación (en Android aún puede girar 180°).
- Android 16 ignora este bloqueo en pantallas grandes (600 dp o más: tablets, plegables abiertos) en las apps que apuntan a la API 36, como los builds de SaasToStore: mantén un diseño adaptable.
- El bloqueo vertical no se aplica al build de Android TV, que se queda en horizontal.
Notificaciones locales
Recordatorios que tu página programa en el teléfono y que aparecen a la hora elegida, incluso con la app cerrada: una cita, una sesión que empieza, un carrito abandonado. Nada sale de tu sitio y ningún servidor interviene: no es push. La notificación abre tu app, y tu página sabe cuál se tocó.
requestPermission() y checkPermission()
Pide una vez, desde una acción del usuario (un botón «Recordármelo»), nunca al abrir la app: Android 13 y posteriores e iOS muestran una ventana del sistema (los Android anteriores responden granted). La respuesta es { granted, status }, con status granted, denied o prompt. Tras un rechazo, el sistema no vuelve a preguntar: lleva al usuario a los ajustes de la app. checkPermission() solo lee el estado.
schedule({ title, body, at | in, id, data })
at es una fecha (milisegundos, o ISO 8601 con zona horaria, lo que da toISOString()) o in un retraso en segundos, nunca los dos; sin ninguno, la notificación aparece ahora. La respuesta es { scheduled: true, id, at }. Límites: título 200 caracteres, texto 1.000, data un objeto JSON de 4 KB, 64 recordatorios pendientes, 366 días de antelación; una fecha pasada aparece ahora; el id de un recordatorio pendiente lo reemplaza. Sin el permiso, { scheduled: false, error: "not_allowed", status }: pídelo antes. Si no, invalid_argument, too_many o failed.
const notif = window.saastostore?.localNotifications; // ask once, from a "Remind me" button, never at launch const p = await notif?.requestPermission(); // { granted: true, status: "granted" } const r = await notif?.schedule({ id: 42, // optional: the same id replaces a pending reminder title: "Your session starts in 10 minutes", body: "Tap to open the room", at: Date.now() + 50 * 60 * 1000, // or in: 3000 (seconds), or an ISO 8601 date data: { roomId: "a1" }, }); // r → { scheduled: true, id: 42, at: 1762000000000 } // without the permission: { scheduled: false, error: "not_allowed", status: "prompt" } window.addEventListener("s2s:localNotifications", (e) => { if (e.detail.type === "tap") openRoom(e.detail.data.roomId); }); const opened = await notif?.getLastOpened(); // { notification } once, then { notification: null } await notif?.cancel({ id: 42 });Toque, lista, cancelación
Un toque abre la app en tu página, que recibe un evento s2s:localNotifications en window (detail.type "tap", con id, title, body, at, data). Si la app estaba cerrada, el evento se dispara antes de que tu página escuche: llama a getLastOpened() al arrancar la página, responde la notificación una vez y luego null. getPending() lista los recordatorios por venir; cancel({ id }) y cancelAll() los quitan, mostrados incluidos.
Hora y reinicio
Android muestra el recordatorio con unos minutos de margen (alarma inexacta, sin el permiso «Alarmas y recordatorios»): vale para una cita, no para una cuenta atrás al segundo. iOS es puntual. Los recordatorios sobreviven a un reinicio del teléfono: Android los reprograma al arrancar, iOS los conserva. En Android, la notificación lleva el icono de tu app y un canal «Notifications» que el usuario puede silenciar en los ajustes del sistema.
Escáner QR y de códigos de barras
Lee un código QR o un código de barras con la cámara, desde tu página: una entrada, un producto, un enlace que abrir. En Android el lector es el de Google (servicios de Google Play), a pantalla completa con zoom automático, y tu app no pide ningún permiso de cámara. En iOS la app abre su propio lector, tras el permiso de cámara.
isAvailable()
{ available: true }, o { available: false, reason }: no_play_services (Android sin servicios de Google Play: Amazon Fire, Huawei), module_not_installed (Android: Google Play descarga el lector una vez; la descarga queda pedida, vuelve a intentarlo más tarde), no_camera (iOS). Compruébalo antes de mostrar un botón de escaneo.
scan({ formats, prompt })
Abre el lector y responde con el primer código leído: { scanned: true, value, format }. formats limita lo que se lee (todo por defecto): qr, ean13, ean8, upca, upce, code128, code39, code93, itf, codabar, datamatrix, pdf417, aztec. prompt es el texto mostrado sobre el visor en iOS (200 caracteres como máximo; Android muestra la interfaz de Google). Cerrado por el usuario: { scanned: false, error: "cancelled" }; si no, not_available, not_allowed, busy, invalid_argument o failed.
const scanner = window.saastostore?.barcode; const ok = await scanner?.isAvailable(); // { available: true } or { available: false, reason: "no_play_services" | "module_not_installed" | "no_camera" } const r = await scanner?.scan({ formats: ["qr"], prompt: "Scan your ticket" }); if (r?.scanned) openTicket(r.value); // { scanned: true, value: "...", format: "qr" } else if (r?.error !== "cancelled") showMessage(r?.error); // not_available, not_allowed, busy, invalid_argument, failedPermiso
Android: ninguno, checkPermission() responde siempre granted. iOS: scan() pide la cámara la primera vez; checkPermission() y requestPermission() permiten pedirla desde un botón tuyo, con status granted, denied o prompt. Tras un rechazo, iOS no vuelve a preguntar: lleva al usuario a los ajustes de la app.
Descarga de archivos
Los enlaces de descarga de tu sitio funcionan en la app, sin código por tu parte: un enlace con el atributo download, un archivo enviado como adjunto, una exportación creada en la página (blob:, data:). Un archivo tras un inicio de sesión también funciona: la app envía sus cookies. En Android el archivo va a la carpeta Descargas del teléfono, con la notificación del sistema. iOS no tiene esa carpeta: la app abre la vista previa del sistema, desde donde el usuario comparte el archivo o lo guarda en Archivos.
Enlaces
Nada que programar para los enlaces de tu dominio. Un PDF, o un tipo de archivo que la app no sabe mostrar, también se trata como descarga. Un enlace a otro dominio sigue abriendo el navegador. En Android, un archivo producido por una petición POST no está soportado: sírvelo con un enlace GET o créalo en la página. En Android 9 y anteriores, el archivo se queda en la carpeta de la app.
download({ url, filename, open })
Inicia una descarga desde tu código y responde al terminar: { downloaded: true, id, filename, mimeType, size }. url: http, https, blob: o data:, relativa o absoluta. filename sustituye el nombre, que la app limpia. open: true abre el archivo al final. Si falla, { downloaded: false, error }: invalid_argument, not_available, busy (5 descargas a la vez) o failed con un reason como http_404 o too_large (100 MB como máximo para blob: y data:).
// Links need no code. From your own code: const files = window.saastostore?.downloads; const r = await files?.download({ url: "/invoices/42.pdf", filename: "invoice-42.pdf" }); if (r?.downloaded) await files.open({ id: r.id }); // { downloaded: true, id, filename, mimeType, size } else if (r) showMessage(r.reason ?? r.error); // invalid_argument, not_available, busy, failed window.addEventListener("s2s:downloads", (e) => { console.log(e.detail); // { state: "started" | "completed" | "failed", id, filename, source: "link" | "api", error? } });open({ id })
Abre un archivo descargado desde que se inició la app, con el id que da download() o el evento: { opened: true }. Si no, not_found, no_app (Android: ninguna app abre este tipo), busy (iOS: ya hay una hoja abierta), invalid_argument o failed.
Evento
s2s:downloads en window, para los enlaces y para download(): e.detail = { state, id, filename, source, error }, con state started, completed o failed, y source link o api. Sirve para mostrar que un enlace tocado se está descargando. isAvailable() responde { available: true }.
Calendario
Añade desde tu página un evento al calendario del teléfono: una reserva, una clase, un webinar. La app abre el editor del propio calendario, rellenado con el título, las fechas, el lugar y las notas que indiques; el usuario lo revisa y lo guarda. Tu sitio nunca lee ni escribe en el calendario. En Android y en iOS 17 y posteriores la app no pide ningún permiso; en iOS 15 y 16, iOS pide acceso al calendario la primera vez.
addEvent({ title, start, end, location, notes })
Abre el editor. title es obligatorio (500 caracteres como máximo). start y end: una fecha y hora ISO 8601 con su zona horaria, milisegundos epoch o un Date; sin end, el evento dura una hora. location (1.000 caracteres como máximo) y notes (8.000) son opcionales. Android responde { ok: true, saved: null } en cuanto se abre el editor: no dice si el evento se guardó. iOS responde al cerrarse el editor, con saved true o false.
const cal = window.saastostore?.calendar; // from a button, never at launch const r = await cal?.addEvent({ title: "Product demo", start: "2026-11-20T09:00:00+01:00", // ISO 8601 with a time zone, epoch ms or a Date end: "2026-11-20T10:30:00+01:00", // optional: one hour after start location: "Lyon", notes: "Room 4, bring your badge", }); // Android → { ok: true, saved: null } (Android does not say whether it was saved) // iOS → { ok: true, saved: true | false } when the editor closes if (r && !r.ok) showMessage(r.error); // invalid_argument, not_available, busy, not_allowed // all day, 20 to 22 November (end included): await cal?.addEvent({ title: "Trade fair", start: "2026-11-20", end: "2026-11-22", allDay: true });Eventos de día completo
allDay: true, con días como start y end: "2026-11-20", o una fecha de la que se toma el día local. end es el último día, incluido; sin él, el evento dura un día. Sin allDay, una fecha sin hora se rechaza: indica una hora y una zona horaria.
Errores
{ ok: false, error }: invalid_argument (sin título, una fecha ilegible, un final antes del inicio), not_available (ninguna app de calendario en el teléfono), busy (iOS: ya hay un editor abierto) o not_allowed (iOS 15 y 16: acceso al calendario denegado; envía al usuario a los ajustes de la app). isAvailable() responde { available: false } en un teléfono Android sin app de calendario: oculta entonces tu botón. Sin repetición ni recordatorio en esta versión.
Escáner de documentos
Escanea documentos en papel desde tu página: un documento de identidad, un ticket, un formulario firmado. Se abre el escáner del propio teléfono, que detecta los bordes de la página y la endereza; el usuario puede repetir una página antes de confirmar. Tu página recibe las páginas como imágenes JPEG o como un solo PDF, en objetos File listos para subir. En Android, el escáner pertenece a los servicios de Google Play y tu app no pide ningún permiso de cámara; en iOS es el escáner de Apple, e iOS pide acceso a la cámara la primera vez.
scan({ pages, format })
Abre el escáner y responde { ok: true, format, pageCount, truncated, files }. format: jpeg (por defecto), un archivo por página (scan-1.jpg, scan-2.jpg…), o pdf, un único scan.pdf. pages: de 1 a 20, 10 por defecto. En Android el escáner se detiene en ese número; en iOS el usuario puede escanear más: se conservan las primeras páginas y truncated vale true. 32 MB como máximo por escaneo; si no, too_large.
const scanner = window.saastostore?.documentScanner; const ok = await scanner?.isAvailable(); // { available: true } or { available: false, reason } // from a button: the phone's own scanner opens const r = await scanner?.scan({ pages: 5, format: "pdf" }); // pages 1 to 20 (10 by default), "jpeg" (default) or "pdf" if (r?.ok) { const body = new FormData(); r.files.forEach((f) => body.append("scan", f)); // File objects: scan.pdf, or scan-1.jpg, scan-2.jpg… await fetch("/api/documents", { method: "POST", body }); } else if (r && r.error !== "cancelled") showMessage(r.error); // busy, not_allowed, not_available, too_large, invalid_argument, failedisAvailable()
{ available: true }, o { available: false, reason }: no_play_services (Android sin servicios de Google Play: Amazon Fire, Huawei), unsupported_device (Android con menos de 1,7 GB de memoria, o un simulador de iOS), module_not_installed (Android: Google Play descarga el escáner una vez; la descarga ya está pedida, vuelve a intentarlo más tarde). Compruébalo antes de mostrar un botón de escaneo.
Errores
{ ok: false, error }: cancelled (cerrado sin ninguna página), busy (ya hay un escaneo abierto), not_allowed (iOS: acceso a la cámara denegado; envía al usuario a los ajustes de la app), not_available, too_large, invalid_argument (pages fuera de 1 a 20, un formato distinto de jpeg o pdf) o failed. En Android el escáner solo toma fotos: la importación desde la galería está desactivada.
Contactos
Deja que el usuario rellene un campo desde sus contactos: un número para invitar a un amigo, un correo para compartir un archivo. La app abre el selector de contactos del teléfono; el usuario elige un contacto y luego un número o una dirección. Tu sitio recibe esa elección y nada más: la app nunca lee la agenda y no pide ningún permiso, en Android como en iOS.
pick({ field })
Abre el selector y responde { ok: true, contact: { name, phones, emails } }. field indica lo que vuelve: phone (por defecto), el número elegido, en phones; email, la dirección elegida, en emails; name, solo el nombre. Solo se pueden elegir contactos que tengan un número (o una dirección). name es null para un contacto sin nombre y se corta a 500 caracteres; la lista queda vacía cuando el valor elegido está vacío o supera 320 caracteres: compruébalo antes de usarlo.
const contacts = window.saastostore?.contacts; // from a button: the phone's own contact picker opens const r = await contacts?.pick({ field: "phone" }); // "phone" (default), "email" or "name" if (r?.ok && r.contact.phones.length) phoneInput.value = r.contact.phones[0]; // { name: "Ada Lovelace", phones: ["+33 6 12 34 56 78"], emails: [] } else if (r && r.error !== "cancelled") showMessage(r.error); // busy, not_available, invalid_argument, failedErrores
{ ok: false, error }: cancelled (cerrado por el usuario), busy (ya hay un selector abierto), not_available (Android: ninguna app de contactos en el teléfono), invalid_argument (un field distinto de phone, email o name) o failed. isAvailable() siempre responde { available: true } en iOS; en Android, { available: false } cuando el teléfono no tiene app de contactos: oculta entonces tu botón.
Insignias de pestañas
Muestra una insignia en una pestaña nativa, para mensajes no leídos o el número de artículos del carrito. Activa Insignia de notificación para esa pestaña en el Shell Designer.
setTabBadge(index, count)
index empieza en 0, en el orden de tus pestañas. Un count mayor que 0 muestra la insignia y 0 la oculta. Según el estilo de la barra, la insignia muestra el número o un punto.
window.saastostore?.setTabBadge?.(2, unreadCount); // 3rd tab, 0 hides it
Navegación y pestaña activa
La carcasa nativa sigue tu sitio sola: cada cambio de dirección (enlaces, history.pushState, botón atrás, cambios de hash) resalta la pestaña cuya Regex URL coincide, y la cabecera muestra el document.title de tu página. Nada que programar para apps de una sola página con React, Vue o Next.js.
navigateTo(path)
Lo que llaman las pestañas nativas y el menú lateral. Una ruta carga esa página, una ruta que empieza por # cambia el hash y una dirección https completa la abre.
window.saastostore?.navigateTo?.("/cart"); // loads /cart window.saastostore?.navigateTo?.("#/cart"); // hash routing (Bubble, Glide)
Compras in-app (Android)
Los contenidos digitales vendidos en una app Android deben pasar por la facturación de Google Play. El puente vende tus suscripciones por esa vía, con RevenueCat. Activa Suscripciones in-app (Google Play) en la biblioteca de plugins con tu clave Android de RevenueCat (goog_…) y tu entitlement, deja Shell V2 activado y lanza un nuevo build. La app iOS aún no lo tiene: oculta allí tu botón de mejora.
Llamadas
billing.identify(userId); // after sign-in, before any purchase billing.getStatus(); // is the subscription active? billing.purchase(productId); // opens the Google Play purchase sheet billing.restore(); // restore purchasesResultados: el evento «s2s:billing»
Cada llamada responde con un evento en window; event.detail.type indica cuál. Un error con valor cancelled significa que la persona cerró la ventana de compra.
identify_result { success, userId, error? } purchase_result { success, productId, isEntitled?, error? } restore_result { success, isEntitled, error? } status { isEntitled, entitlementId, expiresAt? }Ejemplo
const billing = await whenSaasToStore("billing"); window.addEventListener("s2s:billing", (e) => { const d = e.detail; if (d.type === "identify_result" && d.success) billing.getStatus(); if (d.type === "status") setPremium(d.isEntitled); if (d.type === "purchase_result" && d.success) setPremium(d.isEntitled); }); if (billing?.isAvailable) billing.identify(currentUser.id); // after sign-in // your "Upgrade" button, inside the Android app: billing.purchase("premium_monthly");
Firebase Analytics
Envía tus eventos a Google Analytics for Firebase, en tu propio proyecto de Firebase. Añade el plugin, añade tus archivos de Firebase en su detalle y lanza un nuevo build. Sin estos archivos, el plugin queda fuera de la app: nunca se ejecuta en un proyecto compartido con otras apps. No se envía nada antes del consentimiento de tu sitio (setConsent, más abajo). La app no tiene identificador publicitario.
logEvent({ name, params })
Envía un evento. name: 40 caracteres como máximo, letras, dígitos y _, empieza por una letra, sin los prefijos firebase_, google_ y ga_. params: 25 valores como máximo, cada uno un texto, un número o un booleano (enviado como 1 o 0). Un nombre o valor rechazado responde { ok: false, error: "invalid_argument" }; si no, { ok: true }.
const analytics = window.saastostore?.firebaseAnalytics; // undefined: normal browser, plugin not added, or app built before you added it if (analytics) { await analytics.logEvent({ name: "purchase", params: { value: 39, currency: "EUR" } }); }setUserId({ id }), setUserProperty({ name, value })
id: tu propio identificador de usuario, 256 caracteres como máximo, nunca una dirección de correo. name de una propiedad: 24 caracteres como máximo, las mismas reglas que un evento; value: 36 caracteres como máximo. null borra el valor.
setConsent({ analytics, ads })
No se recopila nada hasta que tu sitio llama a este método con analytics: true, ni siquiera los eventos que Firebase registra por sí solo al iniciar. Llámalo cuando el visitante acepte, o al cargar tu página si no necesitas consentimiento. Firebase guarda la respuesta en este teléfono de un inicio a otro. El almacenamiento publicitario y la personalización siguen rechazados hasta ads: true. analytics: false vuelve a detener toda recopilación. Pedir el consentimiento donde la ley lo exige corre de tu cuenta.
// once the visitor has accepted (or at page load if you need no consent) await window.saastostore?.firebaseAnalytics?.setConsent({ analytics: true });
Google Tag Manager
Tu contenedor GTM funciona en la app igual que en la web: tus etiquetas se activan en la página y nada cambia. Si tu app tiene el plugin Firebase Analytics, una etiqueta más copia allí tus eventos.
Etiqueta HTML personalizado
Crea una etiqueta de tipo HTML personalizado con este código:
<script> (function () { var analytics = window.saastostore && window.saastostore.firebaseAnalytics; if (!analytics) return; // normal browser, or app built without the plugin analytics.logEvent({ name: {{Event}}, params: { page_path: {{Page Path}} } }); })(); </script>Activador
Usa un activador Evento personalizado que indique los eventos que quieres copiar, por ejemplo purchase|sign_up con Usar coincidencia de expresiones regulares. No uses Todas las páginas: GTM envía entonces nombres como gtm.js, que Analytics rechaza.
Nombres de eventos
40 caracteres como máximo: letras, números y guiones bajos, empezando por una letra. Cualquier otro nombre se rechaza: la llamada responde { ok: false, error: "invalid_argument" } y la página no se ve afectada.
Solo en la app
Para activar una etiqueta solo en la app, o excluirla, añade una variable JavaScript personalizado y úsala en las condiciones del activador:
function () { return navigator.userAgent.indexOf("SaasToStoreApp") !== -1; }Consentimiento
El banner de consentimiento de tu sitio no llega solo a Analytics. Llama a setConsent desde una etiqueta que se active cuando el visitante haya respondido, antes del primer evento:
<script> (function () { var analytics = window.saastostore && window.saastostore.firebaseAnalytics; if (analytics) analytics.setConsent({ analytics: true, ads: false }); })(); </script>
Puente JavaScript — preguntas frecuentes
¿Tengo que escribir código nativo o añadir un SDK a mi sitio?+
No. El puente forma parte de las apps que crea SaasToStore. Tu sitio lo llama en JavaScript, y tú activas las funciones, o añades plugins, en la biblioteca de plugins de tu proyecto.
¿Cambia mi sitio en un navegador normal?+
No. window.saastostore solo existe dentro de la app. En un navegador, las comprobaciones no devuelven nada y tu sitio se comporta exactamente como antes.
¿Por qué window.saastostore es undefined al arrancar mi página?+
La parte Shell V2 del puente se carga cuando la página termina de cargar: llámala desde una acción del usuario o espérala con la función whenSaasToStore de esta página. Los plugins de la biblioteca están desde la primera línea de tu página.
¿El desbloqueo biométrico sirve para iniciar sesión?+
No. Solo confirma que quien tiene el teléfono es su dueño. Tu sitio mantiene su propio inicio de sesión; la biometría desbloquea una sesión existente o confirma una acción delicada.
¿Qué necesito para las compras in-app?+
Una cuenta de desarrollador de Google Play donde estén creadas tus suscripciones, un proyecto de RevenueCat vinculado a ella y la clave Android de RevenueCat en la biblioteca de plugins. Después, lanza un nuevo build.
Mi app se creó antes de que existiera una función. ¿Qué hago?+
Lanza un nuevo build. Las funciones nativas se añaden al hacer el build; los cambios de tu sitio aparecen sin él.
¿Los plugins necesitan Shell V2?+
No. La respuesta háptica, la reseña en la app, compartir nativo, el control de pantalla, el almacenamiento seguro, la información de la app, el navegador integrado y la orientación de pantalla funcionan con o sin Shell V2, en Android e iOS. La barra de estado funciona con o sin Shell V2 en Android, pero en iOS solo en las apps a pantalla completa: con Shell V2 sigue el header nativo. Añádelos en la biblioteca de plugins y lanza un nuevo build de la app (Android Build (Native) o iOS, no el Android Build (Web)).
¿Por qué appReview.request() no muestra nada?+
Google Play y la App Store deciden cuándo aparece la ventana de valoración y limitan con qué frecuencia. Solo aparece en una app instalada desde la tienda, nunca en TestFlight, y tu código nunca sabe si se dejó una reseña.
¿Las notificaciones locales son notificaciones push?+
No. Una notificación local la crea tu página en el propio teléfono, a la hora que elijas, sin servidor y sin mensaje enviado: un recordatorio, una cita, un carrito abandonado. Necesita el mismo permiso que el push en Android 13 y posteriores y en iOS, pedido desde una acción del usuario. Un mensaje enviado a todos tus usuarios a la vez es push, otro plugin.
¿El escáner necesita el permiso de cámara?+
En Android, no: el lector pertenece a los servicios de Google Play, tu app no declara ningún permiso de cámara ni muestra ninguna ventana de permiso; necesita los servicios de Google Play y, una vez, la descarga del módulo del lector. En iOS, sí: la app abre su propio lector e iOS pide la cámara la primera vez, con el texto fijado por SaasToStore ("This app uses the camera to scan QR codes and barcodes.").
¿Los enlaces de descarga necesitan código?+
No. Una vez añadido el plugin y reconstruida la app, los enlaces de descarga de tu sitio funcionan tal cual, incluidas las exportaciones creadas en la página. Sin el plugin no se guarda nada: en Android el toque no hace nada; en iOS un zip no hace nada, y un PDF o un archivo de texto reemplaza la página, sin botón de volver. En Android el archivo va a la carpeta Descargas; en iOS la app abre la vista previa del sistema, desde donde el usuario lo comparte o lo guarda en Archivos.
¿El plugin de calendario lee el calendario del usuario?+
No. Abre el editor del calendario del teléfono, rellenado con tu evento, y el usuario lo guarda; tu sitio no recibe nada del calendario. En Android y en iOS 17 y posteriores no aparece ninguna ventana de permiso. En iOS 15 y 16, iOS pide acceso al calendario la primera vez, con el texto fijado por SaasToStore ("This app adds events to your calendar when you ask it to.").
¿El plugin de contactos lee la agenda?+
No. Se abre el selector del propio teléfono, y tu sitio solo recibe lo que el usuario elige: un contacto, con un número o una dirección. No hay ninguna ventana de permiso, ni en Android ni en iOS, y tu sitio nunca recibe una lista de contactos.
¿El escáner de documentos necesita el permiso de cámara?+
En Android, no: el escáner pertenece a los servicios de Google Play, así que tu app no declara ningún permiso de cámara; el teléfono necesita los servicios de Google Play, 1,7 GB de memoria y, una vez, la descarga del módulo del escáner. En iOS, sí: iOS pide la cámara la primera vez, con el texto fijado por SaasToStore ("This app uses the camera to scan documents.", o un texto común para documentos y códigos cuando también se añade el escáner QR).
Páginas relacionadas
Primero convierte tu sitio en app
Empieza con un build Android de prueba gratis y luego añade las funciones nativas que necesites.
Empezar una prueba gratis