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:

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/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:

  1. Reclama el frame de forma síncrona, antes de cualquier await, mediante claimBootstrap, que lee/establece la bandera BOOTSTRAP_MARKER en el window del frame. Una entrada estática integrada de content_scripts y un script de usuario dinámico que se solape pueden inyectar ambos content.js en el mismo frame; el window compartido del mundo aislado hace de esto la guarda correcta de una sola vez, y reclamarlo antes de cualquier await significa que dos inyecciones casi simultáneas no pueden superarla ambas.
  2. Carga PRIMERO la configuración persistida mediante loadConfigWithRetry (BOOTSTRAP_CONFIG_RETRIES intentos 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 asigna REVEAL_URLS_CSS mediante textContent), de modo que una lectura fallida no añade ningún <style> y un reintento no puede duplicarlo.
  3. Si la carga de configuración falla en todos los intentos, releaseBootstrap revierte el marcador (solo aquí, antes de que comience la anotación) para que una reinyección posterior pueda reintentarlo; una vez que start se ha ejecutado, el marcador debe persistir, porque un anotador nuevo no sería propietario de los nodos de la pasada anterior.
  4. Cuando config.enabled, llama a start, y siempre registra un escuchador onConfigChanged que 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):

  1. Poda las entradas del registro cuyo anchor ya no está conectado.
  2. Recopila los anchors candidatos con collectAnchors (los a[href] descendientes más la propia raíz cuando es un a[href], sin duplicados).
  3. Resuelve el destino de cada anchor con resolveAnnotatableUrl, que devuelve null (omitir) para un href vacío/dentro de la página, un href no analizable, cualquier esquema distinto de http:/https:, o un host igual a o bajo una entrada de ignoreHosts; resuelve los href relativos contra baseURI para que el resultado sea absoluto.
  4. Toma la ruta rápida idempotente cuando isAnnotationIntact confirma 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.
  5. Calcula la discrepancia con hostMismatch sobre el texto limpio del anchor, respeta showOnlyOnMismatch (dejando intactos los enlaces honestos), y llama a annotateAnchor.

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:

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.

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:

Postura de seguridad y privacidad

Pruebas