Safari is a supported active target with its own entry points. Built-in providers use static scripts and Safari requests no all-sites access. User-added sites receive a runtime optional permission, then the background injects content.js after navigation only for granted, enabled, non-builtin rules, with allFrames per rule. Safari settings are device-local and do not roam between devices. macOS packaging uses Xcode and the Apple Developer Program/App Store route.

Reveal URLs — Arquitectura

Este documento describe como está estruturado Reveal URLs e como funciona a súa lóxica principal, pensado para colaboradores e lectores técnicos. Ao longo do texto cítanse ficheiros e símbolos reais na forma path symbol, para que cada afirmación se poida comprobar no código fonte. A descrición orientada á persoa usuaria está en manual, e os pasos de comprobación manual atópanse en manual test plan.

Visión xeral

Reveal URLs é unha única WebExtension MV3 construída a partir dun núcleo común e limpo para Chrome, Edge, Opera, Firefox, Safari e Thunderbird. Safari usa scripts estáticos só para provedores integrados e está publicada na App Store. A extensión mostra o URL de destino de cada ligazón ao carón da propia ligazón no rendered email e marca unha ligazón cando o texto visible apunta a un dominio registrable distinto do que hai en href.

O repositorio é un monorepo de pnpm e TypeScript descrito nos ficheiros pnpm-workspace.yaml e package.json, e "private": true evita que se publique como paquete. O bundling xestiónao tooling/build.mjs, que compila os paquetes compartidos e os entry point delgados de cada engine nun directorio cargable dist/<target>/. webextension-polyfill bundlease dentro de cada script en vez de depender dun global de runtime.

Dous complementos native de correo amplían o mesmo núcleo de detección a superficies ás que WebExtension non pode chegar: Outlook Add-in sobre Office.js e Gmail Add-on sobre Apps Script CardService. Ambos reutilizan só a análise PURE de packages/core/src/findings.ts, xunto con analyseAnchors, analyseHtml, o modelo neutral Finding[], a lóxica hostMismatch e a obtención do dominio registrable mediante tldts. O que cambia é só o adaptador de host e a presentación. Outlook usa DOMParser e funciona client-side. Gmail usa node-html-parser e funciona server-side sobre a infraestrutura Google Apps Script V8. Como ningún destes marcos permite modificar o DOM da mensaxe renderizada, ambos mostran un panel ou unha tarxeta con achados, e non inline annotation. A ruta de mutación do DOM de linkProcessor.ts e REVEAL_URLS_CSS non se usan aí. Un href relativo só se resolve contra un <base href> fiable dentro da mensaxe; se non, descártase. Este camiño nativo de achados permanece deliberadamente limitado a http:/https:.

A base de código separa con claridade as responsabilidades:

Estrutura do repositorio

packages/core — o núcleo puro

Ningún módulo de aquí importa extension API como browser.*, chrome.* ou messenger.*, e ningún usa string-to-markup DOM sink como innerHTML ou insertAdjacentHTML. Ese contrato aplícase mecanicamente en packages/core/test/purity.test.ts, que percorre src/**/*.ts, elimina os corpos dos comentarios mediante stripComments e comproba que nin EXTENSION_API_PATTERN nin UNSAFE_DOM_PATTERN coincidan con ningún módulo. A superficie pública reexpórtase desde packages/core/src/index.ts, e a única dependencia de runtime é tldts.

Os módulos principais son:

packages/webext — os envoltorios de browser API

Estes módulos PODEN usar browser.*, e é o único lugar onde se tocan as WebExtension API. A superficie pública reexpórtase desde packages/webext/src/index.ts. Aquí están content.ts, contentRegistration.ts, storage.ts, background.ts, toolbarAction.ts, messageDisplay.ts, messageDisplayBackground.ts e options/options.ts.

extensions/<engine> — entry point delgados e manifests

Cada engine ten o seu propio manifest.json e o seu propio icons/. Chrome, Firefox e Thunderbird tamén teñen un directorio src/ con entry point. Edge, Opera e Safari só teñen manifest e iconas e reutilizan o src/ de Chrome. Por exemplo, extensions/chrome/src/content.ts só contén void createContentController().bootstrap();, e extensions/chrome/src/background.ts chama a registerBackground, registerContentReconciliation e registerToolbarToggle. extensions/thunderbird/src/background.ts chama a registerBackground e registerMessageDisplay. extensions/_template é un scaffold, non un build target.

tooling, features e smoke tests

