Reveal URLs — Architektur

Dieses Dokument beschreibt, wie die Reveal-URLs-Erweiterung aufgebaut ist und wie ihre Kernlogik funktioniert, für Mitwirkende und technische Leser. Es zitiert durchgehend echte Dateien und Symbole (in der Form path symbol), sodass jede Aussage gegen den Quellcode geprüft werden kann. Für die nutzerseitige Beschreibung siehe das Handbuch; für die manuellen Verifizierungsschritte siehe den manuellen Testplan.

Überblick

Reveal URLs ist eine einzelne MV3-WebExtension, die für mehrere Engines — Chrome, Edge, Opera, Firefox und Thunderbird — aus einem gemeinsamen, reinen Code-Kern gebaut wird. (Safari ist zurückgestellt: Es hat ein Manifest und ist baubar, wenn es explizit benannt wird, ist aber vom Standard-Build und von CI ausgeschlossen.) Die Erweiterung deckt die Ziel-URL jedes Links neben dem Link in einer dargestellten E-Mail auf und markiert einen Link, dessen sichtbarer Text eine andere registrierbare Domain nennt als sein href — das klassische Phishing-Anzeichen.

Das Repository ist ein pnpm/TypeScript-Monorepo (pnpm-workspace.yaml, package.json mit "private": true). Das Bündeln erfolgt durch ein einziges, esbuild-gesteuertes Skript, tooling/build.mjs, das die gemeinsamen Pakete und die schlanken Einstiegspunkte jeder Engine in ein ladbares Verzeichnis dist/<target>/ kompiliert. webextension-polyfill wird in jedes Skript gebündelt, statt sich darauf als Laufzeit-Global zu verlassen, sodass derselbe Quellcode unverändert über Chromium und Gecko läuft.

Zwei native E-Mail-Add-ons erweitern denselben Erkennungskern auf Oberflächen, die die WebExtension nicht erreichen kann: ein Outlook-Add-in (Office.js-Aufgabenbereich, das Outlook im Web/Windows/Mac/iOS/Android und Outlook.com erreicht) und ein Gmail-Add-on (Apps Script CardService, das die Gmail-Apps für Web/Android/iOS erreicht). Beide nutzen nur die REINE Analyse erneut — ein neues Modul packages/core/src/findings.ts (analyseAnchors/analyseHtml, ein plattformneutrales Finding[]-Modell), das die bestehende hostMismatch-Logik und die tldts-Ableitung der registrierbaren Domain wiederverwendet. Nur der Host-Adapter (wie der Nachrichtentext gelesen und geparst wird) und die Darstellung unterscheiden sich je Oberfläche: Der Outlook-Aufgabenbereich parst den Nachrichtentext mit seinem eigenen DOMParser und läuft clientseitig; das Gmail-Add-on parst mit node-html-parser und läuft serverseitig auf Googles Apps-Script-V8-Infrastruktur (kostenloses Hosting, da Google die Mail ohnehin vorhält; lokal-interaktiv über clasp bereitgestellt). Da kein Framework das dargestellte Nachrichten-DOM im Lesemodus verändern kann (Offices setAsync/prependAsync sind nur zum Verfassen; CardService stellt Karten dar, kein Nachrichten-HTML), präsentieren beide Add-ons ein Panel/eine Karte mit Befunden, keine Inline-Annotation — der bestehende DOM-Mutationspfad linkProcessor.ts und REVEAL_URLS_CSS werden von keinem der beiden Add-ons wiederverwendet. Ein relativer href wird nur gegen einen vertrauenswürdigen <base href> innerhalb der E-Mail aufgelöst und andernfalls übersprungen, nie gegen den Ursprung des Postfachs/Anbieters (was ein Ziel erfinden würde).

Die Codebasis trennt die Belange scharf:

Repository-Aufbau

packages/core — der reine Kern

