L'Extension de navigateur AdGuard VPN a migré sur Manifest V3
Manifest V3, la nouvelle version de l'API Chrome, est devenue une pierre tombale pour de nombreux développeurs. Elle apporte des modifications importantes aux règles que les extensions de navigateur doivent respecter. Ces changements entraînent non seulement des désagréments, mais aussi une perte partielle de fonctionnalité.
En août 2022, AdGuard a lancé la première extension de navigateur bloquant les publicités, basée sur Manifest V3. Le développement a été difficile : nous avons rencontré de nombreux problèmes en cours de route. Cependant, nous avons alors prouvé que les bloqueurs de publicité avaient un avenir, même dans la réalité de Manifest V3.
Bien que Google ait prolongé la durée de vie des extensions Manifest V2 jusqu'à au moins janvier 2024, nous n'avons pas reporté le développement de l'extension de navigateur AdGuard VPN qui fonctionne selon les règles de Manifest V3, et nous sommes prêts à vous la présenter maintenant.
L'Extension de navigateur AdGuard VPN MV3
Avec notre expérience dans le développement d'AdGuard MV3, nous ne nous attendions pas du tout à ce que le travail sur AdGuard VPN MV3 soit une promenade tranquille, et nous avions raison. Parlons des problèmes que nous avons rencontrés et de la façon dont nous les avons résolus.
Le service worker s'endort
La version précédente de Manifest disposait d'une page d'arrière-plan qui permettait à l'extension de s'exécuter une fois après l'installation ou à l'ouverture d'un navigateur, et de continuer à travailler en arrière-plan. Dans Manifest V3, la page d'arrière-plan est remplacée par le service worker.
Il permet aux extensions de s'exécuter en arrière-plan uniquement lorsque l'utilisateur en a besoin. L'idée de Google Chrome est de réduire considérablement la consommation des ressources du système. Et pour les extensions de navigateur peu utilisées, cela fonctionne. Mais si une extension doit aider l'utilisateur en permanence, comme AdGuard VPN, un agent de service qui s'endort et se réveille fréquemment est plus susceptible d'augmenter la charge sur le système que de la réduire.
Et ce n'est pas tout. Chrome surveille de près le travailleur de service. Ainsi, si l'extension n'effectue aucun appel à l'API ou n'enregistre aucun événement dans les 30 secondes pour empêcher le service worker de s'endormir, le navigateur arrêtera tout simplement le script.
L'arrêt du service worker menace l'extension de navigateur AdGuard VPN avec de nombreuses erreurs. Par exemple, elle ne recevra pas les dernières informations sur les emplacements disponibles, le trafic libre restant, les services exclus, les autres appareils de l'utilisateur connectés au compte et les primes disponibles ; elle ne pourra pas mettre à jour les clés d'accès et l'icône de la barre d'état avec l'état de l'extension.
Solution
Nous avons mentionné plus haut que les événements et les appels d'API empêchent le travailleur de service d'entrer en mode hibernation. Il peut s'agir
- de messages provenant d'une fenêtre contextuelle, d'une page d'options ou d'un script de contenu d'une extension
- d'une demande d'authentification par proxy
- l'ouverture d'une fenêtre contextuelle d'une extension
- l'ouverture d'un nouvel onglet de navigateur, l'actualisation d'un onglet existant ou le passage d'une fenêtre de navigateur à une autre
- le choix d'une action dans le menu contextuel de la page (ajout du site web aux exclusions, modification du mode d'exclusion)
- le déclenchement de la minuterie définie via l'API Alarme (plus d'informations à ce sujet ultérieurement).
En résumé, le fonctionnement est le suivant : le service worker enregistre l'auditeur d'événements et le navigateur enregistre l'événement. Lorsque l'événement se produit, le navigateur vérifie s'il a une extension qui doit être réveillée en réponse à cet événement. Si c'est le cas, il réveille l'agent de service de l'extension et transmet l'événement à l'auditeur.
Dans l'environnement Manifest V2, l'emplacement de l'écouteur d'événements dans le code importait peu : son enregistrement pouvait se faire de manière asynchrone, car la page d'arrière-plan était toujours en cours d'exécution et n'était jamais redémarrée.
Dans Manifest V3, le service worker est redémarré lorsqu'un événement se produit. Dans ce cas, pour que l'événement atteigne l'auditeur, il doit être enregistré au niveau supérieur du code (de manière synchrone).
Dans l'exemple ci-dessous, vous pouvez voir comment l'écouteur pour les messages popup, la page d'options et les scripts de contenu est maintenant défini :
// add the listener for messages from popup, options page and userscripts
chrome.runtime.onMessage.addListener((event) => console.log(event));
Cet exemple illustre la manière dont le récepteur des événements d'action du menu contextuel est défini :
// add the listener for context menu actions
chrome.contextMenus.onClicked.addListener((event) => console.log(event));
Ceci montre comment définir l'écouteur pour les événements d'action de l'onglet :
// add the listener for tab updated
chrome.tabs.onUpdated.addListener((event) => console.log(event));
// add the listener for tab 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 nouvelle Alarm API, apparue dans Manifest V3 en plus de setInterval et setTimeout, permet également d'éviter certaines erreurs dues à l'endormissement du 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 });
};
En utilisant 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;
};
Implémentation d'un minuteur pour MV3
Cependant, Alarm API comporte une limitation assez gênante : il est impossible de définir une minuterie qui fonctionne en 3-5-10 secondes, mais seulement en une minute ou plus.
Réveil du service worker
Lorsque le service worker passe en mode de veille prolongée, il décharge les données qui étaient stockées dans la RAM. Ainsi, après son réveil, l'extension met un certain temps à initialiser les modules : 1,5 à 2 secondes en moyenne, ce qui est inexcusable. Comme vous pouvez le constater, il n'y a pas eu de problème avec la page d'arrière-plan, car elle n'a été rechargée qu'au redémarrage du navigateur.
Solution
Nous avons ajouté un module pour stocker chaque changement de statut à partir de la RAM. Le dernier statut sauvegardé est utilisé par AdGuard VPN lors du réveil de l'agent de service pour une récupération rapide. Cela permet de réduire le délai de redémarrage de l'agent de service à 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>;
}
Interface du référentiel des statuts d'extension
Pour mettre en œuvre cette solution, il a fallu déplacer les champs modifiables des classes vers le magasin des statuts. À leur place, nous avons ajouté des getters et des setters aux classes.
Par exemple, si l'API de repli ressemblait à ceci :
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;
}
}
Maintenant, son apparence a changé :
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;
}
}
Voici encore des exemples:
class Endpoints implements EndpointsInterface {
vpnInfo?: VpnExtensionInfoInterface;
}
Ajout d'un statut à la classe Endpoints dans 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;
}
}
Ajout d'un statut à la classe Endpoints dans Manifest V3
export class Credentials implements CredentialsInterface {
vpnToken: VpnTokenData | null;
vpnCredentials: CredentialsDataInterface | null;
}
Ajout d'un statut à la classe Credentials dans 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();
}
}
Ajout d'un statut à la classe Credentials dans Manifest V3
Implémentation de NetworkConnectionObserver
Le NetworkConnectionObserver
est une classe qui surveille le statut du réseau (en ligne/hors ligne
). Elle est utilisée lorsqu'une application ne reçoit pas de réponse du serveur et permet d'identifier la cause du problème : pas de connexion Internet de la part de l'utilisateur ou un problème du côté du serveur. De plus, NetworkConnectionObserver
surveille la transition de l'utilisateur de offline
à online
. Dans ce cas, les droits d'accès sont vérifiés et la connexion est rétablie si elle a été établie avant que l'utilisateur ne soit hors ligne.
Dans MV2, le NetworkConnectionObserver
est implémenté de manière très simple. Il écoute l'événement online
et fait appel à la callback
qui lui a été transmise.
class NetworkConnectionObserver {
constructor(callback: () => Promise<void>) {
window.addEventListener('online', callback);
}
}
Dans MV3, le service worker n'a pas accès à window
, ce qui complique la mise en œuvre. Une solution consisterait à utiliser un document hors écran. Cela créerait une page invisible ayant accès à window
et document
et la possibilité d'y effectuer des actions qui ne sont pas disponibles pour le travailleur de service.
Cependant, Google promet qu'à l'avenir, le document hors écran s'endormira après une période d'inactivité, comme le travailleur de service. De cette façon, le NetworkConnectionObserver
ne sera pas capable d'écouter l'événement online/offline
tout le temps, ce qui conduira à des problèmes divers.
Maintenant, nous devons utiliser une solution moins subtile : toutes les demi-secondes, nous vérifions l'état de navigator.online
et appelons callback
s'il est passé de 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;
}
Problèmes liés à la prise en charge de Websocket
Désormais, dans Manifest V3, les messages reçus par l'extension via une connexion websocket ne réveilleront pas le service worker inactif. Par conséquent, l'extension peut manquer le message concernant la nécessité de mettre à jour les clés d'accès au serveur backend et peut ne pas être en mesure de gérer les messages d'erreur concernant l'atteinte de la limite de trafic, la limite des appareils connectés et les domaines non routables.
Solution
Le premier réflexe était d'aller à l'encontre de la vision de Chrome et de faire un service de travailleur qui ne s'arrête pas. C'est d'ailleurs ce que nous avons fait. Mais ensuite, nous avons décidé d'essayer de respecter les règles de Manifest V3 et de nous connecter au websocket uniquement lorsque le service worker est éveillé.
Malgré la solution trouvée, nous espérons vraiment que Google résoudra le problème que nous avons signalé et ajoutera la possibilité de prolonger la durée de vie du service worker avec des messages reçus via la connexion websocket.
Conclusion
Manifest V3 est loin d'être l'environnement le plus intelligent et le plus stable. Néanmoins, vous pouvez déjà installer l'extension AdGuard VPN MV3 et l'essayer. Comme toujours, nous comptons beaucoup sur vos commentaires : si vous trouvez un bug, merci de le signaler sur GitHub.