Браузерные расширения от А до П, где П — публикация в Google Store

от автора

Всем привет! Меня зовут Александр, я продуктовый инженер в KTS.

Недавно мы разрабатывали AI-копайлот для сервис-деска в виде расширения на Chrome. Копайлот подсказывал оператору ответы для клиента на основе контекста диалога, истории обращений и базы знаний компании. Уже на старте стало понятно, что разработка расширений сильно отличается от привычной фронтенд-разработки.

Основная сложность была не столько в реализации конкретных фич, сколько в архитектуре: где должен жить тот или иной код, как организовать взаимодействие между частями расширения и как не заложить проблемы на будущее. Дополнительно добавились нюансы интеграции в страницу и ограничения, связанные с публикацией в Chrome Web Store.

Поударявшись о всевозможные подводные камни, я решил написать эту статью. В ней я разберу ключевые этапы разработки и публикации, покажу удачные и неудачные подходы и на что стоит обратить внимание, если вы планируете делать браузерное расширение со сложной логикой.

Сразу задам фрейм: в статье речь пойдет именно о расширениях для Chrome (Manifest V3), хотя многие подходы будут применимы и к другим браузерам на базе Chromium.

Старт разработки

Первым вопросом был выбор инструмента для разработки. На первый взгляд кажется, что можно просто создать обычное React-приложение на Vite и начать писать код. Однако браузерное расширение состоит из нескольких независимых частей (popup, content scripts, background/service worker), требует специального манифеста и особой схемы сборки. Настроить все это вручную можно, но по мере роста проекта конфигурация быстро усложняется.

Поэтому я решил посмотреть, какие инструменты уже существуют для разработки расширений и насколько хорошо они сочетаются с нашим текущим стеком, состоящим из:

  • React;

  • Typescript;

  • React Router;

  • React Query.

Еще мне было важно, чтобы инструмент позволял подробно разобраться в том, как это все работает «под капотом», и не скрывал какие-то слои под уровнями абстракций.

В итоге выбор был сделан в пользу плагина CRXJS, так как он:

  • использует привычный нам Vite в качестве сборщика;

  • имеет архитектуру, близкую к обычному React-приложению;

  • дает возможность гибко масштабировать проект по мере роста функциональности.

Отдельным преимуществом стало то, что CRXJS не скрывает базовые механизмы работы браузерных расширений, что упрощает поддержку и делает архитектуру более прозрачной для команды.

CRXJS предлагает свое руководство по созданию простого браузерного расширения с нуля. Примитивное расширение может состоять всего из четырех файлов:

  1. index.html — UI расширения;

  2. manifest.config.js — файл, который хранит базовую информацию о расширении и указывает точку входа в приложение;

  3. package.json — файл с зависимостями;

  4. vite.config.js — файл с настройками сборщика.

В таком расширении не будет никакого JS-кода — только html-разметка в файле index.html:

Дублировать руководство здесь я не буду, сразу перейду к более сложным штукам. Ниже мы рассмотрим варианты файловой структуры более сложных браузерных расширений, которые включают в себя JS-логику.

Структура браузерного расширения

Сейчас в браузерных расширениях фактически используется Manifest V3 — современный стандарт Chrome (и большинства Chromium-браузеров), который пришёл на смену Manifest V2. Переход от V2 к V3 связан с несколькими изменениями на практике:

  • ограничение фоновых страниц в пользу service workers (нет постоянного background-скрипта);

  • более строгая модель сетевых запросов через declarativeNetRequest вместо webRequest;

  • более жесткая политика доступа к данным и сайтам через permissions и host_permissions;

  • улучшенная изоляция и контроль за выполнением кода расширения.

Базовая структура браузерного расширения на V3 — это набор изолированных runtime-контекстов (popup, content scripts, service worker, side panel), которые взаимодействуют через messaging API и совместно формируют поведение расширения.

Теперь перейдем к ключевым файлам.

manifest – сердце расширения

Это сердце расширения. Именно через него браузер знакомится с приложением и начинает «понимать»:

  • что умеет расширение;

  • какие права ему нужны;

  • какие скрипты запускать, где и когда их запускать;

  • на каких сайтах оно будет работать.

popup – интерфейс расширения

Popup — это окно, которое открывается по клику на иконку расширения. С точки зрения React-разработчика, это обычное SPA-приложение.

Тут оговорюсь, что помимо popup есть и другие способы реализации UI расширения, о которых мы поговорим позже.

background – центральная логика расширения

Background можно воспринимать как центрального координатора всего расширения. Background не отображает интерфейс и не работает напрямую со страницей сайта. Его основная задача — координировать работу остальных частей расширения.

Как правило, именно в background располагаются:

  • API-запросы;

  • работа с авторизацией;

  • обработка событий браузера;

  • организация взаимодействия между popup и content scripts.

Background является наиболее подходящим местом для хранения централизованной runtime-логики расширения, так как:

  • popup может быть закрыт пользователем;

  • content script существует только внутри конкретной вкладки.

