Reveal URLs — Architecture
Ce document décrit comment l'extension Reveal URLs est structurée et comment sa
logique centrale fonctionne, à l'intention des contributeurs et des lecteurs
techniques. Il cite des fichiers et des symboles réels tout du long (sous la
forme path symbol) afin que toute affirmation puisse être vérifiée par
rapport au code source. Pour la description destinée à l'utilisateur, consultez
le manuel ; pour les étapes de vérification manuelle, consultez le
plan de test manuel.
Vue d'ensemble
Reveal URLs est une unique WebExtension MV3 construite pour plusieurs moteurs —
Chrome, Edge, Opera, Firefox et Thunderbird — à partir d'un seul noyau de code
partagé et pur. (Safari est différé : il dispose d'un manifeste et est
constructible lorsqu'il est nommé explicitement, mais il est exclu de la
construction par défaut et de l'intégration continue.) L'extension révèle l'URL
de destination de chaque lien à côté du lien dans un courriel rendu, et signale
un lien dont le texte visible nomme un domaine enregistrable différent de celui
de son href — l'indice classique de l'hameçonnage.
Le dépôt est un monorepo pnpm/TypeScript (pnpm-workspace.yaml,
package.json avec "private": true). Le regroupement (bundling) est assuré par
un unique script piloté par esbuild, tooling/build.mjs, qui compile les
paquets partagés et les points d'entrée minces de chaque moteur dans un
répertoire chargeable dist/<target>/. webextension-polyfill est regroupé dans
chaque script plutôt que d'être utilisé comme variable globale à l'exécution,
afin que le même code source s'exécute sans modification sur Chromium et Gecko.
Deux modules complémentaires de messagerie natifs étendent le même noyau de
détection à des surfaces que la WebExtension ne peut pas atteindre : un module
complémentaire Outlook (volet des tâches Office.js, atteignant Outlook sur
web/Windows/Mac/iOS/Android et Outlook.com) et un module complémentaire Gmail
(CardService d'Apps Script, atteignant les applications Gmail web/Android/iOS).
Tous deux ne réutilisent que l'analyse PURE — un nouveau module
packages/core/src/findings.ts (analyseAnchors/analyseHtml, un modèle
Finding[] neutre vis-à-vis de la plateforme) qui réutilise la logique
hostMismatch existante et la dérivation de domaine enregistrable de tldts.
Seuls l'adaptateur hôte (la façon dont le corps est lu et analysé) et la
présentation diffèrent selon la surface : le volet des tâches Outlook analyse le
corps avec son propre DOMParser et s'exécute côté client ; le module
complémentaire Gmail analyse avec node-html-parser et s'exécute côté serveur
sur l'infrastructure V8 d'Apps Script de Google (hébergement gratuit, puisque
Google détient déjà le courrier ; déployé de façon interactive et locale via
clasp). Parce qu'aucun des deux cadres ne peut modifier le DOM du message rendu
en mode lecture (les setAsync/prependAsync d'Office sont réservés à la
rédaction ; CardService rend des cartes, et non du HTML de message), les deux
modules complémentaires présentent un panneau/une carte de constats, et non une
annotation en ligne — le chemin de mutation du DOM existant de linkProcessor.ts
et REVEAL_URLS_CSS ne sont réutilisés par aucun des deux modules complémentaires.
Un href relatif n'est résolu que par rapport à une balise <base href> fiable
présente dans le courriel, et est sinon ignoré, jamais résolu par rapport à
l'origine de la boîte aux lettres/du fournisseur (ce qui fabriquerait une
destination).
Le code sépare nettement les responsabilités :
packages/corecontient la logique PURE — pas d'API de navigateur, pas de stockage, pas de puits de balisage (markup sinks). Il est testable unitairement en JavaScript pur et constitue l'unique source de vérité pour l'annotation des liens, la validation de la configuration, la correspondance de motifs (match-pattern), la comparaison d'hôtes et la coloration tenant compte du contraste.packages/webextenveloppe les API de la WebExtension autour de ce noyau : le cycle de vie du contenu, l'enregistrement dynamique des scripts de contenu, l'interface des options, le câblage de l'arrière-plan, l'action de la barre d'outils et le chemin d'affichage des messages de Thunderbird.extensions/<engine>contient les points d'entrée minces propres à chaque moteur ainsi que lemanifest.jsonde chaque moteur.toolingcontient les scripts de construction, d'estampillage de version et de génération d'icônes.featurescontient les scénarios BDD Cucumber et leurs doublures de test.
Disposition du dépôt
packages/core — le noyau pur
Chaque module ici n'importe aucune API d'extension
(browser.*/chrome.*/messenger.*) et n'utilise aucun puits DOM chaîne-vers-
balisage (innerHTML/insertAdjacentHTML). Ce contrat est appliqué
mécaniquement par packages/core/test/purity.test.ts, qui parcourt par glob
chaque src/**/*.ts, retire le corps des commentaires via son propre
stripComments (afin que les docblocks qui NOMMENT légitimement les jetons
interdits ne déclenchent pas la garde) puis vérifie que ni
EXTENSION_API_PATTERN ni UNSAFE_DOM_PATTERN ne correspondent à aucun module.
La surface publique est réexportée depuis packages/core/src/index.ts. La seule
dépendance d'exécution est tldts (pour la comparaison de domaines tenant compte
du suffixe public).
Les modules sont :
packages/core/src/config.ts— le schémaConfig/SiteRule, les valeurs par défaut et l'unique entonnoir de validationnormaliseConfig.packages/core/src/matchPattern.ts— la grammaire de motifs de correspondance, le comparateur, le classement par spécificité et la couverture des origines accordées.packages/core/src/linkProcessor.ts— l'annotateur à portée de document.packages/core/src/hostMismatch.ts— la vérification de non-concordance de domaine enregistrable.packages/core/src/styles.tsetpackages/core/src/contrast.ts— la feuille de style injectable et la coloration tenant compte du contraste.packages/core/src/safeColour.ts— le prédicat de couleur sûreisSafeColour.
packages/webext — les enveloppes d'API de navigateur
Les modules ici PEUVENT utiliser browser.* et sont le seul endroit où les API
de la WebExtension sont touchées. La surface publique est réexportée depuis
packages/webext/src/index.ts. Les modules sont : content.ts (cycle de vie du
contenu), contentRegistration.ts (enregistrement dynamique), storage.ts
(persistance de la configuration), background.ts (comportement à
l'installation), toolbarAction.ts (bascule + badge), messageDisplay.ts et
messageDisplayBackground.ts (Thunderbird), et options/options.ts (le
contrôleur de la page des paramètres).
extensions/<engine> — points d'entrée minces et manifestes
Chaque moteur porte son propre manifest.json et ses icons/. Chrome, Firefox
et Thunderbird livrent en outre un src/ de points d'entrée ; Edge, Opera et
Safari ne portent qu'un manifeste et des icônes et RÉUTILISENT le src/ de
Chrome (déclaré par le descripteur de construction — voir ci-dessous). Les points
d'entrée sont délibérément minuscules : par exemple
extensions/chrome/src/content.ts n'est que
void createContentController().bootstrap();, et
extensions/chrome/src/background.ts appelle registerBackground,
registerContentReconciliation et registerToolbarToggle. Le point d'entrée
d'arrière-plan de Firefox est identique à celui de Chrome ; celui de Thunderbird
(extensions/thunderbird/src/background.ts) appelle plutôt registerBackground
et registerMessageDisplay (il n'a pas de scripts de contenu).
extensions/_template est un échafaudage Chromium prêt à copier, et non une cible
de construction.
tooling, features et les tests de fumée
tooling contient build.mjs, version.mjs et icons.mjs. features contient
les scénarios Gherkin, leurs définitions d'étapes et les doublures de test
fidèles dans features/support/webext.ts et features/support/world.ts. Chaque
moteur porte également un extensions/<engine>/test/manifest.smoke.test.mjs
(celui de _template est template.smoke.test.mjs) — ce sont le contrat de
manifeste faisant autorité (voir « Construction par moteur et contrat de
manifeste » ci-dessous).
Le pipeline de révélation
Le chemin du contenu commence à un point d'entrée de moteur tel que
extensions/chrome/src/content.ts, qui appelle
createContentController().bootstrap() depuis packages/webext/src/content.ts.
Amorçage (bootstrap)
createContentController bootstrap s'exécute une fois par cadre (frame) :
- Il revendique le cadre de façon synchrone, avant tout
await, viaclaimBootstrap, qui lit/définit le drapeauBOOTSTRAP_MARKERsur lewindowdu cadre. Une entréecontent_scriptsstatique intégrée et un script utilisateur dynamique qui se chevauchent peuvent tous deux injectercontent.jsdans le même cadre ; lewindowpartagé du monde isolé en fait la garde correcte d'unicité, et revendiquer avant toutawaitsignifie que deux injections quasi simultanées ne peuvent pas la franchir toutes les deux. - Il charge d'ABORD la configuration persistée via
loadConfigWithRetry(BOOTSTRAP_CONFIG_RETRIEStentatives supplémentaires sur un rejet transitoire), en maintenant le marqueur pendant les tentatives afin qu'une injection qui se chevauche et qui a déjà fait un no-op ne soit pas laissée en plan. Ce n'est qu'une fois la lecture réussie qu'il injecte la feuille de style (injectStyles, qui assigneREVEAL_URLS_CSSviatextContent), de sorte qu'une lecture échouée n'ajoute aucun<style>et qu'une nouvelle tentative ne peut pas le dupliquer. - Si le chargement de la configuration échoue à chaque tentative,
releaseBootstraprétablit le marqueur (uniquement ici, avant le début de l'annotation) afin qu'une réinjection ultérieure puisse réessayer ; une fois questarts'est exécuté, le marqueur doit persister, car un annotateur neuf ne posséderait pas les nœuds de la passe précédente. - Lorsque
config.enabled, il appellestart, et il enregistre toujours un écouteuronConfigChangedqui réapplique la configuration (couvrant la transition désactivé→activé).
Résolution de la règle de site active
start (et applyConfig) appelle resolveSiteRule pour décider s'il faut — et
où — annoter. resolveSiteRule essaie d'abord l'emplacement PROPRE du document en
cours d'exécution via resolveForHref, qui filtre config.sites vers les règles
activées et délègue au selectMostSpecific du noyau afin que la correspondance la
plus spécifique l'emporte (un élément intégré spécifique bat un caractère
générique utilisateur large). Lorsque l'emplacement propre ne correspond à rien ET
que l'origine propre du document est opaque ou héritée — conditionné par
hasOpaqueOrigin, vrai uniquement pour about:blank/about:srcdoc (hérité via
INHERITED_ORIGIN_URLS) ou blob:/data: (OPAQUE_ORIGIN_SCHEMES) — il se
rabat, dans l'ordre, sur le cadre du HAUT (readTopHref, le cas intégré de
l'iframe de message Proton), la fenêtre OUVREUSE (readOpenerHref, la fenêtre de
lecture détachée d'Outlook ouverte via window.open("about:blank")) et enfin
document.referrer (readReferrer). Chaque lecture externe est protégée contre
l'accès interorigine (qui lève une exception) et ne fournit un candidat que
lorsqu'elle est lisible. Un document ORDINAIRE non correspondant fait autorité et
n'hérite jamais d'une règle du contexte ambiant, laissant le contrôleur inerte.
Cadrage à la racine du contenu et observation des mutations
start enregistre le sélecteur contentRoot de la règle correspondante, crée
l'annotateur via createAnnotator(config), et traite chaque racine correspondante
de selectRoots(doc, contentRoot) (un querySelectorAll enveloppé dans un
try/catch afin qu'un sélecteur invalide échoue sans risque vers []). Il observe
ensuite doc.body (un conteneur stable) avec OBSERVER_OPTIONS
({ childList: true, subtree: true }).
L'observateur alimente processMutations, qui conditionne chaque mutation à la
racine du contenu : il ne retraite une target de mutation que lorsqu'il s'agit
d'un Element à l'intérieur d'une racine (isWithinRoot, via closest), et
achemine chaque Element ajouté via processAddedNode, qui annote un nœud qui est à
l'intérieur d'une racine, EST une racine, ou en CONTIENT une (un montage SPA
paresseux). Ainsi, le brassage à l'intérieur d'une racine et un corps de message
frère qui se monte plus tard sont tous deux pris en compte, tandis que le chrome
applicatif en dehors des racines est laissé intact. Un annotateur nul (l'extension
est arrêtée) est un no-op.
applyConfig réconcilie un changement de configuration en direct : il démonte
lorsqu'aucune règle ne correspond ou que le drapeau d'activation est désactivé ;
redémarre (rétablir + ré-observer) lorsque le contentRoot résolu a changé ; et
sinon, réagence sur place — annotator.setConfig puis annotator.process sur
chaque racine correspondante — sans redémarrer l'observateur.
Annotation d'une ancre unique
L'annotateur réside dans packages/core/src/linkProcessor.ts. createAnnotator
renvoie un Annotator sur un état de fermeture (closure) que la page ne peut pas
lire : une Map forte indexée par l'identité de l'ancre, un jeton data-ru par
annotateur issu de generateToken (crypto.getRandomValues), et la configuration
courante. Sa méthode process(root) :
- Élague les entrées du registre dont l'ancre n'est plus connectée.
- Collecte les ancres candidates avec
collectAnchors(lesa[href]descendants plus la racine elle-même lorsqu'elle est una[href], dédupliquées). - Résout la destination de chaque ancre avec
resolveAnnotatableUrl, qui renvoienull(ignorer) pour un href vide/dans la page, un href non analysable, tout schéma autre quehttp:/https:, ou un hôte égal à ou sous une entréeignoreHosts; il résout les href relatifs par rapport àbaseURIafin que le résultat soit absolu. - Emprunte le chemin rapide idempotent lorsque
isAnnotationIntactconfirme que l'empreinte de rendu est inchangée et que chaque nœud créé est toujours connecté et en place ; sinon, il rétablit l'annotation périmée et effectue un nouveau rendu. - Calcule la non-concordance avec
hostMismatchsur le texte propre de l'ancre, honoreshowOnlyOnMismatch(laissant les liens honnêtes intacts), et appelleannotateAnchor.
annotateAnchor ne rend que du DOM SÛR. En mode "inline", il préfixe un
<span class="reveal-urls-url"> dont l'URL est définie via textContent (préfixée
d'une flèche et d'un espace insécable U+00A0, tronquée par truncateUrl) suivi
d'un <br>, laissant les enfants propres du lien intacts ; le span connecté reçoit
ensuite un arrière-plan de contraste via applyContrastBackdrop. En mode
"title", il sauvegarde le title d'origine (TitleSave), l'écrase avec l'URL —
la seule écriture sur l'ancre — et, sur une non-concordance accentuée, ajoute un
badge frère <span class="reveal-urls-warn"> portant REVEAL_URLS_WARN_LABEL.
Chaque nœud créé est étiqueté data-ru="<token>" (jamais l'ancre), et chaque
entrée enregistre son configFingerprint afin qu'un changement de configuration de
rendu force un réagencement. Les couleurs et les surcharges de police sont
appliquées via des propriétés typées element.style.*
(applyColour/applyFontOverrides), jamais interpolées dans une chaîne CSS.
revertOwned supprime les nœuds créés par référence et restaure le titre
sauvegardé ; revertAll fait cela pour chaque ancre possédée et vide le registre.
truncateUrl opère sur les POINTS DE CODE Unicode ([...displayHref]), de sorte
qu'une coupure ne sépare jamais une paire de substitution (surrogate) ; il préserve
l'origine schéma-et-hôte et raccourcit le reste avec un URL_ELLIPSIS en fin.
Non-concordance d'hôte
packages/core/src/hostMismatch.ts hostMismatch compare le texte visible du lien
à son href par domaine enregistrable, et non par nom d'hôte brut, de sorte qu'un
sous-domaine honnête n'est pas signalé tandis qu'un sosie l'est.
extractHostCandidates découpe le texte et réduit chaque jeton via hostCandidate
(qui exige un point et un suffixe public ICANN reconnu par tldts, rejetant le
texte pointé ordinaire comme e.g). Tant l'hôte du href que chaque candidat sont
réduits à leur domaine enregistrable avec tldts.getDomain, et TOUT candidat dont
le domaine diffère de celui du href produit une non-concordance — de sorte que
nommer le véritable hôte malveillant aux côtés d'un hôte leurre ne peut pas
supprimer l'avertissement. Il ne lève jamais d'exception.
Styles et contraste
packages/core/src/styles.ts contient le REVEAL_URLS_CSS injectable (mise en
page, graisse et forme fixes, et un arrière-plan de pastille blanc fixe et
entièrement opaque — background: #ffffff, opacity: 1 ; les couleurs n'y sont
JAMAIS interpolées) et les utilitaires d'exécution
applyColour/applyFontOverrides/applyContrastBackdrop.
applyContrastBackdrop lit la couleur résolue de l'élément et le premier
arrière-plan opaque trouvé en remontant depuis l'élément LUI-MÊME (son propre
arrière-plan — le blanc par défaut fixe de la pastille ou une surcharge plus
spécifique d'une page hôte — avant tout ancêtre) via le getComputedStyle standard
(provenant de element.ownerDocument?.defaultView, de sorte qu'il est sûr pour les
cadres et stubbable) et, lorsque packages/core/src/contrast.ts needsWhiteBackdrop
indique que le texte échoue au WCAG AA (CONTRAST_THRESHOLD) par rapport à cet
arrière-plan ET que le blanc aide véritablement, définit
style.backgroundColor = "white". contrast.ts fournit parseColour,
relativeLuminance et contrastRatio ; il analyse les formes rgb()/rgba()/hex
que getComputedStyle renvoie et traite une couleur entièrement transparente comme
« non trouvée ».
Hôtes configurables et cadrage du contenu
Une SiteRule (packages/core/src/config.ts) indique OÙ l'annotation s'exécute
(match, allFrames) et QUEL conteneur la cadre (contentRoot), plus enabled
et un drapeau builtin. Les DEFAULT_SITES livrés couvrent Gmail, Proton (avec
allFrames) et les deux hôtes Outlook. La Config agrège les bascules globales,
les couleurs, les surcharges de police et le tableau sites.
Le moteur de motifs de correspondance
packages/core/src/matchPattern.ts contient une grammaire RESTRICTIVE au moment de
l'ajout MATCH_PATTERN (http/https uniquement, caractère générique d'hôte *.
optionnel en tête, glob de chemin ; pas de port, pas de schéma *, pas de
<all_urls>). matchesPattern fait correspondre une URL à un motif validé,
déléguant le chemin à pathGlobMatches (chaque * correspond à n'importe quelle
séquence de caractères) ; il échoue sans risque vers false. parsePatternParts
découpe un motif validé en host/path/scheme/wildcardHost.
Les règles qui se chevauchent se résolvent par « le plus spécifique l'emporte » :
compareSiteSpecificity classe selon (1) l'hôte exact plutôt que l'hôte générique,
(2) l'hôte littéral le plus long, (3) le chemin le plus littéral
(literalPathLength), puis (4) un départage ASCII déterministe ;
selectMostSpecific renvoie la règle activée la plus spécifique correspondant à
une URL.
La couverture des origines accordées est un comparateur SÉPARÉ, délibérément plus
LARGE. permissions.getAll() peut rapporter des octrois dans la grammaire complète
de la WebExtension (<all_urls>, *://*/*, https://*/*, *://*.host/*,
https://host/*) que le MATCH_PATTERN restrictif rejetterait.
parseGrantedOrigin analyse ceux-ci en GrantedOriginParts, et originCovers
indique si une origine accordée couvre le match d'une règle (par exemple, un
octroi large https://*/* couvre véritablement toute règle https).
matchAllowsOriginFallback indique si le chemin d'une règle est exactement
ORIGIN_FALLBACK_PATH (/*) — l'intersection sûre des contraintes de Chromium et
de Gecko/Firefox 128 sur le repli d'origine opaque dynamique.
Enregistrement dynamique
packages/webext/src/contentRegistration.ts enregistre un script de contenu
dynamique par règle ajoutée par l'utilisateur. Les éléments intégrés sont servis
par les entrées content_scripts statiques et ne sont JAMAIS enregistrés
dynamiquement. desiredContentScripts filtre config.sites vers les règles non
intégrées, activées, dont l'origine est isOriginGranted (déléguant à
originCovers), associe chacune à un RegisteredContentScript avec un identifiant
stable (contentScriptId, un hachage FNV-1a du match afin que l'identifiant
n'utilise que le jeu de caractères sûr pour l'API), et ne définit
matchOriginAsFallback: true QUE lorsque matchAllowsOriginFallback(rule.match)
est vrai (un chemin /*). Conditionner le drapeau de cette façon empêche qu'une
règle au chemin non conforme provoque le rejet de l'enregistrement par lot entier ;
une telle règle s'enregistre sans couverture de fenêtre détachée, par conception.
reconcileContentScriptsOnce lit les origines accordées, la configuration stockée
et les enregistrements en direct, puis converge par un diff TENANT COMPTE DES
PROPRIÉTÉS : sameRegistration projette tant le descripteur désiré que la relecture
en direct sur une forme normalisée (en appliquant la vraie valeur par défaut de la
WebExtension de chaque champ — allFrames/matchOriginAsFallback par défaut faux,
persistAcrossSessions par défaut VRAI, runAt par défaut document_idle) afin
qu'un script fraîchement enregistré se compare ÉGAL au descripteur qui l'a produit
et qu'aucune boucle de réenregistrement ne puisse survenir. Comme il n'y a pas de
updateContentScripts, un script modifié est désenregistré puis réenregistré.
reconcileContentScripts enveloppe ceci avec une promesse en cours (inFlight)
afin que deux déclencheurs quasi simultanés (un changement de configuration ET
permissions.onAdded se déclenchant tous deux lors de l'ajout d'un site) soient
sérialisés plutôt qu'en course. registerContentReconciliation câble les
déclencheurs (permissions.onAdded/onRemoved, onConfigChanged) et converge une
fois au démarrage ; il n'est importé que par les points d'entrée d'arrière-plan de
Chrome et de Firefox.
Le flux de permission par adhésion (opt-in)
Les manifestes statiques déclarent optional_host_permissions (http://*/*,
https://*/*) afin qu'un utilisateur puisse accorder un hôte web supplémentaire
arbitraire à l'exécution. packages/webext/src/options/options.ts addSite valide
la règle candidate ENTIÈRE (match ET racine de contenu) via normaliseSiteRule
AVANT de demander toute permission, dérive l'origine de l'hôte avec matchOrigin,
et appelle browser.permissions.request depuis le geste de clic du bouton Ajouter ;
ce n'est que lorsque l'octroi est accordé qu'il ajoute une ligne builtin:false et
persiste via le chemin d'écriture canonique.
Configuration, stockage et le motif canonique
L'unique entonnoir de validation
normaliseConfig dans packages/core/src/config.ts est l'unique chemin de lecture
canonique : il coerce et borne chaque champ, se rabattant sur la valeur par défaut
pour tout ce qui est invalide, et ne lève jamais d'exception. Il est construit à
partir de validateurs par champ — normaliseBoolean, normaliseRenderMode,
normaliseMaxLength, normaliseIgnoreHosts (réduisant chaque entrée à un nom
d'hôte punycode nu via bareHostname),
normaliseMatchColour/normaliseMismatchColour (conditionnés par safeColour.ts
isSafeColour), normaliseCssSize, normaliseFontWeight, normaliseContentRoot
(borné par CONTENT_ROOT_PATTERN/CONTENT_ROOT_MAX_LENGTH),
normaliseMatchPattern (conditionné par MATCH_PATTERN) et normaliseSites.
normaliseSites écarte les rejets, déduplique par match, FORCE builtin: true
sur toute règle dont le match égale celui d'un élément intégré (fermant le vecteur
de masquage exact / double injection), réensemence tout élément intégré manquant
(afin qu'un magasin falsifié ne puisse pas supprimer un fournisseur central) et
trie alphanumériquement par match pour un ordre canonique. configFingerprint
hache uniquement les champs affectant le rendu (excluant enabled, ignoreHosts
et sites, qui pilotent le démarrage/l'arrêt et le saut plutôt que le réagencement)
via fnv1a.
Stockage
packages/webext/src/storage.ts enveloppe l'API de stockage de la WebExtension.
configArea utilise browser.storage.sync lorsqu'elle est disponible (afin que
les paramètres voyagent) et se rabat sur browser.storage.local (par exemple sur
Thunderbird) ; configAreaName indique laquelle est active. getConfig,
setConfig et onConfigChanged font tous transiter leur valeur brute par
normaliseConfig, de sorte qu'un magasin malformé ou falsifié ne peut jamais
remettre une Config invalide à un appelant. onConfigChanged ignore en outre les
événements de la zone de stockage INACTIVE et les changements de clés non
pertinentes.
Les deux modules complémentaires réutilisent le même entonnoir lecture ->
normaliseConfig -> retour sur leurs propres magasins hôtes. Le
extensions/outlook/src/roamingStorage.ts du volet des tâches Outlook enveloppe
Office.context.roamingSettings (get/set synchrones, validés avec saveAsync) ;
le packages/gmail/src/propertiesStorage.ts du module complémentaire Gmail
enveloppe PropertiesService.getUserProperties() (par utilisateur, itinérant sur
les appareils de cet utilisateur, encodé en JSON sous une seule clé, et ne
nécessitant AUCUN champ OAuth supplémentaire — jamais le ScriptProperties partagé).
Tous deux exposent getConfig/setConfig plus les accesseurs pertinents pour la
carte getIgnoreHosts/getHighlightMismatch et protègent une surface hôte
indisponible avec une erreur dédiée. Le déclencheur de page d'accueil de Gmail rend
une carte de paramètres CardService à partir de getConfig, et le gestionnaire de
soumission de formulaire onSaveSettings fusionne les
ignoreHosts/highlightMismatch analysés sur la configuration courante et la
persiste via setConfig ; le déclencheur contextuel achemine ignoreHosts dans
l'adaptateur et highlightMismatch dans le constructeur de carte, en lisant de
façon défensive afin qu'un échec de stockage se rabatte sur les valeurs par défaut
plutôt que de casser l'analyse des messages.
Le motif configuration-canonique + mise à jour ciblée
Trois chemins d'écriture lisent la configuration canonique, changent exactement une chose et la réécrivent — sans jamais valider les modifications de formulaire non enregistrées ni effectuer un nouveau rendu du formulaire :
removeSite(options.ts) : litgetConfig, persistesetConfigsur un tableausitesdont seule la règle supprimée est filtrée (persister D'ABORD, puisrow.remove(), puis unpermissions.removeconditionnel décidé d'après la configuration suivante). Un échec de persistance lèveSiteRemovalSaveErroret laisse le DOM intact ; une révocation échouée lèveSitePermissionRevokeErroraprès que la ligne a déjà disparu.addSite(options.ts) : décrit ci-dessus — valider, demander la permission, ajouter la ligne, puissaveOptions.toggleEnabled(options.ts) : l'interrupteur principal d'activation instantané ne bascule QUEenabledsur la configuration stockée et la réécrit, sans nouveau rendu (afin qu'une édition en cours ne soit jamais écrasée) ; sur une écriture échouée, il rétablit la case à cocher à la valeur stockée.
Le toggleEnabled propre à l'action de la barre d'outils
(packages/webext/src/toolbarAction.ts) suit le même motif depuis le côté
arrière-plan.
Construction par moteur et contrat de manifeste
tooling/build.mjs pilote une unique construction esbuild pour chaque cible de la
WebExtension. Le descripteur TARGETS nomme la CIBLE SOURCE de chaque cible :
Chrome, Firefox et Thunderbird livrent leur propre src/ ; Edge, Opera et Safari
déclarent chrome comme leur source ; le module complémentaire Outlook (Office.js)
est sa propre source. ACTIVE_TARGETS (Chrome, Edge, Firefox, Opera et Thunderbird
— Safari et Outlook sont exclus) est ce que --all/--package construisent ;
Safari et Outlook ne se construisent que lorsqu'ils sont nommés explicitement, et le
module complémentaire Gmail (Apps Script) est un bundle esbuild séparé
(make build-gmail) en dehors de la boucle --all. Ce bundle Gmail cible le
runtime V8 d'Apps Script, qui n'a ni modules ES ni URL natif : il est construit en
ESM puis voit son instruction export {…} finale retirée (afin que les fonctions de
déclenchement restent des variables globales de premier niveau qu'Apps Script peut
invoquer), et il intègre un petit polyfill URL (installé uniquement lorsque URL
est absent) afin que le new URL(...) du noyau partagé résolve les liens côté
serveur. buildTarget nettoie dist/<target>/, copie le manifest.json et les
icons/ par cible verbatim, regroupe chaque script source en IIFE (les
content.ts/messageDisplay.ts injectés via INJECTED_SCRIPTS ne peuvent pas être
des modules ES, et les arrière-plans sont aussi des workers classiques/pages
d'événements) et la page d'options partagée en ESM, et copie
options.html/options.css depuis packages/webext/src/options/.
webextension-polyfill est regroupé dans chaque script.
tooling/version.mjs estampille les versions : il prend MAJEUR.MINEUR du
package.json racine et ajoute un numéro de BUILD à incrémentation automatique (lu
à partir du troisième composant existant le plus élevé parmi les manifestes cibles)
dans chaque manifeste cible, les gardant synchronisés. La plupart des cibles portent
un seul manifest.json ; le module complémentaire Outlook porte deux manifestes
versionnés (manifest.json et manifest.xml) et tous deux sont estampillés, et
l'échafaudage _template est estampillé de façon synchronisée également. Le
package.json racine est maintenu à la même version, mais reste un semver valide
(une version à quatre parties est autorisée dans un manifeste, jamais dans
package.json). tooling/icons.mjs (exécuté via make icons) rastérise l'unique
source vectorielle assets/icon.svg en icons/icon48.png et icons/icon128.png de
chaque cible, en essayant cairosvg, puis inkscape, puis ImageMagick.
Le contrat de manifeste — les champs exacts requis par moteur — est l'unique source
de vérité dans chaque extensions/<engine>/test/manifest.smoke.test.mjs (par
exemple, l'assertManifestShape de
extensions/chrome/test/manifest.smoke.test.mjs, qui construit aussi la cible et
confirme que chaque fichier référencé se résout dans le dist). Conformément à la
convention de déduplication du projet, la forme champ par champ n'est PAS
réénumérée ici ; reportez-vous à ces tests de fumée et à la note « Contrat de
manifeste » dans le
plan de test manuel.
Internationalisation (i18n)
Reveal URLs est localisé dans de nombreuses langues en plus de l'anglais.
L'ensemble est mono-sourcé dans packages/core/src/locales.json
(SUPPORTED_LOCALES, noms natifs et la valeur par défaut), que lisent à la fois les
bundles de l'extension (via l'import JSON résolu) et la construction du site web en
Node pur. Chaque chaîne non anglaise est traduite par machine et en attente de
relecture humaine ; le marqueur de provenance est propre à chaque format (les
_locales utilisent la description de chaque message, les dictionnaires du chrome
du site web l'enregistrent dans site/i18n/README.md, et les sources de
documentation traduites portent un commentaire HTML en première ligne).
Il existe trois surfaces de localisation indépendantes, délibérément non interconnectées, et chacune est scindée selon une ligne temps de construction / exécution.
- La page des options (AD-1). Elle livre un balisage statique en anglais ;
chaque nœud traduisible porte une annotation
data-i18n="<key>"(texte) oudata-i18n-<attr>="<key>"(attribut). À l'exécution,packages/webext/src/options/locale.tsest la couche d'application : ellefetchle_locales/<code>/messages.jsonempaqueté de la page elle-même (viaruntime.getURL, sansweb_accessible_resourcesnécessaire), réécrit chaque nœud annoté partextContentUNIQUEMENT (afin que les indices d'exemple<code>du formulaire restent intacts), et définit<html lang>. La page est ENTIÈREMENT localisée : aux côtés de la prose statique, les libellés des lignes de site et le bouton Supprimer construits en JS portent une clédata-i18n(avec un textetranslated initial) afin que la même couche d'application relocalise les lignes rendues sans nouveau rendu, et les chaînes VRAIMENT dynamiques — la ligne d'état, le retour d'ajout de site et la ligne de version — sont rendues via letranslate(<key>)delocale.ts(currentMessages → repli anglais → clé) au moment de l'appel. Le repli anglais est le catalogue_locales/en/messages.jsonIMPORTÉ (regroupé) dansoptions.jsau moment de la construction, de sorte quetranslatese résout en anglais lisible de façon SYNCHRONE dès le chargement du module — une chaîne dynamique rendue avant que le sélecteur ne se stabilise, ou après l'échec d'une récupération de catalogue à l'exécution, ne dégénère jamais en clé de message brute.locale.tspublie la carte des messages appliqués et déclenche ses écouteursonLocaleChangeaprès chaque application, de sorte qu'options.tseffectue un nouveau rendu de l'état/du retour/de la version actuellement affichés et repointe le lien Manuel en ligne à chaque changement. La ligne de version utilise un messageoptionsVersiondont le marqueur{version}est substitué par le numéro de build (jeton traduit, numéro verbatim). Le lien du manuel suit la locale active vers sa variante de documentation (AD-9) : l'anglais conserve lemanual.htmlde premier niveau, toute autre locale ouvrenl/handleiding.html, préservant le timbre de version?v=. La locale active est le choix sauvegardé de l'utilisateur ou, à défaut, la langue du navigateur résolue vers une langue de base prise en charge par leresolveLocaledu noyau (AD-5) ; une locale non prise en charge ou en échec laisse l'anglais pré-rendu (ettranslatedégénère alors vers le repli anglais). Le sélecteur Langue d'affichage du manuel persiste son choix sous une clé dédiéeuiLocaledansstorage.local— jamais dans laConfigsynchronisée, afin qu'il soit propre à l'appareil et ne voyage jamais. - Le chrome du site web (AD-2). Le générateur de site statique
tooling/site.mjsrend côté serveur chaque nœud du chrome en anglais tout en l'annotantdata-i18n, et conserve<html lang="en-GB">(AD-4). Le chargeur côté navigateursite/scripts/i18n.mjs(un module ES sans dépendance) échange ensuite le chrome vers la langue du visiteur par priorité — un choixlocalStoragepersisté (revealUrlsSiteLocale) → la première correspondance de langue de base prise en charge dansnavigator.languages(AD-5) → l'anglais — en récupérantsite/i18n/<code>.json. Contrairement à la couche d'application des options, il échange viainnerHTML, car les valeurs du chrome du site portent un balisage inline de confiance (<code>,<a>) ; un test de parité épingle la séquence de balises/attributs/URL de chaque valeur non anglaise comme identique octet pour octet à l'anglais afin qu'une traduction ne puisse jamais supprimer un lien ni casser une balise. Une clé manquante conserve l'instantané anglais pré-rendu ; une récupération échouée laisse la page anglaise intacte (dégradation gracieuse, jamais blanche). - Les pages de documentation (AD-8/AD-9). Les pages de documentation sont rendues
PAR LOCALE au moment de la construction, et non échangées à l'exécution. Les docs
anglaises restent aux chemins de premier niveau ; toute autre locale obtient une
copie sous un dossier
<code>/, à partir d'undocs/<code>/<DOC>.mdtraduit lorsqu'il est présent, sinon la source anglaise — un repli par page quebuildSiteCONSIGNE plutôt que d'omettre silencieusement. Le CORPS de la documentation ne porte aucundata-i18n(c'est du markdown rendu brut), de sorte que le chargeur de chrome à l'exécution n'y touche jamais ; seuls les nœuds de chrome partagés sont échangés. Le sélecteur de langue de la page de documentation NAVIGUE vers la page de la locale sœur plutôt que d'échanger sur place. - Localisation des liens internes (Finding 2). La langue sélectionnée se transmet
à travers la navigation de la documentation. Chaque ancre de documentation INTERNE
(les cinq pages
architecture.html,index.html,licence.html,manual.html,privacy.html) est étiquetéedata-doc-link="<page>"par le générateur, et pour le cas sans JavaScript, son href se résout déjà vers la variante de locale propre à la page COURANTE (navDocHref— les trois pages de documentation par locale se localisent ; lesindex.html/licence.htmlréservés à l'anglais restent de premier niveau). LerewriteDocLinksdu chargeur à l'exécution recible alors chaque ancre de documentation interne — celles étiquetées ET toute ancre simple injectée par catalogue telle quemanual.html#installing— vers la variante de la locale ACTIVE, préservant tout#hash/?queryet ne touchant jamais aux liens externes. Sur une page de documentation, un choix STOCKÉ qui diffère de la locale propre à la page NAVIGUE vers la variante correspondante ; la garde de boucle ne se déclenche que sur un choix stocké explicite (jamais une préférence de langue du navigateur seule) et uniquement lorsque la cible diffère de la page déjà affichée. Les catalogues eux-mêmes conservent leurs URL internes en anglais, de sorte que le test de parité reste vert — la localisation est une pure passe d'exécution sur le DOM.
Suppression des « Sites actifs » pour les clients de messagerie (AD-3)
Une cible de client de messagerie (Thunderbird aujourd'hui) voit déjà chaque message
rendu, de sorte que l'éditeur « Sites actifs » par hôte y est redondant et est
supprimé. La décision est portée par un drapeau de descripteur par cible documenté,
mailClient: true, sur le TARGETS.thunderbird de tooling/build.mjs — jamais un
nom de cible codé en dur dans la logique de construction ou d'interface — et est mise
en œuvre en deux moitiés :
- Temps de construction.
copyAssetss'appuie surdescriptorFor(target).mailClient: pour une cible de client de messagerie, il exécute la transformation pureremoveSitesSection(html)(qui retire l'ensemble de l'éditeur d'hôtes<section class="sites">, y compris chaque commande d'ajout de site, et lèveMissingSourceErrorsi la section est absente afin qu'un changement de balisage ne puisse jamais livrer silencieusement l'éditeur) et écrit le résultat ; les cibles de navigateur reçoiventoptions.htmloctet pour octet viacpSync. - Exécution. Le contrôleur regroupé
packages/webext/src/options/options.tstolère la section absente :renderSites/readSites/applyConfig/readFormsondent#sites-listet font un no-op plutôt que de leverOptionsFieldMissingError, etreadFormOMET la clésiteslorsque la section a disparu. Comme omettresiteslaisseraitnormaliseConfigréensemencer les valeurs par défaut intégrées et supprimer les sites de l'utilisateur,saveOptionsrétablit lessitesstockés avant de normaliser chaque fois que la section est absente — c'est ce qui fait qu'un enregistrement de client de messagerie préserve les hôtes configurés. Une garde d'exécution,guardMailClientSites, supprime la section si elle est toujours présente, détectant un client de messagerie par la présence d'API/de fonctionnalité (messages/messageDisplay/scripting.messageDisplaysur lebrowserinjecté), jamais par nom de cible ; elle s'exécute en premier dansinitOptionsafin que le câblage d'ajout/de liste ultérieur saute naturellement les éléments absents. Dans un runtime de navigateur, la garde est un no-op.
Posture de sécurité et de confidentialité
- Aucun réseau ni exfiltration. Aucun module n'effectue de
fetch/XHRni aucun autre appel réseau ; l'extension ne lit que la configuration stockée et le DOM de la page. - DOM sûr uniquement. L'annotation et la feuille de style injectée utilisent
exclusivement
createElement/textContent/des propriétés typéesstyle.*, jamaisinnerHTML/insertAdjacentHTML/eval. Pour le noyau, ceci est appliqué parpackages/core/test/purity.test.ts. - Toute entrée externe/de configuration normalisée. Chaque lecture et écriture
transite par
normaliseConfig; les couleurs passentisSafeColour; les motifs de correspondance passentMATCH_PATTERN; les sélecteurs de racine de contenu sont bornés parCONTENT_ROOT_PATTERNet ne sont jamais remis qu'àquerySelectorAll/closest, jamais interpolés dans du balisage ou du CSS. - Moindre privilège. Les origines de webmail intégrées sont des
host_permissionsfixes ; les hôtes supplémentaires se font par adhésion viaoptional_host_permissionset unpermissions.requestlié à un geste. L'action de la barre d'outils ne déclare aucun popup. - Collecte de données Gecko. Les manifestes Firefox et Thunderbird déclarent
browser_specific_settings.gecko.data_collection_permissions.required: ["none"]. - Licence. Le projet est AGPL-3.0-only (
LICENSE,package.json).
Tests
- Tests unitaires Vitest couvrent les deux paquets :
packages/core/test/*(config, contraste, non-concordance d'hôte, processeur de liens, styles, et la garde de pureté) etpackages/webext/test/*(contenu, stockage, options, enregistrement, barre d'outils, affichage des messages et un test de bout en bout de site personnalisé). Chaque paquet exécutevitest run. - BDD Cucumber les scénarios résident dans
features/(câblés parcucumber.json) et pilotent les vrais modules@reveal-urls/webextà travers des doublures de test fidèles dansfeatures/support/webext.ts— notammentnormaliseReadBack, qui reflète le moteur OMETTANT les drapeaux à valeur fausse à la relecture afin qu'un harnais indulgent ne puisse pas cacher une boucle de réenregistrement — sur un document jsdom configuré dansfeatures/support/world.ts. - Tests de fumée de manifeste par cible
(
extensions/<engine>/test/manifest.smoke.test.mjs, lenode:testintégré de Node) vérifient la forme exacte de chaque manifeste et que le dist construit résout chaque fichier référencé.tooling/test/ajoute des tests de construction, de version et de définition de terminé (definition-of-done). - L'intégration continue (
.github/workflows/ci.yml) conditionne chaque push et chaque pull request àmake test,make bddetmake lint(exécutés directement sur le runner, surchargeant lesRUN/IMAGE_DEPDocker du Makefile). L'estampillage de version et l'empaquetage sont délibérément exclus ; ils résident dans le.github/workflows/release.ymlséparé, déclenché par tag.