Reveal URLs — Архітектура
Цей документ описує, як структуровано розширення Reveal URLs і як працює його
основна логіка, для дописувачів і технічних читачів. Він цитує реальні файли
та символи протягом усього тексту (у формі path symbol), тож будь-яке твердження можна перевірити
проти вихідного коду. Опис для користувача див. у посібнику; щодо
кроків ручної перевірки див. план ручного тестування.
Огляд
Reveal URLs — це єдине MV3 WebExtension, зібране для кількох рушіїв — Chrome,
Edge, Opera, Firefox і Thunderbird — з одного спільного, чистого ядра коду. (Safari
відкладено: він має маніфест і збирається, коли названий явно, але
виключений із типової збірки та з CI.) Розширення розкриває URL-адресу призначення кожного
посилання поряд із посиланням у відображеному листі та позначає посилання, чий
видимий текст називає інший реєстрований домен, ніж його href — класичний
ознака фішингу.
Репозиторій — це монорепозиторій pnpm/TypeScript (pnpm-workspace.yaml,
package.json з "private": true). Пакування — це єдиний скрипт на основі esbuild,
tooling/build.mjs, який компілює спільні пакети та тонкі точки входу кожного
рушія в завантажуваний каталог dist/<target>/.
webextension-polyfill вбудовується в кожен скрипт, а не покладається на нього як на
глобальну змінну середовища виконання, тож той самий вихідний код працює незмінно в Chromium і Gecko.
Дві рідні поштові надбудови розширюють те саме ядро виявлення на поверхні, до яких
WebExtension не може дістатися: надбудова Outlook (панель завдань Office.js, що охоплює
Outlook у web/Windows/Mac/iOS/Android і Outlook.com) і надбудова Gmail
(Apps Script CardService, що охоплює вебзастосунки Gmail web/Android/iOS). Обидві повторно використовують лише
ЧИСТИЙ аналіз — новий модуль packages/core/src/findings.ts
(analyseAnchors/analyseHtml, платформо-нейтральна модель Finding[]), який повторно використовує
наявну логіку hostMismatch і виведення реєстрованого домену tldts. Лише
адаптер хоста (як читається й парситься тіло) і подання різняться на кожній
поверхні: панель завдань Outlook парсить тіло власним DOMParser і працює
на боці клієнта; надбудова Gmail парсить за допомогою node-html-parser і працює
на боці сервера в інфраструктурі Apps Script V8 Google (безкоштовний хостинг, оскільки
Google уже зберігає пошту; розгортається локально-інтерактивно через clasp). Оскільки
жоден фреймворк не може змінити відображений DOM повідомлення в режимі читання (Office'ові
setAsync/prependAsync призначені лише для створення; CardService відображає картки, а не HTML
повідомлення), обидві надбудови подають панель/картку знахідок, а не вбудовану анотацію —
наявний шлях мутації DOM linkProcessor.ts і REVEAL_URLS_CSS НЕ
повторно використовуються жодною надбудовою. Відносний href розв'язується лише проти надійного
внутрішньоповідомленого <base href> і в іншому разі пропускається, ніколи проти джерела
поштової скриньки/провайдера (що сфабрикувало б призначення).
Кодова база різко розділяє відповідальності:
packages/coreмістить ЧИСТУ логіку — жодних браузерних API, жодного сховища, жодних приймачів розмітки. Він тестується юніт-тестами на звичайному JavaScript і є єдиним джерелом істини для анотації посилань, валідації конфігурації, зіставлення шаблонів відповідності, порівняння хостів і контрастно-обізнаного розфарбовування.packages/webextобгортає API WebExtension навколо цього ядра: життєвий цикл вмісту, динамічну реєстрацію контентних скриптів, інтерфейс опцій, фонове з'єднання, дію панелі інструментів і шлях показу повідомлень Thunderbird.extensions/<engine>містить тонкі точки входу для кожного рушія плюсmanifest.jsonкожного рушія.toolingмістить скрипти збірки, штампування версій і генерації піктограм.featuresмістить BDD-сценарії Cucumber та їхні тестові двійники.
Структура репозиторію
packages/core — чисте ядро
Кожен модуль тут не імпортує жодного API розширення (browser.*/chrome.*/messenger.*)
і не використовує жодного приймача DOM «рядок-у-розмітку» (innerHTML/insertAdjacentHTML). Цей
контракт механічно забезпечується packages/core/test/purity.test.ts, який
проходить кожен src/**/*.ts, знімає тіла коментарів через власний stripComments
(тож докблоки, що легітимно НАЗИВАЮТЬ заборонені токени, не спрацьовують на
охоронці) і потім стверджує, що ані EXTENSION_API_PATTERN, ані
UNSAFE_DOM_PATTERN не збігається з жодним модулем. Публічна поверхня повторно експортується з
packages/core/src/index.ts. Єдина залежність середовища виконання — tldts (для
порівняння доменів з обізнаністю про публічні суфікси).
Модулі такі:
packages/core/src/config.ts— схемаConfig/SiteRule, типові значення й єдина воронка валідаціїnormaliseConfig.packages/core/src/matchPattern.ts— граматика шаблонів відповідності, зіставник, ранжування специфічності та охоплення наданих джерел.packages/core/src/linkProcessor.ts— анотатор, обмежений документом.packages/core/src/hostMismatch.ts— перевірка невідповідності реєстрованого домену.packages/core/src/styles.tsіpackages/core/src/contrast.ts— інжектована таблиця стилів і контрастно-обізнане розфарбовування.packages/core/src/safeColour.ts— предикат безпечного кольоруisSafeColour.
packages/webext — обгортки браузерних API
Модулі тут МОЖУТЬ використовувати browser.* і є єдиним місцем, де торкаються API WebExtension.
Публічна поверхня повторно експортується з packages/webext/src/index.ts.
Модулі такі: content.ts (життєвий цикл вмісту), contentRegistration.ts
(динамічна реєстрація), storage.ts (збереження конфігурації), background.ts
(поведінка під час установлення), toolbarAction.ts (перемикання + значок), messageDisplay.ts
і messageDisplayBackground.ts (Thunderbird) та options/options.ts (контролер
сторінки налаштувань).
extensions/<engine> — тонкі точки входу та маніфести
Кожен рушій несе власний manifest.json та icons/. Chrome, Firefox і
Thunderbird додатково постачають src/ точок входу; Edge, Opera і Safari
несуть лише маніфест та піктограми й ПОВТОРНО ВИКОРИСТОВУЮТЬ Chrome'ів src/ (оголошено дескриптором
збірки — див. нижче). Точки входу навмисно крихітні: наприклад
extensions/chrome/src/content.ts — це просто
void createContentController().bootstrap();, а
extensions/chrome/src/background.ts викликає registerBackground,
registerContentReconciliation і registerToolbarToggle. Фонова точка входу Firefox
ідентична Chrome'овій; Thunderbird'ова
(extensions/thunderbird/src/background.ts) натомість викликає registerBackground і
registerMessageDisplay (він не має контентних скриптів). extensions/_template
— це готовий до копіювання каркас Chromium, а не ціль збірки.
tooling, features та димові тести
tooling містить build.mjs, version.mjs і icons.mjs. features містить
Gherkin-сценарії, їхні визначення кроків і достовірні тестові двійники в
features/support/webext.ts і features/support/world.ts. Кожен рушій також
несе extensions/<engine>/test/manifest.smoke.test.mjs (_template'ів —
template.smoke.test.mjs) — це авторитетний контракт маніфеста (див.
«Збірка для кожного рушія та контракт маніфеста» нижче).
Конвеєр розкриття
Шлях вмісту починається з точки входу рушія, як-от
extensions/chrome/src/content.ts, що викликає createContentController().bootstrap()
з packages/webext/src/content.ts.
Ініціалізація
createContentController bootstrap виконується один раз на фрейм:
- Він синхронно захоплює фрейм, перед будь-яким
await, черезclaimBootstrap, який читає/встановлює прапорецьBOOTSTRAP_MARKERнаwindowфрейму. Статична вбудована записcontent_scriptsі перекривний динамічний користувацький скрипт можуть обидва інжектуватиcontent.jsв той самий фрейм; спільний ізольованийwindowробить це правильним одноразовим охоронцем, а захоплення перед будь-якимawaitозначає, що дві майже одночасні інжекції не можуть обидві його пройти. - Він завантажує збережену конфігурацію ПЕРШОЮ через
loadConfigWithRetry(BOOTSTRAP_CONFIG_RETRIESдодаткових спроб при тимчасовому відхиленні), утримуючи маркер під час повторних спроб, тож перекривна інжекція, що вже виконала no-op, не залишається покинутою. Лише щойно читання вдається, він інжектує таблицю стилів (injectStyles, яка присвоюєREVEAL_URLS_CSSчерезtextContent), тож невдале читання не додає жодного<style>, а повторна спроба не може його дублювати. - Якщо завантаження конфігурації зазнає невдачі на кожній спробі,
releaseBootstrapвідкочує маркер назад (лише тут, перед початком анотації), тож пізніша повторна інжекція може повторити спробу; щойноstartвиконається, маркер має зберігатися, оскільки свіжий анотатор не володів би вузлами попереднього проходу. - Коли
config.enabled, він викликаєstartі завжди реєструє слухачonConfigChanged, що повторно застосовує конфігурацію (охоплюючи перехід вимкнено→увімкнено).
Визначення активного правила сайту
start (і applyConfig) викликає resolveSiteRule, щоб вирішити, чи — і де —
анотувати. resolveSiteRule спершу пробує ВЛАСНЕ розташування виконуваного документа
через resolveForHref, який фільтрує config.sites до ввімкнених правил і
делегує ядровому selectMostSpecific, тож перемагає найспецифічніший збіг (специфічний
вбудований перемагає широкий користувацький підстановний знак). Коли власне розташування нічого не збігає І
власне джерело документа непрозоре чи успадковане — обмежено hasOpaqueOrigin,
істинне лише для about:blank/about:srcdoc (успадковано через INHERITED_ORIGIN_URLS)
чи blob:/data: (OPAQUE_ORIGIN_SCHEMES) — він повертається, у порядку, до
ВЕРХНЬОГО фрейму (readTopHref, вбудований випадок повідомлення-iframe Proton), вікна
ВІДКРИВАЧА (readOpenerHref, спливне вікно читання Outlook, відкрите через
window.open("about:blank")) і нарешті document.referrer (readReferrer).
Кожне зовнішнє читання захищене від міжджерельного доступу (що кидає виняток) і
робить внесок кандидата лише коли читабельне. ЗВИЧАЙНИЙ незбіжний документ є
авторитетним і ніколи не успадковує правило з навколишнього контексту, залишаючи
контролер інертним.
Обмеження кореня вмісту та спостереження за мутаціями
start записує селектор contentRoot збіжного правила, створює анотатор
через createAnnotator(config) і обробляє кожен збіжний корінь із
selectRoots(doc, contentRoot) (querySelectorAll, обгорнутий у try/catch, тож
недійсний селектор безпечно зазнає невдачі до []). Потім він спостерігає за doc.body (стабільний
контейнер) з OBSERVER_OPTIONS ({ childList: true, subtree: true }).
Спостерігач живить processMutations, який обмежує кожну мутацію коренем вмісту:
він повторно обробляє target мутації лише коли він є Element у межах
кореня (isWithinRoot, через closest) і скеровує кожен доданий Element через
processAddedNode, який анотує вузол, що всередині кореня, Є коренем або
МІСТИТЬ його (лінива монтація SPA). Тож внутрішньокоренева зміна й сусіднє тіло повідомлення,
що монтується пізніше, обидва вловлюються, тоді як інтерфейс застосунку поза коренями залишається
незайманим. Нульовий анотатор (розширення зупинено) — це no-op.
applyConfig узгоджує живу зміну конфігурації: він розбирає, коли жодне правило не збігається
чи прапорець увімкнення вимкнено; перезапускає (відкат + повторне спостереження), коли розв'язаний
contentRoot змінився; і в іншому разі переливає на місці — annotator.setConfig
потім annotator.process над кожним збіжним коренем — без перезапуску
спостерігача.
Анотування одного посилання
Анотатор живе в packages/core/src/linkProcessor.ts. createAnnotator
повертає Annotator над станом замикання, який сторінка не може прочитати: сильний Map,
що ключується за ідентичністю посилання, токен data-ru для кожного анотатора з
generateToken (crypto.getRandomValues) і поточна конфігурація. Його
метод process(root):
- Обрізає записи реєстру, чиє посилання більше не з'єднане.
- Збирає кандидатні посилання з
collectAnchors(нащадковіa[href]плюс сам корінь, коли він єa[href], з видаленням дублікатів). - Розв'язує призначення кожного посилання з
resolveAnnotatableUrl, що повертаєnull(пропустити) для порожнього/внутрішньосторінкового href, непарсабельного href, будь-якої схеми, крімhttp:/https:, чи хоста, рівного чи під записомignoreHosts; він розв'язує відносні href протиbaseURI, тож результат абсолютний. - Бере ідемпотентний швидкий шлях, коли
isAnnotationIntactпідтверджує, що відбиток відображення незмінений і кожен створений вузол досі з'єднаний і на місці; в іншому разі відкочує застарілу анотацію й повторно відображає. - Обчислює невідповідність з
hostMismatchна чистому тексті посилання, шануєshowOnlyOnMismatch(залишаючи чесні посилання незайманими) і викликаєannotateAnchor.
annotateAnchor відображає лише БЕЗПЕЧНИЙ DOM. У режимі "inline" він додає перед
<span class="reveal-urls-url">, чия URL встановлюється через textContent (з префіксом
стрілки та нерозривного пробілу U+00A0, обрізаного truncateUrl), за яким слідує
<br>, залишаючи власних дітей посилання незайманими; з'єднаний span потім отримує
контрастний фон через applyContrastBackdrop. У режимі "title" він зберігає
оригінальний title (TitleSave), перезаписує його URL-адресою — єдиний запис до
посилання — і, при акцентованій невідповідності, додає сусідній
значок <span class="reveal-urls-warn">, що несе REVEAL_URLS_WARN_LABEL. Кожен
створений вузол позначено data-ru="<token>" (ніколи посилання), і кожен запис
записує свій configFingerprint, тож зміна конфігурації відображення змушує перелив. Кольори
та перевизначення шрифтів застосовуються через типізовані властивості element.style.*
(applyColour/applyFontOverrides), ніколи не інтерполюються в рядок CSS.
revertOwned видаляє створені вузли за посиланням і відновлює збережений title;
revertAll робить це для кожного власного посилання й очищає реєстр.
truncateUrl оперує над Unicode КОДОВИМИ ТОЧКАМИ ([...displayHref]), тож розріз
ніколи не розколює сурогатну пару; він зберігає джерело схеми-та-хоста й
скорочує решту з кінцевим URL_ELLIPSIS.
Невідповідність хоста
packages/core/src/hostMismatch.ts hostMismatch порівнює видимий текст посилання
проти його href за реєстрованим доменом, а не сирим іменем хоста, тож чесний
піддомен не позначається, тоді як схожий на справжній позначається. extractHostCandidates розколює
текст і зводить кожен токен через hostCandidate (що вимагає крапку й
розпізнаний tldts публічний суфікс ICANN, відкидаючи звичайний точковий текст як
e.g). І хост href, і кожен кандидат зводяться до їхнього реєстрованого
домену з tldts.getDomain, і БУДЬ-ЯКИЙ кандидат, чий домен відрізняється від
href'ового, дає невідповідність — тож називання справжнього шкідливого хоста поряд із хостом-приманкою
не може придушити попередження. Він ніколи не кидає винятку.
Стилі та контраст
packages/core/src/styles.ts містить інжектований REVEAL_URLS_CSS (фіксований макет,
товщина, форма й фіксований білий, повністю непрозорий фон чіпа — background: #ffffff,
opacity: 1; кольори НІКОЛИ не інтерполюються в нього) і середовища виконання
помічники applyColour/applyFontOverrides/applyContrastBackdrop.
applyContrastBackdrop читає розв'язаний колір елемента та перший непрозорий
фон, знайдений при обході від САМОГО елемента вгору (його власний фон — фіксований
білий типовий чіпа чи специфічніше перевизначення хост-сторінки — перед будь-яким
предком) через стандартний getComputedStyle (з джерела
element.ownerDocument?.defaultView, тож він безпечний для фреймів і заглушуваний) і,
коли packages/core/src/contrast.ts needsWhiteBackdrop повідомляє, що текст не проходить
WCAG AA (CONTRAST_THRESHOLD) проти цього фону І білий справді допомагає,
встановлює style.backgroundColor = "white". contrast.ts надає parseColour,
relativeLuminance і contrastRatio; він парсить форми rgb()/rgba()/hex,
які повертає getComputedStyle, і трактує повністю прозорий колір як «не знайдено».
Налаштовувані хости та обмеження вмісту
SiteRule (packages/core/src/config.ts) каже, ДЕ виконується анотація (match,
allFrames) і ЯКИЙ контейнер її обмежує (contentRoot), плюс enabled і
прапорець builtin. Постачені DEFAULT_SITES охоплюють Gmail, Proton (з
allFrames) і обидва хости Outlook. Config агрегує глобальні перемикачі,
кольори, перевизначення шрифтів і масив sites.
Рушій шаблонів відповідності
packages/core/src/matchPattern.ts містить навмисно ОБМЕЖУВАЛЬНУ граматику для часу додавання
MATCH_PATTERN (лише http/https, необов'язковий провідний підстановний знак хоста *.,
glob-шлях; без порту, без схеми *, без <all_urls>). matchesPattern зіставляє
URL проти валідованого шаблону, делегуючи шлях pathGlobMatches (кожен
* збігає будь-яку послідовність символів); він безпечно зазнає невдачі до false.
parsePatternParts розколює валідований шаблон на host/path/scheme/
wildcardHost.
Перекривні правила розв'язуються за «найспецифічніший перемагає»:
compareSiteSpecificity ранжує за (1) точний хост над підстановним хостом, (2) довший
буквальний хост, (3) більш буквальний шлях (literalPathLength), потім (4)
детермінований ASCII-розв'язник; selectMostSpecific повертає найспецифічніше
ввімкнене правило, що збігає URL.
Охоплення наданого джерела — це ОКРЕМИЙ, навмисно ШИРШИЙ зіставник.
permissions.getAll() може повідомляти надання в повній граматиці WebExtension
(<all_urls>, *://*/*, https://*/*, *://*.host/*, https://host/*), яку
обмежувальний MATCH_PATTERN відкинув би. parseGrantedOrigin парсить їх
у GrantedOriginParts, а originCovers повідомляє, чи надане джерело
охоплює match правила (наприклад широке надання https://*/* справді охоплює кожне
https-правило). matchAllowsOriginFallback повідомляє, чи шлях правила точно
ORIGIN_FALLBACK_PATH (/*) — безпечний перетин обмежень Chromium і
Gecko/Firefox 128 на динамічний непрозоро-джерельний запасний варіант.
Динамічна реєстрація
packages/webext/src/contentRegistration.ts реєструє один динамічний контентний скрипт
на кожне додане користувачем правило. Вбудовані обслуговуються статичними записами content_scripts
і НІКОЛИ не реєструються динамічно. desiredContentScripts фільтрує
config.sites до невбудованих, увімкнених правил, чиє джерело isOriginGranted
(делегуючи originCovers), відображає кожне в RegisteredContentScript зі
стабільним id (contentScriptId, хеш FNV-1a збігу, тож id використовує лише
API-безпечний набір символів) і встановлює matchOriginAsFallback: true ЛИШЕ коли
matchAllowsOriginFallback(rule.match) виконується (шлях /*). Обмеження прапорця
цим способом запобігає тому, щоб одне правило з невідповідним шляхом спричинило відхилення всієї пакетної
реєстрації; таке правило реєструється без охоплення спливних вікон за
задумом.
reconcileContentScriptsOnce читає надані джерела, збережену конфігурацію
та живі реєстрації, потім сходиться ВЛАСТИВІСНО-ОБІЗНАНИМ diff'ом:
sameRegistration проектує як бажаний дескриптор, так і живе зчитування
на нормалізовану форму (застосовуючи реальне типове значення WebExtension кожного поля —
allFrames/matchOriginAsFallback типово false, persistAcrossSessions
типово TRUE, runAt типово document_idle), тож щойно зареєстрований скрипт
порівнюється РІВНИМ дескриптору, що його породив, і жоден цикл повторної реєстрації не може
виникнути. Оскільки немає updateContentScripts, змінений скрипт
скасовується-реєстрацією потім реєструється знову. reconcileContentScripts обгортає це
проміжним обіцянням (inFlight), тож два майже одночасні тригери (зміна конфігурації
І permissions.onAdded, що обидва спрацьовують при додаванні сайту) серіалізуються, а не
конкурують. registerContentReconciliation з'єднує тригери
(permissions.onAdded/onRemoved, onConfigChanged) і сходиться один раз при
запуску; він імпортується лише фоновими точками входу Chrome і Firefox.
Процес добровільного надання дозволів
Статичні маніфести оголошують optional_host_permissions (http://*/*,
https://*/*), тож користувач може надати довільний додатковий вебхост під час виконання.
packages/webext/src/options/options.ts addSite валідує ВСЕ кандидатне
правило (match І content-root) через normaliseSiteRule ПЕРЕД запитом будь-якого
дозволу, виводить джерело хоста з matchOrigin і викликає
browser.permissions.request зсередини жесту клацання кнопки Додати; лише при
наданні він додає рядок builtin:false і зберігає через канонічний шлях запису.
Конфігурація, сховище та канонічний шаблон
Єдина воронка валідації
normaliseConfig у packages/core/src/config.ts — це єдиний канонічний шлях читання:
він приводить і обмежує кожне поле, повертаючись до типового значення при будь-чому
недійсному, і ніколи не кидає винятку. Він побудований із валідаторів для кожного поля —
normaliseBoolean, normaliseRenderMode, normaliseMaxLength,
normaliseIgnoreHosts (зводячи кожен запис до простого punycode-імені хоста через
bareHostname), normaliseMatchColour/normaliseMismatchColour (обмежено
safeColour.ts isSafeColour), normaliseCssSize, normaliseFontWeight,
normaliseContentRoot (обмежено CONTENT_ROOT_PATTERN/CONTENT_ROOT_MAX_LENGTH),
normaliseMatchPattern (обмежено MATCH_PATTERN) і normaliseSites.
normaliseSites відкидає відхилені, видаляє дублікати за match, ПРИМУШУЄ builtin: true
на будь-якому правилі, чий match дорівнює вбудованому (закриваючи
вектор точний-збіг-затіняє-вбудований / подвійна-інжекція), повторно засіває будь-який відсутній
вбудований (тож підроблене сховище не може відкинути ядрового провайдера) і сортує
алфавітно-цифрово за match для канонічного порядку. configFingerprint хешує
лише поля, що впливають на відображення (виключаючи enabled, ignoreHosts і sites,
які керують запуском/зупинкою та пропуском, а не переливом) через fnv1a.
Сховище
packages/webext/src/storage.ts обгортає API сховища WebExtension.
configArea використовує browser.storage.sync, коли доступно (тож налаштування роумінгом переміщуються), і
повертається до browser.storage.local (наприклад у Thunderbird); configAreaName
повідомляє, яке активне. getConfig, setConfig і onConfigChanged усі
провадять своє сире значення через normaliseConfig, тож спотворене чи підроблене сховище
ніколи не може передати недійсний Config викликачеві. onConfigChanged додатково
ігнорує події з НЕАКТИВНОЇ області сховища та зміни незв'язаних ключів.
Дві надбудови повторно використовують ту саму воронку читання -> normaliseConfig -> повернення над їхніми
власними хост-сховищами. extensions/outlook/src/roamingStorage.ts панелі завдань Outlook
обгортає Office.context.roamingSettings (синхронні get/set, підтверджені
saveAsync); packages/gmail/src/propertiesStorage.ts надбудови Gmail обгортає
PropertiesService.getUserProperties() (для кожного користувача, роумінгом переміщується між пристроями цього
користувача, JSON-закодовано під одним ключем і не потребує жодної додаткової області OAuth — ніколи
спільних ScriptProperties). Обидві надають getConfig/setConfig плюс релевантні для картки
аксесори getIgnoreHosts/getHighlightMismatch і захищають недоступну хост-поверхню
виділеною помилкою. Тригер домашньої сторінки Gmail відображає картку налаштувань CardService
з getConfig, а обробник надсилання форми onSaveSettings зливає
розібрані ignoreHosts/highlightMismatch на поточну конфігурацію й зберігає її
через setConfig; контекстний тригер протягує ignoreHosts в адаптер
і highlightMismatch у будівник картки, читаючи захисно, тож збій сховища
повертається до типових значень, а не ламає аналіз повідомлень.
Шаблон канонічної конфігурації та точкового оновлення
Три шляхи запису читають канонічну конфігурацію, змінюють точно одну річ і записують її назад — ніколи не фіксуючи незбережені правки форми чи повторно не відображаючи форму:
removeSite(options.ts): читаєgetConfig, зберігаєsetConfigнад масивомsitesлише з відфільтрованим видаленим правилом (зберегти СПЕРШУ, потімrow.remove(), потім умовнийpermissions.remove, вирішений із наступної конфігурації). Збій збереження кидаєSiteRemovalSaveErrorі залишає DOM незайманим; невдале відкликання кидаєSitePermissionRevokeErrorпісля того, як рядок уже зник.addSite(options.ts): описано вище — валідувати, запитати дозвіл, додати рядок, потімsaveOptions.toggleEnabled(options.ts): миттєвий головний вимикач Увімкнути перемикає ЛИШЕenabledна збереженій конфігурації й записує її назад, без повторного відображення (тож правка в процесі ніколи не затирається); при невдалому записі він повертає прапорець до збереженого значення.
Власний toggleEnabled дії панелі інструментів
(packages/webext/src/toolbarAction.ts) слідує тому самому шаблону з
фонового боку.
Збірка для кожного рушія та контракт маніфеста
tooling/build.mjs керує єдиною збіркою esbuild для кожної цілі WebExtension.
Дескриптор TARGETS називає ЦІЛЬОВЕ ДЖЕРЕЛО кожної цілі: Chrome, Firefox і
Thunderbird постачають власний src/; Edge, Opera і Safari оголошують chrome як своє
джерело; надбудова Outlook (Office.js) — це власне джерело. ACTIVE_TARGETS (Chrome,
Edge, Firefox, Opera і Thunderbird — Safari і Outlook виключені) — це те, що
збирають --all/--package; Safari і Outlook збираються лише коли названі явно, а
надбудова Gmail (Apps Script) — це окрема збірка esbuild (make build-gmail)
поза циклом --all. Ця збірка Gmail націлена на середовище виконання V8 Apps Script, яке
не має ані ES-модулів, ані рідного URL: вона зібрана як ESM і потім має свою
кінцеву інструкцію export {…} знятою (тож функції-тригери залишаються глобальними верхнього рівня,
які Apps Script може викликати), і вона вбудовує невеликий поліфіл URL (встановлений лише
коли URL відсутній), тож ядрове new URL(...) розв'язує посилання на боці сервера.
buildTarget очищає dist/<target>/, копіює manifest.json кожної цілі та
icons/ дослівно, вбудовує кожен вихідний скрипт як IIFE (інжектовані
INJECTED_SCRIPTS content.ts/messageDisplay.ts не можуть бути ES-модулями, а
фони — теж класичні воркери/сторінки подій) і спільну сторінку опцій як
ESM, і копіює options.html/options.css з packages/webext/src/options/.
webextension-polyfill вбудовується в кожен скрипт.
tooling/version.mjs штампує версії: він бере MAJOR.MINOR з кореневого
package.json і додає авто-інкрементний номер BUILD (зчитаний із
найвищого наявного третього компонента серед маніфестів цілей) у кожен маніфест
цілі, тримаючи їх у синхронізації. Більшість цілей несуть єдиний manifest.json;
надбудова Outlook несе два версіоновані маніфести (manifest.json і manifest.xml)
і обидва штампуються, і каркас _template штампується в синхронізації теж. Кореневий
package.json тримається на тій самій версії, але залишається валідним semver (чотиричастинна
версія дозволена в маніфесті, ніколи в package.json). tooling/icons.mjs
(запускається через make icons) растеризує єдине векторне джерело assets/icon.svg в
кожної цілі icons/icon48.png і icons/icon128.png, пробуючи cairosvg, потім
inkscape, потім ImageMagick.
Контракт маніфеста — точні обов'язкові поля на кожен рушій — це єдине
джерело істини в кожному extensions/<engine>/test/manifest.smoke.test.mjs (наприклад
assertManifestShape в extensions/chrome/test/manifest.smoke.test.mjs, яка
також збирає ціль і підтверджує, що кожен файл, на який посилаються, розв'язується в dist).
За конвенцією проєкту про видалення дублювання, форма поле-за-полем НЕ
перелічується тут знову; звертайтеся до цих димових тестів і до нотатки «Контракт маніфеста»
у плані ручного тестування.
Інтернаціоналізація (i18n)
Reveal URLs локалізовано багатьма мовами, крім англійської. Набір
єдиноджерельний у packages/core/src/locales.json (SUPPORTED_LOCALES, рідні
імена й типове значення), який читають і збірки розширення (через розв'язаний JSON-імпорт),
і чиста-Node збірка вебсайту. Кожен неанглійський рядок
машинно перекладений і очікує перевірки людиною; маркер походження — на кожен формат
(_locales використовують description кожного повідомлення, словники чрому вебсайту
записують його в site/i18n/README.md, а перекладені джерела документів несуть першорядний
HTML-коментар).
Є три незалежні поверхні локалізації, навмисно не перехресно з'єднані, і кожна розділена вздовж лінії час-збірки / час-виконання.
- Сторінка опцій (AD-1). Вона постачає СТАТИЧНУ англійську розмітку; кожен перекладний
вузол несе анотацію
data-i18n="<key>"(текст) чиdata-i18n-<attr>="<key>"(атрибут). Під час виконанняpackages/webext/src/options/locale.ts— це застосовний шар: вінfetchить власний упакований_locales/<code>/messages.jsonсторінки (черезruntime.getURL, без потреби вweb_accessible_resources), переписує кожен анотований вузол заtextContentЛИШЕ (тож приклад-підказки форми<code>залишаються незайманими) і встановлює<html lang>. Сторінка ПОВНІСТЮ локалізована: поряд зі статичною прозою, JS-побудовані мітки рядків сайтів і кнопка Видалити несуть ключdata-i18n(з початковимtranslateнутим текстом), тож той самий застосовний шар повторно локалізує відображені рядки без повторного відображення, а СПРАВДІ динамічні рядки — рядок статусу, зворотний зв'язок додавання сайту та рядок версії — відображаються черезtranslate(<key>)locale.ts'у (currentMessages → англійський запасний варіант → ключ) під час виклику. Англійський запасний варіант — це каталог_locales/en/messages.json, ІМПОРТОВАНИЙ (вбудований) вoptions.jsпід час збірки, тожtranslateрозв'язується до читабельної англійської СИНХРОННО з завантаження модуля — динамічний рядок, відображений до того, як перемикач усталиться, чи після того, як зчитування каталогу середовища виконання зазнає невдачі, ніколи не деградує до сирого ключа повідомлення.locale.tsпублікує застосовану карту повідомлень і спрацьовує своїх слухачівonLocaleChangeпісля кожного застосування, тожoptions.tsповторно відображає поточно показаний статус/зворотний зв'язок/версію й повторно спрямовує посилання Онлайн-посібник при кожному перемиканні. Рядок версії використовує повідомленняoptionsVersion, чий заповнювач{version}замінюється номером збірки (токен перекладено, число дослівно). Посилання посібника слідує за активною локаллю до її варіанта документа (AD-9): англійська тримає верхньорівневийmanual.html, кожна інша локаль відкриває свій локалізований файл під<code>/(наприкладnl/handleiding.html, перекладено через ядровеlocalisedSitePage— та сама спільна карта, під якою збірка вебсайту випускає файл), зберігаючи штамп версії?v=. Активна локаль — це збережений вибір користувача чи, за його відсутності, мова браузера, розв'язана до підтримуваної базової мови ядровимresolveLocale(AD-5); непідтримувана чи невдала локаль залишає попередньо відображену англійську (аtranslateтоді деградує до англійського запасного варіанта). Ручний перемикач Мова відображення зберігає свій вибір під виділеним ключемuiLocaleуstorage.local— ніколи в синхронізованомуConfig, тож він для кожного пристрою й ніколи не роумінгом. - Чрому вебсайту (AD-2). Генератор статичного сайту
tooling/site.mjsсервер-відображає кожен вузол чрому англійською, анотуючи йогоdata-i18n, і тримає<html lang="en-GB">(AD-4). Завантажувач на боці браузераsite/scripts/i18n.mjs(ES-модуль без залежностей) потім міняє чрому на мову відвідувача за пріоритетом — збережений вибірlocalStorage(revealUrlsSiteLocale) → перший підтримуваний базовий збігnavigator.languages(AD-5) → англійська — завантажуючиsite/i18n/<code>.json. На відміну від застосовного шару опцій, він міняє черезinnerHTML, оскільки значення чрому сайту несуть довірену вбудовану розмітку (<code>,<a>); тест паритету пінить послідовність тегів/атрибутів/URL кожного неанглійського значення байт-ідентично англійській, тож переклад ніколи не може відкинути посилання чи зламати тег. Відсутній ключ тримає попередньо відображений англійський знімок; невдале завантаження залишає англійську сторінку незайманою (граційна деградація, ніколи не порожньою). - Сторінки документації (AD-8/AD-9). Сторінки документів відображаються НА КОЖНУ ЛОКАЛЬ під час
збірки, а не міняються під час виконання. Англійські документи залишаються на верхньорівневих шляхах; кожна
інша локаль отримує копію під текою
<code>/, під іменем файлу, ПЕРЕКЛАДЕНИМ на локаль (nl/handleiding.html,de/datenschutz.html) зі спільної картиpackages/core/src/site-page-names.json(домашня сторінка залишаєтьсяindex.html), з перекладеногоdocs/<code>/<DOC>.md, коли присутній, інакше англійського джерела — запасний варіант на кожну сторінку, якийbuildSiteЛОГУЄ, а не тихо опускає. Оскільки попередня модель публікувала їх під їхніми англійськими іменами, збірка також випускає тонкеnoindex-перенаправлення на кожному перейменованому старому шляху (nl/manual.html→nl/handleiding.html), тож старий закладок, результат пошуку чи посилання посібника вже встановленого старшого розширення досі розв'язується. ТІЛО документа не несеdata-i18n(це сирий відображений markdown), тож завантажувач чрому середовища виконання ніколи його не торкається; міняються лише спільні вузли чрому. Перемикач мови сторінки документа НАВІГУЄ до сторінки сусідньої локалі, а не міняє на місці. - Локалізація внутрішніх посилань (Finding 2). Вибрана мова переноситься через
навігацію документів. Кожне ВНУТРІШНЄ посилання документа (п'ять сторінок
architecture.html,index.html,licence.html,manual.html,privacy.html) позначеноdata-doc-link="<page>"генератором, і для випадку без JavaScript його href уже розв'язується до варіанта ПОТОЧНОЇ сторінки її власної локалі, під її перекладеним іменем файлу (navDocHref— кожна внутрішня сторінка тепер має варіант на кожну локаль; лише домашня сторінка тримаєindex.htmlу кожній локалі). Завантажувача середовища виконанняrewriteDocLinksпотім повторно спрямовує кожне внутрішнє посилання документа — ті, що позначені, І будь-яке впроваджене каталогом просте посилання, як-отmanual.html#installing— до варіанта АКТИВНОЇ локалі, зберігаючи будь-який#hash/?queryі ніколи не торкаючись зовнішніх посилань. Тегdata-doc-linkзавжди несе КАНОНІЧНЕ англійське ім'я сторінки; розв'язаний href використовує ПЕРЕКЛАДЕНЕ ім'я файлу локалі (AD-9,nl/handleiding.html), тож завантажувач відображає локалізоване ім'я файлу назад до його канонічної сторінки, щоб залишатися ідемпотентним. На сторінці документа, ЗБЕРЕЖЕНИЙ вибір, що відрізняється від власної локалі сторінки, НАВІГУЄ до відповідного варіанта; охоронець циклу спрацьовує лише при явному збереженому виборі (ніколи саме на перевагу мови навігатора) і лише коли ціль відрізняється від уже показаної сторінки. Самі каталоги тримають свої англійські внутрішні URL, тож тест паритету залишається зеленим — локалізація — це чистий прохід середовища виконання над DOM.
Придушення «Активних сайтів» у поштовому клієнті (AD-3)
Ціль поштового клієнта (Thunderbird сьогодні) уже бачить кожне відображене повідомлення, тож
редактор «Активні сайти» за хостами там надлишковий і видаляється. Рішення
несеться документованим дескриптором на кожну ціль, прапорцем mailClient: true, на
TARGETS.thunderbird tooling/build.mjs — ніколи жорстко закодованим ім'ям цілі в
логіці збірки чи UI — і реалізується у дві половини:
- Час збірки.
copyAssetsключується заdescriptorFor(target).mailClient: для цілі поштового клієнта він виконує чисту трансформаціюremoveSitesSection(html)(яка знімає весь редактор хостів<section class="sites">, зокрема кожен елемент керування додаванням сайту, і кидаєMissingSourceError, якщо розділ відсутній, тож зміна розмітки ніколи не може тихо постачити редактор) і записує результат; браузерні цілі отримуютьoptions.htmlбайт-за-байтом черезcpSync. - Час виконання. Вбудований контролер
packages/webext/src/options/options.tsтолерує відсутній розділ:renderSites/readSites/applyConfig/readFormпрощупують#sites-listі виконують no-op, а не кидаютьOptionsFieldMissingError, аreadFormОПУСКАЄ ключsites, коли розділ зник. Оскільки опущенняsitesдозволило бnormaliseConfigповторно засіяти вбудовані типові значення й відкинути сайти користувача,saveOptionsвідновлює збереженіsitesперед нормалізацією щоразу, коли розділ відсутній — це те, що змушує збереження поштового клієнта зберігати налаштовані хости. Охоронець середовища виконання,guardMailClientSites, видаляє розділ, якщо він досі присутній, виявляючи поштовий клієнт за наявністю API/функції (messages/messageDisplay/scripting.messageDisplayна інжектованомуbrowser), ніколи за іменем цілі; він виконується першим уinitOptions, тож пізніше з'єднання додавання/списку природно пропускає відсутні елементи. У браузерному середовищі виконання охоронець — це no-op.
Постава безпеки та конфіденційності
- Жодної мережі чи ексфільтрації. Жоден модуль не виконує
fetch/XHRчи будь-який інший мережевий виклик; розширення читає лише збережену конфігурацію та DOM сторінки. - Лише безпечний DOM. Анотація та інжектована таблиця стилів використовують
createElement/textContent/типізовані властивостіstyle.*виключно, ніколиinnerHTML/insertAdjacentHTML/eval. Для ядра це забезпечуєтьсяpackages/core/test/purity.test.ts. - Уся зовнішня/конфігураційна вхідна нормалізована. Кожне читання й запис провадиться через
normaliseConfig; кольори проходятьisSafeColour; шаблони відповідності проходятьMATCH_PATTERN; селектори content-root обмеженоCONTENT_ROOT_PATTERNі завжди передаються лишеquerySelectorAll/closest, ніколи не інтерполюються в розмітку чи CSS. - Найменша привілея. Вбудовані джерела вебпошти — це фіксовані
host_permissions; додаткові хости — добровільні черезoptional_host_permissionsі прив'язаний до жестуpermissions.request. Дія панелі інструментів не оголошує спливного вікна. - Збір даних Gecko. Маніфести Firefox і Thunderbird оголошують
browser_specific_settings.gecko.data_collection_permissions.required: ["none"]. - Ліцензія. Проєкт — AGPL-3.0-only (
LICENSE,package.json).
Тестування
- Юніт-тести Vitest охоплюють обидва пакети:
packages/core/test/*(конфігурація, контраст, невідповідність хоста, процесор посилань, стилі та охоронець чистоти) іpackages/webext/test/*(вміст, сховище, опції, реєстрація, панель інструментів, показ повідомлень і власний наскрізний тест користувацького сайту). Кожен пакет запускаєvitest run. - BDD Cucumber сценарії живуть у
features/(з'єднаноcucumber.json) і проводять реальні модулі@reveal-urls/webextчерез достовірні тестові двійники вfeatures/support/webext.ts— зокремаnormaliseReadBack, який віддзеркалює рушій, що ОПУСКАЄ прапорці зі значенням false при зчитуванні, тож поблажливий каркас не може приховати цикл повторної реєстрації — над документом jsdom, налаштованим уfeatures/support/world.ts. - Димові тести маніфеста на кожну ціль (
extensions/<engine>/test/manifest.smoke.test.mjs, вбудованийnode:testNode) стверджують точну форму кожного маніфеста й те, що зібраний dist розв'язує кожен файл, на який посилаються.tooling/test/додає тести збірки, версії та визначення-готовності. - CI (
.github/workflows/ci.yml) обмежує кожен push і pull request наmake test,make bddіmake lint(запущені безпосередньо на раннері, перевизначаючи Docker'овіRUN/IMAGE_DEPMakefile'у). Штампування версій і пакування навмисно виключені; вони живуть в окремому, запущеному тегом.github/workflows/release.yml.