Reveal URLs — Arquitectura
Este documento describe cómo está estructurada la extensión Reveal URLs y cómo
funciona su lógica central, para colaboradores y lectores técnicos. Cita archivos
y símbolos reales a lo largo del texto (con la forma path symbol) para que
cualquier afirmación pueda comprobarse contra el código fuente. Para la descripción
de cara al usuario, consulta el manual; para los pasos de verificación
manual, consulta el plan de pruebas manuales.
Visión general
Reveal URLs es una única WebExtension MV3 compilada para varios motores — Chrome,
Edge, Opera, Firefox y Thunderbird — a partir de un único núcleo de código puro y
compartido. (Safari está aplazado: tiene un manifiesto y se puede compilar cuando
se nombra explícitamente, pero está excluido de la compilación predeterminada y de
CI.) La extensión revela la URL de destino de cada enlace junto al enlace en un
correo renderizado, y marca un enlace cuyo texto visible nombra un dominio
registrable distinto del de su href — la señal clásica del phishing.
El repositorio es un monorepo de pnpm/TypeScript (pnpm-workspace.yaml,
package.json con "private": true). El empaquetado es un único script impulsado
por esbuild, tooling/build.mjs, que compila los paquetes compartidos y los puntos
de entrada ligeros de cada motor en un directorio dist/<target>/ cargable.
webextension-polyfill se empaqueta en cada script en lugar de depender de él como
variable global en tiempo de ejecución, de modo que el mismo código fuente se
ejecuta sin cambios en Chromium y Gecko.
Dos complementos de correo nativos extienden el mismo núcleo de detección a
superficies que la WebExtension no puede alcanzar: un complemento para Outlook
(panel de tareas de Office.js, que llega a Outlook en web/Windows/Mac/iOS/Android y
Outlook.com) y un complemento para Gmail (CardService de Apps Script, que llega
a las apps de Gmail en web/Android/iOS). Ambos reutilizan solo el análisis PURO —
un nuevo módulo packages/core/src/findings.ts
(analyseAnchors/analyseHtml, un modelo Finding[] neutral respecto a la
plataforma) que reutiliza la lógica hostMismatch existente y la derivación de
dominio registrable de tldts. Solo el adaptador de host (cómo se lee y analiza el
cuerpo) y la presentación difieren según la superficie: el panel de tareas de
Outlook analiza el cuerpo con su propio DOMParser y se ejecuta en el lado del
cliente; el complemento para Gmail analiza con node-html-parser y se ejecuta
en el lado del servidor sobre la infraestructura V8 de Apps Script de Google
(alojamiento gratuito, ya que Google ya posee el correo; desplegado de forma
local-interactiva mediante clasp). Como ningún marco de trabajo puede mutar el DOM
del mensaje renderizado en modo lectura (los setAsync/prependAsync de Office son
solo para redacción; CardService renderiza tarjetas, no el HTML del mensaje), ambos
complementos presentan un panel/tarjeta de hallazgos, no una anotación en
línea — la ruta de mutación del DOM existente de linkProcessor.ts y
REVEAL_URLS_CSS NO son reutilizadas por ninguno de los complementos. Un href
relativo se resuelve solo contra un <base href> fiable dentro del correo y, en
caso contrario, se omite, nunca contra el origen del buzón/proveedor (lo que
fabricaría un destino).
El código base separa las responsabilidades de forma estricta:
packages/corecontiene lógica PURA — sin APIs de navegador, sin almacenamiento, sin sumideros de marcado. Es comprobable con pruebas unitarias en JavaScript puro y es la única fuente de verdad para la anotación de enlaces, la validación de configuración, la coincidencia de patrones, la comparación de hosts y el coloreado consciente del contraste.packages/webextenvuelve las APIs de WebExtension alrededor de ese núcleo: el ciclo de vida del contenido, el registro dinámico de content-scripts, la interfaz de opciones, el cableado en segundo plano, la acción de la barra de herramientas y la ruta de visualización de mensajes de Thunderbird.extensions/<engine>contiene los puntos de entrada ligeros por motor más elmanifest.jsonde cada motor.toolingcontiene los scripts de compilación, de estampado de versión y de generación de iconos.featurescontiene los escenarios BDD de Cucumber y sus dobles de prueba.
Estructura del repositorio
packages/core — el núcleo puro
Ningún módulo de aquí importa una API de extensión
(browser.*/chrome.*/messenger.*) ni usa ningún sumidero del DOM de
cadena-a-marcado (innerHTML/insertAdjacentHTML). Ese contrato lo impone
mecánicamente packages/core/test/purity.test.ts, que recorre con glob cada
src/**/*.ts, elimina los cuerpos de los comentarios mediante su propio
stripComments (para que los docblocks que legítimamente NOMBRAN los tokens
prohibidos no hagan saltar la guarda) y luego afirma que ni
EXTENSION_API_PATTERN ni UNSAFE_DOM_PATTERN coinciden con ningún módulo. La
superficie pública se reexporta desde packages/core/src/index.ts. La única
dependencia en tiempo de ejecución es tldts (para la comparación de dominios
consciente del sufijo público).
Los módulos son:
packages/core/src/config.ts— el esquemaConfig/SiteRule, los valores predeterminados y el único embudo de validaciónnormaliseConfig.packages/core/src/matchPattern.ts— la gramática de patrones de coincidencia, el comparador, la clasificación por especificidad y la cobertura de orígenes concedidos.packages/core/src/linkProcessor.ts— el anotador con alcance de documento.packages/core/src/hostMismatch.ts— la comprobación de discrepancia de dominio registrable.packages/core/src/styles.tsypackages/core/src/contrast.ts— la hoja de estilos inyectable y el coloreado consciente del contraste.packages/core/src/safeColour.ts— el predicado de color seguroisSafeColour.
packages/webext — los envoltorios de la API del navegador
Los módulos de aquí PUEDEN usar browser.* y son el único lugar donde se tocan las
APIs de WebExtension. La superficie pública se reexporta desde
packages/webext/src/index.ts. Los módulos son: content.ts (ciclo de vida del
contenido), contentRegistration.ts (registro dinámico), storage.ts (persistencia
de configuración), background.ts (comportamiento al instalar), toolbarAction.ts
(conmutador + insignia), messageDisplay.ts y messageDisplayBackground.ts
(Thunderbird), y options/options.ts (el controlador de la página de ajustes).
extensions/<engine> — puntos de entrada ligeros y manifiestos
Cada motor lleva su propio manifest.json e icons/. Chrome, Firefox y
Thunderbird incluyen además un src/ de puntos de entrada; Edge, Opera y Safari
llevan solo un manifiesto e iconos y REUTILIZAN el src/ de Chrome (declarado por
el descriptor de compilación — ver más abajo). Los puntos de entrada son
deliberadamente diminutos: por ejemplo extensions/chrome/src/content.ts es
simplemente void createContentController().bootstrap();, y
extensions/chrome/src/background.ts llama a registerBackground,
registerContentReconciliation y registerToolbarToggle. El punto de entrada en
segundo plano de Firefox es idéntico al de Chrome; el de Thunderbird
(extensions/thunderbird/src/background.ts) llama a registerBackground y
registerMessageDisplay en su lugar (no tiene content scripts).
extensions/_template es un andamiaje de Chromium listo para copiar, no un destino
de compilación.
tooling, features y las pruebas de humo
tooling contiene build.mjs, version.mjs e icons.mjs. features contiene los
escenarios de Gherkin, sus definiciones de pasos y los fieles dobles de prueba en
features/support/webext.ts y features/support/world.ts. Cada motor lleva también
un extensions/<engine>/test/manifest.smoke.test.mjs (el de _template es
template.smoke.test.mjs) — estos son el contrato de manifiesto autorizado
(consulta «Compilación por motor y el contrato de manifiesto» más abajo).
La cadena de revelado
La ruta de contenido comienza en un punto de entrada de motor como
extensions/chrome/src/content.ts, que llama a createContentController().bootstrap()
desde packages/webext/src/content.ts.
Bootstrap
createContentController bootstrap se ejecuta una vez por frame:
- Reclama el frame de forma síncrona, antes de cualquier
await, medianteclaimBootstrap, que lee/establece la banderaBOOTSTRAP_MARKERen elwindowdel frame. Una entrada estática integrada decontent_scriptsy un script de usuario dinámico que se solape pueden inyectar amboscontent.jsen el mismo frame; elwindowcompartido del mundo aislado hace de esto la guarda correcta de una sola vez, y reclamarlo antes de cualquierawaitsignifica que dos inyecciones casi simultáneas no pueden superarla ambas. - Carga PRIMERO la configuración persistida mediante
loadConfigWithRetry(BOOTSTRAP_CONFIG_RETRIESintentos adicionales ante un rechazo transitorio), manteniendo el marcador durante los reintentos para que una inyección solapada que ya no hizo nada no quede varada. Solo una vez que la lectura tiene éxito inyecta la hoja de estilos (injectStyles, que asignaREVEAL_URLS_CSSmediantetextContent), de modo que una lectura fallida no añade ningún<style>y un reintento no puede duplicarlo. - Si la carga de configuración falla en todos los intentos,
releaseBootstraprevierte el marcador (solo aquí, antes de que comience la anotación) para que una reinyección posterior pueda reintentarlo; una vez questartse ha ejecutado, el marcador debe persistir, porque un anotador nuevo no sería propietario de los nodos de la pasada anterior. - Cuando
config.enabled, llama astart, y siempre registra un escuchadoronConfigChangedque vuelve a aplicar la configuración (cubriendo la transición desactivado→activado).
Resolver la regla de sitio activa
start (y applyConfig) llama a resolveSiteRule para decidir si — y dónde —
anotar. resolveSiteRule primero prueba la ubicación PROPIA del documento en
ejecución mediante resolveForHref, que filtra config.sites a las reglas
activadas y delega en selectMostSpecific del núcleo para que gane la coincidencia
más específica (un integrado específico vence a un comodín de usuario amplio).
Cuando la ubicación propia no coincide con nada Y el origen propio del documento es
opaco o heredado — controlado por hasOpaqueOrigin, verdadero solo para
about:blank/about:srcdoc (heredado mediante INHERITED_ORIGIN_URLS) o
blob:/data: (OPAQUE_ORIGIN_SCHEMES) — recurre, en orden, al frame SUPERIOR
(readTopHref, el caso integrado del iframe de mensajes de Proton), a la ventana
ABRIDORA (readOpenerHref, la ventana de lectura emergente de Outlook abierta
mediante window.open("about:blank")) y finalmente a document.referrer
(readReferrer). Toda lectura externa está protegida contra el acceso entre
orígenes (que lanza una excepción) y aporta un candidato solo cuando es legible. Un
documento ORDINARIO sin coincidencia es autoritativo y nunca hereda una regla del
contexto ambiental, dejando el controlador inerte.
Acotar a la raíz de contenido y observar las mutaciones
start registra el selector contentRoot de la regla coincidente, crea el anotador
mediante createAnnotator(config), y procesa cada raíz coincidente de
selectRoots(doc, contentRoot) (un querySelectorAll envuelto en try/catch para
que un selector inválido falle de forma segura a []). Luego observa doc.body (un
contenedor estable) con OBSERVER_OPTIONS ({ childList: true, subtree: true }).
El observador alimenta processMutations, que condiciona cada mutación a la raíz de
contenido: reprocesa un target de mutación solo cuando es un Element dentro de
una raíz (isWithinRoot, mediante closest), y encamina cada Element añadido a
través de processAddedNode, que anota un nodo que está dentro de una raíz, ES una
raíz o CONTIENE una (un montaje SPA perezoso). Así, tanto la agitación dentro de la
raíz como el cuerpo de un mensaje hermano que se monta más tarde quedan capturados,
mientras que la interfaz de la app fuera de las raíces se deja intacta. Un anotador
nulo (la extensión está detenida) no hace nada.
applyConfig reconcilia un cambio de configuración en vivo: desmonta cuando ninguna
regla coincide o la bandera de activación está desactivada; reinicia (revertir +
reobservar) cuando el contentRoot resuelto ha cambiado; y, en caso contrario,
vuelve a fluir en el sitio — annotator.setConfig y luego annotator.process sobre
cada raíz coincidente — sin reiniciar el observador.
Anotar un único anchor
El anotador reside en packages/core/src/linkProcessor.ts. createAnnotator
devuelve un Annotator sobre un estado de clausura que la página no puede leer: un
Map fuerte indexado por la identidad del anchor, un token data-ru por anotador de
generateToken (crypto.getRandomValues), y la configuración actual. Su método
process(root):
- Poda las entradas del registro cuyo anchor ya no está conectado.
- Recopila los anchors candidatos con
collectAnchors(losa[href]descendientes más la propia raíz cuando es una[href], sin duplicados). - Resuelve el destino de cada anchor con
resolveAnnotatableUrl, que devuelvenull(omitir) para un href vacío/dentro de la página, un href no analizable, cualquier esquema distinto dehttp:/https:, o un host igual a o bajo una entrada deignoreHosts; resuelve los href relativos contrabaseURIpara que el resultado sea absoluto. - Toma la ruta rápida idempotente cuando
isAnnotationIntactconfirma que la huella de renderizado no ha cambiado y que cada nodo creado sigue conectado y en su posición; en caso contrario, revierte la anotación obsoleta y vuelve a renderizar. - Calcula la discrepancia con
hostMismatchsobre el texto limpio del anchor, respetashowOnlyOnMismatch(dejando intactos los enlaces honestos), y llama aannotateAnchor.
annotateAnchor renderiza solo DOM SEGURO. En el modo "inline" antepone un
<span class="reveal-urls-url"> cuya URL se establece mediante textContent
(precedida por una flecha y un espacio de no separación U+00A0, truncada por
truncateUrl) seguido de un <br>, dejando intactos los hijos propios del enlace;
el span conectado recibe luego un fondo de contraste mediante
applyContrastBackdrop. En el modo "title" guarda el title original
(TitleSave), lo sobrescribe con la URL — la única escritura en el anchor — y, ante
una discrepancia enfatizada, añade una insignia hermana
<span class="reveal-urls-warn"> que lleva REVEAL_URLS_WARN_LABEL. Cada nodo
creado se etiqueta con data-ru="<token>" (nunca el anchor), y cada entrada registra
su configFingerprint para que un cambio de configuración de renderizado fuerce un
reflujo. Los colores y las anulaciones de fuente se aplican a través de propiedades
tipadas element.style.* (applyColour/applyFontOverrides), nunca interpoladas en
una cadena CSS. revertOwned elimina los nodos creados por referencia y restaura el
title guardado; revertAll hace esto para cada anchor del que es propietario y
limpia el registro.
truncateUrl opera sobre PUNTOS DE CÓDIGO Unicode ([...displayHref]), de modo que
un corte nunca divide un par sustituto; preserva el origen de esquema-y-host y acorta
el resto con un URL_ELLIPSIS final.
Discrepancia de host
hostMismatch de packages/core/src/hostMismatch.ts compara el texto visible del
enlace con su href por dominio registrable, no por nombre de host en bruto, de modo
que un subdominio honesto no se marca mientras que uno similar sí.
extractHostCandidates divide el texto y reduce cada token mediante hostCandidate
(que requiere un punto y un sufijo público ICANN reconocido por tldts, rechazando
texto con puntos ordinario como e.g). Tanto el host del href como cada candidato se
reducen a su dominio registrable con tldts.getDomain, y CUALQUIER candidato cuyo
dominio difiera del del href produce una discrepancia — de modo que nombrar el host
malicioso real junto a un host señuelo no puede suprimir la advertencia. Nunca lanza
una excepción.
Estilos y contraste
packages/core/src/styles.ts contiene el REVEAL_URLS_CSS inyectable (disposición,
grosor y forma fijos y un fondo de chip blanco fijo y totalmente opaco —
background: #ffffff, opacity: 1; los colores NUNCA se interpolan en él) y los
ayudantes en tiempo de ejecución
applyColour/applyFontOverrides/applyContrastBackdrop. applyContrastBackdrop
lee el color resuelto del elemento y el primer fondo opaco encontrado recorriendo
desde el PROPIO elemento hacia arriba (su propio fondo — el blanco fijo predeterminado
del chip o una anulación más específica de la página anfitriona — antes que cualquier
ancestro) a través del estándar getComputedStyle (obtenido de
element.ownerDocument?.defaultView, de modo que es seguro entre frames y simulable)
y, cuando needsWhiteBackdrop de packages/core/src/contrast.ts informa de que el
texto no supera WCAG AA (CONTRAST_THRESHOLD) contra ese fondo Y el blanco realmente
ayuda, establece style.backgroundColor = "white". contrast.ts proporciona
parseColour, relativeLuminance y contrastRatio; analiza las formas
rgb()/rgba()/hex que devuelve getComputedStyle y trata un color totalmente
transparente como «no encontrado».
Hosts configurables y acotación de contenido
Una SiteRule (packages/core/src/config.ts) indica DÓNDE se ejecuta la anotación
(match, allFrames) y QUÉ contenedor la acota (contentRoot), además de enabled
y una bandera builtin. Las DEFAULT_SITES que se incluyen cubren Gmail, Proton
(con allFrames) y ambos hosts de Outlook. La Config agrega los conmutadores
globales, los colores, las anulaciones de fuente y el array sites.
El motor de patrones de coincidencia
packages/core/src/matchPattern.ts contiene una gramática de tiempo de adición
MATCH_PATTERN deliberadamente RESTRICTIVA (solo http/https, comodín de host *.
inicial opcional, ruta glob; sin puerto, sin esquema *, sin <all_urls>).
matchesPattern compara una URL con un patrón validado, delegando la ruta en
pathGlobMatches (cada * coincide con cualquier secuencia de caracteres); falla de
forma segura a false. parsePatternParts divide un patrón validado en
host/path/scheme/wildcardHost.
Las reglas que se solapan se resuelven con «la más específica gana»:
compareSiteSpecificity clasifica por (1) host exacto sobre host comodín, (2) host
literal más largo, (3) ruta más literal (literalPathLength), y luego (4) un
desempate ASCII determinista; selectMostSpecific devuelve la regla activada más
específica que coincide con una URL.
La cobertura de orígenes concedidos es un comparador SEPARADO y deliberadamente MÁS
AMPLIO. permissions.getAll() puede informar de concesiones en la gramática completa
de WebExtension (<all_urls>, *://*/*, https://*/*, *://*.host/*,
https://host/*) que el restrictivo MATCH_PATTERN rechazaría. parseGrantedOrigin
las analiza en GrantedOriginParts, y originCovers informa de si un origen
concedido cubre el match de una regla (p. ej. una concesión amplia https://*/*
cubre genuinamente toda regla https). matchAllowsOriginFallback informa de si la
ruta de una regla es exactamente ORIGIN_FALLBACK_PATH (/*) — la intersección
segura de las restricciones de Chromium y de Gecko/Firefox 128 sobre el respaldo
dinámico de origen opaco.
Registro dinámico
packages/webext/src/contentRegistration.ts registra un content script dinámico por
cada regla añadida por el usuario. Los integrados los sirven las entradas estáticas
de content_scripts y NUNCA se registran dinámicamente. desiredContentScripts
filtra config.sites a las reglas no integradas y activadas cuyo origen
isOriginGranted (delegando en originCovers), asigna cada una a un
RegisteredContentScript con un id estable (contentScriptId, un hash FNV-1a del
match para que el id use solo el conjunto de caracteres seguro para la API), y
establece matchOriginAsFallback: true SOLO cuando se cumple
matchAllowsOriginFallback(rule.match) (una ruta /*). Controlar la bandera de este
modo evita que una regla con ruta no conforme provoque el rechazo de todo el registro
por lotes; tal regla se registra sin cobertura de ventana emergente por diseño.
reconcileContentScriptsOnce lee los orígenes concedidos, la configuración
almacenada y los registros en vivo, y luego converge mediante un diff CONSCIENTE DE
LAS PROPIEDADES: sameRegistration proyecta tanto el descriptor deseado como la
relectura en vivo sobre una forma normalizada (aplicando el valor predeterminado real
de WebExtension de cada campo — allFrames/matchOriginAsFallback predeterminan a
false, persistAcrossSessions predetermina a TRUE, runAt predetermina a
document_idle) para que un script recién registrado compare IGUAL al descriptor que
lo produjo y no pueda surgir ningún bucle de reregistro. Como no existe
updateContentScripts, un script modificado se desregistra y luego se vuelve a
registrar. reconcileContentScripts envuelve esto con una promesa en curso
(inFlight) para que dos disparadores casi simultáneos (un cambio de configuración Y
permissions.onAdded activándose ambos al añadir un sitio) se serialicen en lugar de
competir. registerContentReconciliation cablea los disparadores
(permissions.onAdded/onRemoved, onConfigChanged) y converge una vez al arrancar;
solo lo importan los puntos de entrada en segundo plano de Chrome y Firefox.
El flujo de permiso de aceptación explícita
Los manifiestos estáticos declaran optional_host_permissions (http://*/*,
https://*/*) para que un usuario pueda conceder un host web adicional arbitrario en
tiempo de ejecución. addSite de packages/webext/src/options/options.ts valida la
regla candidata COMPLETA (match Y content-root) mediante normaliseSiteRule ANTES de
solicitar ningún permiso, deriva el origen del host con matchOrigin, y llama a
browser.permissions.request desde dentro del gesto de clic del botón Añadir; solo al
concederse añade una fila builtin:false y la persiste a través de la ruta de
escritura canónica.
Configuración, almacenamiento y el patrón canónico
El único embudo de validación
normaliseConfig en packages/core/src/config.ts es la única ruta de lectura
canónica: coacciona y acota cada campo, recurriendo al valor predeterminado ante
cualquier cosa inválida, y nunca lanza una excepción. Está construida a partir de
validadores por campo — normaliseBoolean, normaliseRenderMode,
normaliseMaxLength, normaliseIgnoreHosts (reduciendo cada entrada a un nombre de
host punycode desnudo mediante bareHostname),
normaliseMatchColour/normaliseMismatchColour (controlados por isSafeColour de
safeColour.ts), normaliseCssSize, normaliseFontWeight, normaliseContentRoot
(acotado por CONTENT_ROOT_PATTERN/CONTENT_ROOT_MAX_LENGTH), normaliseMatchPattern
(controlado por MATCH_PATTERN) y normaliseSites. normaliseSites descarta los
rechazos, elimina duplicados por match, FUERZA builtin: true en cualquier regla
cuyo match sea igual al de un integrado (cerrando el vector de
coincidencia-exacta-eclipsa-integrado / doble inyección), vuelve a sembrar cualquier
integrado que falte (para que un almacén manipulado no pueda eliminar un proveedor
central) y ordena alfanuméricamente por match para un orden canónico.
configFingerprint aplica hash solo a los campos que afectan al renderizado
(excluyendo enabled, ignoreHosts y sites, que impulsan arranque/parada y
omisión en lugar de reflujo) mediante fnv1a.
Almacenamiento
packages/webext/src/storage.ts envuelve la API de almacenamiento de WebExtension.
configArea usa browser.storage.sync cuando está disponible (para que los ajustes
itineren) y recurre a browser.storage.local (p. ej. en Thunderbird);
configAreaName informa de cuál está activo. getConfig, setConfig y
onConfigChanged canalizan todos su valor en bruto a través de normaliseConfig, de
modo que un almacén malformado o manipulado nunca puede entregar una Config inválida
a quien la solicita. onConfigChanged ignora además los eventos del área de
almacenamiento INACTIVA y los cambios en claves no relacionadas.
Los dos complementos reutilizan el mismo embudo lectura -> normaliseConfig ->
retorno sobre sus propios almacenes de host. El
extensions/outlook/src/roamingStorage.ts del panel de tareas de Outlook envuelve
Office.context.roamingSettings (get/set síncronos, confirmados con saveAsync);
el packages/gmail/src/propertiesStorage.ts del complemento para Gmail envuelve
PropertiesService.getUserProperties() (por usuario, itinerante entre los
dispositivos de ese usuario, codificado en JSON bajo una sola clave, y sin necesitar
NINGÚN ámbito OAuth adicional — nunca las ScriptProperties compartidas). Ambos exponen
getConfig/setConfig más los accesores relevantes para la tarjeta
getIgnoreHosts/getHighlightMismatch y protegen una superficie de host no
disponible con un error dedicado. El disparador de la página de inicio de Gmail
renderiza una tarjeta de ajustes de CardService a partir de getConfig, y el
manejador de envío de formulario onSaveSettings fusiona los
ignoreHosts/highlightMismatch analizados sobre la configuración actual y la
persiste mediante setConfig; el disparador contextual enhebra ignoreHosts en el
adaptador y highlightMismatch en el constructor de tarjetas, leyendo de forma
defensiva para que un fallo de almacenamiento recurra a los valores predeterminados en
lugar de romper el análisis de mensajes.
El patrón de configuración-canónica + actualización-dirigida
Tres rutas de escritura leen la configuración canónica, cambian exactamente una cosa y la vuelven a escribir — sin confirmar nunca ediciones de formulario no guardadas ni volver a renderizar el formulario:
removeSite(options.ts): leegetConfig, persistesetConfigsobre un arraysitescon solo la regla eliminada filtrada (persistir PRIMERO, luegorow.remove(), luego unpermissions.removecondicional decidido a partir de la siguiente configuración). Un fallo de persistencia lanzaSiteRemovalSaveErrory deja el DOM intacto; una revocación fallida lanzaSitePermissionRevokeErrordespués de que la fila ya ha desaparecido.addSite(options.ts): descrito arriba — validar, solicitar permiso, añadir la fila y luegosaveOptions.toggleEnabled(options.ts): el interruptor maestro instantáneo de Activar cambia SOLOenableden la configuración almacenada y la vuelve a escribir, sin volver a renderizar (para que una edición en curso nunca se machaque); ante una escritura fallida revierte la casilla al valor almacenado.
El toggleEnabled propio de la acción de la barra de herramientas
(packages/webext/src/toolbarAction.ts) sigue el mismo patrón desde el lado del
segundo plano.
Compilación por motor y el contrato de manifiesto
tooling/build.mjs impulsa una única compilación de esbuild para cada destino de
WebExtension. El descriptor TARGETS nombra el DESTINO DE ORIGEN de cada destino:
Chrome, Firefox y Thunderbird incluyen su propio src/; Edge, Opera y Safari declaran
chrome como su origen; el complemento para Outlook (Office.js) es su propio origen.
ACTIVE_TARGETS (Chrome, Edge, Firefox, Opera y Thunderbird — Safari y Outlook están
excluidos) es lo que compilan --all/--package; Safari y Outlook se compilan solo
cuando se nombran explícitamente, y el complemento para Gmail (Apps Script) es un
bundle de esbuild separado (make build-gmail) fuera del bucle --all. Ese bundle de
Gmail apunta al entorno de ejecución V8 de Apps Script, que no tiene ni módulos ES ni
un URL nativo: se compila como ESM y luego se le elimina su sentencia export {…}
final (para que las funciones disparadoras sigan siendo globales de nivel superior que
Apps Script pueda invocar), y empaqueta un pequeño polyfill de URL (instalado solo
cuando URL está ausente) para que el new URL(...) del núcleo compartido resuelva
los enlaces en el lado del servidor. buildTarget limpia dist/<target>/, copia el
manifest.json e icons/ por destino al pie de la letra, empaqueta cada script de
origen como IIFE (los content.ts/messageDisplay.ts inyectados de
INJECTED_SCRIPTS no pueden ser módulos ES, y los segundos planos son también
workers/páginas de eventos clásicos) y la página de opciones compartida como ESM, y
copia options.html/options.css de packages/webext/src/options/.
webextension-polyfill se empaqueta en cada script.
tooling/version.mjs estampa las versiones: toma MAJOR.MINOR del package.json raíz
y añade un número de BUILD autoincremental (leído del tercer componente existente más
alto entre los manifiestos de destino) en cada manifiesto de destino, manteniéndolos
sincronizados. La mayoría de los destinos llevan un único manifest.json; el
complemento para Outlook lleva dos manifiestos versionados (manifest.json y
manifest.xml) y ambos se estampan, y el andamiaje _template también se estampa de
forma sincronizada. El package.json raíz se mantiene en la misma versión, pero sigue
siendo semver válido (se permite una versión de cuatro partes en un manifiesto, nunca
en package.json). tooling/icons.mjs (ejecutado mediante make icons) rasteriza el
único origen vectorial assets/icon.svg en el icons/icon48.png e icons/icon128.png
de cada destino, probando cairosvg, luego inkscape, luego ImageMagick.
El contrato de manifiesto — los campos exactos requeridos por motor — es la única
fuente de verdad en cada extensions/<engine>/test/manifest.smoke.test.mjs (p. ej. el
assertManifestShape de extensions/chrome/test/manifest.smoke.test.mjs, que además
compila el destino y confirma que cada archivo referenciado se resuelve en el dist).
Conforme a la convención de deduplicación del proyecto, la forma campo por campo NO se
vuelve a enumerar aquí; consulta esas pruebas de humo y la nota «Contrato de
manifiesto» en el plan de pruebas manuales.
Internacionalización (i18n)
Reveal URLs está localizado en muchos idiomas además del inglés. El conjunto tiene una
única fuente en packages/core/src/locales.json (SUPPORTED_LOCALES, nombres nativos
y el predeterminado), que leen tanto los bundles de la extensión (mediante la
importación de JSON resuelta) como la compilación del sitio web en Node puro. Cada
cadena no inglesa está traducida automáticamente y pendiente de revisión humana; el
marcador de procedencia es por formato (los _locales usan la description de cada
mensaje, los diccionarios de la interfaz del sitio web lo registran en
site/i18n/README.md, y las fuentes de documentación traducidas llevan un comentario
HTML en la primera línea).
Hay tres superficies de localización independientes, deliberadamente no entrecruzadas, y cada una se divide a lo largo de una línea de tiempo-de-compilación / tiempo-de-ejecución.
- La página de opciones (AD-1). Incluye marcado en inglés ESTÁTICO; cada nodo
traducible lleva una anotación
data-i18n="<key>"(texto) odata-i18n-<attr>="<key>"(atributo). En tiempo de ejecución,packages/webext/src/options/locale.tses la capa de aplicación: hacefetchdel propio_locales/<code>/messages.jsonempaquetado de la página (medianteruntime.getURL, sin necesidad deweb_accessible_resources), reescribe cada nodo anotado SOLO portextContent(para que las sugerencias de ejemplo<code>del formulario sigan intactas), y establece<html lang>. La página está COMPLETAMENTE localizada: junto a la prosa estática, las etiquetas de fila de sitio y el botón Eliminar construidos con JS llevan una clavedata-i18n(con un texto inicialtranslatedo) para que la misma capa de aplicación vuelva a localizar las filas renderizadas sin volver a renderizar, y las cadenas VERDADERAMENTE dinámicas — la línea de estado, la retroalimentación de añadir-sitio y la línea de versión — se renderizan mediante eltranslate(<key>)delocale.ts(currentMessages → respaldo en inglés → clave) en el momento de la llamada. El respaldo en inglés es el catálogo_locales/en/messages.jsonIMPORTADO (empaquetado) enoptions.jsen tiempo de compilación, de modo quetranslatese resuelve a inglés legible de forma SÍNCRONA desde la carga del módulo — una cadena dinámica renderizada antes de que el conmutador se asiente, o después de que una obtención de catálogo en tiempo de ejecución falle, nunca degrada a una clave de mensaje en bruto.locale.tspublica el mapa de mensajes aplicado y dispara sus escuchadoresonLocaleChangedespués de cada aplicación, de modo queoptions.tsvuelve a renderizar el estado/retroalimentación/versión mostrados actualmente y reorienta el enlace del manual en línea en cada cambio. La línea de versión usa un mensajeoptionsVersioncuyo marcador de posición{version}se sustituye por el número de build (token traducido, número al pie de la letra). El enlace del manual sigue el locale activo hasta su variante de documentación (AD-9): el inglés mantiene elmanual.htmlde nivel superior, cualquier otro locale abrenl/handleiding.html, preservando el sello de versión?v=. El locale activo es la elección guardada del usuario o, en su defecto, el idioma del navegador resuelto a un idioma base compatible por elresolveLocaledel núcleo (AD-5); un locale no compatible o fallido deja el inglés prerrenderizado (ytranslatedegrada entonces al respaldo en inglés). El conmutador Idioma de visualización del manual persiste su elección bajo una claveuiLocalededicada enstorage.local— nunca en laConfigsincronizada, de modo que es por dispositivo y nunca itinera. - La interfaz del sitio web (AD-2). El generador de sitio estático
tooling/site.mjsrenderiza en el servidor cada nodo de la interfaz en inglés mientras lo anota condata-i18n, y mantiene<html lang="en-GB">(AD-4). El cargador del lado del navegadorsite/scripts/i18n.mjs(un módulo ES sin dependencias) intercambia luego la interfaz al idioma del visitante por precedencia — una elección persistida enlocalStorage(revealUrlsSiteLocale) → la primera coincidencia de base compatible denavigator.languages(AD-5) → inglés — obteniendosite/i18n/<code>.json. A diferencia de la capa de aplicación de opciones, intercambia medianteinnerHTML, porque los valores de la interfaz del sitio llevan marcado en línea de confianza (<code>,<a>); una prueba de paridad fija la secuencia de etiquetas/atributos/URL de cada valor no inglés idéntica byte a byte al inglés, de modo que una traducción nunca puede eliminar un enlace ni romper una etiqueta. Una clave ausente mantiene la instantánea en inglés prerrenderizada; una obtención fallida deja la página en inglés intacta (degradación elegante, nunca en blanco). - Las páginas de documentación (AD-8/AD-9). Las páginas de documentación se
renderizan POR LOCALE en tiempo de compilación, no se intercambian en tiempo de
ejecución. La documentación en inglés se queda en las rutas de nivel superior;
cualquier otro locale obtiene una copia bajo una carpeta
<code>/, a partir de undocs/<code>/<DOC>.mdtraducido cuando existe, o si no la fuente en inglés — un respaldo por página quebuildSiteREGISTRA en el log en lugar de omitir en silencio. El CUERPO de la documentación no lleva ningúndata-i18n(es markdown renderizado en bruto), de modo que el cargador de interfaz en tiempo de ejecución nunca lo toca; solo se intercambian los nodos de interfaz compartidos. El conmutador de idioma de la página de documentación NAVEGA a la página del locale hermano en lugar de intercambiar en el sitio. - Localización de enlaces internos (Finding 2). El idioma seleccionado se mantiene
a lo largo de la navegación de la documentación. Cada anchor INTERNO de documentación
(las cinco páginas
architecture.html,index.html,licence.html,manual.html,privacy.html) lo etiqueta el generador condata-doc-link="<page>", y para el caso sin JavaScript su href ya se resuelve a la propia variante de locale de la página ACTUAL (navDocHref— las tres páginas de documentación por locale se localizan; las páginas solo en inglésindex.html/licence.htmlse quedan en el nivel superior). ElrewriteDocLinksdel cargador en tiempo de ejecución reorienta luego cada anchor interno de documentación — los etiquetados Y cualquier anchor simple inyectado por el catálogo comomanual.html#installing— a la variante del locale ACTIVO, preservando cualquier#hash/?queryy sin tocar nunca los enlaces externos. En una página de documentación, una elección ALMACENADA que difiera del propio locale de la página NAVEGA a la variante coincidente; la guarda de bucle se dispara solo ante una elección almacenada explícita (nunca una preferencia de idioma del navegador por sí sola) y solo cuando el destino difiere de la página ya mostrada. Los catálogos en sí mantienen sus URLs internas en inglés, de modo que la prueba de paridad sigue en verde — la localización es una pasada pura en tiempo de ejecución sobre el DOM.
Supresión de «Sitios activos» en clientes de correo (AD-3)
Un destino de cliente de correo (Thunderbird hoy) ya ve cada mensaje renderizado, de
modo que el editor «Sitios activos» por host es redundante ahí y se elimina. La
decisión la lleva una bandera de descriptor por destino documentada, mailClient: true,
en TARGETS.thunderbird de tooling/build.mjs — nunca un nombre de destino codificado
a mano en la lógica de compilación o de la interfaz — y se implementa en dos mitades:
- Tiempo de compilación.
copyAssetsse basa endescriptorFor(target).mailClient: para un destino de cliente de correo ejecuta la transformación puraremoveSitesSection(html)(que elimina todo el editor de host<section class="sites">, incluido cada control de añadir-sitio, y lanzaMissingSourceErrorsi la sección está ausente, de modo que un cambio de marcado nunca pueda enviar el editor en silencio) y escribe el resultado; los destinos de navegador recibenoptions.htmlbyte a byte mediantecpSync. - Tiempo de ejecución. El controlador empaquetado
packages/webext/src/options/options.tstolera la sección ausente:renderSites/readSites/applyConfig/readFormsondean#sites-listy no hacen nada en lugar de lanzarOptionsFieldMissingError, yreadFormOMITE la clavesitescuando la sección no está. Como omitirsitespermitiría quenormaliseConfigvolviera a sembrar los valores predeterminados integrados y descartara los sitios del usuario,saveOptionsrestablece lossitesalmacenados antes de normalizar siempre que la sección está ausente — eso es lo que hace que un guardado de cliente de correo preserve los hosts configurados. Una guarda en tiempo de ejecución,guardMailClientSites, elimina la sección si todavía está presente, detectando un cliente de correo por la presencia de API/característica (messages/messageDisplay/scripting.messageDisplayen elbrowserinyectado), nunca por el nombre de destino; se ejecuta primero eninitOptionspara que el cableado posterior de añadir/listar omita naturalmente los elementos ausentes. En un entorno de ejecución de navegador la guarda no hace nada.
Postura de seguridad y privacidad
- Sin red ni exfiltración. Ningún módulo realiza
fetch/XHRni ninguna otra llamada de red; la extensión lee solo la configuración almacenada y el DOM de la página. - Solo DOM seguro. La anotación y la hoja de estilos inyectada usan exclusivamente
createElement/textContent/propiedades tipadasstyle.*, nuncainnerHTML/insertAdjacentHTML/eval. Para el núcleo esto lo imponepackages/core/test/purity.test.ts. - Toda entrada externa/de configuración normalizada. Cada lectura y escritura se
canaliza a través de
normaliseConfig; los colores pasanisSafeColour; los patrones de coincidencia pasanMATCH_PATTERN; los selectores de content-root están acotados porCONTENT_ROOT_PATTERNy solo se entregan aquerySelectorAll/closest, nunca se interpolan en marcado ni CSS. - Privilegio mínimo. Los orígenes de webmail integrados son
host_permissionsfijos; los hosts adicionales son de aceptación explícita medianteoptional_host_permissionsy unpermissions.requestvinculado a un gesto. La acción de la barra de herramientas no declara ningún popup. - Recopilación de datos de Gecko. Los manifiestos de Firefox y Thunderbird declaran
browser_specific_settings.gecko.data_collection_permissions.required: ["none"]. - Licencia. El proyecto es AGPL-3.0-only (
LICENSE,package.json).
Pruebas
- Las pruebas unitarias de Vitest cubren ambos paquetes:
packages/core/test/*(configuración, contraste, discrepancia de host, procesador de enlaces, estilos y la guarda de pureza) ypackages/webext/test/*(contenido, almacenamiento, opciones, registro, barra de herramientas, visualización de mensajes y una prueba de extremo a extremo de sitio personalizado). Cada paquete ejecutavitest run. - Los escenarios BDD de Cucumber residen en
features/(cableados porcucumber.json) e impulsan los módulos reales@reveal-urls/webexta través de fieles dobles de prueba enfeatures/support/webext.ts— en particularnormaliseReadBack, que refleja al motor OMITIENDO las banderas con valor false en la relectura, de modo que un arnés indulgente no pueda ocultar un bucle de reregistro — sobre un documento jsdom configurado enfeatures/support/world.ts. - Las pruebas de humo de manifiesto por destino
(
extensions/<engine>/test/manifest.smoke.test.mjs, elnode:testintegrado de Node) afirman la forma exacta de cada manifiesto y que el dist compilado resuelve cada archivo referenciado.tooling/test/añade pruebas de compilación, de versión y de definición-de-terminado. - CI (
.github/workflows/ci.yml) condiciona cada push y pull request amake test,make bddymake lint(ejecutados directamente en el runner, anulando elRUN/IMAGE_DEPde Docker del Makefile). El estampado de versión y el empaquetado se excluyen deliberadamente; residen en el.github/workflows/release.ymlseparado, disparado por etiquetas.