Jedes Modul hier importiert keine Erweiterungs-API (browser.*/chrome.*/messenger.*) und nutzt keine String-zu-Markup-DOM-Senke (innerHTML/insertAdjacentHTML). Dieser Vertrag wird mechanisch durch packages/core/test/purity.test.ts durchgesetzt, das jedes src/**/*.ts per Glob erfasst, die Kommentarinhalte über sein eigenes stripComments entfernt (sodass die Docblocks, die die verbotenen Token legitim NENNEN, den Schutz nicht auslösen) und dann sicherstellt, dass weder EXTENSION_API_PATTERN noch UNSAFE_DOM_PATTERN auf irgendein Modul passt. Die öffentliche Oberfläche wird aus packages/core/src/index.ts re-exportiert. Die einzige Laufzeitabhängigkeit ist tldts (für den public-suffix-bewussten Domain-Vergleich).

Die Module sind:

packages/webext — die Browser-API-Wrapper

Module hier DÜRFEN browser.* verwenden und sind der einzige Ort, an dem die WebExtension-APIs berührt werden. Die öffentliche Oberfläche wird aus packages/webext/src/index.ts re-exportiert. Die Module sind: content.ts (Content-Lebenszyklus), contentRegistration.ts (dynamische Registrierung), storage.ts (Konfigurations-Persistenz), background.ts (Verhalten bei Installation), toolbarAction.ts (Umschalten + Badge), messageDisplay.ts und messageDisplayBackground.ts (Thunderbird) sowie options/options.ts (der Controller der Einstellungsseite).

extensions/<engine> — schlanke Einstiegspunkte und Manifeste

Jede Engine trägt ihr eigenes manifest.json und icons/. Chrome, Firefox und Thunderbird liefern zusätzlich ein src/ mit Einstiegspunkten; Edge, Opera und Safari tragen nur ein Manifest und Icons und VERWENDEN Chromes src/ erneut (deklariert durch den Build-Deskriptor — siehe unten). Die Einstiegspunkte sind bewusst winzig: zum Beispiel ist extensions/chrome/src/content.ts nur void createContentController().bootstrap();, und extensions/chrome/src/background.ts ruft registerBackground, registerContentReconciliation und registerToolbarToggle auf. Firefox' Hintergrund-Einstiegspunkt ist mit dem von Chrome identisch; der von Thunderbird (extensions/thunderbird/src/background.ts) ruft stattdessen registerBackground und registerMessageDisplay auf (es hat keine Content-Scripts). extensions/_template ist ein kopierfertiges Chromium-Gerüst, kein Build-Ziel.

tooling, features und die Smoke-Tests