tooling inclúe build.mjs, version.mjs e icons.mjs. features inclúe os escenarios Gherkin, as step definitions e os doubles fieis dos ficheiros features/support/webext.ts e features/support/world.ts. Cada engine tamén ten extensions/<engine>/test/manifest.smoke.test.mjs, mentres que _template usa template.smoke.test.mjs.

O fluxo de revelación

A ruta de contido comeza nun entry point dun engine como extensions/chrome/src/content.ts, que chama a createContentController().bootstrap() desde packages/webext/src/content.ts.

Bootstrap

createContentController bootstrap execútase unha vez por frame. Reivindica o frame antes de cada await mediante claimBootstrap, carga o config con loadConfigWithRetry, injecta a stylesheet con injectStyles só despois dunha lectura correcta e restaura o marcador con releaseBootstrap se a carga acaba fallando. Cando config.enabled está activo, chama a start, e en calquera caso rexistra o listener onConfigChanged.

Resolver a regra activa do sitio

start e applyConfig usan resolveSiteRule para decidir se hai que facer annotation e onde facelo. Primeiro próbase a localización do propio documento mediante resolveForHref, que filtra config.sites ata as regras activadas e deixa a selección final en selectMostSpecific. Se o documento non coincide e a súa orixe é opaque ou inherited, como comproba hasOpaqueOrigin, faise un fallback con readTopHref, readOpenerHref e readReferrer, con protección fronte a lecturas cross-origin.

Acoutar ao content root e observar os cambios

start lembra contentRoot, crea o annotator con createAnnotator(config) e procesa cada raíz de selectRoots(doc, contentRoot), que envolve querySelectorAll nun try/catch para que un selector incorrecto devolva [] de maneira segura. Despois observa doc.body con OBSERVER_OPTIONS. processMutations filtra cada cambio segundo o root, reprocesa o target só cando é un Element dentro do root mediante isWithinRoot e closest, e reserva a detección tardía de roots ao percorrido limitado discoverMatchedRoots() por batch cun único querySelectorAll(contentRoot), deduplicado con WeakSet. pendingShadowHosts volve comprobar hosts plain anteriores ata que aparece un open shadow root, para cubrir tamén os shadow upgrades. applyConfig fai teardown, restart ou reflow segundo o cambio da regra activa e de contentRoot.

Annotation dunha ligazón única

O annotator está en packages/core/src/linkProcessor.ts. createAnnotator devolve un Annotator cun estado de peche que a páxina non pode ler: un Map forte indexado pola identidade da áncora, un token data-ru por annotator de generateToken e a configuración actual. O seu método process(root):

  1. Poda as entradas do rexistro cuxa áncora xa non está conectada.
  2. Recolle as áncoras candidatas con collectAnchors (os a[href] descendentes e a propia raíz cando é un a[href], sen duplicados).
  3. Resolve o destino de cada áncora con resolveAnnotatableUrl, que delega en resolveAnnotatableHref. Só devolve null (saltar) para un href baleiro/dentro da páxina ou non analizábel, resolve os href relativos contra baseURI e acepta todo esquema analizábel, incluídos mailto:, tel:, sms: e os URL opacos javascript:, data: e blob:. ignoreHosts aplícase só a ligazóns http:/https: con host.
  4. Toma a vía rápida idempotente cando isAnnotationIntact confirma que a pegada de renderizado non mudou e todos os nodos creados seguen conectados e no seu lugar; se non, reverte a anotación obsoleta e volve renderizar.
  5. Calcula a discrepancia segundo o esquema con destinationMismatch sobre o texto limpo da áncora: classifyScheme envía as ligazóns web a hostMismatch, o correo a mailMismatch, o teléfono a phoneMismatch e o resto a opaqueMismatch. Despois chama a annotateAnchor; showOnlyOnMismatch só suprime a revelación en liña visible dunha ligazón sen discrepancias, conservando o seu title de destino.

annotateAnchor renderiza só SAFE-DOM. Garda sempre o title orixinal (TitleSave) e escribe o título de destino — a única escrita na áncora. Non hai un modo de título separado nin unha insignia de aviso. Agás cando showOnlyOnMismatch oculta unha ligazón sen discrepancias, antepón un <span class="reveal-urls-url"> e un <br> final, deixando intactos os fillos da ligazón. needsLeadingInlineRevealBreak engade un <br> inicial cando un contido en liña visible precede a ligazón na mesma liña. O span mostra un href absoluto redactado literalmente, ou un href relativo resolto contra baseURI, mediante textContent inerte (co prefixo dunha frecha e un espazo sen quebra U+00A0, e logo truncado por truncateUrl); unha vez conectado, recibe un fondo de contraste con applyContrastBackdrop. Para unha discrepancia enfatizada, REVEAL_URLS_MISMATCH_CLASS engádese a REVEAL_URLS_URL_CLASS, aplicando a cor de discrepancia configurada mediante applyColour e a negra por defecto en REVEAL_URLS_CSS; unha substitución explícita do peso da fonte mediante applyFontOverrides ten preferencia. Todos os nodos creados reciben data-ru="<token>", e cada entrada rexistra o seu configFingerprint para que un cambio na configuración de renderizado obrigue a unha reorganización. revertOwned elimina os nodos creados por referencia e restaura o título gardado; revertAll fai isto para cada áncora posuída e baleira o rexistro.

