Браузерное расширение AdGuard VPN перешло на Manifest V3
Manifest V3 — новая версия API Chrome — стала камнем преткновения для многих разработчиков. Она вносит значительные изменения в правила, которым должны следовать браузерные расширения. И некоторые из них не просто создают неудобства, а приводят к потере части функциональности.
В августе 2022 года AdGuard выпустил первое в мире блокирующее рекламу браузерное расширение на Manifest V3. В процессе разработки мы столкнулись с большим количеством сложностей — некоторые удалось решить, с некоторыми мы боремся до сих пор. Тем не менее, тогда мы доказали, что у блокировщиков рекламы есть будущее даже в реалиях Manifest V3.
Несмотря на то, что Google продлил срок жизни расширений на Manifest V2 как минимум до января 2024 года, мы не стали откладывать разработку браузерного расширения AdGuard VPN, работающего по правилам Manifest V3, — и готовы представить его вам уже сейчас.
Браузерное расширение AdGuard VPN MV3
Имея за плечами опыт разработки расширения AdGuard MV3, мы не ждали, что работа над AdGuard VPN MV3 будет «лёгкой прогулкой» — и были правы. Дальше расскажем о проблемах, которые встретились на пути, и как мы их решили.
Засыпание Service worker
В предыдущем варианте манифеста была фоновая страница (background page), которая позволяла расширению запускаться один раз после установки или при открытии браузера и работать в фоновом режиме.
В Manifest V3 на смену фоновой странице пришёл service worker.
С ним расширения могут работать в фоновом режиме только тогда, когда это нужно пользователю. По задумке Google Chrome это должно значительно уменьшить потребление ресурсов системы. И в случае редко используемых браузерных расширений так оно и будет. Но если расширение должно постоянно помогать пользователю, как, например, AdGuard VPN, частое засыпание и пробуждение service worker скорее увеличит нагрузку на систему, чем снизит её.
И это ещё не всё. Chrome пристально наблюдает за service worker. Если в расширении в течение 30 секунд не происходят события и вызовы API, препятствующие засыпанию service worker, браузер просто останавливает работу скрипта.
Остановка service worker грозит расширению AdGuard VPN множеством ошибок. Например, оно не будет получать актуальную информацию о доступных локациях, оставшемся бесплатном трафике, сервисах исключений, других устройствах пользователя, подключённых к аккаунту, и доступных бонусах, не сможет обновить ключи доступа и иконку в трее со статусом расширения.
Решение
Мы уже говорили выше, что события и вызовы API не дают service worker перейти в спящий режим. Это могут быть:
- сообщения от попапа расширения, страницы опций или контент-скрипта
- запрос авторизации прокси
- открытие попапа расширения
- открытие новой вкладки браузера, обновление существующей вкладки или переключение между окнами браузера
- выбор любого действия из контекстного меню на странице (добавление сайта в исключения, смена режима исключений)
- срабатывание таймера, установленного с помощью Alarm API (подробнее расскажем чуть позже)
Если вкратце, это работает так: service worker регистрирует слушателя события (event listener), а браузер в свою очередь его запоминает. Когда событие происходит, браузер смотрит, есть ли у него расширение, которое требуется разбудить в ответ на это событие. Если есть, то он будит service worker расширения и передаёт событие в слушатель.
В условиях Manifest V2 было неважно, где в коде находится слушатель событий — его регистрация могла происходить асинхронно, так как фоновая страница работала непрерывно и не перезапускалась.
В Manifest V3 service worker перезапускается при наступлении события. В этом случае для того, чтобы событие попало в слушатель, оно должно быть зарегистрировано на верхнем уровне кода (синхронно).
На примере ниже видно, как теперь устанавливается слушатель для сообщений от попапа, страницы опций и контент-скриптов:
// add listener for messages from popup, options page and userscripts
chrome.runtime.onMessage.addListener((event) => console.log(event));
А этот пример иллюстрирует, как устанавливается слушатель для событий действий контекстного меню:
// add listener for context menu actions
chrome.contextMenus.onClicked.addListener((event) => console.log(event));
Здесь показано, как устанавливается слушатель для событий действий с вкладками:
// add listener for tab updated
chrome.tabs.onUpdated.addListener((event) => console.log(event));
// add listener for tab activated
chrome.tabs.onActivated.addListener((event) => console.log(event));
// add listener for tab activated from another window
chrome.windows.onFocusChanged.addListener((event) => console.log(event));
Также избежать некоторых ошибок, вызванных засыпанием server worker, помогает новый Alarm API, который появился в Manifest V3 в дополнение к setInterval и setTimeout.
/**
* Creates an alarm with the specified name and delay
* @param alarmName
* @param delay in ms
*/
const createAlarm = (alarmName: string, delay: number) => {
chrome.alarms.create(alarmName, { when: Date.now() + delay });
};
/**
* Creates a periodic alarm with the specified name and interval
* @param alarmName
* @param interval in minutes!
*/
const createPeriodicAlarm = (alarmName: string, interval: number) => {
chrome.alarms.create(alarmName, { periodInMinutes: interval });
};
Использование Alarm API
/**
* setTimeout implementation
* @param callback
* @param timeout in ms
*/
setTimeout = (callback: () => void, timeout: number): number => {
const timerId = this.generateId();
alarmApi.createAlarm(`${timerId}`, timeout);
alarmApi.onAlarmFires(`${timerId}`, callback);
return timerId;
};
/**
* setInterval implementation
* @param callback
* @param interval in ms
*/
setInterval = (callback: () => void, interval: number): number => {
const timerId = this.generateId();
alarmApi.createPeriodicAlarm(`${timerId}`, this.convertMsToMin(interval));
alarmApi.onAlarmFires(`${timerId}`, callback);
return timerId;
};
Имплементация таймеров для MV3
Он предназначен для установки таймеров и действий на длинных интервалах и работает, даже когда server worker, отвечающий за фоновые задачи расширения, заснул.
Например, если пользователь долго не активен в браузере, server worker может перестать работать, но благодаря Alarm API, расширение всё равно сможет обновить ключи доступа к бэкенд-серверу , проверить статус лицензии и время до её истечения.
Однако у Alarm API есть достаточно неудобное ограничение: нельзя установить промежуток времени меньше одной минуты. То есть с Alarm API не получится установить таймер, который сработает через 3–5–10 секунд, только через минуту или больше.
Пробуждение service worker
Когда service worker уходит в спящий режим, он выгружает данные, которые хранились в оперативной памяти. Поэтому после пробуждения расширению требуется время на инициализацию модулей: в среднем непростительно долгие 1,5–2 секунды. Как вы понимаете, с фоновой страницей таких проблем не было, так как она перезагружалась только с перезапуском браузера.
Решение
Мы добавили модуль для хранения каждого изменения состояния из оперативной памяти. Последнее сохранённое состояние AdGuard VPN использует при пробуждении service worker для быстрого восстановления. Это позволяет сократить задержки при перезапуске service worker до 150–200 мс.
interface StateStorageInterface {
// Gets the value for the specified key from the session storage
getItem<T>(key: StorageKey): T;
// Sets the value for the specified key in the session storage
setItem<T>(key: StorageKey, value: T): void;
// Initializes the storage by loading the data from the session storage,
// or creating a new storage with the default data if none exists.
init(): Promise<void>;
}
Интерфейс хранилища состояния расширения
Реализация этого решения потребовала перенести изменяемые поля из классов в хранилище состояния. На их место в классы мы добавили геттеры и сеттеры.
Например, если раньше Fallback API выглядел так:
export class FallbackApi {
fallbackInfo: FallbackInfo;
private setFallbackInfo(fallbackInfo: FallbackInfo) {
this.fallbackInfo = fallbackInfo;
}
private async getFallbackInfo(): Promise<FallbackInfo> {
if (FallbackApi.needsUpdate(this.fallbackInfo)) {
await this.updateFallbackInfo();
}
return this.fallbackInfo;
}
}
То теперь его внешний вид изменился:
export class FallbackApi {
private get fallbackInfo(): FallbackInfo {
return sessionState.getItem(StorageKey.FallbackInfo);
}
private set fallbackInfo(value: FallbackInfo) {
sessionState.setItem(StorageKey.FallbackInfo, value);
}
private static needsUpdate(fallbackInfo: FallbackInfo): boolean {
return fallbackInfo.expiresInMs < Date.now();
}
private async getFallbackInfo(): Promise<FallbackInfo> {
if (this.fallbackInfo && FallbackApi.needsUpdate(this.fallbackInfo)) {
await this.updateFallbackInfo();
if (this.fallbackInfo) {
return this.fallbackInfo;
}
}
return this.fallbackInfo;
}
}
Вот ещё несколько примеров:
class Endpoints implements EndpointsInterface {
vpnInfo?: VpnExtensionInfoInterface;
}
Добавление состояния в класс Endpoints в Manifest V2
class Endpoints implements EndpointsInterface {
#state: EndpointsState | undefined;
private get vpnInfo(): VpnExtensionInfoInterface | null {
return this.state.vpnInfo;
}
private set vpnInfo(vpnInfo: VpnExtensionInfoInterface | null) {
this.state.vpnInfo = vpnInfo;
sessionState.setItem(StorageKey.Endpoints, this.state);
}
public get state(): EndpointsState {
if (!this.#state) {
throw new Error('Endpoints API is not initialized');
}
return this.#state;
}
public set state(value: EndpointsState) {
this.#state = value;
}
}
Добавление состояния в класс Endpoints в Manifest V3
export class Credentials implements CredentialsInterface {
vpnToken: VpnTokenData | null;
vpnCredentials: CredentialsDataInterface | null;
}
Добавление состояния в класс Credentials в Manifest V2
export class Credentials implements CredentialsInterface {
state: CredentialsState;
saveCredentialsState = () => {
sessionState.setItem(StorageKey.CredentialsState, this.state);
};
get vpnToken() {
return this.state.vpnToken;
}
set vpnToken(vpnToken: VpnTokenData | null) {
this.state.vpnToken = vpnToken;
this.saveCredentialsState();
}
get vpnCredentials() {
return this.state.vpnCredentials;
}
set vpnCredentials(vpnCredentials: CredentialsDataInterface | null) {
this.state.vpnCredentials = vpnCredentials;
this.saveCredentialsState();
}
}
Добавление состояния в класс Credentials в Manifest V3
Имплементация NetworkConnectionObserver
NetworkConnectionObserver
— это класс, отслеживающий состояние сети (online/offline
). Он используется в случае, когда приложение не получает ответ от сервера, и помогает выявить причину проблемы: отсутствие интернет-соединения у пользователя или ошибка на стороне сервера. Также NetworkConnectionObserver
отслеживает переход пользователя из оффлайна в онлайн. В этом случае проверяются права доступа и восстанавливается соединение, если оно было установлено до перехода в оффлайн.
В MV2 NetworkConnectionObserver
реализован очень просто. Он слушает событие online
и вызывает переданный ему callback
.
class NetworkConnectionObserver {
constructor(callback: () => Promise<void>) {
window.addEventListener('online', callback);
}
}
В MV3 у service worker нет доступа к window
, поэтому реализация усложняется. Решением могло бы стать использование offscreen document. Это бы позволило создать невидимую пользователю страницу с доступом к window
и document
и возможностью выполнять там действия, недоступные service worker.
Однако Google обещает, что в будущем offscreen document будет засыпать через некоторое время бездействия подобно service worker. При таком раскладе NetworkConnectionObserver
не сможет постоянно слушать событие online/offline
, что приведёт к различным сбоям.
Сейчас мы вынуждены использовать не самое изящное решение: раз в полсекунды мы проверяем состояние navigator.online
и вызываем callback
, если оно поменялось с offline
на online
.
/**
* Starts checking if the network connection is online at a specified time interval.
*/
private startCheckIsOnline() {
setInterval(() => {
this.setIsOnline(navigator.onLine);
}, this.CHECK_ONLINE_INTERVAL_MS);
}
/**
* Calls handler if network connection becomes online and sets isOnline value.
*/
private setIsOnline(isOnline: boolean) {
if (isOnline && !this.isOnline) {
this.onlineHandler(); // callback
}
this.isOnline = isOnline;
}
Проблемы с поддержкой веб-сокетов
Сейчас в Manifest V3 сообщения, которые расширение получает по веб-сокет соединению, не пробуждают уснувший service worker. Из-за этого расширение может пропустить сообщение о необходимости обновления ключей доступа к серверу бэкенда, а сможет обработать сообщения с ошибками о достижении лимита трафика, лимита подключённых устройств и немаршрутизируемых доменах.
Решение
Первоначальным порывом было пойти наперекор задумке Chrome и сделать незасыпающий service worker. В общем-то мы его сделали. Но потом решили попробовать поиграть по правилам Manifest V3 и подключаться к веб-сокету только, когда service worker бодрствует.
Несмотря на найденное решение, мы очень надеемся, что Google решит проблему, о которой мы сообщили, и добавит возможность продлевать жизнь service worker с помощью сообщений, полученных по веб-сокет соединению.
Раннее срабатывание обработчика событий onAuthRequired
Мы также решили проблему, связанную с появлением браузерного попапа с запросом авторизации в прокси. Это происходило, если при пробуждении service worker хранилище состояния не успевало загрузить учётные данные до срабатывания обработчика события onAuthRequired
.
Решение
В качестве решения мы внедрили асинхронный callback для обработчика события onAuthRequired
. Теперь он ждёт, пока хранилище состояния расширения загрузит необходимую информацию и подставит её.
/**
* Adds listener for the onAuthRequired event
*/
private addOnAuthRequiredListener = () => {
chrome.webRequest.onAuthRequired.addListener(
this.onAuthRequiredHandler,
{ urls: ['<all_urls>'] },
['blocking'],
);
};
onAuthRequired
с синхронным callback
/**
* Adds listener for the onAuthRequired event
*/
private addOnAuthRequiredListener = () => {
chrome.webRequest.onAuthRequired.addListener(
this.onAuthRequiredHandler,
{ urls: ['<all_urls>'] },
['asyncBlocking'],
);
};
onAuthRequired
с асинхронным callback
В заключение
Manifest V3 — далеко не самая продуманная и стабильная среда, в особенности для таких расширений, как AdGuard VPN. Тем не менее, вы уже сейчас можете его установить и опробовать в деле. Мы будем признательны, если, обнаружив ошибку, вы сообщите о ней в GitHub.