Меню
Меню
Главная
RU
AdGuard VPN Блог Браузерное расширение AdGuard VPN перешло на Manifest V3

Браузерное расширение 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.

Понравился пост?

AdGuard VPN
для Windows

Используйте любой браузер или приложение и больше не беспокойтесь об анонимности. С AdGuard VPN весь интернет у вас под рукой.
Узнать больше
Скачать
Скачивая программу, вы принимаете условия Лицензионного соглашения

AdGuard VPN
для Mac

Выберите любую из наших 65+ локаций по всему миру всего за два клика, и ваши данные будут надёжно скрыты от посторонних глаз корпораций и правительств.
Узнать больше
Скачать
Скачивая программу, вы принимаете условия Лицензионного соглашения

AdGuard VPN
для iOS

Защитите свои данные в интернете. Наслаждайтесь любимыми фильмами и шоу и оставайтесь в безопасности, пока смотрите их с AdGuard VPN!
Узнать больше
App Store
Скачивая программу, вы принимаете условия Лицензионного соглашения

AdGuard VPN
для Android

Сохраняйте анонимность, где бы вы ни были. Десятки локаций, быстрое и надёжное соединение — всё это в вашем кармане.
Узнать больше
Google Play
Скачивая программу, вы принимаете условия Лицензионного соглашения
Скачать
Скачивая программу, вы принимаете условия Лицензионного соглашения

AdGuard VPN
для Chrome

Скройте своё местоположение и «переместитесь» в любую часть мира, просматривайте контент без ограничений скорости и будьте анонимны в сети.
Узнать больше
Установить
Скачивая программу, вы принимаете условия Лицензионного соглашения

AdGuard VPN
для Edge

Подключитесь к любой локации в один клик, скройте свой IP и сделайте использование интернета безопасным и анонимным.
Узнать больше
Установить
Скачивая программу, вы принимаете условия Лицензионного соглашения

AdGuard VPN
для Firefox

Защитите конфиденциальность, скройте своё настоящее местоположение и решите, на каких сайтах вам нужен VPN, а на каких нет!
Узнать больше
Установить
Скачивая программу, вы принимаете условия Лицензионного соглашения

AdGuard VPN
для Opera

Скрывайтесь от посторонних глаз в браузере Opera: перемещайтесь по щелчку пальцев в любую точку мира и оставайтесь незамеченными.
Узнать больше
Установить
Скачивая программу, вы принимаете условия Лицензионного соглашения
Началась загрузка
AdGuard VPN
Стрелка указывает на файл: нажмите на него, и установка начнётся.
Отсканируйте, чтобы скачать AdGuard VPN на смартфон или планшет