DOCUMENTATION DÉVELOPPEUR
Pont JavaScript : les fonctions natives depuis votre site
Mis à jour le 4 novembre 2026
Prompt pour Lovable, Bolt ou Base44
Ajoute à mon projet la prise en charge du pont d'app SaasToStore.
Contexte : mon site tourne aussi dans une app mobile native construite avec SaasToStore. Dans cette app, un objet global `window.saastostore` donne accès aux fonctions natives. Dans un navigateur normal, cet objet n'existe pas, et le site doit continuer à fonctionner exactement comme aujourd'hui. Ne modifie rien d'autre.
1. Crée `src/lib/saastostore.ts` avec :
- `isInApp()` : renvoie `typeof window !== "undefined" && navigator.userAgent.includes("SaasToStoreApp")`.
- `whenSaasToStore(key, timeoutMs = 3000)` : renvoie `window.saastostore?.[key]` dès qu'il existe (vérification toutes les 100 ms), ou `null` après le délai. Le pont ne se charge qu'une fois la page chargée.
- Les types TypeScript de `window.saastostore`, tous les membres optionnels :
- `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)
Les résultats des achats arrivent par un événement `window` nommé `"s2s:billing"` ; `event.detail.type` vaut `identify_result`, `purchase_result`, `restore_result` ou `status`.
2. Verrou biométrique : dans la page des réglages, affiche un interrupteur « Déverrouiller avec l'empreinte / Face ID » seulement si `isInApp()` et si `biometric.isAvailable()` renvoie `available: true`. Enregistre le choix dans le localStorage. S'il est activé, masque le contenu connecté à l'ouverture de l'app et à son retour au premier plan (`visibilitychange`), appelle `biometric.authenticate("Déverrouiller [NOM DE L'APP]")`, et réaffiche le contenu seulement si `success` vaut true, avec un bouton « Réessayer » sinon.
3. Badge d'onglet : à chaque changement de mon nombre de non-lus, appelle `window.saastostore?.setTabBadge?.([NUMÉRO DE L'ONGLET], count)` (0 masque le badge).
4. Abonnements dans l'app Android : quand `isInApp()` et `billing.isAvailable` valent true, appelle `billing.identify(user.id)` après la connexion, puis `billing.getStatus()` après l'événement `identify_result`. Dans ce cas, mon bouton de passage à l'offre payante appelle `billing.purchase("[ID DU PRODUIT]")` au lieu du paiement web ; écoute `s2s:billing` et débloque l'offre payante quand un événement `purchase_result` ou `status` contient `isEntitled: true`. Dans l'app iOS (`isInApp()` et `/iPhone|iPad/.test(navigator.userAgent)`), masque ce bouton. Dans un navigateur normal, garde le paiement actuel.
5. Plugins natifs : seulement pour les plugins que j'ai ajoutés dans SaasToStore (supprime les autres lignes). Appelle chacun de façon optionnelle (`window.saastostore?.haptics?.…`) ; sa promesse ne rejette jamais.
- Retour haptique : appelle `haptics.impact({ style: "light" })` à chaque appui sur un bouton principal, et `haptics.notification({ type: "success" })` ou `({ type: "error" })` quand un formulaire réussit ou échoue.
- Avis dans l'app : appelle `appReview.request()` une fois, après [UN MOMENT DE RÉUSSITE], jamais depuis un bouton.
- Partage natif : dans l'app, fais appeler `share({ title, text, url })` à mes boutons de partage au lieu du code de partage web.
- Contrôle de l'écran : sur [PAGE AVEC UN QR CODE OU UN BILLET], appelle `screen.keepAwake(true)` et `screen.setBrightness(1)` à l'ouverture, puis `screen.setBrightness(null)` et `screen.keepAwake(false)` à la fermeture.
- Stockage sécurisé : dans l'app, enregistre aussi [VALEUR À PROTÉGER] avec `secureStorage.set(key, value)` (clé : lettres, chiffres, `.`, `_`, `-` seulement, par exemple `auth.refresh_token` ; valeur : une chaîne, `JSON.stringify` pour un objet ; ne passe jamais `undefined`), relis-la avec `secureStorage.get(key)`, et appelle `secureStorage.clear()` à la déconnexion.
- Infos de l'app : affiche la `version` et le `build` renvoyés par `appInfo.get()` dans la page des réglages.
- Navigateur intégré : dans l'app, ouvre les liens vers d'autres domaines (pas les liens de connexion) avec `browser.open(url)` au lieu d'un nouvel onglet.
- Barre d'état : sur les pages sombres appelle `statusBar.setColor(FOND DE LA PAGE)` puis `statusBar.setStyle("dark")`, sur les pages claires `statusBar.setColor(FOND DE LA PAGE)` puis `statusBar.setStyle("light")` (setColor ne marche que jusqu'à Android 14 ; ignore son `not_supported`).
- Orientation de l'écran : rien à coder, c'est un réglage de build.
- Scanner QR et codes-barres : sur [LE BOUTON QUI SCANNE], appelle `barcode.scan({ formats: ["qr"], prompt: "Scannez votre code" })` et utilise `value` quand `scanned` vaut true ; ignore `error: "cancelled"`, affiche un message pour les autres erreurs ; cache le bouton quand `barcode.isAvailable()` répond `available: false`.
- Téléchargement de fichiers : mes liens de téléchargement marchent dans l'app sans code. Seulement pour [LE BOUTON QUI EXPORTE UN FICHIER], dans l'app, appelle `downloads.download({ url, filename })` et affiche un message quand `downloaded` vaut false.
- Calendrier : sur [LE BOUTON QUI AJOUTE UN ÉVÉNEMENT], dans l'app, appelle `calendar.addEvent({ title, start, end, location, notes })` avec `start` et `end` en `date.toISOString()` (ou `{ title, start: "YYYY-MM-DD", end: "YYYY-MM-DD", allDay: true }` pour des journées entières, `end` compris) ; affiche un message quand `ok` vaut false ; cache le bouton quand `calendar.isAvailable()` répond `available: false` ; hors de l'app, le bouton garde ce qu'il fait aujourd'hui.
- Scanner de documents : sur [LE BOUTON QUI SCANNE UN DOCUMENT], dans l'app, appelle `documentScanner.scan({ format: "pdf" })` et envoie `files` comme un fichier choisi dans mon formulaire ; ne fais rien quand `error` vaut `"cancelled"`, affiche un message pour les autres erreurs ; cache le bouton quand `documentScanner.isAvailable()` répond `available: false` ; hors de l'app, garde mon champ de fichier.
- Contacts : sur [LE BOUTON À CÔTÉ DU CHAMP TÉLÉPHONE], dans l'app, appelle `contacts.pick({ field: "phone" })` (ou `"email"`) et mets `contact.phones[0]` (ou `contact.emails[0]`) dans le champ quand la liste n'est pas vide ; ne fais rien quand `error` vaut `"cancelled"` ; hors de l'app, cache ce bouton et garde le champ tel quel.
- Notifications locales : sur [ACTION QUI MÉRITE UN RAPPEL], appelle d'abord `localNotifications.requestPermission()` (une fois, depuis ce bouton, jamais au lancement), puis `localNotifications.schedule({ id, title, body, at })` avec `at` sous la forme `Date.now() + …` ou `date.toISOString()` ; écoute `window.addEventListener("s2s:localNotifications", e => …)` et appelle `localNotifications.getLastOpened()` au démarrage de la page, pour ouvrir [LA PAGE DU RAPPEL] ; appelle `localNotifications.cancel({ id })` quand le rappel n'a plus lieu d'être.Avant de coller, supprimez les étapes inutiles et remplacez les valeurs entre [CROCHETS]. Relancez ensuite un build sur SaasToStore si vous avez activé une nouvelle fonction native.
Détecter l'app
Chaque appel décrit ici n'a de sens que dans l'app. Vérifiez d'abord, et votre site continue de fonctionner sans changement dans un navigateur normal.
Le user agent contient « SaasToStoreApp »
Les apps Android construites depuis juillet 2026 et les apps iOS construites depuis le 19 octobre 2026 l'ajoutent à chaque requête et à navigator.userAgent :
const inApp = navigator.userAgent.includes("SaasToStoreApp");Utilisez includes(), jamais une égalité exacte
Le marqueur est ajouté après le user agent du système, avec un espacement qui varie selon la plateforme. Votre serveur peut aussi le lire, dans l'en-tête User-Agent.
Puis vérifiez l'API elle-même
window.saastostore n'existe que dans l'app, et chaque fonction seulement quand elle est activée. Testez le membre dont vous avez besoin, par exemple window.saastostore?.biometric, avant de l'appeler.
Ce qui marche où
Les badges d'onglets, la navigation et les achats in-app viennent avec Shell V2, la coque native (en-tête, barre d'onglets, menu latéral) que vous activez dans le Shell Designer de votre projet ; les plugins de la bibliothèque marchent avec ou sans (sur iOS, la barre d'état seulement dans les apps plein écran). La biométrie aussi, sauf que dans une app iOS sans Shell V2 elle demande en plus l'un de ces plugins : Retour haptique, Avis dans l'app, Partage natif, Contrôle de l'écran, Stockage sécurisé, Infos de l'app, Navigateur intégré, Barre d'état ou Orientation de l'écran. Les fonctions natives sont ajoutées au moment du build : après en avoir activé une, lancez un nouveau build. Les changements de votre site apparaissent tout de suite dans l'app ; les changements natifs, non. Rien de tout cela n'existe dans l'Android Build (Web) (TWA), qui ouvre votre site dans Chrome : choisissez l'Android Build (Native) ou le build Android TV.
| Fonction | App Android (build natif) | App iOS |
|---|---|---|
| Détecter l'app (user agent) | Builds depuis juillet 2026 | Builds depuis le 19/10/2026 |
| Déverrouillage biométrique (biometric) | Builds depuis le 28/10/2026, avec ou sans Shell V2 | Avec Shell V2 ; sans, si l'un de ces plugins est ajouté : haptics, appReview, share, screen, secureStorage, appInfo, browser, statusBar ou orientation (nouveau build) |
| Plugins de la bibliothèque (haptique, partage, écran…) | Une fois ajoutés, dès votre prochain build, avec ou sans Shell V2 | Une fois ajoutés, dès votre prochain build, avec ou sans Shell V2 (barre d'état : apps plein écran seulement) |
| Badges d'onglets (setTabBadge) | Shell V2 avec barre d'onglets | Shell V2 avec barre d'onglets |
| Onglet actif et titre de l'en-tête | Shell V2 | Shell V2 |
| Achats in-app (billing) | Shell V2 + Abonnements in-app (Google Play) | Pas encore disponible |
Attendre le pont
Le pont se charge une fois la page chargée : il peut manquer pendant les toutes premières lignes de votre code. Appelez-le depuis une action de l'utilisateur, ou attendez-le avec cette fonction. Elle renvoie la fonction demandée, ou null hors de l'app. Les plugins de la bibliothèque, eux, sont là dès la première ligne de votre page.
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); })(); }); }
Déverrouillage biométrique
Demande à la personne qui tient le téléphone de prouver que c'est bien elle : empreinte, visage, ou code du téléphone en repli. Cela ne connecte personne : servez-vous-en pour déverrouiller une session que votre site a déjà, ou pour confirmer une action sensible. Activez Déverrouillage biométrique dans la bibliothèque de plugins de votre projet (activé par défaut), puis relancez un build. Sur iOS, il marche aussi sans Shell V2, à condition d'ajouter aussi l'un de ces plugins : Retour haptique, Avis dans l'app, Partage natif, Contrôle de l'écran, Stockage sécurisé, Infos de l'app, Navigateur intégré, Barre d'état ou Orientation de l'écran. L'app l'obtient à son prochain build.
isAvailable()
Une empreinte ou un visage est-il enregistré sur ce téléphone ? type vaut fingerprint, face, iris ou biometric sur Android, faceid ou touchid sur iOS.
await window.saastostore.biometric.isAvailable(); // → { available: true, type: "fingerprint" } // → { available: false, type: "none" }authenticate(reason)
Ouvre la fenêtre du système avec votre motif sous le nom de l'app. error contient le message du système quand la personne annule. Sur Android, il peut aussi valoir not_available (ni biométrie ni verrouillage d'écran) ou busy (une fenêtre est déjà ouverte).
await window.saastostore.biometric.authenticate("Unlock your account"); // → { success: true, error: null } // → { success: false, error: "Authentication canceled" }Exemple
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 bibliothèque
Les plugins ci-dessous sont éteints tant que vous ne les ajoutez pas dans la bibliothèque de plugins de votre projet ; lancez ensuite un nouveau build. Ils marchent dans les builds Android natifs (Android Build (Native), Android TV) et dans les apps iOS, avec ou sans Shell V2 (sur iOS, la barre d'état seulement dans les apps plein écran). Ils ne sont pas dans l'Android Build (Web) (TWA) : window.saastostore n'y existe pas.
Prêts dès la première ligne
Leur API est posée avant le premier script de votre page, sur le domaine de l'app et ses sous-domaines, dans le cadre principal seulement : jamais dans une iframe ni sur un autre site.
Des promesses qui ne rejettent jamais
Chaque méthode renvoie une Promise qui ne rejette jamais. Quand l'appel échoue, elle vaut un objet avec un champ error : not_available, not_supported, invalid_argument, busy, timeout ou failed ; et null si l'app ne peut pas répondre. Hors de l'app, ou dans une app construite avant l'ajout du plugin, le plugin est simplement absent : vérifiez-le avant de l'appeler.
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 }
Retour haptique
De courtes vibrations qui confirment une touche, une réussite ou une erreur. Ce que ressent la personne dépend de son téléphone et de son réglage de retour haptique.
impact, notification, selection, vibrate
impact prend un style (light, medium par défaut, heavy), notification un type (success par défaut, warning, error) ; selection est un léger tic, pour un sélecteur. vibrate prend une duration de 1 à 1000 ms (300 par défaut) ; iOS ne sait pas régler la durée et joue un impact fort à la place. Une duration qui n'est pas un nombre de 1 à 1000 répond invalid_argument. Sur Android, impact, notification, selection et vibrate répondent not_available quand l'appareil ne joue rien (pas de vibreur, comme la plupart des tablettes et des TV, ou retour haptique désactivé) ; iOS répond ok: true dans ce cas.
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
Avis dans l'app
Ouvre la fenêtre de notation du store sans quitter l'app. Le store décide si elle s'affiche et en limite la fréquence ; votre code ne sait jamais si un avis a été laissé. Elle n'apparaît que dans une app installée depuis Google Play ou l'App Store, jamais dans TestFlight.
request()
Appelez-la après une réussite (une commande livrée, un objectif atteint), pas comme seule action d'un bouton « Notez-nous » : la fenêtre peut ne pas s'afficher. error peut valoir not_available (pas de Google Play sur l'appareil), busy (une demande est déjà en cours), timeout ou failed. requested: true revient dès que la demande est passée au store, sans attendre la fermeture de la fenêtre. Sur Android, une deuxième demande pendant que la première fenêtre est encore ouverte répond 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
Contrôle de l'écran
Gardez l'écran allumé et réglez sa luminosité, pour une recette, une carte d'embarquement ou un QR code à présenter. Les réglages valent tant que l'app est ouverte.
keepAwake(on)
true garde l'écran allumé tant que l'app est au premier plan ; false rend la main au téléphone.
setBrightness(value), getBrightness()
value va de 0 à 1 ; null revient à la luminosité du système. Sur Android, seule la fenêtre de l'app change. Sur iOS, c'est tout l'écran : l'app remet la luminosité d'origine quand elle passe en arrière-plan, et réapplique la vôtre à son retour. Les valeurs sont vérifiées, jamais converties : setBrightness prend null ou un nombre de 0 à 1, keepAwake un booléen (sans argument : true) ; tout le reste répond invalid_argument. Sur Android, tant que l'app n'a pas réglé la luminosité, getBrightness renvoie une approximation du réglage système.
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" }
Stockage sécurisé
Un petit stockage clé-valeur chiffré par le téléphone, pour un jeton ou un secret dont votre site a besoin d'un lancement à l'autre. Sur Android, les valeurs sont chiffrées en AES-256-GCM avec une clé de l'Android Keystore qui ne quitte jamais l'appareil. Sur iOS, elles vont dans le Keychain, lisibles par cette app seulement, sur cet appareil seulement, après son premier déverrouillage.
get, set, remove, keys, clear
Les clés utilisent lettres, chiffres, point, tiret bas et tiret (1 à 128 caractères) ; les valeurs sont des chaînes de 64 Ko au plus : enregistrez un objet avec JSON.stringify. Rien n'est converti à votre place : une clé dans un autre format (comme auth:token), une valeur qui n'est pas une chaîne (undefined, un nombre, un objet) ou trop longue répond invalid_argument, et rien n'est enregistré.
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 stringLimites à connaître
Sur iOS, le Keychain survit à la désinstallation de l'app : une réinstallation retrouve les anciennes valeurs. Appelez clear() à la déconnexion, et ne prenez pas un stockage vide pour une première installation. Sur Android, tout est effacé avec l'app et rien n'est jamais sauvegardé : une réinstallation, ou un nouveau téléphone, repart d'un stockage vide. Chaque site a son propre stockage, comme localStorage : www.exemple.com et exemple.com partagent le même, aide.exemple.com a le sien, et keys() / clear() ne voient que les clés du site qui appelle. Le JavaScript de votre page peut lire les valeurs : elles sont protégées sur le téléphone, pas contre un script injecté dans votre site.
Infos de l'app
La version et le numéro de build de l'app, la plateforme, la version du système et la langue du téléphone : pour votre page de réglages ou un message au support.
get()
build est une chaîne sur les deux plateformes. model est un nom de modèle générique (le modèle sur Android, « iPhone » sur iOS) : aucun identifiant d'appareil n'est renvoyé.
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" }
Navigateur intégré
Ouvre une page dans un onglet de navigateur par-dessus l'app : Custom Tabs sur Android (ou le navigateur par défaut), Safari View Controller sur iOS. Pour les pages hors de votre domaine : conditions, aide, site d'un partenaire.
open(url)
http et https seulement. La page tourne hors de votre app : elle n'a ni window.saastostore, ni le user agent SaasToStoreApp, ni la session de votre app. Laissez la connexion Google ou Apple à l'app, qui s'en charge déjà. error peut valoir invalid_argument, busy (iOS : une autre feuille est ouverte), not_available ou failed. Sur Android, un lien vers un site qui a sa propre app sur le téléphone (un site de vidéos ou un réseau social, ou votre propre domaine quand les deep links sont actifs) peut ouvrir cette app au lieu d'un onglet.
const r = await window.saastostore?.browser?.open("https://example.com/terms"); // → { ok: true } // → { ok: false, error: "invalid_argument" } not http or https
Barre d'état
Accordez la barre d'état à votre page : icônes foncées sur une page claire, icônes claires sur une page sombre. Jusqu'à Android 14, la barre d'état est une bande au-dessus de la page, pas la page elle-même : appelez setColor avec la couleur de fond de votre page en même temps que setStyle. À partir d'Android 15 et sur iOS, la page passe sous la barre d'état : setStyle suffit, et la réponse not_supported de setColor peut être ignorée.
setStyle(style)
"light" pour une page claire (icônes foncées), "dark" pour une page sombre (icônes claires). Sur iOS avec Shell V2, la réponse est not_supported : la barre d'état suit l'en-tête natif.
setColor(hex)
Couleur de fond au format #RRGGBB, sur Android 14 et versions antérieures seulement. Android 15 et suivants dessinent les apps bord à bord, et iOS n'a pas de couleur de barre d'état : l'appel y répond 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
Orientation de l'écran
Un réglage de build, sans JavaScript : ajoutez Orientation de l'écran dans la bibliothèque de plugins, choisissez Portrait ou Paysage, puis relancez un build. L'app reste dans cette orientation (sur Android, elle peut encore se retourner tête en bas).
- Android 16 ignore ce verrou sur grand écran (600 dp et plus : tablettes, pliables ouverts) pour les apps qui ciblent l'API 36, ce qui est le cas des builds SaasToStore : gardez une mise en page adaptable.
- Le verrou portrait n'est pas appliqué au build Android TV, qui reste en paysage.
Notifications locales
Des rappels programmés sur le téléphone par votre page, affichés à l'heure choisie même quand l'app est fermée : un rendez-vous, une séance qui commence, un panier abandonné. Rien ne part de votre site, aucun serveur n'intervient : ce n'est pas du push. La notification ouvre votre app, et votre page sait laquelle a été touchée.
requestPermission() et checkPermission()
Demandez une fois, depuis une action de l'utilisateur (un bouton « Me rappeler »), jamais au lancement : Android 13 et suivants et iOS affichent une fenêtre système (les Android plus anciens répondent granted). La réponse est { granted, status }, avec status granted, denied ou prompt. Après un refus, le système ne redemande plus : envoyez l'utilisateur dans les réglages de l'app. checkPermission() lit seulement l'état.
schedule({ title, body, at | in, id, data })
at est une date (millisecondes, ou ISO 8601 avec fuseau, ce que donne toISOString()) ou in un délai en secondes, jamais les deux ; sans l'un ni l'autre, la notification s'affiche tout de suite. La réponse est { scheduled: true, id, at }. Limites : titre 200 caractères, texte 1 000, data un objet JSON de 4 Ko, 64 rappels en attente, 366 jours à l'avance ; une date passée s'affiche tout de suite ; l'id d'un rappel en attente le remplace. Sans la permission, { scheduled: false, error: "not_allowed", status } : demandez-la d'abord. Sinon invalid_argument, too_many ou 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 });Appui, liste, annulation
Un appui ouvre l'app sur votre page, qui reçoit un événement s2s:localNotifications sur window (detail.type "tap", avec id, title, body, at, data). Si l'app était fermée, l'événement part avant que votre page n'écoute : appelez getLastOpened() au démarrage de la page, il répond la notification une fois, puis null. getPending() liste les rappels à venir ; cancel({ id }) et cancelAll() les retirent, affichés compris.
Heure et redémarrage
Android affiche le rappel à quelques minutes près (alarme inexacte, sans la permission « Alarmes et rappels ») : juste pour un rendez-vous, pas pour un compte à rebours à la seconde. iOS est à l'heure. Les rappels survivent à un redémarrage du téléphone : Android les reprogramme au démarrage, iOS les garde. Sur Android, la notification porte l'icône de votre app et un canal « Notifications » que l'utilisateur peut couper dans les réglages du système.
Scanner QR et codes-barres
Lisez un QR code ou un code-barres avec l'appareil photo, depuis votre page : un billet, un produit, un lien à ouvrir. Sur Android, le lecteur est celui de Google (services Google Play), plein écran avec zoom automatique, et votre app ne demande aucune permission caméra. Sur iOS, l'app ouvre son propre lecteur, après la permission caméra.
isAvailable()
{ available: true }, ou { available: false, reason } : no_play_services (Android sans services Google Play : Amazon Fire, Huawei), module_not_installed (Android : Google Play télécharge le lecteur une fois ; le téléchargement est demandé, réessayez plus tard), no_camera (iOS). Vérifiez-le avant d'afficher un bouton de scan.
scan({ formats, prompt })
Ouvre le lecteur et répond avec le premier code lu : { scanned: true, value, format }. formats limite ce qui est lu (tout par défaut) : qr, ean13, ean8, upca, upce, code128, code39, code93, itf, codabar, datamatrix, pdf417, aztec. prompt est le texte affiché au-dessus du cadre de visée sur iOS (200 caractères au plus ; Android affiche l'interface de Google). Fermé par l'utilisateur : { scanned: false, error: "cancelled" } ; sinon not_available, not_allowed, busy, invalid_argument ou 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, failedPermission
Android : aucune, checkPermission() répond toujours granted. iOS : scan() demande l'appareil photo la première fois ; checkPermission() et requestPermission() permettent de demander depuis un bouton à vous, avec status granted, denied ou prompt. Après un refus, iOS ne redemande plus : envoyez l'utilisateur dans les réglages de l'app.
Téléchargement de fichiers
Les liens de téléchargement de votre site marchent dans l'app, sans code de votre côté : un lien avec l'attribut download, un fichier envoyé en pièce jointe, un export fabriqué dans la page (blob:, data:). Un fichier derrière une connexion marche aussi : l'app envoie ses cookies. Sur Android, le fichier va dans le dossier Téléchargements du téléphone, avec la notification du système. iOS n'a pas ce dossier : l'app ouvre l'aperçu du système, d'où l'utilisateur partage le fichier ou l'enregistre dans Fichiers.
Liens
Rien à coder pour les liens de votre domaine. Un PDF, ou un type de fichier que l'app ne sait pas afficher, est aussi traité comme un téléchargement. Un lien vers un autre domaine continue d'ouvrir le navigateur. Sur Android, un fichier produit par une requête POST n'est pas pris en charge : servez-le par un lien GET ou fabriquez-le dans la page. Sur Android 9 et avant, le fichier reste dans le dossier de l'app.
download({ url, filename, open })
Lance un téléchargement depuis votre code et répond quand il est fini : { downloaded: true, id, filename, mimeType, size }. url : http, https, blob: ou data:, relative ou absolue. filename remplace le nom, que l'app nettoie. open: true ouvre le fichier à la fin. En cas d'échec, { downloaded: false, error } : invalid_argument, not_available, busy (5 téléchargements à la fois) ou failed avec un reason comme http_404 ou too_large (100 Mo au plus pour blob: et 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 })
Ouvre un fichier téléchargé depuis le lancement de l'app, avec l'id donné par download() ou par l'événement : { opened: true }. Sinon not_found, no_app (Android : aucune app n'ouvre ce type), busy (iOS : une feuille est déjà ouverte), invalid_argument ou failed.
Événement
s2s:downloads sur window, pour les liens et pour download() : e.detail = { state, id, filename, source, error }, avec state started, completed ou failed, et source link ou api. Il sert à montrer qu'un lien touché est en cours de téléchargement. isAvailable() répond { available: true }.
Calendrier
Ajoutez depuis votre page un événement au calendrier du téléphone : une réservation, un cours, un webinaire. L'app ouvre l'éditeur du calendrier lui-même, prérempli avec le titre, les dates, le lieu et les notes que vous donnez ; l'utilisateur vérifie et enregistre. Votre site ne lit ni n'écrit jamais dans le calendrier. Sur Android et sur iOS 17 et après, l'app ne demande aucune autorisation ; sur iOS 15 et 16, iOS demande l'accès au calendrier la première fois.
addEvent({ title, start, end, location, notes })
Ouvre l'éditeur. title est obligatoire (500 caractères au plus). start et end : une date et une heure ISO 8601 avec leur fuseau, des millisecondes epoch ou une Date ; sans end, l'événement dure une heure. location (1 000 caractères au plus) et notes (8 000) sont facultatifs. Android répond { ok: true, saved: null } dès que l'éditeur s'ouvre : il ne dit pas si l'événement a été enregistré. iOS répond à la fermeture de l'éditeur, avec saved true ou 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 });Journées entières
allDay: true, avec des jours comme start et end : "2026-11-20", ou une date dont on retient le jour local. end est le dernier jour, compris ; sans lui, l'événement dure un jour. Sans allDay, une date sans heure est refusée : donnez une heure et un fuseau.
Erreurs
{ ok: false, error } : invalid_argument (pas de titre, une date illisible, une fin avant le début), not_available (aucune app de calendrier sur le téléphone), busy (iOS : un éditeur est déjà ouvert) ou not_allowed (iOS 15 et 16 : accès au calendrier refusé ; envoyez l'utilisateur vers les réglages de l'app). isAvailable() répond { available: false } sur un téléphone Android sans app de calendrier : cachez alors votre bouton. Ni répétition ni rappel dans cette version.
Scanner de documents
Numérisez des documents papier depuis votre page : une pièce d'identité, un ticket de caisse, un formulaire signé. Le scanner du téléphone s'ouvre, trouve les bords de la page et la redresse ; l'utilisateur peut reprendre une page avant de valider. Votre page reçoit les pages en images JPEG ou en un seul PDF, sous forme d'objets File prêts à envoyer. Sur Android, le scanner appartient aux services Google Play et votre app ne demande aucune autorisation caméra ; sur iOS, c'est le scanner d'Apple, et iOS demande l'accès à l'appareil photo la première fois.
scan({ pages, format })
Ouvre le scanner et répond { ok: true, format, pageCount, truncated, files }. format : jpeg (par défaut), un fichier par page (scan-1.jpg, scan-2.jpg…), ou pdf, un seul scan.pdf. pages : de 1 à 20, 10 par défaut. Sur Android, le scanner s'arrête à ce nombre ; sur iOS, l'utilisateur peut en numériser plus : les premières pages sont gardées et truncated vaut true. 32 Mo au plus par numérisation, sinon 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 }, ou { available: false, reason } : no_play_services (Android sans services Google Play : Amazon Fire, Huawei), unsupported_device (Android avec moins de 1,7 Go de mémoire, ou simulateur iOS), module_not_installed (Android : Google Play télécharge le scanner une fois ; le téléchargement est demandé, réessayez plus tard). Vérifiez-le avant d'afficher un bouton de numérisation.
Erreurs
{ ok: false, error } : cancelled (fermé sans aucune page), busy (une numérisation est déjà ouverte), not_allowed (iOS : accès à l'appareil photo refusé ; envoyez l'utilisateur vers les réglages de l'app), not_available, too_large, invalid_argument (pages hors de 1 à 20, un format autre que jpeg ou pdf) ou failed. Sur Android, le scanner ne prend que des photos : l'import depuis la galerie est désactivé.
Contacts
Laissez l'utilisateur remplir un champ depuis ses contacts : un numéro pour inviter un ami, une adresse e-mail pour partager un fichier. L'app ouvre le sélecteur de contacts du téléphone ; l'utilisateur choisit un contact, puis un numéro ou une adresse. Votre site reçoit ce choix et rien d'autre : l'app ne lit jamais le carnet d'adresses et ne demande aucune autorisation, sur Android comme sur iOS.
pick({ field })
Ouvre le sélecteur et répond { ok: true, contact: { name, phones, emails } }. field dit ce qui revient : phone (par défaut), le numéro choisi, dans phones ; email, l'adresse choisie, dans emails ; name, le nom seul. Seuls les contacts qui ont un numéro (ou une adresse) peuvent être choisis. name vaut null pour un contact sans nom et est coupé à 500 caractères ; la liste reste vide quand la valeur choisie est vide ou dépasse 320 caractères : vérifiez-la avant de l'utiliser.
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, failedErreurs
{ ok: false, error } : cancelled (fermé par l'utilisateur), busy (un sélecteur est déjà ouvert), not_available (Android : aucune app de contacts sur le téléphone), invalid_argument (un field autre que phone, email ou name) ou failed. isAvailable() répond toujours { available: true } sur iOS ; sur Android, { available: false } quand le téléphone n'a aucune app de contacts : cachez alors votre bouton.
Badges d'onglets
Affichez un badge sur un onglet natif, pour des messages non lus ou le nombre d'articles d'un panier. Activez Badge de notification pour cet onglet dans le Shell Designer.
setTabBadge(index, count)
index commence à 0, dans l'ordre de vos onglets. Un count supérieur à 0 affiche le badge, 0 le masque. Selon le style de la barre d'onglets, le badge montre le nombre ou un point.
window.saastostore?.setTabBadge?.(2, unreadCount); // 3rd tab, 0 hides it
Navigation et onglet actif
La coque native suit votre site toute seule : chaque changement d'adresse (liens, history.pushState, bouton retour, changement de hash) met en avant l'onglet dont la Regex URL correspond, et l'en-tête affiche le document.title de votre page. Rien à coder pour les applications monopage en React, Vue ou Next.js.
navigateTo(path)
Ce qu'appellent les onglets natifs et le menu latéral. Un chemin charge cette page, un chemin qui commence par # change le hash, une adresse https complète l'ouvre.
window.saastostore?.navigateTo?.("/cart"); // loads /cart window.saastostore?.navigateTo?.("#/cart"); // hash routing (Bubble, Glide)
Achats in-app (Android)
Les contenus numériques vendus dans une app Android doivent passer par la facturation Google Play. Le pont vend vos abonnements par ce biais, via RevenueCat. Activez Abonnements in-app (Google Play) dans la bibliothèque de plugins avec votre clé Android RevenueCat (goog_…) et votre entitlement, laissez Shell V2 activé, puis relancez un build. L'app iOS ne l'a pas encore : masquez-y votre bouton de passage à l'offre payante.
Appels
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 purchasesRésultats : l'événement « s2s:billing »
Chaque appel répond par un événement sur window ; event.detail.type indique lequel. Une error qui vaut cancelled signifie que la personne a fermé la fenêtre d'achat.
identify_result { success, userId, error? } purchase_result { success, productId, isEntitled?, error? } restore_result { success, isEntitled, error? } status { isEntitled, entitlementId, expiresAt? }Exemple
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
Envoyez vos événements à Google Analytics for Firebase, sur votre propre projet Firebase. Ajoutez le plugin, ajoutez vos fichiers Firebase dans son détail, puis lancez un nouveau build. Sans ces fichiers, le plugin reste hors de l'app : il ne tourne jamais sur un projet partagé avec d'autres apps. Rien n'est envoyé avant l'accord donné par votre site (setConsent, plus bas). L'app n'a pas d'identifiant publicitaire.
logEvent({ name, params })
Envoie un événement. name : 40 caractères au plus, lettres, chiffres et _, commence par une lettre, sans les préfixes firebase_, google_ et ga_. params : 25 valeurs au plus, chacune un texte, un nombre ou un booléen (envoyé en 1 ou 0). Un nom ou une valeur refusé répond { ok: false, error: "invalid_argument" } ; sinon { 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 : votre propre identifiant d'utilisateur, 256 caractères au plus, jamais une adresse e-mail. name d'une propriété : 24 caractères au plus, mêmes règles qu'un événement ; value : 36 caractères au plus. null efface la valeur.
setConsent({ analytics, ads })
Rien n'est collecté tant que votre site n'a pas appelé cette méthode avec analytics: true, pas même les événements que Firebase enregistre seul au lancement. Appelez-la quand le visiteur accepte, ou dès le chargement de votre page si vous n'avez pas besoin de consentement. Firebase garde la réponse sur ce téléphone d'un lancement à l'autre. Le stockage publicitaire et la personnalisation restent refusés jusqu'à ads: true. analytics: false arrête de nouveau toute collecte. Demander le consentement là où la loi l'exige reste à votre charge.
// once the visitor has accepted (or at page load if you need no consent) await window.saastostore?.firebaseAnalytics?.setConsent({ analytics: true });
Google Tag Manager
Votre conteneur GTM tourne dans l'app comme sur le web : vos balises se déclenchent dans la page et rien ne change. Si votre app a le plugin Firebase Analytics, une balise de plus y recopie vos événements.
Balise HTML personnalisée
Créez une balise de type HTML personnalisé avec ce code :
<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>Déclencheur
Utilisez un déclencheur Événement personnalisé qui liste les événements à recopier, par exemple purchase|sign_up avec Utiliser la correspondance d'expressions régulières. N'utilisez pas Toutes les pages : GTM envoie alors des noms comme gtm.js, qu'Analytics refuse.
Noms d'événements
40 caractères au plus : lettres, chiffres et tirets bas, en commençant par une lettre. Tout autre nom est refusé : l'appel répond { ok: false, error: "invalid_argument" } et la page n'est pas touchée.
Seulement dans l'app
Pour déclencher une balise uniquement dans l'app, ou l'en exclure, ajoutez une variable JavaScript personnalisé et utilisez-la dans les conditions du déclencheur :
function () { return navigator.userAgent.indexOf("SaasToStoreApp") !== -1; }Consentement
Le bandeau de consentement de votre site n'arrive pas seul jusqu'à Analytics. Appelez setConsent depuis une balise déclenchée une fois que le visiteur a répondu, avant le premier événement :
<script> (function () { var analytics = window.saastostore && window.saastostore.firebaseAnalytics; if (analytics) analytics.setConsent({ analytics: true, ads: false }); })(); </script>
Pont JavaScript — questions fréquentes
Faut-il écrire du code natif ou ajouter un SDK à mon site ?+
Non. Le pont fait partie des apps construites par SaasToStore. Votre site l'appelle en JavaScript, et vous activez les fonctions, ou ajoutez des plugins, dans la bibliothèque de plugins de votre projet.
Est-ce que cela change mon site dans un navigateur normal ?+
Non. window.saastostore n'existe que dans l'app. Dans un navigateur, les vérifications ne renvoient rien et votre site se comporte exactement comme avant.
Pourquoi window.saastostore est-il undefined au démarrage de ma page ?+
La partie Shell V2 du pont se charge une fois la page chargée : appelez-la depuis une action de l'utilisateur, ou attendez-la avec la fonction whenSaasToStore de cette page. Les plugins de la bibliothèque sont là dès la première ligne de votre page.
Le déverrouillage biométrique permet-il de connecter les utilisateurs ?+
Non. Il confirme seulement que la personne qui tient le téléphone en est la propriétaire. Votre site garde sa propre connexion ; la biométrie déverrouille une session existante ou confirme une action sensible.
Que faut-il pour les achats in-app ?+
Un compte développeur Google Play où vos abonnements sont créés, un projet RevenueCat relié à ce compte, et la clé Android RevenueCat saisie dans la bibliothèque de plugins. Relancez ensuite un build de l'app.
Mon app a été construite avant qu'une fonction existe. Que faire ?+
Lancez un nouveau build. Les fonctions natives sont ajoutées au moment du build ; les mises à jour de votre site, elles, apparaissent sans.
Les plugins ont-ils besoin de Shell V2 ?+
Non. Le retour haptique, l'avis dans l'app, le partage natif, le contrôle de l'écran, le stockage sécurisé, les infos de l'app, le navigateur intégré et l'orientation de l'écran marchent avec ou sans Shell V2, sur Android et iOS. La barre d'état marche avec ou sans Shell V2 sur Android, mais sur iOS seulement dans les apps plein écran : avec Shell V2, elle suit le header natif. Ajoutez-les dans la bibliothèque de plugins, puis relancez un build de l'app (Android Build (Native) ou iOS, pas l'Android Build (Web)).
Pourquoi appReview.request() n'affiche-t-il rien ?+
Google Play et l'App Store décident quand la fenêtre de notation apparaît et en limitent la fréquence. Elle n'apparaît que dans une app installée depuis le store, jamais dans TestFlight, et votre code ne sait jamais si un avis a été laissé.
Les notifications locales, c'est du push ?+
Non. Une notification locale est créée par votre page sur le téléphone lui-même, à l'heure que vous choisissez, sans serveur et sans message envoyé : un rappel, un rendez-vous, un panier abandonné. Elle demande la même permission que le push sur Android 13 et suivants et sur iOS, à demander depuis une action de l'utilisateur. Un message envoyé à tous vos utilisateurs à la fois, c'est du push, un autre plugin.
Le scanner a-t-il besoin de la permission caméra ?+
Sur Android, non : le lecteur appartient aux services Google Play, votre app ne déclare aucune permission caméra et n'affiche aucune fenêtre d'autorisation ; il lui faut les services Google Play et, une fois, le téléchargement du module du lecteur. Sur iOS, oui : l'app ouvre son propre lecteur et iOS demande l'appareil photo la première fois, avec le texte posé par SaasToStore (« This app uses the camera to scan QR codes and barcodes. »).
Les liens de téléchargement demandent-ils du code ?+
Non. Une fois le plugin ajouté et l'app rebuildée, les liens de téléchargement de votre site marchent tels quels, exports fabriqués dans la page compris. Sans le plugin, rien n'est enregistré : sur Android le toucher ne fait rien ; sur iOS un zip ne fait rien, et un PDF ou un fichier texte remplace la page, sans bouton retour. Sur Android le fichier va dans le dossier Téléchargements ; sur iOS l'app ouvre l'aperçu du système, d'où l'utilisateur le partage ou l'enregistre dans Fichiers.
Le plugin calendrier lit-il le calendrier de l'utilisateur ?+
Non. Il ouvre l'éditeur du calendrier du téléphone, prérempli avec votre événement, et l'utilisateur l'enregistre ; votre site ne reçoit rien du calendrier. Sur Android et sur iOS 17 et après, aucune fenêtre d'autorisation. Sur iOS 15 et 16, iOS demande l'accès au calendrier la première fois, avec le texte posé par SaasToStore (« This app adds events to your calendar when you ask it to. »).
Le plugin contacts lit-il le carnet d'adresses ?+
Non. Le sélecteur du téléphone s'ouvre, et votre site ne reçoit que ce que l'utilisateur choisit : un contact, avec un numéro ou une adresse. Aucune fenêtre d'autorisation, sur Android comme sur iOS, et aucune liste de contacts n'est jamais envoyée à votre site.
Le scanner de documents a-t-il besoin de la permission caméra ?+
Sur Android, non : le scanner appartient aux services Google Play, votre app ne déclare donc aucune permission caméra ; le téléphone doit avoir les services Google Play, 1,7 Go de mémoire et, une fois, le téléchargement du module du scanner. Sur iOS, oui : iOS demande l'appareil photo la première fois, avec le texte posé par SaasToStore (« This app uses the camera to scan documents. », ou un texte commun aux documents et aux codes quand le scanner QR est aussi ajouté).
Pages liées
Transformez d'abord votre site en app
Commencez par un build Android de test gratuit, puis ajoutez les fonctions natives utiles.
Lancer un test gratuit