truncateUrl traballa sobre PUNTOS DE CÓDIGO Unicode ([...displayHref]), de modo que un corte nunca divide un par substituto; conserva a orixe de esquema-e-host e acurta o resto cun URL_ELLIPSIS final.

Discrepancia de host

packages/core/src/hostMismatch.ts hostMismatch é a rama web de destinationMismatch: compara o texto visible da ligazón co seu href a nivel de dominio registrable, e non do host en bruto, polo que un subdominio sen discrepancias non se marca mentres que un sosia si. extractHostCandidates divide o texto en tokens e reduce cada un mediante hostCandidate (que require un punto e un sufixo público ICANN recoñecido por tldts, rexeitando texto ordinario con puntos como e.g). Tanto o host do href como cada candidato redúcense ao seu dominio registrable con tldts.getDomain, e CALQUERA candidato cuxo dominio difira do href produce unha discrepancia — así, nomear o host malicioso real xunto a un host engado non pode suprimir o aviso. Nunca lanza unha excepción.

Para ligazóns de correo, mailMismatch compara todos os dominios registrables dos destinatarios, incluídos to, cc e bcc; phoneMismatch compara os números marcados que se poden resolver. opaqueMismatch marca sempre SCRIPT_LIKE_SCHEMESjavascript:, data: e vbscript: —, mentres que outros esquemas opacos só se marcan cando o texto visible nomea un destino web, de correo ou telefónico concreto.

Estilos e contraste

packages/core/src/styles.ts garda REVEAL_URLS_CSS e as funcións de axuda applyColour, applyFontOverrides e applyContrastBackdrop. applyContrastBackdrop le a cor calculada do elemento e o primeiro fondo opaque que atopa a través de getComputedStyle, e logo usa needsWhiteBackdrop de packages/core/src/contrast.ts para decidir se cómpre establecer style.backgroundColor = "white". contrast.ts fornece parseColour, relativeLuminance e contrastRatio.

Host configurables e acoutación do contido

SiteRule en packages/core/src/config.ts define ON se executa a annotation mediante match e allFrames, e QUE container a limita mediante contentRoot, xunto con enabled e a marca builtin. DEFAULT_SITES inclúe Gmail, Proton con allFrames e os dous host de Outlook. Config agrupa os toggle globais, os colours, os font overrides e a lista sites.

O motor de match pattern

packages/core/src/matchPattern.ts contén a gramática restritiva MATCH_PATTERN: só http e https, host wildcard opcional *., glob path, sen porto, sen * scheme e sen <all_urls>. matchesPattern compara un URL cun pattern validado e delega a parte do path en pathGlobMatches. parsePatternParts divide o pattern en host, path, scheme e wildcardHost.

Cando as regras se superpoñen, aplícase a norma "most specific wins". compareSiteSpecificity dá preferencia ao host exacto fronte ao host wildcard, despois ao host literal máis longo, despois ao path máis literal a través de literalPathLength, e finalmente usa un ASCII tiebreak. selectMostSpecific devolve a regra activada máis específica que coincide co URL.

permissions.getAll() pode informar de grant en toda a gramática WebExtension, como <all_urls>, *://*/*, https://*/*, *://*.host/* e https://host/*) que MATCH_PATTERN rexeitaría. parseGrantedOrigin convérteos en GrantedOriginParts, originCovers comproba a cobertura sobre match, e matchAllowsOriginFallback indica se o path é exactamente ORIGIN_FALLBACK_PATH, é dicir /*.

Rexistro dinámico

packages/webext/src/contentRegistration.ts rexistra un dynamic content script por cada regra engadida pola persoa usuaria. As regras built-in sérvense coas entradas static content_scripts e nunca se rexistran de forma dinámica. desiredContentScripts filtra config.sites, usa isOriginGranted, originCovers e contentScriptId, e establece matchOriginAsFallback: true só cando o permite matchAllowsOriginFallback(rule.match).