Если background необходимо взаимодействовать с сайтом, он делает это через content scripts.

В Manifest V3 background реализуется в виде service worker. Он запускается по событию и может автоматически выгружаться браузером при отсутствии активности. Поэтому состояние, которое должно сохраняться между запусками service worker, обычно хранится в chrome.storage, IndexedDB или других механизмах постоянного хранения данных.

content scripts – логика расширения внутри сайта

Content script — это код расширения, который браузер запускает в контексте открытой веб-страницы. Благодаря content scripts расширение может взаимодействовать с содержимым сайта и его интерфейсом. В отличие от popup и background, content script работает внутри конкретной вкладки браузера и имеет доступ к DOM страницы.

Обычно content scripts используются для:

  • чтения содержимого страницы;

  • изменения DOM;

  • вставки собственного UI поверх сайта;

  • отслеживания действий пользователя на странице;

  • получения данных со страницы для дальнейшей обработки расширением.

Content script существует только в рамках конкретной вкладки и не может напрямую взаимодействовать с другими. Для обмена данными с background или popup обычно используется механизм сообщений (message passing).

Сетап расширения с background и content scripts

Структура файлового расширения с использованием background script и content script может выглядеть следующим образом:

Логика примерно следующая:

  1. Файл манифеста показывает, что старт приложения начинается из default_popup –  index.html.

  2. index.html подключает файл src/main.tsx.

  3. main.tsx начинает исполняться и встраивает React-приложение в DOM-дерево попапа.

Примеры файлов под спойлерами.

manifest.config.js
import { defineManifest } from '@crxjs/vite-plugin';import packageJson from './package.json';export default defineManifest({ manifest_version: 3, name: 'KTS Browser Extension', version: packageJson.version, description: 'Браузерное расширение от KTS', action: {   default_title: 'KTS Browser Extension',   default_icon: {     '16': 'public/icons/icon-16.png',     '48': 'public/icons/icon-48.png',     '128': 'public/icons/icon-128.png',   }, }, background: {   service_worker: 'src/background.ts',   type: 'module', }, content_scripts: [   {     matches: ['http://*/*', 'https://*/*'],     js: ['src/content-script.ts'],   }, ], web_accessible_resources: [   {     resources: ['index.html', 'assets/*', 'public/*'],     matches: ['http://*/*', 'https://*/*'],   }, ], icons: {   '16': 'public/icons/icon-16.png',   '48': 'public/icons/icon-48.png',   '128': 'public/icons/icon-128.png', },});
index.html
<!doctype html><html lang="ru"> <head>   <meta charset="UTF-8" />   <meta name="viewport" content="width=device-width, initial-scale=1.0" />   <title>KTS Browser Extension</title> </head> <body>   <div id="root"></div>   <script type="module" src="/src/main.tsx"></script> </body></html>
main.tsx
import { StrictMode } from 'react';import { createRoot } from 'react-dom/client';import { HashRouter } from 'react-router-dom';import { MantineProvider } from '@mantine/core';import '@mantine/core/styles.css';import { App } from './App';import { theme } from './theme';import './index.css';createRoot(document.getElementById('root')!).render( <StrictMode>   <MantineProvider theme={theme} forceColorScheme="dark">     <HashRouter>       <App />     </HashRouter>   </MantineProvider> </StrictMode>,);

Обратите внимание, что используется не привычный Browser Router, а именно HashRouter — это связано со спецификой работы роутинга в расширениях.

Полностью код можно посмотреть на GitHub.

Запуск браузерного расширения

Чтобы посмотреть на получившийся результат, нужно:

  1. Сбилдить бандл расширения с помощью команды yarn dev (для дев-разработки) или в yarn build (для продакшена).

  2. Загрузить бандл в менеджер расширений.

В менеджер расширений можно перейти по кнопке пазла в правом верхнем углу (если вы используете Google Chrome), либо написав в адресной строке браузера chrome://extensions/. Обязательно включите Developer Mode, чтобы иметь возможность загрузить расширение.

Далее нажмите кнопку Load unpacked и загрузите папку dist.

После этого по той же иконке пазла можно вызвать расширение на любой странице:

Здесь стоит отметить, что не все ошибки из браузерного расширения будут отображаться на странице, на которой будет открыто расширение. Все зависит от того, в каком из контекстов возникла ошибка.

Ошибки из service-workers (background-файлы) можно увидеть, если перейти в service-worker на карточке самого расширения по ссылке service worker:

Он выглядит аналогично DevTools:

Способы встраивания

default_popup

Это способ, который мы рассмотрели выше. Манифест указывает default_popup: index.html как точку входа. В свою очередь, файл index.html может подключить какой-нибудь JS-скрипт.

Плюсы:

  • простая реализация;

  • полностью изолирован от страницы, на которой открывается расширение.

