La extensión de navegador AdGuard VPN ha migrado a Manifest V3
Manifest V3, la nueva versión de la API de Chrome, se ha convertido en un gran inconveniente para muchos desarrolladores, ya que introduce cambios significativos que deben ser seguidos por las extensiones de navegador. Esto no solo causa algunos inconvenientes, sino que también resulta en una pérdida parcial de funcionalidades.
En agosto de 2022, AdGuard lanzó la primera extensión de bloqueo de anuncios para navegador basada en Manifest V3. Su desarrollo fue un verdadero desafío: enfrentamos muchos problemas a lo largo del camino. Aun así, logramos demostrar que existía un futuro para el bloqueo de anuncios incluso dentro de la realidad de Manifest V3.
Aunque Google ha extendido la vida útil de las extensiones Manifest V2 hasta enero de 2024, no perdimos tiempo y desarrollamos una extensión de navegador de AdGuard VPN que cumple con las reglas de Manifest V3, y estamos listos para presentarla ahora.
Extensión de navegador AdGuard VPN MV3
Después de desarrollar el bloqueador de anuncios AdGuard MV3, teníamos la certeza de que trabajar en AdGuard VPN MV3 no sería una tarea fácil. Estábamos en lo correcto. Vamos a hablar un poco más sobre los problemas que encontramos y cómo logramos resolverlos.
Service worker suspendido
La versión anterior del Manifesto tenía una página de fondo que permitía que la extensión se ejecutara justo después de la instalación o cuando se abría el navegador, continuando su funcionamiento en segundo plano. En Manifest V3, esta página en segundo plano es reemplazada por el service worker.
Esto permite que las extensiones se ejecuten en segundo plano solo cuando el usuario las necesita. La idea detrás de esto es reducir significativamente el consumo de recursos del sistema. Para extensiones de navegador poco utilizadas, esta es una excelente idea y realmente funciona. Sin embargo, si una extensión necesita ser utilizada constantemente por el usuario, como es el caso de AdGuard VPN, un service worker que se suspende y se activa con frecuencia puede sobrecargar el sistema en lugar de optimizarlo.
Y eso no es todo. Chrome monitorea el service worker. Entonces, si la extensión no realiza ninguna llamada a la API o no registra ningún evento en 30 segundos para evitar que el service worker entre en modo suspendido, el navegador simplemente detiene el script.
Detener el service worker puede llevar a varios errores en la extensión de navegador AdGuard VPN. Por ejemplo, no recibiría la última información sobre las ubicaciones disponibles, la cantidad de tráfico disponible, los servicios excluidos, otros dispositivos conectados a la cuenta y bonificaciones disponibles; no sería posible actualizar las claves de acceso y el icono del menú con el estado de la extensión.
Solución
Mencionamos anteriormente que los eventos y las llamadas a la API evitan que el service worker entre en modo de hibernación. Estas acciones incluyen:
- Mensajes desde una ventana emergente de la extensión, página de opciones o script de contenido.
- Solicitud de autenticación del proxy.
- Apertura de una ventana emergente de la extensión.
- Apertura de una nueva pestaña en el navegador, actualización de una pestaña existente o cambio de una ventana a otra.
- Selección de cualquier acción del menú en la página (agregar un sitio a las exclusiones, cambiar el modo de exclusión).
- Activación de una alarma a través de la API de Alarma (más información sobre esto próximamente).
En resumen, funciona de la siguiente manera: el service worker registra el escuchador de eventos y el navegador registra el evento. Cuando ocurre el evento, el navegador entiende que la extensión necesita ser "despertada" para responder a él. Por lo tanto, despierta al service worker de la extensión y transmite el evento al escuchador.
En el entorno de Manifest V2, no importaba dónde se encontrara el escuchador de eventos, su registro podía producirse de forma asíncrona porque la página en segundo plano siempre estaba funcionando y nunca se reiniciaba.
No Manifest V3, o service worker é reiniciado quando um evento ocorre. Neste caso, para que o evento chegue até o escutador, ele deve ser registrado de forma síncrona no nível superior do código.
En el siguiente ejemplo, puedes ver cómo se encuentran actualmente los escuchadores de mensajes de la ventana emergente, la página de opciones y los scripts de contenido:
// add the listener for messages from popup, options page and userscripts chrome.runtime.onMessage.addListener((event) => console.log(event));
Este ejemplo ilustra el escuchador de eventos de acción del menú contextual:
// add the listener for context menu actions chrome.contextMenus.onClicked.addListener((event) => console.log(event));
Esto muestra cómo configurar el escuchador de eventos de acción en pestañas:
// add the listener for tab updated
chrome.tabs.onUpdated.addListener((event) => console.log(event));
// add the listener for tabs activated
chrome.tabs.onActivated.addListener((event) => console.log(event));
// add the listener for tab activated from another window
chrome.windows.onFocusChanged.addListener((event) => console.log(event));
La nueva API de Alarma, que apareció en Manifest V3 en adición a setInterval y setTimeout, también ayuda a evitar algunos errores causados por la hibernación del service worker.
/**
* 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 });
};
Usando 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;
};
Implementación de Timer para MV3
Sin embargo, la API de Alarma tiene una limitación un tanto inconveniente: es imposible establecer un temporizador que funcione en 3-5-10 segundos. El mínimo es de 1 minuto o más.
Despertando al service worker
Cuando el service worker entra en modo de hibernación, descarga los datos almacenados en la RAM. Por lo tanto, después de despertar, lleva un poco de tiempo inicializar los módulos de la extensión, aproximadamente 1,5-2 segundos en promedio. Como se puede ver, cuando la página en segundo plano todavía existía, este problema no ocurría, ya que solo se recargaba cuando se reiniciaba el navegador.
Solución
Hemos agregado un módulo para almacenar cada cambio de estado en la RAM. El último estado guardado es utilizado por AdGuard VPN cuando el service worker se despierta para una recuperación rápida. Esto reduce el retraso en la reinicialización del service worker a 150-200 ms.
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>;
}
Repositorio de la interfaz del estado de la extensión
Implementar esta solución requirió el movimiento de campos mutables de las clases al estado de almacenamiento. En su lugar, agregamos getters y setters a las clases.
Por ejemplo, el API Fallback era así:
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;
}
}
Y ahora está así:
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;
}
}
Aquí tienes otros ejemplos:
class Endpoints implements EndpointsInterface {
vpnInfo?: VpnExtensionInfoInterface;
...
}
Añadir un estado a la clase Endpoints en 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;
}
}
Añadir un estado a la clase Endpoints en Manifest V3
export class Credentials implements CredentialsInterface {
vpnToken: VpnTokenData | null;
vpnCredentials: CredentialsDataInterface | null;
}
Añadir un estado a la clase Credentials en 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();
}
}
Añadir un estado a la clase Credentials en Manifest V3
Implementación de NetworkConnectionObserver
NetworkConnectionObserver
es una clase que monitorea el estado de la red (online/offline
). Se utiliza cuando la aplicación no recibe una respuesta del servidor y ayuda a identificar la causa del problema: falta de conexión a Internet por parte del usuario o algún problema del lado del servidor. Además, NetworkConnectionObserver
monitorea la transición del usuario de offline
a online
. En este caso, se verifica los derechos de acceso y se restaura la conexión si se había establecido antes de que el usuario quedara sin conexión.
En MV2, NetworkConnectionObserver
se implementa de manera muy simple. Escucha el evento online
y se comunica con la callback
que se le pasó.
class NetworkConnectionObserver {
constructor(callback: () => Promise<void>) {
window.addEventListener('online', callback);
}
}
Dentro de MV3, el service worker no tiene acceso a window
, por lo que su implementación es más complicada. Una solución sería utilizar un documento fuera de pantalla (offscreen). Esto crearía una página invisible con acceso a window
y document
, y la capacidad de realizar acciones allí que no están disponibles para el service worker.
Sin embargo, Google promete que en el futuro, el documento fuera de pantalla entrará en modo de hibernación después de un período de inactividad, similar al service worker. Esto significa que NetworkConnectionObserver
no podrá escuchar el evento online/offline
todo el tiempo, lo que puede llevar a varios problemas.
Ahora, necesitamos utilizar una solución menos sutil: verificamos el estado de navigator.online
cada medio segundo y llamamos a la callback
si hay un cambio de offline
a 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;
}
Problema de compatibilidad con Websocket
Ahora, en el Manifest V3, los mensajes recibidos por la extensión sobre una conexión websocket no despertarán al service worker en estado de hibernación. Como resultado, la extensión puede perder el mensaje sobre la necesidad de actualizar las claves de acceso al servidor backend y puede no poder manejar mensajes de error sobre el límite de tráfico alcanzado, límite de dispositivos conectados y dominios no enrutados.
Solución
La primera solución que consideramos fue ir en contra de la visión de Chrome y crear un service worker que no entra en modo de hibernación. Incluso llegamos a implementarlo. Sin embargo, luego decidimos seguir las reglas del Manifest V3 y conectar al websocket solo cuando el service worker esté activo.
Aunque hemos encontrado una solución, realmente esperamos que Google resuelva el problema que hemos reportado y agregue la posibilidad de extender la vida del service worker con mensajes recibidos a través de la conexión websocket.
Activación anticipada en el controlador de eventos onAuthRequired
También hemos resuelto un problema relacionado con la aparición de una ventana emergente en el navegador que solicita autorización de proxy. Esto ocurría cuando la sesión de almacenamiento no podía cargar las credenciales antes de que el controlador de eventos onAuthRequired
se descartara después de la activación del service worker.
Solución
Para resolver este problema, hemos agregado un callback asíncrono al controlador onAuthRequired
. Ahora está configurado para esperar hasta que la sesión de almacenamiento haya cargado las credenciales e insertadolas.
/**
* Adds listener for the onAuthRequired event
*/
private addOnAuthRequiredListener = () => {
chrome.webRequest.onAuthRequired.addListener(
this.onAuthRequiredHandler,
{ urls: ['<all_urls>'] },
['blocking'],
);
};
Controlador onAuthRequired
con una devolución de llamada sincrónica
/**
* Adds listener for the onAuthRequired event
*/
private addOnAuthRequiredListener = () => {
chrome.webRequest.onAuthRequired.addListener(
this.onAuthRequiredHandler,
{ urls: ['<all_urls>'] },
['asyncBlocking'],
);
};
Controlador onAuthRequired
con una devolución de llamada asíncrona
En conclusión
El Manifest V3 está lejos de ser un entorno estable e inteligente. Sin embargo, ya puedes instalar la extensión de navegador AdGuard VPN MV3 y probarla. Como siempre, agradecemos tus comentarios: si encuentras algún error, puedes informarlo en GitHub.