tooling enthält build.mjs, version.mjs und icons.mjs. features enthält die Gherkin-Szenarien, ihre Schrittdefinitionen und die getreuen Test-Doubles in features/support/webext.ts und features/support/world.ts. Jede Engine trägt außerdem ein extensions/<engine>/test/manifest.smoke.test.mjs (das von _template ist template.smoke.test.mjs) — diese sind der maßgebliche Manifest-Vertrag (siehe „Build je Engine und der Manifest-Vertrag" unten).

Die Aufdeckungs-Pipeline

Der Content-Pfad beginnt an einem Engine-Einstiegspunkt wie extensions/chrome/src/content.ts, der createContentController().bootstrap() aus packages/webext/src/content.ts aufruft.

Bootstrap

createContentController bootstrap läuft einmal pro Frame:

  1. Es beansprucht den Frame synchron, vor jedem await, über claimBootstrap, das das BOOTSTRAP_MARKER-Flag am window des Frames liest/setzt. Ein statischer, eingebauter content_scripts-Eintrag und ein überlappendes dynamisches Benutzer-Script können beide content.js in denselben Frame injizieren; das gemeinsame window der isolierten Welt macht dies zum korrekten Einmal-Schutz, und das Beanspruchen vor jedem await bedeutet, dass zwei nahezu gleichzeitige Injektionen ihn nicht beide passieren können.
  2. Es lädt ZUERST die persistierte Konfiguration über loadConfigWithRetry (BOOTSTRAP_CONFIG_RETRIES zusätzliche Versuche bei einer vorübergehenden Ablehnung) und hält den Marker über die Wiederholungen hinweg, sodass eine überlappende Injektion, die bereits zum No-op wurde, nicht hängen bleibt. Erst wenn das Lesen erfolgreich ist, injiziert es das Stylesheet (injectStyles, das REVEAL_URLS_CSS über textContent zuweist), sodass ein fehlgeschlagenes Lesen kein <style> anhängt und eine Wiederholung es nicht duplizieren kann.
  3. Wenn das Laden der Konfiguration bei jedem Versuch fehlschlägt, setzt releaseBootstrap den Marker zurück (nur hier, bevor die Annotation beginnt), sodass eine spätere erneute Injektion es noch einmal versuchen kann; sobald start gelaufen ist, muss der Marker bestehen bleiben, da ein frischer Annotierer die Knoten des vorherigen Durchlaufs nicht besäße.
  4. Wenn config.enabled gilt, ruft es start auf, und es registriert stets einen onConfigChanged-Listener, der die Konfiguration erneut anwendet (was den Übergang deaktiviert→aktiviert abdeckt).

Auflösen der aktiven Site-Regel

start (und applyConfig) ruft resolveSiteRule auf, um zu entscheiden, ob — und wo — annotiert wird. resolveSiteRule versucht zuerst die EIGENE Location des laufenden Dokuments über resolveForHref, das config.sites auf die aktivierten Regeln filtert und sich an selectMostSpecific des Kerns wendet, sodass die spezifischste Übereinstimmung gewinnt (ein spezifischer eingebauter Eintrag schlägt einen breiten Benutzer-Wildcard). Wenn die eigene Location nichts trifft UND der eigene Ursprung des Dokuments opak oder geerbt ist — gesteuert durch hasOpaqueOrigin, wahr nur für about:blank/about:srcdoc (geerbt über INHERITED_ORIGIN_URLS) oder blob:/data: (OPAQUE_ORIGIN_SCHEMES) — greift es der Reihe nach auf den OBERSTEN Frame (readTopHref, der eingebaute Fall des Proton-Nachrichten-iframes), das ÖFFNENDE Fenster (readOpenerHref, das über window.open("about:blank") geöffnete Outlook-Pop-out-Lesefenster) und schließlich auf document.referrer (readReferrer) zurück. Jedes externe Lesen ist gegen Cross-Origin-Zugriff (der eine Ausnahme wirft) abgesichert und steuert nur dann einen Kandidaten bei, wenn es lesbar ist. Ein GEWÖHNLICHES, nicht übereinstimmendes Dokument ist maßgeblich und erbt nie eine Regel aus dem Umgebungskontext, sodass der Controller inaktiv bleibt.

Eingrenzung auf den Content-Root und Beobachtung von Mutationen

start erfasst den contentRoot-Selektor der übereinstimmenden Regel, erstellt den Annotierer über createAnnotator(config) und verarbeitet jeden übereinstimmenden Root aus selectRoots(doc, contentRoot) (ein in try/catch gehülltes querySelectorAll, sodass ein ungültiger Selektor sicher zu [] fehlschlägt). Anschließend beobachtet es doc.body (einen stabilen Container) mit OBSERVER_OPTIONS ({ childList: true, subtree: true }).

Der Observer speist processMutations, das jede Mutation am Content-Root abprüft: Es verarbeitet ein Mutations-target nur dann erneut, wenn es ein Element innerhalb eines Roots ist (isWithinRoot, über closest), und leitet jedes hinzugefügte Element durch processAddedNode, das einen Knoten annotiert, der innerhalb eines Roots liegt, ein Root IST oder einen ENTHÄLT (ein verzögerter SPA-Mount). So werden sowohl Veränderungen innerhalb des Roots als auch ein später einhängender Geschwister-Nachrichtentext erfasst, während die App-Chrome außerhalb der Roots unangetastet bleibt. Ein Null-Annotierer (die Erweiterung ist gestoppt) ist ein No-op.

applyConfig gleicht eine Live-Konfigurationsänderung ab: Es baut ab, wenn keine Regel passt oder das Aktiviert-Flag aus ist; startet neu (Zurücksetzen + erneut beobachten), wenn sich der aufgelöste contentRoot geändert hat; und führt andernfalls einen Reflow an Ort und Stelle durch — annotator.setConfig, dann annotator.process über jeden übereinstimmenden Root — ohne den Observer neu zu starten.

Annotation eines einzelnen Ankers

Der Annotierer befindet sich in packages/core/src/linkProcessor.ts. createAnnotator gibt einen Annotator über Closure-Zustand zurück, den die Seite nicht lesen kann: eine starke Map mit der Anker-Identität als Schlüssel, ein data-ru-Token je Annotierer aus generateToken (crypto.getRandomValues) und die aktuelle Konfiguration. Seine Methode process(root):

  1. Beschneidet Registrierungseinträge, deren Anker nicht mehr verbunden ist.
  2. Sammelt Kandidaten-Anker mit collectAnchors (nachkommende a[href] plus den Root selbst, wenn er ein a[href] ist, dedupliziert).
  3. Löst das Ziel jedes Ankers mit resolveAnnotatableUrl auf, das null (überspringen) zurückgibt für einen leeren/seiteninternen href, einen nicht parsbaren href, jedes Schema außer http:/https: oder einen Host, der einem ignoreHosts-Eintrag entspricht oder darunter liegt; es löst relative hrefs gegen baseURI auf, sodass das Ergebnis absolut ist.
  4. Nimmt den idempotenten schnellen Pfad, wenn isAnnotationIntact bestätigt, dass der Render-Fingerabdruck unverändert ist und jeder erzeugte Knoten noch verbunden und an seiner Position ist; andernfalls setzt es die veraltete Annotation zurück und rendert neu.
  5. Berechnet die Abweichung mit hostMismatch am bereinigten Anker-Text, berücksichtigt showOnlyOnMismatch (lässt ehrliche Links unangetastet) und ruft annotateAnchor auf.

annotateAnchor rendert ausschließlich SAFE-DOM. Im Modus "inline" stellt es ein <span class="reveal-urls-url"> voran, dessen URL über textContent gesetzt wird (mit einem Pfeil und einem geschützten Leerzeichen U+00A0 als Präfix, durch truncateUrl gekürzt), gefolgt von einem <br>, wobei die eigenen Kinder des Links unangetastet bleiben; das verbundene span erhält dann über applyContrastBackdrop einen Kontrasthintergrund. Im Modus "title" sichert es den ursprünglichen title (TitleSave), überschreibt ihn mit der URL — der einzige Schreibvorgang am Anker — und hängt bei einer hervorgehobenen Abweichung ein benachbartes Badge <span class="reveal-urls-warn"> an, das REVEAL_URLS_WARN_LABEL trägt. Jeder erzeugte Knoten wird mit data-ru="<token>" markiert (nie der Anker), und jeder Eintrag erfasst seinen configFingerprint, sodass eine Änderung der Render-Konfiguration einen Reflow erzwingt. Farben und Schriftart-Überschreibungen werden über typisierte element.style.*-Eigenschaften (applyColour/applyFontOverrides) angewendet, nie in einen CSS-String interpoliert. revertOwned entfernt erzeugte Knoten per Referenz und stellt den gesicherten title wieder her; revertAll tut dies für jeden besessenen Anker und leert die Registry.

truncateUrl operiert über Unicode-CODE-POINTS ([...displayHref]), sodass ein Schnitt nie ein Surrogatpaar teilt; es bewahrt den Schema-und-Host-Ursprung und kürzt den Rest mit einem nachgestellten URL_ELLIPSIS.

Host-Abweichung

packages/core/src/hostMismatch.ts hostMismatch vergleicht den sichtbaren Text des Links mit seinem href anhand der registrierbaren Domain, nicht des rohen Hostnamens, sodass eine ehrliche Subdomain nicht markiert wird, ein Look-alike hingegen schon. extractHostCandidates zerlegt den Text und reduziert jeden Token über hostCandidate (das einen Punkt und ein von tldts erkanntes ICANN-Public-Suffix verlangt und gewöhnlichen punktierten Text wie e.g ablehnt). Sowohl der href-Host als auch jeder Kandidat werden mit tldts.getDomain auf ihre registrierbare Domain reduziert, und JEDER Kandidat, dessen Domain sich von der des href unterscheidet, ergibt eine Abweichung — sodass das Nennen des echten bösartigen Hosts neben einem Köder-Host die Warnung nicht unterdrücken kann. Es wirft nie eine Ausnahme.

Stile und Kontrast

packages/core/src/styles.ts enthält das injizierbare REVEAL_URLS_CSS (festes Layout, feste Stärke, feste Form und ein fester weißer, vollständig undurchsichtiger Chip-Hintergrund — background: #ffffff, opacity: 1; Farben werden NIE darin interpoliert) und die Laufzeit-Helfer applyColour/applyFontOverrides/applyContrastBackdrop. applyContrastBackdrop liest die aufgelöste Farbe des Elements und den ersten undurchsichtigen Hintergrund, der beim Hochlaufen vom Element SELBST aufwärts gefunden wird (sein eigener Hintergrund — der feste weiße Standard des Chips oder eine spezifischere Überschreibung der Host-Seite — vor jedem Vorfahren) über das standardmäßige getComputedStyle (bezogen aus element.ownerDocument?.defaultView, sodass es frame-sicher und stubbar ist), und setzt, wenn packages/core/src/contrast.ts needsWhiteBackdrop meldet, dass der Text gegen diesen Hintergrund WCAG AA (CONTRAST_THRESHOLD) nicht besteht UND Weiß wirklich hilft, style.backgroundColor = "white". contrast.ts stellt parseColour, relativeLuminance und contrastRatio bereit; es parst die rgb()/rgba()/Hex-Formen, die getComputedStyle zurückgibt, und behandelt eine vollständig transparente Farbe als „nicht gefunden".

Konfigurierbare Hosts und Content-Eingrenzung

Eine SiteRule (packages/core/src/config.ts) gibt an, WO die Annotation läuft (match, allFrames) und WELCHER Container sie eingrenzt (contentRoot), dazu enabled und ein builtin-Flag. Die ausgelieferten DEFAULT_SITES decken Gmail, Proton (mit allFrames) und beide Outlook-Hosts ab. Die Config aggregiert die globalen Schalter, Farben, Schriftart-Überschreibungen und das sites-Array.

Die Match-Pattern-Engine

packages/core/src/matchPattern.ts enthält eine bewusst RESTRIKTIVE Grammatik MATCH_PATTERN zur Hinzufügezeit (nur http/https, optionaler führender *.-Host-Wildcard, Glob-Pfad; kein Port, kein *-Schema, kein <all_urls>). matchesPattern gleicht eine URL gegen ein validiertes Muster ab und delegiert den Pfad an pathGlobMatches (jedes * passt auf eine beliebige Zeichenfolge); es schlägt sicher zu false fehl. parsePatternParts zerlegt ein validiertes Muster in host/path/scheme/ wildcardHost.

Überlappende Regeln werden nach „die spezifischste gewinnt" aufgelöst: compareSiteSpecificity ordnet nach (1) exaktem Host vor Wildcard-Host, (2) längerem literalem Host, (3) literalerem Pfad (literalPathLength), dann (4) einem deterministischen ASCII-Stichentscheid; selectMostSpecific gibt die spezifischste aktivierte Regel zurück, die auf eine URL passt.

Die Abdeckung erteilter Ursprünge ist ein SEPARATER, bewusst BREITERER Matcher. permissions.getAll() kann Erteilungen in der vollen WebExtension-Grammatik melden (<all_urls>, *://*/*, https://*/*, *://*.host/*, https://host/*), die das restriktive MATCH_PATTERN ablehnen würde. parseGrantedOrigin parst diese in GrantedOriginParts, und originCovers meldet, ob ein erteilter Ursprung den match einer Regel abdeckt (z. B. deckt eine breite https://*/* Erteilung wirklich jede https-Regel ab). matchAllowsOriginFallback meldet, ob der Pfad einer Regel genau ORIGIN_FALLBACK_PATH (/*) ist — die sichere Schnittmenge der Einschränkungen von Chromium und Gecko/Firefox 128 für den dynamischen Opaque-Origin-Fallback.

Dynamische Registrierung

packages/webext/src/contentRegistration.ts registriert ein dynamisches Content-Script je vom Benutzer hinzugefügter Regel. Eingebaute werden von den statischen content_scripts-Einträgen bedient und werden NIE dynamisch registriert. desiredContentScripts filtert config.sites auf nicht eingebaute, aktivierte Regeln, deren Ursprung isOriginGranted ist (delegiert an originCovers), bildet jede auf ein RegisteredContentScript mit einer stabilen ID ab (contentScriptId, ein FNV-1a-Hash des match, sodass die ID nur den API-sicheren Zeichensatz verwendet) und setzt matchOriginAsFallback: true NUR, wenn matchAllowsOriginFallback(rule.match) gilt (ein /*-Pfad). Das so abgesicherte Flag verhindert, dass eine Regel mit nicht konformem Pfad die gesamte Stapelregistrierung scheitern lässt; eine solche Regel registriert sich konstruktionsbedingt ohne Pop-out-Abdeckung.

reconcileContentScriptsOnce liest die erteilten Ursprünge, die gespeicherte Konfiguration und die Live-Registrierungen und konvergiert dann über einen EIGENSCHAFTSBEWUSSTEN Diff: sameRegistration projiziert sowohl den gewünschten Deskriptor als auch den Live-Rücklesevorgang auf eine normalisierte Form (wobei der echte WebExtension-Standard jedes Feldes angewendet wird — allFrames/matchOriginAsFallback standardmäßig false, persistAcrossSessions standardmäßig TRUE, runAt standardmäßig document_idle), sodass ein frisch registriertes Script GLEICH dem Deskriptor ist, der es erzeugt hat, und keine Schleife zur erneuten Registrierung entstehen kann. Da es kein updateContentScripts gibt, wird ein geändertes Script abgemeldet und dann erneut registriert. reconcileContentScripts umhüllt dies mit einem laufenden Promise (inFlight), sodass zwei nahezu gleichzeitige Auslöser (eine Konfigurationsänderung UND permissions.onAdded, die beide bei einem Site-Hinzufügen feuern) serialisiert werden, statt um die Wette zu laufen. registerContentReconciliation verdrahtet die Auslöser (permissions.onAdded/onRemoved, onConfigChanged) und konvergiert einmal beim Start; es wird nur von den Hintergrund-Einstiegspunkten von Chrome und Firefox importiert.

Der Opt-in-Berechtigungsfluss

Die statischen Manifeste deklarieren optional_host_permissions (http://*/*, https://*/*), sodass ein Benutzer zur Laufzeit einen beliebigen zusätzlichen Web-Host erteilen kann. packages/webext/src/options/options.ts addSite validiert die GESAMTE Kandidatenregel (match UND content-root) über normaliseSiteRule, BEVOR es irgendeine Berechtigung anfordert, leitet den Host-Ursprung mit matchOrigin ab und ruft browser.permissions.request innerhalb der Klickgeste der Hinzufügen-Schaltfläche auf; nur bei Erteilung hängt es eine builtin:false-Zeile an und persistiert über den kanonischen Schreibpfad.

Konfiguration, Speicher und das kanonische Muster

Der einzige Validierungstrichter

normaliseConfig in packages/core/src/config.ts ist der eine kanonische Lesepfad: Es erzwingt und begrenzt jedes Feld, fällt bei allem Ungültigen auf den Standard zurück und wirft nie eine Ausnahme. Es ist aus Validatoren je Feld aufgebaut — normaliseBoolean, normaliseRenderMode, normaliseMaxLength, normaliseIgnoreHosts (das jeden Eintrag über bareHostname auf einen bloßen Punycode-Hostnamen reduziert), normaliseMatchColour/normaliseMismatchColour (gesteuert durch safeColour.ts isSafeColour), normaliseCssSize, normaliseFontWeight, normaliseContentRoot (begrenzt durch CONTENT_ROOT_PATTERN/CONTENT_ROOT_MAX_LENGTH), normaliseMatchPattern (gesteuert durch MATCH_PATTERN) und normaliseSites. normaliseSites verwirft Ablehnungen, dedupliziert nach match, ERZWINGT builtin: true bei jeder Regel, deren match dem eines eingebauten entspricht (was den Vektor exakte-Übereinstimmung-überschattet-eingebauten / Doppelinjektion schließt), sät jeden fehlenden eingebauten erneut ein (sodass ein manipulierter Speicher keinen Kern-Anbieter fallen lassen kann) und sortiert alphanumerisch nach match für eine kanonische Reihenfolge. configFingerprint hasht nur die rendering-relevanten Felder (unter Ausschluss von enabled, ignoreHosts und sites, die Start/Stopp und Überspringen statt Reflow steuern) über fnv1a.

Speicher

packages/webext/src/storage.ts umhüllt die WebExtension-Storage-API. configArea verwendet browser.storage.sync, wenn verfügbar (sodass Einstellungen roamen), und fällt auf browser.storage.local zurück (z. B. unter Thunderbird); configAreaName meldet, welcher aktiv ist. getConfig, setConfig und onConfigChanged leiten alle ihren Rohwert durch normaliseConfig, sodass ein fehlerhafter oder manipulierter Speicher einem Aufrufer nie eine ungültige Config übergeben kann. onConfigChanged ignoriert zusätzlich Ereignisse aus dem INAKTIVEN Speicherbereich und Änderungen an nicht verwandten Schlüsseln.

Die zwei Add-ons verwenden denselben Trichter Lesen -> normaliseConfig -> Rückgabe über ihre eigenen Host-Speicher erneut. Das extensions/outlook/src/roamingStorage.ts des Outlook-Aufgabenbereichs umhüllt Office.context.roamingSettings (synchrones get/set, mit saveAsync festgeschrieben); das packages/gmail/src/propertiesStorage.ts des Gmail-Add-ons umhüllt PropertiesService.getUserProperties() (pro Benutzer, roamend über die Geräte dieses Benutzers, JSON-kodiert unter einem Schlüssel und OHNE zusätzlichen OAuth-Scope — nie die geteilten ScriptProperties). Beide stellen getConfig/setConfig sowie die kartenrelevanten Accessoren getIgnoreHosts/getHighlightMismatch bereit und sichern eine nicht verfügbare Host-Oberfläche mit einem dedizierten Fehler ab. Der Gmail-Homepage-Auslöser rendert eine CardService-Einstellungskarte aus getConfig, und der Formular-Absende-Handler onSaveSettings führt die geparsten ignoreHosts/highlightMismatch mit der aktuellen Konfiguration zusammen und persistiert sie über setConfig; der kontextuelle Auslöser fädelt ignoreHosts in den Adapter und highlightMismatch in den Kartenbauer ein und liest defensiv, sodass ein Speicherfehler auf die Standardwerte zurückfällt, statt die Nachrichtenanalyse zu zerstören.

Das Muster kanonische-Konfiguration + gezielte-Aktualisierung

Drei Schreibpfade lesen die kanonische Konfiguration, ändern genau eine Sache und schreiben sie zurück — ohne je ungespeicherte Formularänderungen festzuschreiben oder das Formular neu zu rendern:

Das eigene toggleEnabled der Symbolleisten-Aktion (packages/webext/src/toolbarAction.ts) folgt demselben Muster von der Hintergrundseite aus.

Build je Engine und der Manifest-Vertrag

tooling/build.mjs treibt einen einzigen esbuild-Build für jedes WebExtension-Ziel an. Der TARGETS-Deskriptor benennt das QUELL-ZIEL jedes Ziels: Chrome, Firefox und Thunderbird liefern ihr eigenes src/; Edge, Opera und Safari deklarieren chrome als ihre Quelle; das Outlook-(Office.js-)Add-in ist seine eigene Quelle. ACTIVE_TARGETS (Chrome, Edge, Firefox, Opera und Thunderbird — Safari und Outlook sind ausgeschlossen) ist das, was --all/--package baut; Safari und Outlook bauen nur, wenn sie explizit benannt werden, und das Gmail-(Apps-Script-)Add-on ist ein separates esbuild-Bundle (make build-gmail) außerhalb der --all-Schleife. Dieses Gmail-Bundle zielt auf die V8-Laufzeit von Apps Script ab, die weder ES-Module noch ein natives URL hat: Es wird als ESM gebaut und dann wird seine abschließende export {…}-Anweisung entfernt (sodass die Auslöserfunktionen Top-Level-Globals bleiben, die Apps Script aufrufen kann), und es bündelt ein kleines URL-Polyfill (nur installiert, wenn URL fehlt), sodass das new URL(...) des gemeinsamen Kerns Links serverseitig auflöst. buildTarget bereinigt dist/<target>/, kopiert das ziel-spezifische manifest.json und icons/ wortgetreu, bündelt jedes Quell-Script als IIFE (die injizierten INJECTED_SCRIPTS content.ts/messageDisplay.ts können keine ES-Module sein, und Hintergründe sind ebenfalls klassische Worker/Event-Pages) und die gemeinsame Optionsseite als ESM und kopiert options.html/options.css aus packages/webext/src/options/. webextension-polyfill wird in jedes Script gebündelt.

tooling/version.mjs stempelt Versionen: Es nimmt MAJOR.MINOR aus dem Wurzel-package.json und hängt eine automatisch hochzählende BUILD-Nummer (gelesen aus der höchsten bestehenden dritten Komponente über die Ziel-Manifeste hinweg) an jedes Ziel-Manifest an und hält sie im Gleichschritt. Die meisten Ziele tragen ein einzelnes manifest.json; das Outlook-Add-in trägt zwei versionierte Manifeste (manifest.json und manifest.xml), und beide werden gestempelt, und das _template-Gerüst wird ebenfalls im Gleichschritt gestempelt. Das Wurzel-package.json wird auf derselben Version gehalten, bleibt aber gültiges Semver (eine vierteilige Version ist in einem Manifest erlaubt, nie in package.json). tooling/icons.mjs (über make icons ausgeführt) rastert die einzige Vektorquelle assets/icon.svg in das icons/icon48.png und icons/icon128.png jedes Ziels, wobei es zuerst cairosvg, dann inkscape, dann ImageMagick versucht.

Der Manifest-Vertrag — die exakt erforderlichen Felder je Engine — ist die einzige Quelle der Wahrheit in jedem extensions/<engine>/test/manifest.smoke.test.mjs (z. B. assertManifestShape von extensions/chrome/test/manifest.smoke.test.mjs, das außerdem das Ziel baut und bestätigt, dass jede referenzierte Datei im dist auflösbar ist). Gemäß der De-Duplizierungskonvention des Projekts wird die feldweise Form hier NICHT erneut aufgezählt; siehe diese Smoke-Tests und die Anmerkung „Manifest contract" im manuellen Testplan.

Internationalisierung (i18n)

Reveal URLs ist in viele Sprachen außer Englisch lokalisiert. Die Menge wird einzeln in packages/core/src/locales.json (SUPPORTED_LOCALES, native Namen und der Standard) gepflegt, das sowohl die Erweiterung bündelt (über den aufgelösten JSON-Import) als auch der reine Node-Website-Build liest. Jeder nicht englische String ist maschinell übersetzt und steht zur menschlichen Überprüfung aus; der Herkunftsmarker ist je Format (die _locales verwenden die description jeder Nachricht, die Website-Chrome-Wörterbücher erfassen ihn in site/i18n/README.md, und übersetzte Doku-Quellen tragen einen HTML-Kommentar in der ersten Zeile).

Es gibt drei unabhängige Lokalisierungsoberflächen, bewusst nicht miteinander verdrahtet, und jede ist entlang einer Build-Zeit-/Laufzeit-Linie aufgeteilt.

Unterdrückung der „Aktiven Sites" im Mail-Client (AD-3)

Ein Mail-Client-Ziel (heute Thunderbird) sieht bereits jede dargestellte Nachricht, sodass der „Aktive Sites"-Editor je Host dort redundant ist und entfernt wird. Die Entscheidung wird von einem dokumentierten Deskriptor-Flag je Ziel getragen, mailClient: true, an TARGETS.thunderbird von tooling/build.mjs — nie ein fest codierter Zielname in der Build- oder UI-Logik — und ist in zwei Hälften umgesetzt:

Sicherheits- und Datenschutzhaltung

Tests