Introduction
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.
Sneak peek de l’architecture finale
Sur le plan architectural, une extension iOS Safari repose essentiellement sur les 5 fichiers suivants :
manifest.jsondécrivant notamment les permissionsbackground.jspour la logique d’arrière-plancontent.jsqui est injecté dans les pagespopup.jsqui est l’interface de l’extensionSafariWebExtensionHandler.swiftqui fait office d’interface entre l’application native et l’extension
Notre objectif est d’exploiter les possibilités offertes par React, et de faire en sorte que tout soit transformé en ces 5 fichiers !

Mise en place du projet
Création du projet “Extension” dans Xcode
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.
.png)
Xcode ajoute alors une nouvelle target pour l’extension, en plus de la target existante de l’application. La structure du projet inclut :
- Un dossier
Resources/avec les fichiers initiaux d’une extension web - Une classe
SafariWebExtensionHandler.swiftimplémentant le protocoleNSExtensionRequestHandlingqui permet de recevoir et traiter les messages provenant du code JavaScript.
.png)
À 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.
Création de l’application React
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 à ↓
.png)
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 code source → groups 🩶
.png)
- Pour les fichiers de ressources (images, assets, JSON, etc.) → folders 💙
.png)
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.
.png)
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.
.png)
Injection de l’application React
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 :
- Une
divavec un ID unique (point d'ancrage pour React). - Le script JS (bundle React) qui monte l'application dans cette
div. - Les styles CSS associés.
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 devva lancer l’application localement en injectant le contenu de notre fichierindex.htmlà l’aide de notre script.npm run buildva re-générer nos artefactscontent.bundle.cssetcontent.bundle.js. Ce sont ces fichiers qui sont le point d’entrée de l’extension.
.png)
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:
- Utiliser une iframe pour wrapper le conteneur, ce qui a pour avantage d’isoler complètement les styles mais son utilisation ajoute de la complexité
- Intégrer directement la div container et manuellement isoler les styles de l’application
Plutôt que d'utiliser un iframe, nous optons pour une intégration directe avec des précautions :
- Réinitialiser le CSS au root : Appliquer
#dummy-extension-root * { all: initial; }au début de votre feuille de style - Limiter les sélecteurs : Préfixer tous les sélecteurs avec #root afin de les encapsuler (
#dummy-extension-root * { ... }) - Préfixer les classes CSS (ex:
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.
Communication entre l’application React, l’extension et l’application native
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 :
.png)
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.
Hot-reload et debugging
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.
.png)
Ensuite, pour tester l'intégration réelle dans Safari, on peut utiliser le simulateur iOS via Xcode.
.png)
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).
.png)
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.
Distinction des contextes d’exécution
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 :
- Le contexte de la page web :
- Le code de notre application React est injecté directement dans le DOM de la page web. Il peut manipuler librement la structure HTML et les styles CSS de la page, mais n’a accès à aucune API spécifique de l’extension (ex.
browser.runtime,browser.storage, etc.). Pour communiquer avec les scripts propres à l’extension, il doit impérativement passer par des messages via l’APIwindow.postMessage.
- Le code de notre application React est injecté directement dans le DOM de la page web. Il peut manipuler librement la structure HTML et les styles CSS de la page, mais n’a accès à aucune API spécifique de l’extension (ex.
- Le contexte du script de contenu (content script) :
- Ce script injecté par l’extension s’exécute aussi dans la page web mais dans un contexte JavaScript isolé. Il possède un accès direct au DOM de la page, tout en ayant accès à certaines API spécifiques de l’extension (par exemple
browser.runtime.sendMessageetbrowser.storage).
- Ce script injecté par l’extension s’exécute aussi dans la page web mais dans un contexte JavaScript isolé. Il possède un accès direct au DOM de la page, tout en ayant accès à certaines API spécifiques de l’extension (par exemple
- Le contexte du script d’arrière plan (background) :
- Le code tournant dans ce contexte dispose de privilèges spéciaux. Il peut utiliser pleinement les API spécifiques aux extensions, comme
browser.tabs,browser.storage.
- Le code tournant dans ce contexte dispose de privilèges spéciaux. Il peut utiliser pleinement les API spécifiques aux extensions, comme
.png)
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.
- L’application React ne peut communiquer que par messages via l’API de
window. Elle ne pourra pas directement appeler l’APIbrowser.runtime.getURL(””). - Le content script peut appeler une partie des API d'extension (ex:
browser.runtime.sendMessage,browser.storagepeut ê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). - Le background n’a aucun accès direct au DOM des pages web et doit passer par les scripts de contenu pour effectuer toute interaction directe avec celles-ci.
Les configurations additionnelles
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
- Problème : Certaines ressources peuvent soit être chargées directement en tant que Data URI (
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 buildassetsInlineLimit) directement dans le bundle (Data URI), mais les fichiers plus volumineux restent séparés. Ces derniers doivent nécessairement faire partie desweb_accessible_resourcespour être récupérés et sont soumis à la CSP des pages web. - Point d’attention :
- Les images encodées avec Data URI peuvent être importées et lues sans être configurées dans le manifest, car elles sont directement présente dans le code JS. Pour les autres images dans l'onglet Réseau, inspectez les requêtes réseau pour confirmer que les fichiers volumineux sont bien chargés (statut
200 OK). - Vous ne pouvez pas utiliser la syntaxe d’import
import image from “./assets/image.png”sur les fichiers séparés de votre bundle, cela causera une erreurSyntaxError: import.meta is only valid inside modulescar 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 exemplebrowser.runtime.getURL("assets/image.png")et expliciter ce chemin en tant que source pour pouvoir accéder aux fichiers plus volumineux. - Si votre image doit être récupérée dans l’extension React en écrivant le chemin directement (par exemple pour des affichages conditionnels), vite ne va pas bundler votre ressource automatiquement et vous devez utiliser la même logique en plaçant votre ressource dans le dossier
public/assets. Dans ce cas l’image sera nécessairement séparée ? du bundle et pourra être lue.
- Les images encodées avec Data URI peuvent être importées et lues sans être configurées dans le manifest, car elles sont directement présente dans le code JS. Pour les autres images dans l'onglet Réseau, inspectez les requêtes réseau pour confirmer que les fichiers volumineux sont bien chargés (statut
.png)
Configuration des références de Xcode avec le fichier project.pbxproj
- Problème : Sur iOS, les fichiers d’extension doivent être intégrés dans le projet Xcode et correctement référencés dans le fichier
pbxproj. Sinon, l’application mobile ne peut pas savoir que ces fichiers existent. - Point d'attention :
- Vérifiez que les fichiers dans le
manifest.jsonsont cohérents avec la structure du projet Xcode, et qu’ils sont référencés correctement (présents dans leproject.pbxproj). C’est très important par exemple si vous déciderez d’ajouter de nouveaux scripts de contenu à votre extension.
- Vérifiez que les fichiers dans le
.png)
- Vous pouvez voir une erreur
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 mobile
.png)
Non persistance du script de background
- Problème :
- Les scripts d'arrière-plan (background) ne peuvent pas rester persistants sur iOS avec le Manifest V3. Le déploiement de l’application échouera si on ne respecte pas cette condition.
- Solution :
- On doit configurer notre background en tant que service worker dans le
manifest.jsoncomme suit :
- On doit configurer notre background en tant que service worker dans le
Bundler en IIFE pour éviter les erreurs de doublon de variable à cause de la minification de plusieurs content scripts
- Problème :
- Si plusieurs scripts de contenu sont injectés dans la même page, ils peuvent entrer en conflit si des variables globales ou des fonctions portent le même nom (surtout après minification). Par exemple, deux scripts pourraient définir une variable
var a = 10;, ce qui causerait des erreurs.
- Si plusieurs scripts de contenu sont injectés dans la même page, ils peuvent entrer en conflit si des variables globales ou des fonctions portent le même nom (surtout après minification). Par exemple, deux scripts pourraient définir une variable
- Solution :
- On utilise un plugin qui va bundler nos scripts avec la syntaxe IIFE (Immediately Invoked Function Expression) pour encapsuler le code de chaque script dans une portée locale. Cela évite les conflits de variables globales dans le contexte de la page web. On ajoute cette configuration au fichier
vite.config.ts:
- On utilise un plugin qui va bundler nos scripts avec la syntaxe IIFE (Immediately Invoked Function Expression) pour encapsuler le code de chaque script dans une portée locale. Cela évite les conflits de variables globales dans le contexte de la page web. On ajoute cette configuration au fichier
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 !
Repository Github
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