Минусы:

  • закрывается при потере фокуса;

  • не подходит для длительной работы с интерфейсом;

  • плохо подходит для сценариев, где нужны длительные и сложные взаимодействия со страницей.

Content scripts + внедрение UI в DOM

Если ваше расширение должно работать только в рамках одного сайта, вам хорошо подойдет подход с встраиванием расширения в DOM-дерево. В этом случае в файле манифеста точкой входа являются файлы content-scripts — они обеспечивают старт и отдельно загружают файл index.html, либо вставляют ноды напрямую в DOM-дерево

Файл background в данном случае обрабатывает клик на иконку браузерного расширения в меню и отправляет событие об этом в content-script.

manifest.config.js
import { defineManifest } from '@crxjs/vite-plugin';import packageJson from './package.json';export default defineManifest({ manifest_version: 3, name: 'KTS Browser Extension', version: packageJson.version, description: 'Браузерное расширение от KTS', action: {   default_title: 'KTS Browser Extension',   default_icon: {     '16': 'public/icons/icon-16.png',     '48': 'public/icons/icon-48.png',     '128': 'public/icons/icon-128.png',   }, }, background: {   service_worker: 'src/background.ts',   type: 'module', }, content_scripts: [   {     matches: ['http://*/*', 'https://*/*'],     js: ['src/content-script.ts'],   }, ], web_accessible_resources: [   {     resources: ['index.html', 'assets/*', 'public/*'],     matches: ['http://*/*', 'https://*/*'],   }, ],});
background.js
const TOGGLE_WIDGET_MESSAGE = 'KTS_EXTENSION_TOGGLE_WIDGET';chrome.action.onClicked.addListener((tab) => { if (!tab.id) {   return; } void chrome.tabs.sendMessage(tab.id, { type: TOGGLE_WIDGET_MESSAGE }).catch(() => {   // Content scripts are only available on regular http(s) pages. });});
content-script.js
const WIDGET_ID = 'kts-browser-extension-widget';const TOGGLE_WIDGET_MESSAGE = 'KTS_EXTENSION_TOGGLE_WIDGET';function createWidget() { const widget = document.createElement('div'); widget.id = WIDGET_ID; widget.style.position = 'fixed'; widget.style.top = '24px'; widget.style.right = '24px'; widget.style.width = '420px'; widget.style.height = '600px'; widget.style.border = '1px solid rgba(255, 255, 255, 0.18)'; widget.style.borderRadius = '16px'; widget.style.overflow = 'hidden'; widget.style.boxShadow = '0 20px 60px rgba(0, 0, 0, 0.35)'; widget.style.background = '#1a1b1e'; widget.style.zIndex = '2147483647'; const iframe = document.createElement('iframe'); iframe.src = chrome.runtime.getURL('index.html'); iframe.title = 'KTS Browser Extension'; iframe.style.width = '100%'; iframe.style.height = '100%'; iframe.style.border = '0'; widget.append(iframe); return widget;}function toggleWidget() { const existingWidget = document.getElementById(WIDGET_ID); if (existingWidget) {   existingWidget.remove();   return; } document.body.append(createWidget());}chrome.runtime.onMessage.addListener((message: { type?: string }) => { if (message.type === TOGGLE_WIDGET_MESSAGE) {   toggleWidget(); }});
index.html
<!doctype html><html lang="ru"> <head>   <meta charset="UTF-8" />   <meta name="viewport" content="width=device-width, initial-scale=1.0" />   <title>KTS Browser Extension</title> </head> <body>   <div id="root"></div>   <script type="module" src="/src/main.tsx"></script> </body></html>
main.tsx
import { StrictMode } from 'react';import { createRoot } from 'react-dom/client';import { HashRouter } from 'react-router-dom';import { MantineProvider } from '@mantine/core';import '@mantine/core/styles.css';import { App } from './App';import { theme } from './theme';import './index.css';createRoot(document.getElementById('root')!).render( <StrictMode>   <MantineProvider theme={theme} forceColorScheme="dark">     <HashRouter>       <App />     </HashRouter>   </MantineProvider> </StrictMode>,);

Весь код для этого подхода с внедрением UI расширения можно посмотреть здесь.

Плюсы:

  • максимальная интеграция с сайтом;

  • отлично подходит для обработки событий пользователя на самой веб-странице;

  • можно гибко настраивать положение расширения в UI на странице;

  • можно гибко настраивать логику видимости расширения.

Минусы: 

  • сложен в реализации: внедрение в DOM, взаимодействие файлов между собой;

  • потенциальные конфликты CSS, JS: стили и скрипты от расширения могут конфликтовать со стилями и скриптами самой страницы;

  • сайт может блокировать такой подход с помощью CSP.

Side panel

Одним из модных способов интеграции браузерного расширения в пользовательский интерфейс является использование Side Panel. Это боковая панель браузера, которая открывается рядом с содержимым текущей вкладки и остаётся доступной пользователю во время работы с сайтом.

manifest.config.js
import { defineManifest } from '@crxjs/vite-plugin';import packageJson from './package.json';export default defineManifest({ manifest_version: 3, name: 'KTS Browser Extension', version: packageJson.version, description: 'Браузерное расширение от KTS', permissions: ['sidePanel'], action: {   default_title: 'KTS Browser Extension',   default_icon: {     '16': 'public/icons/icon-16.png',     '48': 'public/icons/icon-48.png',     '128': 'public/icons/icon-128.png',   }, }, side_panel: {   default_path: 'index.html', }, background: {   service_worker: 'src/background.ts',   type: 'module', },});
index.html
<!doctype html><html lang="ru"> <head>   <meta charset="UTF-8" />   <meta name="viewport" content="width=device-width, initial-scale=1.0" />   <title>KTS Browser Extension</title> </head> <body>   <div id="root"></div>   <script type="module" src="/src/main.tsx"></script> </body></html>
main.tsx
import { StrictMode } from 'react';import { createRoot } from 'react-dom/client';import { HashRouter } from 'react-router-dom';import { MantineProvider } from '@mantine/core';import '@mantine/core/styles.css';import { App } from './App';import { theme } from './theme';import './index.css';createRoot(document.getElementById('root')!).render( <StrictMode>   <MantineProvider theme={theme} forceColorScheme="dark">     <HashRouter>       <App />     </HashRouter>   </MantineProvider> </StrictMode>,);

Примерно так это будет выглядеть:

Плюсы:

  • UI расширения явно отделен от страницы сайта;

  • не закрывается при потере фокуса;

  • видимость сохраняется при переходе между страницами;

  • независим от самой страницы, поэтому конфликты файлов и действий исключены.

Минусы:

  • доступна только в Chromium-браузерах: Google Chrome, Microsoft Edge, Opera (частично). Нет поддержки в Firefox, Safari;

  • сложна в реализации взаимодействия с DOM страницы.

Остальные способы встраивания

Встроенными UI-контейнерами браузера, которые я рассмотрел выше, варианты не ограничиваются. Есть несколько способов открыть интерфейс расширения отдельно, фактически как самостоятельную HTML-страницу, изолированную от сайта и стандартных поверхностей браузера.

Отдельное окно через chrome.windows.create

Один из наиболее прямых способов — открыть интерфейс расширения в отдельном окне браузера с помощью chrome.windows.create. В этом случае расширение фактически рендерит свою HTML-страницу как отдельное приложение.

Замена новой вкладки (Override New Tab)

Другой способ — переопределение стандартной страницы новой вкладки браузера. В этом случае расширение подменяет системную страницу New Tab своей HTML-страницей.

Панель в DevTools (DevTools Extension)

Ещё один способ отрисовать интерфейс расширения — добавить собственную панель внутри инструментов разработчика браузера (DevTools).

В этом случае расширение регистрирует отдельную вкладку в DevTools, которая открывается рядом с Console, Elements и другими стандартными панелями. Фактически это тоже HTML-интерфейс, который живёт в отдельном окружении и работает в контексте текущей страницы.

Чаще всего этот способ используется не для пользовательских интерфейсов, а для инструментов разработчика — например, для дебага приложений, анализа состояния или работы с фреймворками.

О других менее популярных способах можно почитать здесь в документации Chrome.

Полезные свойства и доступы

Для корректной работы расширения необходимо описать его возможности в манифесте.
На основании манифеста браузер определяет, к каким API, сайтам и данным расширение получит доступ.

Содержимое манифеста напрямую влияет как на процесс публикации, так и на работу расширения на разных сайтах. Некоторые настройки требуют дополнительного обоснования при публикации, а отдельные механизмы безопасности сайтов могут ограничивать заявленную функциональность. Поэтому при проектировании расширения рекомендуется запрашивать только те разрешения и возможности манифеста, которые действительно необходимы для функциональности продукта.

Чуть подробнее я разберу возможные ограничения ниже, в разделе про публикацию расширения в Chrome Web Store. А пока поговорим о том, что и как задается в манифесте.

Свойства manifest-файла

1. host_permissions

Это список сайтов (origin’ов), к которым расширению разрешено получать привилегированный доступ. Само по себе оно не запускает код на сайте и не делает инжект, а только дает права на работу с этим сайтом.

Нужно для:

  • динамического инжекта скриптов через chrome.scripting.executeScript() на указанных сайтах (используется в связке со свойством content_scripts: matches);

  • выполнения запросов (fetch) из service worker/background к указанным доменам;

  • работы с cookies этих сайтов через chrome.cookies;

  • получения чувствительных данных вкладки (tab.url, tab.title, favIconUrl) для этих сайтов;

  • доступа к некоторым сетевым API браузера, связанным с указанными хостами;

  • запроса прав у пользователя на конкретные сайты вместо доступа ко всем сайтам.

2. declarative_net_request

Описывает систему правил для изменения сетевых запросов. Подключается следующим образом:

"declarative_net_request": {    "rule_resources": [      {        "id": "ruleset_1",        "enabled": true,        "path": "rules.json"      }    ]  },

В файле rules.json хранятся сами правила. Для них указываются:

  • id правила;

  • приоритет правила;

  • условие, при котором должно сработать правило.

  • само действие, которое должно произойти при срабатывании.

Подробнее эти правила я разберу в конце, в разделе Кейс: использование виджета в качестве браузерного расширения.

3. content_security_policy

Указывает на ограничение использования JS внутри расширения.

4. content_scripts + matches + all_frames

Показывают, какие файлы на каких страницах сайта должны запускаться автоматически.

5. web_accessible_resources

Показывает, какие файлы расширения доступны внутри страницы.

Пример:

{  "web_accessible_resources": [    {      "resources": ["assets/*"],      "matches": ["<all_urls>"]    }  ]}

Разрешения manifest.permissions

Свойство permissions показывает браузеру и пользователю какие разрешения ему нужны для работы. Мы рассмотрим самые часто используемые свойства:

1. storage

Дает доступ к хранилищу расширения. Нужно для:

  • сохранения токенов;

  • сохранения настроек;

  • кеширования данных;

  • хранения состояния между перезапусками браузера.

Без него не работают:

  • chrome.storage.local;

  • chrome.storage.sync;

  • chrome.storage.session.

2. tabs

Дает доступ к управлению вкладками. Нужно, чтобы вкладки можно было:

  • искать;

  • переключать;

  • открывать;

  • закрывать;

  • перезагружать.

3. activeTab

Частный случай свойства tabs. Дает временный доступ к текущей вкладке после действия пользователя. Нужно для:

  • чтения содержимого текущей страницы;

  • получения URL активной вкладки.

4. scripting

Нужно для:

  • внедрения JS в открытую вкладку;

  • внедрения CSS;

  • запуска скрипта после клика пользователя.

Без него не работают:

  • chrome.scripting.executeScript(…);

  • chrome.scripting.insertCSS(…).

5. sidePanel

Дает доступ к Side Panel API. Нужно для:

  • открытия боковой панели;

  • управления ее состоянием.

Без него не работают:

  • chrome.sidePanel.open(…);

  • chrome.sidePanel.setOptions(…).

6. contextMenus

Дает доступ к контекстному меню. Нужно для:

  • добавления пункта по правому клику;

  • обработки выбора текста.

Без него не работает chrome.contextMenus.create(…).

7. declarativeNetRequest и declarativeNetRequestWithHostAccess

Дает доступ к движку правил сетевых запросов. Нужно для:

  • блокировки рекламы;

  • редиректов;

  • изменения заголовков.

В большинстве случаев DNR работает через rulesets. Собственно, вся идея DNR заключается в том, что ты описываешь набор правил, а браузер применяет их самостоятельно. Например, следующий код помогает внедрить скрипт на страницу в обход ограничений браузера:

[ {   "id": 1,   "priority": 1,   "action": {     "type": "modifyHeaders",     "responseHeaders": [       {         "header": "content-security-policy",         "operation": "remove"       }     ]   },   "condition": {     "urlFilter": "*site-for-inject.com*",     "resourceTypes": ["main_frame", "sub_frame"]   } },

Есть другие полезные permissions, которые используются реже (например, notifications, cookies, downloads, bookmarks и другие). Подробнее о них вы можете почитать здесь.

Публикация расширения в Google Store

Регистрация аккаунта

Для публикации в Chrome Web Store необходимо зарегистрировать аккаунт разработчика и оплатить единоразовый регистрационный взнос. После этого становится доступна публикация расширений и управление их версиями.

Стоит заранее продумать, кто будет поддерживать расширение. В консоли можно добавить других участников команды и выдать им различные уровни доступа, чтобы обновления не зависели от одного человека.

Подготовка страницы расширения

Помимо самого кода, потребуется подготовить материалы для страницы в магазине:

  • название и краткое описание;

  • подробное описание функциональности;

  • иконки разных размеров;

  • скриншоты работы расширения;

  • при необходимости — рекламные изображения для продвижения.

Хорошо оформленная страница влияет не только на одобрение, но и на доверие пользователей.

Один из самых важных моментов — запрашивать только те разрешения, которые действительно необходимы. Например:

{ "permissions": ["storage", "tabs"]}

Каждое дополнительное разрешение вызывает вопросы как у пользователей, так и у модерации. Особенно внимательно проверяются:

  • tabs;

  • scripting;

  • webRequest;

  • cookies;

  • доступ ко всем сайтам (<all_urls>).

Чем меньше привилегий требует расширение, тем проще проходит проверка.

Privacy Policy

Если расширение:

  • отправляет данные на сервер;

  • работает с пользовательскими сообщениями;

  • собирает аналитику;

  • использует авторизацию,

то потребуется политика конфиденциальности (Privacy Policy).

Сейчас Chrome уделяет этому гораздо больше внимания, чем несколько лет назад.

Обоснование разрешений

Для чувствительных разрешений Chrome может попросить объяснить:

  • зачем они нужны;

  • как используются;

  • какие данные обрабатываются.

Поэтому полезно заранее подготовить краткое описание архитектуры расширения и сценариев использования.

Типичные примеры ограничений и причин для дополнительной проверки:

  • использование разрешения host_permissions: [«<all_urls>»], предоставляющего доступ ко всем сайтам;

  • запрос доступа к API tabs, позволяющему получать информацию об открытых вкладках пользователя, когда это не требуется;

  • регистрация content_scripts на всех сайтах вместо ограниченного списка доменов;

  • наличие фонового процесса (background.service_worker), выполняющего действия вне пользовательского интерфейса;

  • перехват или модификация сетевых запросов через API наподобие declarativeNetRequest;

  • внедрение пользовательского интерфейса в DOM страницы через content scripts;

  • взаимодействие с внешними серверами через дополнительные разрешения и настройки Content Security Policy.

Проверка перед публикацией

Перед отправкой желательно протестировать:

  • установку с нуля;

  • обновление со старой версии;

  • работу после перезагрузки браузера;

  • работу на разных сайтах;

  • миграции данных в chrome.storage.

Многие ошибки проявляются именно после обновления версии, а не при чистой установке.

Модерация

После загрузки новой версии расширение проходит автоматическую и иногда ручную проверку.

Скорость проверки зависит от:

  • набора разрешений;

  • изменений относительно предыдущей версии;

  • наличия удалённого кода;

  • истории аккаунта разработчика.

Небольшие обновления могут пройти за несколько минут, а более серьезные изменения иногда проверяются несколько дней.

Запрет на удаленный код

Это один из самых частых сюрпризов для разработчиков. Chrome запрещает загружать и исполнять JavaScript-код с внешних серверов: <script src="https://my-server.com/script.js"></script> или eval(serverResponse).

Весь исполняемый код должен находиться внутри пакета расширения. Исключение составляют страницы, загружаемые через iframe: внутри них может работать обычное веб-приложение со своими скриптами, поскольку этот код уже не считается частью расширения.

Поэтому если приложение использует динамически загружаемые скрипты, архитектуру придется адаптировать под требования Chrome.

Чек-лист

Из практики разработчиков расширений чаще всего забывают:

  • добавить Privacy Policy;

  • минимизировать список разрешений;

  • проверить обновление со старой версии;

  • протестировать работу после перезапуска браузера;

  • подготовить качественные скриншоты;

  • настроить доступы для нескольких участников команды;

  • убедиться, что в расширении нет удаленно исполняемого кода.

Publisher account

После регистрации в Chrome Web Store разработчик получает возможность создавать отдельные Publisher Accounts — сущности, от имени которых публикуются расширения. На первый взгляд может показаться, что расширение привязано непосредственно к Google-аккаунту разработчика, однако фактически между ними существует дополнительный уровень:

Publisher Account выступает контейнером для расширений и позволяет управлять ими независимо от личного аккаунта разработчика.

На странице профиля можно увидеть информацию о developer account, статус регистрации и список созданных Publisher Accounts.

Главное преимущество Publisher Account — возможность отделить проект от конкретного человека. Если расширение публикуется через личный аккаунт разработчика, со временем могут возникнуть сложности:

  • сотрудник увольняется;

  • теряется доступ к аккаунту;

  • проект передаётся другой команде;

  • появляется необходимость подключить нескольких разработчиков.

Использование отдельного Publisher Account помогает избежать подобных проблем и делает управление расширением более прозрачным.

Для Publisher Account можно настраивать доступы и приглашать других участников команды. Это позволяет:

  • публиковать новые версии нескольким разработчикам;

  • разделять обязанности между участниками;

  • не хранить доступ к релизам у одного человека.

Для корпоративных проектов такой подход фактически является обязательным. Если расширение создается для компании или заказчика, лучше сразу публиковать его через отдельный Publisher Account, а не через личный Google-аккаунт разработчика.

Вот так выглядит создание нового Publisher Account. Обратите внимание, что на один Google-аккаунт можно дополнительно создать всего один Publisher Account:

И затем уже в сам Publisher Account можно добавлять пользователей:

Trade Account / Trader Status

Если расширение распространяется в коммерческих целях, особенно для пользователей из ЕС, появляется тема trader/non-trader статуса. На странице расширения может отображаться информация о разработчике и его статусе предпринимателя или не-предпринимателя.

Также могут потребоваться:

  • юридические данные;

  • адрес;

  • контактная информация;

  • дополнительные проверки аккаунта.

Некоторые разработчики неожиданно обнаруживают, что на странице расширения с trader-аккаунтом отображаются личные данные, которые они не планировали публиковать.

Мониторинг ошибок в Sentry

После публикации расширения полезно как можно раньше подключить систему мониторинга ошибок. В нашем случае для этого использовался Sentry. Однако при работе с браузерными расширениями есть один нюанс: далеко не все ошибки, которые вы увидите в Sentry, относятся к вашему коду.

Пользовательский браузер обычно содержит множество сторонних расширений:

  • переводчики;

  • блокировщики рекламы;

  • менеджеры паролей;

  • корпоративные расширения;

  • инструменты разработчика.

В Sentry существует специальная настройка:

Filter out errors known to be caused by browser extensions

Для обычных веб-приложений ее часто оставляют включенной: Sentry отфильтровывает ошибки, вызванные сторонними расширениями пользователей, что уменьшает количество шума в отчетах. Это позволяет значительно уменьшить количество шума и сосредоточиться на ошибках, действительно относящихся к вашему продукту.

Однако если вы разрабатываете само браузерное расширение, стоит проверить состояние этой настройки. В противном случае Sentry может отфильтровывать ошибки вашего расширения, и часть событий просто не будет попадать в мониторинг.

Также для расширений полезно разделять ошибки по источникам: popup, background/service worker и content scripts. Это существенно упрощает расследование инцидентов, поскольку проблемы в каждом из этих контекстов обычно имеют разную природу.

Хранение сторонних скриптов в бандле расширения

При разработке браузерного расширения важно учитывать ограничения Chrome Web Store на использование стороннего кода.

В отличие от обычных веб-приложений, расширение не может загружать и выполнять JavaScript-код с внешних серверов во время работы. Все исполняемые скрипты должны входить в состав пакета расширения и поставляться вместе с его сборкой.

Такое ограничение повышает безопасность расширений и позволяет Chrome Web Store проверять весь код, который будет выполняться на стороне пользователя. Однако при проектировании архитектуры этот нюанс стоит учитывать заранее, поскольку он может потребовать изменений в привычном подходе к загрузке внешних ресурсов.

Обновление расширения

Отдельной кнопки «Обновить расширение» в Chrome Web Store не существует — обновление представляет собой публикацию новой версии уже существующего расширения. Это отличает Chrome Web Store от многих мобильных магазинов приложений и поначалу может быть неочевидно.

Каждая новая публикация должна содержать увеличенный номер версии:

{ "version": "1.2.0"}

После публикации расширения процесс выпуска новых версий практически не отличается от обычного релизного цикла веб-приложения. Разработчик собирает новую версию расширения, увеличивает номер версии в manifest.json и загружает обновленный пакет в Chrome Web Store.

Новая версия проходит проверку модерацией, после чего становится доступна пользователям. При этом переустанавливать расширение вручную не требуется — браузер автоматически скачивает и устанавливает обновление.

Важно учитывать, что обновление распространяется не мгновенно. Между публикацией новой версии и ее появлением у пользователей может пройти некоторое время, поэтому в течение переходного периода разные пользователи могут работать на разных версиях расширения.

Кейс: использование виджета в качестве браузерного расширения

Теперь немного о практике с проекта, который я упомянул в предисловии. У одного из наших заказчиков уже существовало веб-приложение для общения с пользователями. Через чат-виджет посетители сайта задавали вопросы, а сотрудники поддержки отвечали им в режиме реального времени.

Позже появилась новая задача: предоставить сотрудникам поддержки аналогичный интерфейс для работы на сторонних площадках, где встроить существующий виджет было невозможно без доступа к их коду. Для этого было решено разработать браузерное расширение.

Поскольку значительная часть бизнес-логики чата уже была готова, мы решили переиспользовать ее в расширении вместо создания решения с нуля. Это позволило избежать дублирования функциональности и сосредоточиться на доработке текущего решения, адаптировав его для работы в формате браузерного расширения.

Хочется рассказать логику работы данного расширения и тонкости использования свойств и правил, с которыми мы столкнулись. Обо всем по порядку.

Взаимодействие файлов между собой

Всего в приложении можно было выделить 3 изолированных друг от друга контекста:

  • React-компоненты;

  • файл background.js;

  • content-scripts файлы.

Мы выбрали способ взаимодействия через событийную модель. Файл хранил все ивенты из приложения:

// Extension custom eventsexport enum EXTENSION_CUSTOM_EVENT { TOGGLE_EXTENSION = 'TOGGLE_EXTENSION', CHECK_AUTH_BG = 'CHECK_AUTH_BG', INJECT_SCRIPT = 'INJECT_SCRIPT', REMOVE_WIDGET = 'REMOVE_WIDGET', CHECK_WIDGET_INITIALIZED = 'CHECK_WIDGET_INITIALIZED', REFRESH_EXTENSION = 'REFRESH_EXTENSION', RELOAD_TABS = 'RELOAD_TABS',}

Далее файлы подписывались на обработку этих ивентов.

Чтобы инициализировать наше приложение, формировалась следующая цепочка:

1. В React-компоненте мы вызывали функцию utils/addScriptStrBody – функция просто формировала текст нашего скрипта для вставки:

await addScriptStrToBody({ scriptText });

Но корректно встроить скрипт мы можем только из background-файла. Поэтому функция addScriptToStrBody лишь прокидывала нужный ивент:

   chrome.runtime.sendMessage(     {       type: EXTENSION_CUSTOM_EVENT.INJECT_SCRIPT,       scriptCode: code,     },

2. Файл backround уже имеет опцию внедрения скриптов и успешно делал это:

// Execute script in MAIN world (page context)   chrome.scripting     .executeScript({       target: { tabId },       world: 'MAIN', // Execute in page context, not content script context       func: (code: string, rootScriptId: string) => {         console.log('🎯 Page context: About to inject script');         // Create script element with ID for later removal         const scriptNode = document.createElement('script');         scriptNode.id = rootScriptId;         scriptNode.type = 'text/javascript';         scriptNode.textContent = code;         (document.head || document.documentElement).appendChild(scriptNode);         console.log('📌 Script element added to DOM');       },       args: [scriptCode, HTML_ID_INJECT_ROOT_SCRIPT],     })

Обратите внимание: здесь довольно специфичный случай использования executeScript с wolrd: ‘MAIN’ и args, потому что нам было нужно, чтобы скрипт имел доступ к window страницы.

3. А вот таким образом мы управляли состоянием отображения нашего расширения. Клик на иконку расширения в адресной строке мы можем обрабатывать только через chrome.action.onClicked.addListener, который доступен в файле background.js:

chrome.action.onClicked.addListener((tab) => { if (tab.id) {   chrome.tabs.sendMessage(tab.id, { type: EXTENSION_CUSTOM_EVENT.TOGGLE_EXTENSION }); }});

4. Сама логика расширения хранилась в content-script файле, так как для инжекта виджета нужен был доступ к странице:

chrome.runtime.onMessage.addListener((message) => { if (message.type === EXTENSION_CUSTOM_EVENT.TOGGLE_EXTENSION) {   toggleExtensionUI(); }}

API-запросы с авторизацией

Никакой запрос с авторизацией из реакт компонентов не подхватывал куки, который мы получали после авторизации в нашем сервисе. Поэтому вся логика запросов была расположена в background файле.

React-компонент:

 // Get config from API const getConfigResponse = await new Promise<{   integrationKey: string | null;   error?: string; }>((resolve) => {   chrome.runtime.sendMessage(     {       type: EXTENSION_CUSTOM_EVENT.GET_CONFIG,       domain: window.location.hostname,     },     (response) => resolve(response),   ); });

background-файл:

chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (message.type === EXTENSION_CUSTOM_EVENT.GET_CONFIG) {   const { domain } = message;   getConfig(domain)     .then((response) => sendResponse(response))     .catch((error) =>       sendResponse({         integrationKey: null,         error: error.message || 'Failed to get config',       }),     );   return true; }

Невозможность внедрить сторонний скрипт на страницу

Не все сайты позволяют внедрить к себе на страницу скрипт с другого домена, но есть способы для обхода. Здесь нам на помощь придет свойство, о котором мы говорили ранее: declarative_net_request.rule_resources. С его помощью мы можем убрать заголовки, которые запрещают нам внедрить скрипт на страницу. Делается это примерно так:

"declarative_net_request": {   "rule_resources": [     {       "id": "ruleset_1",       "enabled": true,       "path": "rules.json"     }   ] },

Файл rules.json:

{  {    "id": 3,   "priority": 1,   "action": {     "type": "modifyHeaders",     "responseHeaders": [       {         "header": "content-security-policy",         "operation": "remove"       }     ]   },   "condition": {     "urlFilter": "*your-site-for-inject.com*",     "resourceTypes": ["main_frame", "sub_frame"]   } }

Отказ в публикации из-за использования скрипта с другого домена

С этим мы тоже столкнулись. Chrome Web Store отказывался публиковать расширение, так как не контролирует скрипт, который внедряет наш задеплоенный виджет, и не может позволить себе такие риски. Решение простое — мы продублировали код для инжекта нашего виджета в папку с расширением:

Из минусов — нужно не забыть обновить этот файл в репозитории с расширением, если скрипт обновится.

Заключение

Надеюсь, эта статья помогла вам разобраться с неочевидными нюансами разработки браузерных расширений. Я постарался осветить все составляющие этого процесса: и способы встраивания от попапов до отдельных окон, и доступы, и особенности Google Store.

Если где-то остались вопросы или вы знаете другие полезные штуки, которые я не раскрыл — пишите в комментарии, обсудим. А если вам интересно почитать еще о том, как мы пилим фронтенд, то вот целая пачка статей из нашего блога:

ссылка на оригинал статьи https://habr.com/ru/articles/1050500/