Depuis iOS 15 (09/2021), Apple ouvre la porte aux extensions Safari sur iPhone et iPad, offrant aux utilisateurs mobiles des fonctionnalités proches de celles sur desktop. Développer une extension Safari sur iOS permet d’enrichir la navigation (blocage de contenu, intégrations de services, UI personnalisées, etc.) tout en réutilisant des technologies web.
Créer une extension Safari iOS présente toutefois quelques spécificités par rapport aux extensions classiques (Chrome, Firefox ou même Safari desktop). L’extension doit être distribuée au sein d’une application iOS (via l’App Store), et non sous forme de package indépendant.
Dans cet article, nous dévoilons comment, en quelques étapes, tirer parti de React pour concevoir et déployer votre propre extension Safari iOS.
Sur le plan architectural, une extension iOS Safari repose essentiellement sur les 5 fichiers suivants :
manifest.json
décrivant notamment les permissionsbackground.js
pour la logique d’arrière-plancontent.js
qui est injecté dans les pagespopup.js
qui est l’interface de l’extensionSafariWebExtensionHandler.swift
qui fait office d’interface entre l’application native et l’extensionNotre objectif est d’exploiter les possibilités offertes par React, et de faire en sorte que tout soit transformé en ces 5 fichiers !
Nous partons du principe que nous disposons déjà d’une application iOS existante. Pour y intégrer une extension Safari, aller dans File > New > Target… et rechercher Safari Extension.
Xcode ajoute alors une nouvelle target pour l’extension, en plus de la target existante de l’application. La structure du projet inclut :
Resources/
avec les fichiers initiaux d’une extension webSafariWebExtensionHandler.swift
implémentant le protocole NSExtensionRequestHandling
qui permet de recevoir et traiter les messages provenant du code JavaScript.À ce stade, les fichiers JS générés par défaut conviendraient pour une extension basique. Dans notre cas, nous allons générer une application React dans un dossier séparé du projet Xcode (par exemple dummy-extension-react-app/
) puis nous la transformerons en un ensemble de fichiers compréhensibles par l’extension.
Supprimer tous les fichiers du dossier Resources/
sauf manifest.json
, nous les remplacerons ultérieurement.
Nous emploierons une stack web moderne afin de faciliter le développement de l’UI : React, Vite (pour le build), et TypeScript. Bien qu’une extension peut être codée en Vanilla JS, utiliser cette stack nous permet de bénéficier d’une meilleure DevX (Developer eXperience) dans la construction d’un projet complexe.
Initialiser le project React avec Vite
Créer les scripts de l’extension
Adapter la configuration Vite
Adapter le manifest.json
À ce stade, lorsqu’on exécute la commande npm run build
, lorsqu’on regarde l’explorateur de fichiers Xcode, on voit quelque chose qui ressemble à ↓
Dans Xcode, il existe deux manières de référencer des dossiers et fichiers : folders & groups (cf. Xcode Folders & Groups pour plus d’informations). Comme dernière étape de la mise en place, on applique la répartition suivante pour les différents dossiers & fichiers :
Pour le dossier des assets
, vérifiez que la localisation est Relative to Group, et ajoutez si besoin l’extension dans la Target Membership. Apply Once to Folder et ajoutez si besoin l’extension dans la Target Membership. Cela nous permet d’embarquer les fichiers dans ce dossier dans notre application.
Désormais, on peut retirer les références aux dossiers qui ne sont pas indispensables au fonctionnement de l’extension, sans réellement les supprimer de son disque dur.
Supprimons les références aux dossiers .idea
et DummyExtensionReactApp
. Cela modifie le fichier project.pbxproj
et permet de ne pas avoir à répéter l’opération au cours des développement.
Après le bundling, une app React se résume à ces éléments : des assets, un fichier JS qui gère la logique, un fichier CSS pour les styles, et une div
pour le rendu. On utilise la capacité de l'extension à manipuler le DOM via un script de contenu qui s'exécute dans le contexte de la page web pour injecter à la fois :
div
avec un ID unique (point d'ancrage pour React).div
.Modifier le point d’entrée de l’application
On veut avoir un comportement iso entre notre environnement local et notre navigateur qui affiche l’extension. Dans le point d’entrée local index.html
, on va maintenant utiliser notre script de contenu en point d’entrée et s’assurer qu’on crée la div root de notre application.
Injecter la div et monter l’application via notre script de contenu
Maintenant, on va créer la même div qui correspond au conteneur de l’extension dans la page web du navigateur.
Concrètement, on va utiliser comme point d’entrée notre script de contenu de l’extension pour créer la div react, lui injecter la feuille de style générée et le script associé.
On modifie notre script content.tsx
comme suit:
Le manifest.json
est configuré de sorte à ce que le script et la feuille de style soient bien chargés par l’extension de la façon suivante:
On re-génère nos fichiers statiques avec la commande npm run build
.
On peut observer deux choses:
npm run dev
va lancer l’application localement en injectant le contenu de notre fichier index.html
à l’aide de notre script.npm run build
va re-générer nos artefacts content.bundle.css
et content.bundle.js
. Ce sont ces fichiers qui sont le point d’entrée de l’extension.Isoler les styles CSS de l’extension
Si vous testez à ce stade de visualiser votre extension sur votre navigateur, vous verrez que le style de la page web est influencé par le style de notre extension. Inversement, le style de notre application React est impacté par les styles de la page web !
L'isolation des styles est cruciale pour éviter les conflits entre l'extension et le site web. Étant donné qu'on injecte du HTML/CSS dans la page, il y a un risque de conflit entre les styles de l'extension et ceux du site web.
On a deux options pour isoler les styles:
Plutôt que d'utiliser un iframe, nous optons pour une intégration directe avec des précautions :
#dummy-extension-root * { all: initial; }
au début de votre feuille de style#dummy-extension-root * { ... }
)extension-
avec Tailwind) pour les rendre uniques.⚠️ Il ne s’agit pas d’une isolation complète, ce qui peut entrainer des incohérences avec des interactions externes.
Supposons que votre application native stocke la donnée d’un utilisateur et que vous voulez la transmettre à l’extension. Le point d’entrée de l’implémentation native est le fichier SafariWebExtensionHandler.swift
avec le contenu suivant:
On peut voir ce code comme une API avec un endpoint getUserInfo
qui renvoie de la donnée sous la forme {"firstName": "John", "lastName": "Doe"}.
Nous allons réaliser une communication de bout en bout de notre application React située dans le DOM de la page web jusqu’à l’application native.
Modifier le fichier App.tsx
pour poster l’événement d’une requête et écouter sa réponse:
Modifier le script de contenu content.tsx
pour faire la requête au script d’arrière plan et renvoyer sa réponse :
Finalement, modifier le script d’arrière plan background.ts
pour envoyer un message natif à l’application iOS et retourner son résultat:
Au clic sur le bouton, notre application React envoie une requête qui va être écoutée par notre script de contenu content.tsx
. Elle attend également une réponse de manière asynchrone. Si le script de contenu détecte qu’une requête est formulée, il transmet cette demande vers le script d’arrière-plan background.ts
, puis éventuellement vers l’application iOS via le SafariWebExtensionHandler
. Une fois la réponse récupérée, le script de contenu renvoie les données au composant React, qui les écoute et met à jour l’interface utilisateur. Le nom est alors affiché à l’écran 🎉.
Visuellement, voici le schéma représentant le code qu’on vient d’écrire :
On a donc vu comment implémenter une communication de bout en bout pour permettre de récupérer des informations et appeler des méthodes du code natif.
Maintenant, vous pouvez aller plus loin en envisageant des fonctionnalités natives comme des vérifications par biométrie qui peuvent être directement appelées via votre extension.
Une fois le build effectué, on veut déployer l’application sur un appareil iOS (réel ou simulateur), puis activer l’extension dans les réglages Safari avant de la tester en conditions réelles. Voici nos meilleures pratiques concernant l’expérience développeur.
Pendant la phase de développement de l'UI, il est possible de tester l'application React en dehors de l'extension d'abord. On peut exécuter npm run dev
pour voir l'interface et itérer rapidement sur le design et le comportement des composants.
Ensuite, pour tester l'intégration réelle dans Safari, on peut utiliser le simulateur iOS via Xcode.
Il faut ensuite activer l’extension sur Safari ↓
À chaque fois qu'on bundle l’application React via npm run build
, puis qu’on build l’application depuis Xcode, Safari charge la nouvelle version de l'extension.
C'est moins rapide que le HMR de Vite, mais on peut réduire les frictions en scriptant au maximum la reconstruction. Par exemple, nous avons mis en place un hook de pre-commit avec husky
qui déclenche un npm run build
après chaque commit.
PS : Vous n'avez pas besoin de ré-activer l'extension sur Safari après avoir re-build le project sur Xcode.
Enfin, pour le debugging en direct sur iOS, utiliser Safari Web Inspector après avoir activé l’onglet Développement (cf. https://support.apple.com/en-mz/guide/safari/sfri20948/mac).
Vous aurez accès à la console JS de la page, qui inclut les logs de votre extension, et vous pourrez inspecter le DOM, y compris votre portion injectée.
Quand on développe une extension web, il est important de comprendre les contextes dans lesquels tourne notre code. Dans notre cas, on a principalement trois contextes :
browser.runtime
, browser.storage
, etc.). Pour communiquer avec les scripts propres à l’extension, il doit impérativement passer par des messages via l’API window.postMessage
.browser.runtime.sendMessage
et browser.storage
).browser.tabs
, browser.storage
.Il faut alors voir notre application React comme une boite injectée dans le DOM avec laquelle le script de contenu ne peut communiquer que via des messages. De la même façon, la communication entre le script de contenu et le script d’arrière plan respecte les règles classiques de communication des extension.
Il faut donc connaître les API disponibles selon le contexte.
window
. Elle ne pourra pas directement appeler l’API browser.runtime.getURL(””)
.browser.runtime.sendMessage
, browser.storage
peut être accessible directement, etc.) Il n’a pas accès aux API réservées exclusivement au contexte d’arrière-plan, comme celles permettant de manipuler directement les onglets (browser.tabs
).Notre expérience sur le développement d’une extension n’était pas sans obstacle et nous avons tiré de nombreux apprentissages sur les particularités liées à cette technologie. Il y a plusieurs bottlenecks à prendre en compte lors du développement d’une extension. Nous avons récapitulé les problèmes que nous avons rencontré et les solutions ou points d’attention pour chacun, qui vous seront primordiaux si vous décidez de vous lancer dans le développement d’une extension sur iOS.
Lire les images et ressources d’extension
data:image/...
) ou via un chemin vers un fichier séparé (assets/icon.svg
). Par défaut, Vite intègre les petits fichiers (moins de 4 KiB, définie par défaut par vite avec la configuration de build assetsInlineLimit
) directement dans le bundle (Data URI), mais les fichiers plus volumineux restent séparés. Ces derniers doivent nécessairement faire partie des web_accessible_resources
pour être récupérés et sont soumis à la CSP des pages web.200 OK
).import image from “./assets/image.png”
sur les fichiers séparés de votre bundle, cela causera une erreur SyntaxError: import.meta is only valid inside modules
car les scripts de contenu d’extension ne supportent aujourd’hui pas la syntaxe ESModule. Vous devez récupérer le chemin de l’url de votre extension avec par exemple browser.runtime.getURL("assets/image.png")
et expliciter ce chemin en tant que source pour pouvoir accéder aux fichiers plus volumineux.public/assets
. Dans ce cas l’image sera nécessairement séparée ? du bundle et pourra être lue.Configuration des références de Xcode avec le fichier project.pbxproj
pbxproj
. Sinon, l’application mobile ne peut pas savoir que ces fichiers existent.manifest.json
sont cohérents avec la structure du projet Xcode, et qu’ils sont référencés correctement (présents dans le project.pbxproj
). C’est très important par exemple si vous déciderez d’ajouter de nouveaux scripts de contenu à votre extension.Failed to load resource: The requested URL was not found on this server.
malgré un chemin vers une ressource qui est correct et une bonne configuration du manifest; cela veut probablement dire que le fichier n’a pas été embarqué correctement dans le build de l’app mobileNon persistance du script de background
manifest.json
comme suit :Bundler en IIFE pour éviter les erreurs de doublon de variable à cause de la minification de plusieurs content scripts
var a = 10;
, ce qui causerait des erreurs.vite.config.ts
:Vous avez maintenant toutes les clés en main pour développer une extension Safari sur iOS de bout en bout.
Si vous voulez découvrir d’autres articles écrits par nos talentueux collègues de Theodo Fintech, c’est 🎯 ici que ça se passe !
Pour expérimenter, nous avons fourni un repository Github qui reprend les étapes décrites précédemment.
Si vous avez des questions, n’hésitez pas à nous contacter via Github ou les adresses mail suivantes et nous pourrons y répondre avec plaisir :
mahdi.lazraq@theodo.com
gabriela.bertoli@theodo.com