15
minutes
Mis à jour le
20/3/2025


Share this post

Créez une extension Safari iOS avec React ! Apprenez à développer, configurer et déployer une web extension complexe.

#
React
#
iOS
#
extension
#
webextension
#
safari
Mahdi Lazraq
Software Engineer

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.json décrivant notamment les permissions
  • background.js pour la logique d’arrière-plan
  • content.js qui est injecté dans les pages
  • popup.js qui est l’interface de l’extension
  • SafariWebExtensionHandler.swift qui 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.

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.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.

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 à ↓

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 🩶
  • Pour les fichiers de ressources (images, assets, JSON, etc.) → folders 💙

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.

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 div avec 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 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:

  • 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 :

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.

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.

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’API window.postMessage.
  • 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.sendMessage et browser.storage).
  • 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.

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’API browser.runtime.getURL(””).
  • Le content script peut appeler une partie des API d'extension (ex: 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).
  • 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 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.
  • 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 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.
    • 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.
En étant une ressource publique, l’image xxl.png est bien bundlée dans notre dossier Resources/assets après npm run build

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.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.
Exemple du Copy Bundle Resources: tous les dossiers et fichiers attendus doivent être présents
  • 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
Exemple d’une erreur du à l’absence de référence correcte qui empêche de lire le fichier

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.json comme suit :

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.
  • 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 :

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