reconcileContentScriptsOnce le as orixes concedidas, o config gardado e os rexistros activos e harmonízaos cun property-aware diff. sameRegistration normaliza tanto o descriptor desexado como o live read-back. Como non existe updateContentScripts, un script modificado primeiro desrexístrase e despois vólvese rexistrar. reconcileContentScripts usa a promise inFlight para serializar triggers que poden producirse case ao mesmo tempo.

O fluxo de permiso explícito

Os manifests static declaran optional_host_permissions (http://*/*, https://*/*) para que a persoa usuaria poida conceder en runtime permiso para un host adicional. packages/webext/src/options/options.ts addSite valida toda a regra candidata mediante normaliseSiteRule, extrae o host origin con matchOrigin e chama a browser.permissions.request desde o click gesture do botón Add. Só despois da aprobación engade a fila builtin:false.

Configuración, storage e o patrón canónico

O único validational funnel

normaliseConfig en packages/core/src/config.ts é a única ruta de lectura canónica. Forza e limita cada campo, volve ao valor predeterminado cando a entrada é incorrecta e nunca lanza unha exception. Está composto por validators por campos como normaliseBoolean, normaliseMaxLength, normaliseIgnoreHosts, normaliseMatchColour, normaliseMismatchColour, normaliseCssSize, normaliseFontWeight, normaliseContentRoot, normaliseMatchPattern e normaliseSites. normaliseSites expulsa as entradas incorrectas, elimina duplicados por match, forza builtin: true cando a regra coincide cunha regra built-in e restaura todas as entradas integradas que falten.

Storage

packages/webext/src/storage.ts envolve a WebExtension storage API. configArea usa browser.storage.sync cando está dispoñible e recorre a browser.storage.local, por exemplo en Thunderbird. getConfig, setConfig e onConfigChanged fan pasar sempre os valores por normaliseConfig, de maneira que un storage corrompido nunca pode devolver un Config inválido. As dúas formas de add-on usan o mesmo funnel sobre o seu host storage propio.

Configuración canónica e actualizacións dirixidas

Tres rutas de write len o config canónico, cambian exactamente unha cousa e escríbeno de novo, sen gardar form edits inacabados:

toggleEnabled en packages/webext/src/toolbarAction.ts segue o mesmo patrón desde o lado do background.

Build por engine e o contrato do manifest

tooling/build.mjs executa un build esbuild común para cada WebExtension target. O descriptor TARGETS indica o SOURCE TARGET de cada target. Chrome, Firefox e Thunderbird teñen o seu propio src/. Edge, Opera e Safari usan chrome como orixe. Outlook add-in ten unha orixe á parte. ACTIVE_TARGETS, é dicir, Chrome, Edge, Firefox, Opera e Thunderbird, son os que se constrúen con --all e --package. Safari e Outlook só se constrúen cando se especifican de maneira explícita, e Gmail usa make build-gmail.

O bundle de Gmail apunta ao runtime Apps Script V8, que non ten nin ES modules nin URL native. Por iso constrúese como ESM e, despois, elimínaselle o export {…} final para que as funcións trigger continúen a ser globais. O pequeno URL polyfill só se inclúe cando fai falta. buildTarget limpa dist/<target>/, copia manifest.json e icons/, bundlea os scripts como IIFE, a páxina de options como ESM e copia options.html e options.css.

tooling/version.mjs estampa as versións. Toma o MAJOR.MINOR do package.json raíz, atopa o BUILD máis alto e engádeo a cada manifest de target. Outlook add-in ten tanto manifest.json como manifest.xml. tooling/icons.mjs, executado con make icons, rasteriza assets/icon.svg cara a icon48.png e icon128.png.

O contrato do manifest defínese nos ficheiros extensions/<engine>/test/manifest.smoke.test.mjs, onde se comproba a forma de cada manifest e a existencia de cada referenced file.

Internacionalización (i18n)

Reveal URLs está localizado en moitos idiomas ademais do inglés. A única fonte deste conxunto é packages/core/src/locales.json, que contén SUPPORTED_LOCALES, os nomes locais e o idioma predeterminado. Cada string non inglesa é machine-translated e queda pendente de revisión humana. A marca de orixe depende do formato: _locales lévana no campo description, site catalogues rexístrana en site/i18n/README.md, e os docs traducidos levan un comentario HTML na primeira liña.

Hai tres superficies de localización independentes:

Supresión de «Active sites» no cliente de correo (AD-3)

Un mail client target como Thunderbird xa ve todos os rendered messages, polo que o editor host-based de «Active sites» resulta sobrante. A decisión transmítese co campo mailClient: true de tooling/build.mjs.

Postura de seguridade e privacidade

Probas