Reveal URLs — Arquitetura
Este documento descreve como a extensão Reveal URLs está estruturada e como funciona a
sua lógica central, para contribuidores e leitores técnicos. Cita ficheiros e símbolos
reais ao longo do texto (na forma path symbol) para que qualquer afirmação possa ser
verificada contra o código-fonte. Para a descrição orientada ao utilizador consulte o manual; para os
passos de verificação manual consulte o plano de teste manual.
Visão geral
O Reveal URLs é uma única WebExtension MV3 construída para vários motores — Chrome,
Edge, Opera, Firefox e Thunderbird — a partir de um núcleo de código partilhado e puro.
(O Safari está adiado: tem um manifesto e é construível quando nomeado explicitamente,
mas está excluído da compilação predefinida e da CI.) A extensão revela o URL de destino
de cada ligação ao lado da ligação num e-mail renderizado, e assinala uma ligação cujo
texto visível indica um domínio registável diferente do seu href — o clássico sinal de
phishing.
O repositório é um monorepo pnpm/TypeScript (pnpm-workspace.yaml,
package.json com "private": true). O empacotamento é um único script conduzido por
esbuild, tooling/build.mjs, que compila os pacotes partilhados e os pontos de entrada
finos de cada motor num diretório dist/<target>/ carregável.
O webextension-polyfill é empacotado em cada script em vez de ser dependente de um
global de runtime, pelo que o mesmo código-fonte é executado sem alterações no Chromium e
no Gecko.
Dois extras de e-mail nativos estendem o mesmo núcleo de deteção a superfícies que a
WebExtension não consegue alcançar: um Suplemento do Outlook (painel de tarefas
Office.js, alcançando o Outlook na web/Windows/Mac/iOS/Android e o Outlook.com) e um
Extra do Gmail (Apps Script CardService, alcançando as aplicações web/Android/iOS do
Gmail). Ambos reutilizam apenas a análise PURA — um novo módulo packages/core/src/findings.ts
(analyseAnchors/analyseHtml, um modelo Finding[] neutro em relação à plataforma) que
reutiliza a lógica hostMismatch existente e a derivação de domínio registável do
tldts. Apenas o adaptador do anfitrião (como o corpo é lido e analisado) e a
apresentação diferem por superfície: o painel de tarefas do Outlook analisa o corpo com o
seu próprio DOMParser e é executado no lado do cliente; o extra do Gmail analisa com
node-html-parser e é executado no lado do servidor na infraestrutura Apps Script V8 da
Google (alojamento gratuito, uma vez que a Google já detém o correio; implementado
localmente de forma interativa via clasp). Como nenhum dos frameworks consegue mutar o
DOM da mensagem renderizada em modo de leitura (os setAsync/prependAsync do Office são
apenas de composição; o CardService renderiza cartões, não HTML de mensagem), ambos os
extras apresentam um painel/cartão de conclusões, não uma anotação inline — o caminho
de mutação do DOM linkProcessor.ts existente e o REVEAL_URLS_CSS NÃO são reutilizados
por nenhum dos extras. Um href relativo é resolvido apenas contra um <base href> de
confiança no e-mail e de outra forma é ignorado, nunca contra a origem da
caixa de correio/fornecedor (o que fabricaria um destino).
A base de código separa as responsabilidades de forma clara:
packages/corecontém lógica PURA — sem APIs de navegador, sem armazenamento, sem sumidouros de marcação. É testável por unidades em JavaScript simples e é a única fonte de verdade para a anotação de ligações, a validação de configuração, a correspondência de padrões, a comparação de anfitriões e a coloração ciente do contraste.packages/webextenvolve as APIs de WebExtension em torno desse núcleo: o ciclo de vida de conteúdo, o registo dinâmico de content-scripts, a UI de opções, a ligação de fundo, a ação da barra de ferramentas e o caminho de apresentação de mensagens do Thunderbird.extensions/<engine>contém os pontos de entrada finos por motor mais omanifest.jsonde cada motor.toolingcontém os scripts de compilação, de aposição de versão e de geração de ícones.featurescontém os cenários BDD do Cucumber e os seus test doubles.
Estrutura do repositório
packages/core — o núcleo puro
Cada módulo aqui não importa nenhuma API de extensão (browser.*/chrome.*/messenger.*)
e não usa nenhum sumidouro de DOM string-para-marcação (innerHTML/insertAdjacentHTML).
Esse contrato é mecanicamente imposto por packages/core/test/purity.test.ts, que
percorre com glob cada src/**/*.ts, retira os corpos de comentários via o seu próprio
stripComments (para que os docblocks que legitimamente NOMEIAM os tokens proibidos não
acionem a guarda) e depois afirma que nem EXTENSION_API_PATTERN nem
UNSAFE_DOM_PATTERN correspondem a qualquer módulo. A superfície pública é reexportada a
partir de packages/core/src/index.ts. A única dependência de runtime é tldts (para
comparação de domínios ciente do sufixo público).
Os módulos são:
packages/core/src/config.ts— o esquemaConfig/SiteRule, as predefinições e o único funil de validaçãonormaliseConfig.packages/core/src/matchPattern.ts— a gramática de padrões de correspondência, o correspondente, a classificação de especificidade e a cobertura de origens concedidas.packages/core/src/linkProcessor.ts— o anotador com âmbito de documento.packages/core/src/hostMismatch.ts— a verificação de não correspondência de domínio registável.packages/core/src/styles.tsepackages/core/src/contrast.ts— a folha de estilos injetável e a coloração ciente do contraste.packages/core/src/safeColour.ts— o predicado de cor seguraisSafeColour.
packages/webext — os invólucros da API do navegador
Os módulos aqui PODEM usar browser.* e são o único sítio onde as APIs de WebExtension
são tocadas. A superfície pública é reexportada a partir de packages/webext/src/index.ts.
Os módulos são: content.ts (ciclo de vida de conteúdo), contentRegistration.ts
(registo dinâmico), storage.ts (persistência de configuração), background.ts
(comportamento na instalação), toolbarAction.ts (alternância + emblema), messageDisplay.ts
e messageDisplayBackground.ts (Thunderbird), e options/options.ts (o
controlador da página de definições).
extensions/<engine> — pontos de entrada finos e manifestos
Cada motor tem o seu próprio manifest.json e icons/. O Chrome, o Firefox e o
Thunderbird adicionalmente fornecem um src/ de pontos de entrada; o Edge, o Opera e o
Safari têm apenas um manifesto e ícones e REUTILIZAM o src/ do Chrome (declarado pelo
descritor de compilação — ver abaixo). Os pontos de entrada são deliberadamente minúsculos:
por exemplo extensions/chrome/src/content.ts é apenas
void createContentController().bootstrap();, e
extensions/chrome/src/background.ts chama registerBackground,
registerContentReconciliation e registerToolbarToggle. O ponto de entrada de fundo do
Firefox é idêntico ao do Chrome; o do Thunderbird
(extensions/thunderbird/src/background.ts) chama registerBackground e
registerMessageDisplay em vez disso (não tem content scripts). O extensions/_template
é um scaffold Chromium pronto a copiar, não um alvo de compilação.
tooling, features e os testes de fumo
O tooling contém build.mjs, version.mjs e icons.mjs. O features contém os
cenários Gherkin, as suas definições de passos e os test doubles fiéis em
features/support/webext.ts e features/support/world.ts. Cada motor também
tem um extensions/<engine>/test/manifest.smoke.test.mjs (o do _template é
template.smoke.test.mjs) — estes são o contrato de manifesto autoritativo (ver
"Compilação por motor e o contrato de manifesto" abaixo).
O pipeline de revelação
O caminho de conteúdo começa num ponto de entrada de motor como
extensions/chrome/src/content.ts, que chama createContentController().bootstrap()
a partir de packages/webext/src/content.ts.
Bootstrap
createContentController bootstrap é executado uma vez por frame:
- Reivindica o frame de forma síncrona, antes de qualquer
await, viaclaimBootstrap, que lê/define a flagBOOTSTRAP_MARKERnowindowdo frame. Uma entrada estática incorporada decontent_scriptse um user script dinâmico sobreposto podem ambos injetarcontent.jsno mesmo frame; owindowde mundo isolado partilhado torna isto na guarda correta de execução única, e reivindicar antes de qualquerawaitsignifica que duas injeções quase simultâneas não podem ambas passá-la. - Carrega a configuração persistida PRIMEIRO via
loadConfigWithRetry(BOOTSTRAP_CONFIG_RETRIEStentativas extra numa rejeição transitória), mantendo o marcador ao longo das tentativas para que uma injeção sobreposta que já se anulou não fique presa. Só depois de a leitura ter êxito é que injeta a folha de estilos (injectStyles, que atribuiREVEAL_URLS_CSSviatextContent), pelo que uma leitura falhada não acrescenta nenhum<style>e uma nova tentativa não o pode duplicar. - Se o carregamento da configuração falhar em todas as tentativas,
releaseBootstrapreverte o marcador (só aqui, antes de a anotação começar) para que uma reinjeção posterior possa tentar de novo; uma vez questarttenha sido executado o marcador tem de persistir, porque um anotador novo não seria dono dos nós da passagem anterior. - Quando
config.enabledchamastart, e regista sempre um ouvinteonConfigChangedque reaplica a configuração (cobrindo a transição desativado→ativado).
Resolver a regra de site ativa
start (e applyConfig) chama resolveSiteRule para decidir se — e onde —
anotar. resolveSiteRule tenta primeiro a PRÓPRIA localização do documento em execução
via resolveForHref, que filtra config.sites para as regras ativadas e
delega ao selectMostSpecific do núcleo para que ganhe a correspondência mais específica
(um incorporado específico vence um wildcard de utilizador amplo). Quando a própria
localização não corresponde a nada E a própria origem do documento é opaca ou herdada —
condicionado por hasOpaqueOrigin, verdadeiro apenas para about:blank/about:srcdoc
(herdado via INHERITED_ORIGIN_URLS) ou blob:/data: (OPAQUE_ORIGIN_SCHEMES) — recorre,
por ordem, ao frame de TOPO (readTopHref, o caso incorporado do iframe de mensagem do
Proton), à janela OPENER (readOpenerHref, a janela de leitura pop-out do Outlook aberta
via window.open("about:blank")) e finalmente ao document.referrer (readReferrer).
Cada leitura externa é protegida contra o acesso entre origens (que lança exceção) e
contribui com um candidato apenas quando legível. Um documento ORDINÁRIO não correspondido
é autoritativo e nunca herda uma regra do contexto ambiente, deixando o
controlador inerte.
Limitar à raiz de conteúdo e observar mutações
start regista o seletor contentRoot da regra correspondente, cria o anotador
via createAnnotator(config), e processa cada raiz correspondente de
selectRoots(doc, contentRoot) (um querySelectorAll envolvido em try/catch para que um
seletor inválido falhe em segurança para []). Depois observa doc.body (um contentor
estável) com OBSERVER_OPTIONS ({ childList: true, subtree: true }).
O observador alimenta processMutations, que condiciona cada mutação à raiz de
conteúdo: reprocessa um target de mutação apenas quando é um Element dentro de uma
raiz (isWithinRoot, via closest), e encaminha cada Element adicionado através de
processAddedNode, que anota um nó que está dentro de uma raiz, É uma raiz, ou
CONTÉM uma (uma montagem SPA preguiçosa). Assim, tanto a agitação dentro da raiz como um
corpo de mensagem irmão a montar mais tarde são ambos apanhados, enquanto a interface da
aplicação fora das raízes é deixada intacta. Um anotador nulo (a extensão está parada) é
uma operação nula.
applyConfig reconcilia uma alteração de configuração ao vivo: desmonta quando nenhuma
regra corresponde ou a flag de ativação está desligada; reinicia (reverter + reobservar)
quando o contentRoot resolvido mudou; e de outra forma reflui no lugar — annotator.setConfig
depois annotator.process sobre cada raiz correspondente — sem reiniciar o
observador.
Anotar uma única âncora
O anotador vive em packages/core/src/linkProcessor.ts. createAnnotator
devolve um Annotator sobre estado de closure que a página não consegue ler: um Map
forte com chave por identidade de âncora, um token data-ru por anotador de
generateToken (crypto.getRandomValues), e a configuração atual. O seu
método process(root):
- Poda as entradas de registo cuja âncora já não está ligada.
- Recolhe âncoras candidatas com
collectAnchors(descendentesa[href]mais a própria raiz quando é uma[href], sem duplicados). - Resolve o destino de cada âncora com
resolveAnnotatableUrl, que devolvenull(ignorar) para um href vazio/na mesma página, um href não analisável, qualquer esquema diferente dehttp:/https:, ou um anfitrião igual ou sob uma entradaignoreHosts; resolve hrefs relativos contrabaseURIpara que o resultado seja absoluto. - Toma o caminho rápido idempotente quando
isAnnotationIntactconfirma que a impressão digital de renderização não mudou e que cada nó criado ainda está ligado e no lugar; de outra forma reverte a anotação obsoleta e volta a renderizar. - Calcula a não correspondência com
hostMismatchsobre o texto limpo da âncora, honrashowOnlyOnMismatch(deixando as ligações honestas intactas), e chamaannotateAnchor.
annotateAnchor renderiza apenas SAFE-DOM. No modo "inline" antepõe um
<span class="reveal-urls-url"> cujo URL é definido via textContent (prefixado com
uma seta e um espaço inquebrável U+00A0, truncado por truncateUrl) seguido de
um <br>, deixando os próprios filhos da ligação intactos; o span ligado recebe então um
fundo de contraste via applyContrastBackdrop. No modo "title" guarda o
title original (TitleSave), sobrescreve-o com o URL — a única escrita na
âncora — e, numa não correspondência enfatizada, anexa um emblema irmão
<span class="reveal-urls-warn"> com REVEAL_URLS_WARN_LABEL. Cada
nó criado é etiquetado data-ru="<token>" (nunca a âncora), e cada entrada
regista o seu configFingerprint para que uma alteração de configuração de renderização
force um refluxo. As cores e as substituições de tipo de letra são aplicadas através de
propriedades tipadas element.style.* (applyColour/applyFontOverrides), nunca
interpoladas numa string de CSS. revertOwned remove os nós criados por referência e
restaura o título guardado; revertAll faz isto para cada âncora possuída e limpa o
registo.
truncateUrl opera sobre PONTOS DE CÓDIGO Unicode ([...displayHref]), pelo que um corte
nunca divide um par de substituição; preserva a origem esquema-e-anfitrião e
encurta o resto com um URL_ELLIPSIS final.
Não correspondência de anfitrião
packages/core/src/hostMismatch.ts hostMismatch compara o texto visível da ligação
contra o seu href por domínio registável, não pelo nome de anfitrião bruto, pelo que um
subdomínio honesto não é assinalado enquanto um semelhante é. extractHostCandidates
divide o texto e reduz cada token via hostCandidate (que exige um ponto e um
sufixo público ICANN reconhecido pelo tldts, rejeitando texto com pontos comum como
e.g). Tanto o anfitrião do href como cada candidato são reduzidos ao seu domínio
registável com tldts.getDomain, e QUALQUER candidato cujo domínio difira do
do href produz uma não correspondência — pelo que nomear o verdadeiro anfitrião malicioso
ao lado de um anfitrião isco não pode suprimir o aviso. Nunca lança exceção.
Estilos e contraste
packages/core/src/styles.ts contém o REVEAL_URLS_CSS injetável (disposição fixa,
espessura, forma e um fundo de chip branco fixo, totalmente opaco — background: #ffffff,
opacity: 1; as cores NUNCA são interpoladas nele) e os auxiliares de runtime
applyColour/applyFontOverrides/applyContrastBackdrop.
applyContrastBackdrop lê a cor resolvida do elemento e o primeiro fundo opaco
encontrado ao percorrer do PRÓPRIO elemento para cima (o seu próprio fundo — o branco
predefinido fixo do chip ou uma substituição mais específica da página anfitriã — antes de
qualquer ascendente) através do getComputedStyle padrão (obtido de
element.ownerDocument?.defaultView, pelo que é seguro em frames e stubbable) e,
quando packages/core/src/contrast.ts needsWhiteBackdrop reporta que o texto falha
o WCAG AA (CONTRAST_THRESHOLD) contra esse fundo E o branco genuinamente ajuda,
define style.backgroundColor = "white". contrast.ts fornece parseColour,
relativeLuminance e contrastRatio; analisa as formas rgb()/rgba()/hex
que o getComputedStyle devolve e trata uma cor totalmente transparente como "não
encontrada".
Anfitriões configuráveis e âmbito de conteúdo
Uma SiteRule (packages/core/src/config.ts) diz ONDE a anotação é executada (match,
allFrames) e QUAL contentor a limita (contentRoot), mais enabled e uma
flag builtin. Os DEFAULT_SITES fornecidos cobrem o Gmail, o Proton (com
allFrames) e ambos os anfitriões do Outlook. A Config agrega os interruptores globais,
as cores, as substituições de tipo de letra e o array sites.
O motor de padrões de correspondência
packages/core/src/matchPattern.ts contém uma gramática de tempo de adição
deliberadamente RESTRITIVA MATCH_PATTERN (apenas http/https, wildcard de anfitrião
principal *. opcional, glob de caminho; sem porta, sem esquema *, sem <all_urls>).
matchesPattern faz corresponder um URL contra um padrão validado, delegando o caminho a
pathGlobMatches (cada * corresponde a qualquer sequência de caracteres); falha em
segurança para false. parsePatternParts divide um padrão validado em
host/path/scheme/wildcardHost.
As regras sobrepostas resolvem-se por "o mais específico vence":
compareSiteSpecificity classifica por (1) anfitrião exato sobre anfitrião wildcard, (2)
anfitrião literal mais longo, (3) caminho mais literal (literalPathLength), depois (4) um
desempate ASCII determinístico; selectMostSpecific devolve a regra ativada mais
específica que corresponde a um URL.
A cobertura de origem concedida é um correspondente SEPARADO, deliberadamente MAIS AMPLO.
permissions.getAll() pode reportar concessões na gramática completa de WebExtension
(<all_urls>, *://*/*, https://*/*, *://*.host/*, https://host/*) que
o MATCH_PATTERN restritivo rejeitaria. parseGrantedOrigin analisa essas
em GrantedOriginParts, e originCovers reporta se uma origem concedida
cobre o match de uma regra (por exemplo, uma concessão ampla https://*/* cobre
genuinamente cada regra https). matchAllowsOriginFallback reporta se o caminho de uma
regra é exatamente ORIGIN_FALLBACK_PATH (/*) — a interseção segura das restrições do
Chromium e do Gecko/Firefox 128 sobre o fallback dinâmico de origem opaca.
Registo dinâmico
packages/webext/src/contentRegistration.ts regista um content script dinâmico
por regra adicionada pelo utilizador. Os incorporados são servidos pelas entradas
estáticas content_scripts e NUNCA são registados dinamicamente. desiredContentScripts
filtra config.sites para regras não incorporadas e ativadas cuja origem isOriginGranted
(delegando a originCovers), mapeia cada uma para um RegisteredContentScript com um
id estável (contentScriptId, um hash FNV-1a do match para que o id use apenas o
conjunto de caracteres seguro para a API), e define matchOriginAsFallback: true APENAS
quando matchAllowsOriginFallback(rule.match) se verifica (um caminho /*). Condicionar
a flag desta forma evita que uma regra de caminho não conforme faça com que todo o registo
em lote seja rejeitado; tal regra regista-se sem cobertura pop-out por
design.
reconcileContentScriptsOnce lê as origens concedidas, a configuração armazenada
e os registos ao vivo, depois converge por uma diferença CIENTE DE PROPRIEDADES:
sameRegistration projeta tanto o descritor desejado como a leitura ao vivo
numa forma normalizada (aplicando a predefinição real de WebExtension de cada campo —
allFrames/matchOriginAsFallback predefinem false, persistAcrossSessions
predefine TRUE, runAt predefine document_idle) para que um script recém-registado
compare IGUAL ao descritor que o produziu e nenhum ciclo de novo registo possa
surgir. Como não há updateContentScripts, um script alterado é
anulado o registo e depois registado de novo. reconcileContentScripts envolve isto com
uma promessa em curso (inFlight) para que dois acionamentos quase simultâneos (uma
alteração de configuração E permissions.onAdded a disparar ambos numa adição de site)
sejam serializados em vez de correrem em competição. registerContentReconciliation liga
os acionamentos (permissions.onAdded/onRemoved, onConfigChanged) e converge uma vez
no arranque; é importado apenas pelos pontos de entrada de fundo do Chrome e do Firefox.
O fluxo de permissão opt-in
Os manifestos estáticos declaram optional_host_permissions (http://*/*,
https://*/*) para que um utilizador possa conceder um anfitrião web adicional arbitrário
em tempo de execução. packages/webext/src/options/options.ts addSite valida TODA a
regra candidata (match E raiz de conteúdo) através de normaliseSiteRule ANTES de pedir
qualquer permissão, deriva a origem do anfitrião com matchOrigin, e chama
browser.permissions.request a partir do gesto de clique do botão Adicionar; apenas na
concessão é que anexa uma linha builtin:false e persiste através do caminho de escrita
canónico.
Configuração, armazenamento e o padrão canónico
O único funil de validação
normaliseConfig em packages/core/src/config.ts é o único caminho de leitura canónico:
coage e limita cada campo, recorrendo à predefinição em tudo o que for
inválido, e nunca lança exceção. É construído a partir de validadores por campo —
normaliseBoolean, normaliseRenderMode, normaliseMaxLength,
normaliseIgnoreHosts (reduzindo cada entrada a um nome de anfitrião punycode simples via
bareHostname), normaliseMatchColour/normaliseMismatchColour (condicionados pelo
isSafeColour de safeColour.ts), normaliseCssSize, normaliseFontWeight,
normaliseContentRoot (limitado por CONTENT_ROOT_PATTERN/CONTENT_ROOT_MAX_LENGTH),
normaliseMatchPattern (condicionado por MATCH_PATTERN) e normaliseSites.
normaliseSites descarta os rejeitados, remove duplicados por match, FORÇA builtin: true
em qualquer regra cujo match seja igual ao de um incorporado (fechando o
vetor de correspondência-exata-oculta-incorporado / dupla injeção), volta a semear qualquer
incorporado em falta (para que um armazenamento adulterado não possa remover um fornecedor
central) e ordena alfanumericamente por match para uma ordem canónica. configFingerprint
faz hash apenas dos campos que afetam a renderização (excluindo enabled, ignoreHosts e
sites, que conduzem iniciar/parar e ignorar em vez de refluir) via fnv1a.
Armazenamento
packages/webext/src/storage.ts envolve a API de armazenamento de WebExtension.
configArea usa browser.storage.sync quando disponível (para que as definições viagem) e
recorre a browser.storage.local (por exemplo no Thunderbird); configAreaName
reporta qual está ativo. getConfig, setConfig e onConfigChanged todos
canalizam o seu valor bruto através de normaliseConfig, pelo que um armazenamento
malformado ou adulterado nunca pode entregar uma Config inválida a um chamador.
onConfigChanged adicionalmente ignora eventos da área de armazenamento INATIVA e
alterações a chaves não relacionadas.
Os dois extras reutilizam o mesmo funil ler -> normaliseConfig -> devolver sobre os seus
próprios armazenamentos de anfitrião. O extensions/outlook/src/roamingStorage.ts do
painel de tarefas do Outlook envolve Office.context.roamingSettings (get/set
síncronos, confirmados com saveAsync); o packages/gmail/src/propertiesStorage.ts do
extra do Gmail envolve PropertiesService.getUserProperties() (por utilizador, com roaming
entre os dispositivos desse utilizador, codificado em JSON sob uma chave, e não precisando
de NENHUM âmbito OAuth extra — nunca as ScriptProperties partilhadas). Ambos expõem
getConfig/setConfig mais os acessores relevantes para o cartão
getIgnoreHosts/getHighlightMismatch e protegem uma superfície de anfitrião indisponível
com um erro dedicado. O acionamento de página inicial do Gmail renderiza um cartão de
definições CardService a partir de getConfig, e o manipulador de submissão de formulário
onSaveSettings funde o ignoreHosts/highlightMismatch analisado sobre a configuração
atual e persiste-o através de setConfig; o acionamento contextual encaminha ignoreHosts
para o adaptador e highlightMismatch para o construtor de cartões, lendo de forma
defensiva para que uma falha de armazenamento recorra às predefinições em vez de quebrar a
análise de mensagens.
O padrão configuração-canónica + atualização-direcionada
Três caminhos de escrita leem a configuração canónica, alteram exatamente uma coisa e escrevem-na de volta — nunca confirmando edições de formulário não guardadas ou voltando a renderizar o formulário:
removeSite(options.ts): lêgetConfig, persistesetConfigsobre um arraysitesapenas com a regra removida filtrada (persistir PRIMEIRO, depoisrow.remove(), depois umpermissions.removecondicional decidido a partir da configuração seguinte). Uma falha de persistência lançaSiteRemovalSaveErrore deixa o DOM intacto; uma revogação falhada lançaSitePermissionRevokeErrordepois de a linha já ter desaparecido.addSite(options.ts): descrito acima — validar, pedir permissão, anexar a linha, depoissaveOptions.toggleEnabled(options.ts): o interruptor principal Ativar instantâneo alterna APENASenabledna configuração armazenada e escreve-o de volta, sem voltar a renderizar (para que uma edição em curso nunca seja atropelada); numa escrita falhada reverte a caixa de verificação para o valor armazenado.
O próprio toggleEnabled da ação da barra de ferramentas
(packages/webext/src/toolbarAction.ts) segue o mesmo padrão a partir do
lado de fundo.
Compilação por motor e o contrato de manifesto
tooling/build.mjs conduz uma única compilação esbuild para cada alvo de WebExtension. O
descritor TARGETS nomeia o ALVO DE ORIGEM de cada alvo: o Chrome, o Firefox e o
Thunderbird fornecem o seu próprio src/; o Edge, o Opera e o Safari declaram chrome como
a sua origem; o suplemento do Outlook (Office.js) é a sua própria origem. ACTIVE_TARGETS
(Chrome, Edge, Firefox, Opera e Thunderbird — o Safari e o Outlook estão excluídos) é o que
--all/--package compilam; o Safari e o Outlook compilam apenas quando nomeados
explicitamente, e o extra do Gmail (Apps Script) é um pacote esbuild separado (make build-gmail)
fora do ciclo --all. Esse pacote do Gmail visa o runtime V8 do Apps Script, que
não tem nem módulos ES nem um URL nativo: é construído como ESM e depois tem a sua
instrução export {…} final retirada (para que as funções de acionamento permaneçam
globais de nível superior que o Apps Script pode invocar), e empacota um pequeno polyfill
de URL (instalado apenas quando URL está ausente) para que o new URL(...) do núcleo
partilhado resolva ligações no lado do servidor. buildTarget limpa dist/<target>/,
copia o manifest.json e icons/ por alvo textualmente, empacota cada script de origem
como IIFE (os INJECTED_SCRIPTS injetados content.ts/messageDisplay.ts não podem ser
módulos ES, e os fundos são workers clássicos/event pages também) e a página de opções
partilhada como ESM, e copia options.html/options.css de packages/webext/src/options/.
O webextension-polyfill é empacotado em cada script.
tooling/version.mjs apõe versões: pega em MAJOR.MINOR do package.json raiz
e anexa um número de BUILD auto-incrementável (lido do
terceiro componente existente mais alto entre os manifestos de alvo) em cada manifesto de
alvo, mantendo-os em sincronia. A maioria dos alvos tem um único manifest.json; o
suplemento do Outlook tem dois manifestos versionados (manifest.json e manifest.xml)
e ambos são apostos, e o scaffold _template é aposto em sincronia também. O
package.json raiz é mantido na mesma versão, mas permanece semver válido (uma versão de
quatro partes é permitida num manifesto, nunca em package.json). tooling/icons.mjs
(executado via make icons) rasteriza a única fonte vetorial assets/icon.svg em
cada icons/icon48.png e icons/icon128.png de alvo, tentando cairosvg, depois
inkscape, depois ImageMagick.
O contrato de manifesto — os campos exatos exigidos por motor — é a única
fonte de verdade em cada extensions/<engine>/test/manifest.smoke.test.mjs (por exemplo o
assertManifestShape de extensions/chrome/test/manifest.smoke.test.mjs, que
também constrói o alvo e confirma que cada ficheiro referenciado resolve na dist).
Por convenção de desduplicação do projeto, a forma campo-a-campo NÃO é
reenumerada aqui; consulte esses testes de fumo e a nota "Contrato de manifesto"
no plano de teste manual.
Internacionalização (i18n)
O Reveal URLs está localizado em muitos idiomas além do inglês. O conjunto é
fornecido a partir de uma única fonte em packages/core/src/locales.json (SUPPORTED_LOCALES, nomes
nativos e a predefinição), que tanto os pacotes da extensão (via a importação JSON resolvida)
como a compilação do sítio web em Node simples leem. Cada string não inglesa é
traduzida automaticamente e está em espera de revisão humana; o marcador de proveniência é
por formato (os _locales usam a description de cada mensagem, os dicionários de chrome
do sítio web registam-no em site/i18n/README.md, e as fontes de documentos traduzidos
transportam um comentário HTML na primeira linha).
Há três superfícies de localização independentes, deliberadamente não interligadas, e cada uma é dividida ao longo de uma linha tempo-de-compilação / tempo-de-execução.
- A página de opções (AD-1). Fornece marcação estática em inglês; cada nó traduzível
transporta uma anotação
data-i18n="<key>"(texto) oudata-i18n-<attr>="<key>"(atributo). Em tempo de execuçãopackages/webext/src/options/locale.tsé a camada de aplicação: fazfetchdo_locales/<code>/messages.jsonempacotado da própria página (viaruntime.getURL, semweb_accessible_resourcesnecessário), reescreve cada nó anotado apenas portextContent(para que as dicas de exemplo<code>do formulário permaneçam intactas), e define<html lang>. A página está TOTALMENTE localizada: ao lado da prosa estática, as etiquetas de linha de site e o botão Remover construídos em JS transportam uma chavedata-i18n(com um texto inicialtranslated) para que a mesma camada de aplicação volte a localizar as linhas renderizadas sem uma nova renderização, e as strings VERDADEIRAMENTE dinâmicas — a linha de estado, o feedback de adição de site e a linha de versão — renderizam através dotranslate(<key>)delocale.ts(currentMessages → fallback inglês → chave) no momento da chamada. O fallback inglês é o catálogo_locales/en/messages.jsonIMPORTADO (empacotado) emoptions.jsno tempo de compilação, pelo quetranslateresolve para inglês legível SINCRONAMENTE a partir do carregamento do módulo — uma string dinâmica renderizada antes de o seletor assentar, ou depois de uma obtenção de catálogo de runtime falhar, nunca degrada para uma chave de mensagem bruta.locale.tspublica o mapa de mensagens aplicado e dispara os seus ouvintesonLocaleChangeapós cada aplicação, pelo queoptions.tsvolta a renderizar o estado/feedback/versão atualmente mostrado e volta a apontar a ligação Manual online em cada mudança. A linha de versão usa uma mensagemoptionsVersioncujo marcador{version}é substituído pelo número de compilação (token traduzido, número textual). A ligação do manual segue o idioma ativo para a sua variante de documento (AD-9): o inglês mantém omanual.htmlde nível superior, cada outro idioma abre o seu nome de ficheiro localizado sob<code>/(por exemplonl/handleiding.html, traduzido via olocalisedSitePagedo núcleo — o mesmo mapa partilhado sob o qual a compilação do sítio web emite o ficheiro), preservando o carimbo de versão?v=. O idioma ativo é a escolha guardada do utilizador ou, na sua falta, o idioma do navegador resolvido para um idioma base suportado peloresolveLocaledo núcleo (AD-5); um idioma não suportado ou falhado deixa o inglês pré-renderizado (etranslatedegrada então para o fallback inglês). O seletor Idioma de apresentação do manual persiste a sua escolha sob uma chaveuiLocalededicada emstorage.local— nunca naConfigsincronizada, pelo que é por dispositivo e nunca faz roaming. - O chrome do sítio web (AD-2). O gerador de sítio estático
tooling/site.mjsrenderiza no servidor cada nó de chrome em inglês enquanto o anotadata-i18n, e mantém<html lang="en-GB">(AD-4). O carregador do lado do navegadorsite/scripts/i18n.mjs(um módulo ES sem dependências) troca então o chrome para o idioma do visitante por precedência — uma escolhalocalStoragepersistida (revealUrlsSiteLocale) → a primeira correspondência de basenavigator.languagessuportada (AD-5) → inglês, obtendosite/i18n/<code>.json. Ao contrário da camada de aplicação de opções troca viainnerHTML, porque os valores de chrome do sítio transportam marcação inline de confiança (<code>,<a>); um teste de paridade fixa a sequência de tag/atributo/URL de cada valor não inglês byte-idêntica ao inglês para que uma tradução nunca possa remover uma ligação ou quebrar uma tag. Uma chave em falta mantém o snapshot inglês pré-renderizado; uma obtenção falhada deixa a página inglesa intacta (degradação graciosa, nunca em branco). - As páginas de documentação (AD-8/AD-9). As páginas de documentos são renderizadas POR
IDIOMA no tempo de compilação, não trocadas em tempo de execução. Os documentos ingleses
permanecem nos caminhos de nível superior; cada outro idioma recebe uma cópia sob uma
pasta
<code>/, sob um nome de ficheiro TRADUZIDO para o idioma (nl/handleiding.html,de/datenschutz.html) a partir do mapa partilhadopackages/core/src/site-page-names.json(a página inicial permaneceindex.html), a partir de umdocs/<code>/<DOC>.mdtraduzido quando presente, senão a fonte inglesa — um fallback por página quebuildSiteREGISTA em vez de omitir silenciosamente. Como o modelo anterior publicava estes sob os seus nomes ingleses, a compilação também emite um redirecionamentonoindexfino em cada caminho antigo renomeado (nl/manual.html→nl/handleiding.html) para que um marcador antigo, um resultado de pesquisa ou a ligação do manual de uma extensão mais antiga já instalada ainda resolvam. O CORPO do documento não transportadata-i18n(é markdown renderizado em bruto), pelo que o carregador de chrome de runtime nunca lhe toca; apenas os nós de chrome partilhados trocam. O seletor de idioma da página de documentos NAVEGA para a página do idioma irmão em vez de trocar no lugar. - Localização de ligações internas (Finding 2). O idioma selecionado transporta-se
através da navegação de documentos. Cada âncora de documento INTERNA (as cinco páginas
architecture.html,index.html,licence.html,manual.html,privacy.html) é etiquetadadata-doc-link="<page>"pelo gerador, e para o caso sem JavaScript o seu href já resolve para a variante de idioma da PÁGINA ATUAL, sob o seu nome de ficheiro traduzido (navDocHref— cada página interna tem agora uma variante por idioma; apenas a página inicial mantémindex.htmlem cada idioma). OrewriteDocLinksdo carregador de runtime volta então a direcionar cada âncora de documento interna — as etiquetadas E qualquer âncora simples injetada por catálogo comomanual.html#installing— para a variante do idioma ATIVO, preservando qualquer#hash/?querye nunca tocando em ligações externas. A etiquetadata-doc-linktransporta sempre o nome de página canónico inglês; o href resolvido usa o nome de ficheiro TRADUZIDO do idioma (AD-9,nl/handleiding.html), pelo que o carregador mapeia um nome de ficheiro localizado de volta para a sua página canónica para permanecer idempotente. Numa página de documentos, uma escolha ARMAZENADA que difira do próprio idioma da página NAVEGA para a variante correspondente; a guarda de ciclo dispara apenas numa escolha armazenada explícita (nunca numa preferência de idioma de navegador só por si) e apenas quando o alvo difere da página já mostrada. Os próprios catálogos mantêm os seus URLs internos ingleses, pelo que o teste de paridade permanece verde — a localização é uma passagem de runtime pura sobre o DOM.
Supressão de "Sites ativos" no cliente de e-mail (AD-3)
Um alvo de cliente de e-mail (o Thunderbird hoje) já vê cada mensagem renderizada, pelo
que o editor "Sites ativos" por anfitrião é redundante aí e é removido. A decisão
é transportada por uma flag de descritor por alvo documentada, mailClient: true, no
TARGETS.thunderbird de tooling/build.mjs — nunca um nome de alvo codificado na lógica
de compilação ou UI — e é implementada em duas metades:
- Tempo de compilação.
copyAssetsbaseia-se emdescriptorFor(target).mailClient: para um alvo de cliente de e-mail executa a transformação puraremoveSitesSection(html)(que retira todo o editor de anfitrião<section class="sites">, incluindo cada controlo de adição de site, e lançaMissingSourceErrorse a secção estiver ausente para que uma alteração de marcação nunca possa fornecer silenciosamente o editor) e escreve o resultado; os alvos de navegador recebemoptions.htmlbyte-a-byte viacpSync. - Tempo de execução. O controlador empacotado
packages/webext/src/options/options.tstolera a secção ausente:renderSites/readSites/applyConfig/readFormsondam#sites-liste não fazem nada em vez de lançarOptionsFieldMissingError, ereadFormOMITE a chavesitesquando a secção desapareceu. Como omitirsitesdeixarianormaliseConfigvoltar a semear as predefinições incorporadas e descartar os sites do utilizador,saveOptionsreinstala ossitesarmazenados antes de normalizar sempre que a secção está ausente — é isso que faz com que uma gravação de cliente de e-mail preserve os anfitriões configurados. Uma guarda de runtime,guardMailClientSites, remove a secção se ela ainda estiver presente, detetando um cliente de e-mail por presença de API/funcionalidade (messages/messageDisplay/scripting.messageDisplaynobrowserinjetado), nunca por nome de alvo; é executada primeiro eminitOptionspara que a ligação posterior de adição/lista naturalmente ignore os elementos ausentes. Num runtime de navegador a guarda é uma operação nula.
Postura de segurança e privacidade
- Sem rede ou exfiltração. Nenhum módulo executa
fetch/XHRou qualquer outra chamada de rede; a extensão lê apenas a configuração armazenada e o DOM da página. - Apenas Safe-DOM. A anotação e a folha de estilos injetada usam
createElement/textContent/propriedades tipadasstyle.*exclusivamente, nuncainnerHTML/insertAdjacentHTML/eval. Para o núcleo isto é imposto porpackages/core/test/purity.test.ts. - Toda a entrada externa/de configuração normalizada. Cada leitura e escrita canaliza
através de
normaliseConfig; as cores passamisSafeColour; os padrões de correspondência passamMATCH_PATTERN; os seletores de raiz de conteúdo são limitados porCONTENT_ROOT_PATTERNe apenas entregues aquerySelectorAll/closest, nunca interpolados em marcação ou CSS. - Menor privilégio. As origens de webmail incorporadas são
host_permissionsfixas; os anfitriões adicionais são opt-in viaoptional_host_permissionse umpermissions.requestligado a gesto. A ação da barra de ferramentas não declara popup. - Recolha de dados Gecko. Os manifestos do Firefox e do Thunderbird declaram
browser_specific_settings.gecko.data_collection_permissions.required: ["none"]. - Licença. O projeto é AGPL-3.0-only (
LICENSE,package.json).
Testes
- Testes unitários Vitest cobrem ambos os pacotes:
packages/core/test/*(config, contraste, não correspondência de anfitrião, processador de ligações, estilos e a guarda de pureza) epackages/webext/test/*(conteúdo, armazenamento, opções, registo, barra de ferramentas, apresentação de mensagens e um teste ponta a ponta de site personalizado). Cada pacote executavitest run. - BDD Cucumber os cenários vivem em
features/(ligados porcucumber.json) e conduzem os módulos reais@reveal-urls/webextatravés de test doubles fiéis emfeatures/support/webext.ts— notavelmentenormaliseReadBack, que espelha o motor a OMITIR flags de valor falso na releitura para que um arnês tolerante não possa esconder um ciclo de novo registo — sobre um documento jsdom configurado emfeatures/support/world.ts. - Testes de fumo de manifesto por alvo (
extensions/<engine>/test/manifest.smoke.test.mjs, onode:testincorporado do Node) afirmam a forma exata de cada manifesto e que o dist construído resolve cada ficheiro referenciado.tooling/test/acrescenta testes de compilação, versão e definition-of-done. - CI (
.github/workflows/ci.yml) protege cada push e pull request emmake test,make bddemake lint(executados diretamente no runner, substituindo oRUN/IMAGE_DEPDocker do Makefile). A aposição de versão e o empacotamento são deliberadamente excluídos; vivem no.github/workflows/release.ymlseparado, acionado por tag.