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:
packages/coreenthält REINE Logik — keine Browser-APIs, keinen Speicher, keine Markup-Senken. Es ist in reinem JavaScript Unit-testbar und ist die einzige Quelle der Wahrheit für Link-Annotation, Konfigurationsvalidierung, Match-Pattern-Abgleich, Host-Vergleich und kontrastbewusste Einfärbung.packages/webextlegt die WebExtension-APIs um diesen Kern: den Content-Lebenszyklus, die dynamische Content-Script-Registrierung, die Optionen-UI, die Hintergrund-Verdrahtung, die Symbolleisten-Aktion und den Thunderbird-Nachrichtenanzeigepfad.extensions/<engine>enthält die schlanken Einstiegspunkte je Engine sowie dasmanifest.jsonjeder Engine.toolingenthält die Skripte für Build, Versionsstempelung und Icon-Erzeugung.featuresenthält die Cucumber-BDD-Szenarien und ihre Test-Doubles.
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/core/src/config.ts— dasConfig/SiteRule-Schema, die Standardwerte und der einzige ValidierungstrichternormaliseConfig.packages/core/src/matchPattern.ts— die Match-Pattern-Grammatik, der Matcher, die Spezifitäts-Rangfolge und die Abdeckung erteilter Ursprünge.packages/core/src/linkProcessor.ts— der dokumentbezogene Annotierer.packages/core/src/hostMismatch.ts— die Prüfung auf Abweichung der registrierbaren Domain.packages/core/src/styles.tsundpackages/core/src/contrast.ts— das injizierbare Stylesheet und die kontrastbewusste Einfärbung.packages/core/src/safeColour.ts— das Safe-Colour-PrädikatisSafeColour.
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:
- Es beansprucht den Frame synchron, vor jedem
await, überclaimBootstrap, das dasBOOTSTRAP_MARKER-Flag amwindowdes Frames liest/setzt. Ein statischer, eingebautercontent_scripts-Eintrag und ein überlappendes dynamisches Benutzer-Script können beidecontent.jsin denselben Frame injizieren; das gemeinsamewindowder isolierten Welt macht dies zum korrekten Einmal-Schutz, und das Beanspruchen vor jedemawaitbedeutet, dass zwei nahezu gleichzeitige Injektionen ihn nicht beide passieren können. - Es lädt ZUERST die persistierte Konfiguration über
loadConfigWithRetry(BOOTSTRAP_CONFIG_RETRIESzusä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, dasREVEAL_URLS_CSSübertextContentzuweist), sodass ein fehlgeschlagenes Lesen kein<style>anhängt und eine Wiederholung es nicht duplizieren kann. - Wenn das Laden der Konfiguration bei jedem Versuch fehlschlägt, setzt
releaseBootstrapden Marker zurück (nur hier, bevor die Annotation beginnt), sodass eine spätere erneute Injektion es noch einmal versuchen kann; sobaldstartgelaufen ist, muss der Marker bestehen bleiben, da ein frischer Annotierer die Knoten des vorherigen Durchlaufs nicht besäße. - Wenn
config.enabledgilt, ruft esstartauf, und es registriert stets einenonConfigChanged-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):
- Beschneidet Registrierungseinträge, deren Anker nicht mehr verbunden ist.
- Sammelt Kandidaten-Anker mit
collectAnchors(nachkommendea[href]plus den Root selbst, wenn er eina[href]ist, dedupliziert). - Löst das Ziel jedes Ankers mit
resolveAnnotatableUrlauf, dasnull(überspringen) zurückgibt für einen leeren/seiteninternen href, einen nicht parsbaren href, jedes Schema außerhttp:/https:oder einen Host, der einemignoreHosts-Eintrag entspricht oder darunter liegt; es löst relative hrefs gegenbaseURIauf, sodass das Ergebnis absolut ist. - Nimmt den idempotenten schnellen Pfad, wenn
isAnnotationIntactbestä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. - Berechnet die Abweichung mit
hostMismatcham bereinigten Anker-Text, berücksichtigtshowOnlyOnMismatch(lässt ehrliche Links unangetastet) und ruftannotateAnchorauf.
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:
removeSite(options.ts): liestgetConfig, persistiertsetConfigüber einsites-Array, in dem nur die entfernte Regel herausgefiltert ist (ZUERST persistieren, dannrow.remove(), dann ein bedingtespermissions.remove, das aus der nächsten Konfiguration entschieden wird). Ein Persistierungsfehler wirftSiteRemovalSaveErrorund lässt das DOM unangetastet; ein fehlgeschlagener Widerruf wirftSitePermissionRevokeError, nachdem die Zeile bereits entfernt ist.addSite(options.ts): oben beschrieben — validieren, Berechtigung anfordern, die Zeile anhängen, dannsaveOptions.toggleEnabled(options.ts): der sofortige Hauptschalter Aktivieren kippt NURenabledan der gespeicherten Konfiguration und schreibt sie zurück, ohne neu zu rendern (sodass eine laufende Bearbeitung nie überschrieben wird); bei einem fehlgeschlagenen Schreibvorgang setzt er die Checkbox auf den gespeicherten Wert zurück.
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.
- Die Optionsseite (AD-1). Sie liefert STATISCHES englisches Markup; jeder übersetzbare
Knoten trägt eine
data-i18n="<key>"-(Text-) oderdata-i18n-<attr>="<key>"-(Attribut-) Annotation. Zur Laufzeit istpackages/webext/src/options/locale.tsdie Anwendungsschicht: Esfetcht das seitenspezifisch gepackte_locales/<code>/messages.json(überruntime.getURL, keineweb_accessible_resourcesnötig), schreibt jeden annotierten Knoten NUR pertextContentum (sodass die<code>-Beispielhinweise des Formulars intakt bleiben) und setzt<html lang>. Die Seite ist VOLLSTÄNDIG lokalisiert: Neben der statischen Prosa tragen die per JS gebauten Site-Zeilen-Labels und die Entfernen-Schaltfläche einendata-i18n-Schlüssel (mit einem initialentranslate-Text), sodass dieselbe Anwendungsschicht gerenderte Zeilen ohne Neu-Rendern erneut lokalisiert, und die WIRKLICH dynamischen Strings — die Statuszeile, das Site-Hinzufügen-Feedback und die Versionszeile — rendern zur Aufrufzeit übertranslate(<key>)vonlocale.ts(currentMessages → englischer Fallback → Schlüssel). Der englische Fallback ist der Katalog_locales/en/messages.json, der zur Build-Zeit inoptions.jsIMPORTIERT (gebündelt) wird, sodasstranslateab dem Modul-Laden SYNCHRON zu lesbarem Englisch auflöst — ein dynamischer String, der gerendert wird, bevor sich der Umschalter einpendelt, oder nachdem ein Laufzeit-Katalog-Fetch fehlschlägt, degradiert nie zu einem rohen Nachrichtenschlüssel.locale.tsveröffentlicht die angewendete Nachrichten-Map und feuert seineonLocaleChange-Listener nach jeder Anwendung, sodassoptions.tsden aktuell gezeigten Status/das Feedback/die Version neu rendert und den Online-Handbuch-Link bei jedem Wechsel neu ausrichtet. Die Versionszeile verwendet eineoptionsVersion-Nachricht, deren{version}-Platzhalter durch die Build-Nummer ersetzt wird (Token übersetzt, Nummer wortgetreu). Der Handbuch-Link folgt der aktiven Locale zu ihrer Doku-Variante (AD-9): Englisch behält das oberstemanual.html, jede andere Locale öffnetnl/handleiding.htmlund bewahrt den?v=-Versionsstempel. Die aktive Locale ist die gespeicherte Wahl des Benutzers oder, andernfalls, die durchresolveLocaledes Kerns auf eine unterstützte Basissprache aufgelöste Browsersprache (AD-5); eine nicht unterstützte oder fehlgeschlagene Locale belässt das vorgerenderte Englisch (undtranslatedegradiert dann zum englischen Fallback). Der Anzeigesprache-Umschalter des Handbuchs persistiert seine Wahl unter einem dediziertenuiLocale-Schlüssel instorage.local— nie in der synchronisiertenConfig, sodass sie pro Gerät gilt und nie roamt. - Die Website-Chrome (AD-2). Der Generator für statische Seiten
tooling/site.mjsrendert serverseitig jeden Chrome-Knoten auf Englisch, während er ihn mitdata-i18nannotiert, und behält<html lang="en-GB">bei (AD-4). Der browserseitige Loadersite/scripts/i18n.mjs(ein abhängigkeitsfreies ES-Modul) tauscht dann die Chrome in die Sprache des Besuchers nach Vorrang aus — eine persistiertelocalStorage-Wahl (revealUrlsSiteLocale) → die erste unterstützte Basis-Übereinstimmung ausnavigator.languages(AD-5) → Englisch — und holtsite/i18n/<code>.json. Anders als die Anwendungsschicht der Optionen tauscht er überinnerHTML, da die Werte der Website-Chrome vertrauenswürdiges Inline-Markup tragen (<code>,<a>); ein Paritätstest fixiert die Tag-/Attribut-/URL-Sequenz jedes nicht englischen Wertes byte-identisch zum Englischen, sodass eine Übersetzung nie einen Link fallen lassen oder ein Tag brechen kann. Ein fehlender Schlüssel behält den vorgerenderten englischen Schnappschuss; ein fehlgeschlagener Fetch lässt die englische Seite intakt (anmutige Degradation, nie leer). - Die Dokumentationsseiten (AD-8/AD-9). Doku-Seiten werden zur Build-Zeit JE LOCALE
gerendert, nicht zur Laufzeit getauscht. Englische Dokus bleiben auf den obersten Pfaden;
jede andere Locale erhält eine Kopie unter einem
<code>/-Ordner, aus einem übersetztendocs/<code>/<DOC>.md, wenn vorhanden, sonst der englischen Quelle — ein Fallback je Seite, denbuildSitePROTOKOLLIERT, statt ihn stillschweigend auszulassen. Der Doku-INHALT trägt keindata-i18n(es ist roh gerendertes Markdown), sodass der Laufzeit-Chrome-Loader ihn nie berührt; nur die gemeinsamen Chrome-Knoten tauschen. Der Sprachumschalter der Doku-Seite NAVIGIERT zur Seite der Geschwister-Locale, statt an Ort und Stelle zu tauschen. - Lokalisierung interner Links (Finding 2). Die gewählte Sprache wird über die
Doku-Navigation hinweg getragen. Jeder INTERNE Doku-Anker (die fünf Seiten
architecture.html,index.html,licence.html,manual.html,privacy.html) wird vom Generator mitdata-doc-link="<page>"markiert, und für den Fall ohne JavaScript löst sein href bereits zur eigenen Locale-Variante der AKTUELLEN Seite auf (navDocHref— die drei Doku-Seiten je Locale lokalisieren; die rein englischenindex.html/licence.htmlbleiben auf oberster Ebene). DasrewriteDocLinksdes Laufzeit-Loaders richtet dann jeden internen Doku-Anker — die markierten UND jeden aus dem Katalog injizierten einfachen Anker wiemanual.html#installing— auf die Variante der AKTIVEN Locale neu aus, bewahrt jeden#hash/?queryund berührt nie externe Links. Auf einer Doku-Seite NAVIGIERT eine GESPEICHERTE Wahl, die von der eigenen Locale der Seite abweicht, zur passenden Variante; der Schleifenschutz feuert nur bei einer expliziten gespeicherten Wahl (nie bei einer Navigator-Sprachpräferenz allein) und nur, wenn das Ziel von der bereits gezeigten Seite abweicht. Die Kataloge selbst behalten ihre englischen internen URLs, sodass der Paritätstest grün bleibt — die Lokalisierung ist ein reiner Laufzeit-Durchlauf über das DOM.
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:
- Build-Zeit.
copyAssetsrichtet sich nachdescriptorFor(target).mailClient: Für ein Mail-Client-Ziel führt es die reine TransformationremoveSitesSection(html)aus (die den gesamten Host-Editor<section class="sites">einschließlich jeder Site-Hinzufügen-Steuerung entfernt undMissingSourceErrorwirft, wenn der Abschnitt fehlt, sodass eine Markup-Änderung den Editor nie stillschweigend ausliefern kann) und schreibt das Ergebnis; Browser-Ziele erhaltenoptions.htmlbyteweise übercpSync. - Laufzeit. Der gebündelte Controller
packages/webext/src/options/options.tstoleriert den fehlenden Abschnitt:renderSites/readSites/applyConfig/readFormprüfen#sites-listund werden zum No-op, stattOptionsFieldMissingErrorzu werfen, undreadFormLÄSST densites-Schlüssel WEG, wenn der Abschnitt fehlt. Da das Weglassen vonsitesnormaliseConfigdie eingebauten Standardwerte erneut einsäen und die Sites des Benutzers fallen lassen ließe, stelltsaveOptionsdie gespeichertensitesvor dem Normalisieren wieder her, wann immer der Abschnitt fehlt — das ist es, was ein Speichern im Mail-Client die konfigurierten Hosts bewahren lässt. Ein Laufzeitschutz,guardMailClientSites, entfernt den Abschnitt, falls er noch vorhanden ist, und erkennt einen Mail-Client am Vorhandensein von API/Funktion (messages/messageDisplay/scripting.messageDisplayam injiziertenbrowser), nie am Zielnamen; er läuft als Erstes ininitOptions, sodass die spätere Hinzufügen-/Listen-Verdrahtung die fehlenden Elemente natürlich überspringt. In einer Browser-Laufzeit ist der Schutz ein No-op.
Sicherheits- und Datenschutzhaltung
- Kein Netzwerk und keine Exfiltration. Kein Modul führt
fetch/XHRoder einen anderen Netzwerkaufruf aus; die Erweiterung liest nur die gespeicherte Konfiguration und das DOM der Seite. - Nur Safe-DOM. Annotation und das injizierte Stylesheet verwenden ausschließlich
createElement/textContent/typisiertestyle.*-Eigenschaften, nieinnerHTML/insertAdjacentHTML/eval. Für den Kern wird dies durchpackages/core/test/purity.test.tsdurchgesetzt. - Alle externen/Konfigurations-Eingaben normalisiert. Jeder Lese- und Schreibvorgang
läuft durch
normaliseConfig; Farben passierenisSafeColour; Match-Patterns passierenMATCH_PATTERN; Content-Root-Selektoren sind durchCONTENT_ROOT_PATTERNbegrenzt und werden nur jemals anquerySelectorAll/closestübergeben, nie in Markup oder CSS interpoliert. - Geringste Rechte. Eingebaute Webmail-Ursprünge sind feste
host_permissions; zusätzliche Hosts sind per Opt-in überoptional_host_permissionsund ein an eine Geste gebundenespermissions.requestmöglich. Die Symbolleisten-Aktion deklariert kein Popup. - Gecko-Datenerhebung. Die Firefox- und Thunderbird-Manifeste deklarieren
browser_specific_settings.gecko.data_collection_permissions.required: ["none"]. - Lizenz. Das Projekt ist AGPL-3.0-only (
LICENSE,package.json).
Tests
- Vitest-Unit-Tests decken beide Pakete ab:
packages/core/test/*(config, contrast, host mismatch, link processor, styles und den Reinheitsschutz) undpackages/webext/test/*(content, storage, options, registration, toolbar, message display und ein End-to-End-Test für benutzerdefinierte Sites). Jedes Paket führtvitest runaus. - Cucumber-BDD-Szenarien liegen in
features/(verdrahtet durchcucumber.json) und treiben die echten@reveal-urls/webext-Module durch getreue Test-Doubles infeatures/support/webext.ts— insbesonderenormaliseReadBack, das die Engine widerspiegelt, die beim Rücklesen falsch-wertige Flags WEGLÄSST, sodass ein nachsichtiges Harness keine Schleife zur erneuten Registrierung verbergen kann — über ein infeatures/support/world.tseingerichtetes jsdom-Dokument. - Manifest-Smoke-Tests je Ziel (
extensions/<engine>/test/manifest.smoke.test.mjs, Nodes eingebautesnode:test) sichern die exakte Form jedes Manifests und dass das gebaute dist jede referenzierte Datei auflöst.tooling/test/fügt Build-, Versions- und Definition-of-Done-Tests hinzu. - CI (
.github/workflows/ci.yml) sperrt jeden Push und Pull Request hintermake test,make bddundmake lint(direkt auf dem Runner ausgeführt, was die Docker-RUN/IMAGE_DEPdes Makefiles überschreibt). Versionsstempelung und Paketierung sind bewusst ausgeschlossen; sie liegen im separaten, tag-ausgelösten.github/workflows/release.yml.