AdGuard VPN Browsererweiterung ist auf Manifest V3 migriert
Manifest V3, die neue Chrome-API-Version, ist für viele Entwickler zu einem Hindernis geworden. Sie nimmt erhebliche Änderungen an den Regeln vor, die Browsererweiterungen befolgen müssen. Sie verursachen nicht nur einige Unannehmlichkeiten, sondern führen auch zu einem teilweisen Verlust der Funktionalität.
Im August 2022 veröffentlichte AdGuard die weltweit erste werbeblockierende Browsererweiterung auf Basis von Manifest V3. Die Entwicklung war eine Herausforderung: Wir hatten viele Probleme auf dem Weg dorthin. Damals haben wir jedoch bewiesen, dass Werbeblocker auch in der Realität von Manifest V3 eine Zukunft haben.
Trotz der Tatsache, dass Google die Lebensdauer der Manifest V2 Erweiterungen bis mindestens Januar 2024 verlängert hat, haben wir die Entwicklung der AdGuard VPN Browsererweiterung, die nach den Manifest V3 Regeln arbeitet, nicht aufgeschoben und sind bereit, sie Ihnen jetzt zu präsentieren.
AdGuard VPN MV3-Browsererweiterung
Mit unserer Erfahrung bei der Entwicklung von AdGuard MV3 haben wir nicht erwartet, dass die Arbeit an AdGuard VPN MV3 ein Spaziergang sein würde, und wir hatten Recht. Lassen Sie uns über die Probleme erzählen, auf die wir gestoßen sind, und wie wir sie gelöst haben.
Service Worker schläft ein
In der vorherigen Version von Manifest gab es eine Hintergrundseite, die es ermöglichte, die Erweiterung einmal nach der Installation oder beim Öffnen eines Browsers zu starten und im Hintergrund weiterzuarbeiten. In Manifest V3 wird die Hintergrundseite durch den Service Worker ersetzt.
Sie ermöglicht es, dass Erweiterungen nur dann im Hintergrund laufen, wenn der Nutzer sie braucht. Die Idee hinter Google Chrome ist es, den Verbrauch von Systemressourcen stark zu reduzieren. Und für selten genutzte Browsererweiterungen funktioniert das auch. Aber wenn eine Erweiterung dem Nutzer ständig helfen muss, wie z. B. AdGuard VPN, wird ein Service Worker, der häufig in den Ruhezustand geht und wieder aufwacht, das System eher belasten als entlasten.
Und das ist noch nicht alles. Chrome überwacht den Service Worker sorgfältig. Wenn die Erweiterung also innerhalb von 30 Sekunden keine API-Aufrufe oder Events tätigt, um zu verhindern, dass der Service Worker einschläft, hält der Browser das Skript einfach an.
Wenn Service Worker gestoppt wird, erzeugt AdGuard VPN Browsererweiterung mit vielen Fehlern. Zum Beispiel erhält sie nicht die neuesten Informationen über verfügbare Standorte, verbleibenden freien Datenverkehr, ausgeschlossene Dienste, andere mit dem Konto verbundene Benutzergeräte und verfügbare Boni; sie kann die Zugriffsschlüssel und das Tray-Symbol mit dem Status der Erweiterung nicht aktualisieren.
Die Lösung
Wir haben bereits erwähnt, dass Events und API-Aufrufe verhindern, dass Service Worker in den Ruhezustand übergeht. Diese können sein:
- Meldungen von Popup-Fenstern, Optionsseiten oder Inhaltsskripten einer Erweiterung
- Proxy-Authentifizierungsanfrage
- das Öffnen eines Popups der Erweiterung
- das Öffnen einer neuen Browser-Registerkarte, das Aktualisieren einer bestehenden Registerkarte oder das Umschalten zwischen Browser-Fenstern
- Auswahl einer beliebigen Aktion aus dem Kontextmenü auf der Seite (Hinzufügen der Website zu den Ausschlüssen, Ändern des Ausschlussmodus)
- Auslösung des über die Alarm-API eingestellten Timers (mehr dazu später)
Kurz gesagt, funktioniert es so: Service Worker protokolliert den Event-Listener, und der Browser zeichnet das Event auf. Wenn das Event eintritt, prüft der Browser, ob er eine Erweiterung hat, die als Reaktion auf dieses Event aufgeweckt werden muss. Ist dies der Fall, weckt er den Service Worker der Erweiterung auf und übergibt das Event an den Listener.
In der Manifest-V2-Umgebung spielte es keine Rolle, wo im Code sich der Listener befand — die Events-Protokollierung konnte asynchron erfolgen, da die Hintergrundseite immer lief und nie neu gestartet wurde.
In Manifest V3 wird der Service Worker neu gestartet, wenn ein Event eintritt. In diesem Fall muss das Event auf der obersten Ebene des Codes (synchron) registriert werden, damit es den Listener erreicht.
Im folgenden Beispiel können Sie sehen, wie der Listener für Popup-Meldungen, Optionsseite und Inhaltsskripte jetzt eingestellt ist:
// add the listener for messages from popup, options page and userscripts
chrome.runtime.onMessage.addListener((event) => console.log(event));
Und dieses Beispiel zeigt, wie der Listener für Kontextmenü-Aktions-Events gesetzt wird:
// add the listener for context menu actions
chrome.contextMenus.onClicked.addListener((event) => console.log(event));
Hier wird gezeigt, wie man den Listener für Tab-Action-Events einstellt:
// 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));
Die neue Alarm-API, die in Manifest V3 zusätzlich zu setInterval und setTimeout eingeführt wurde, hilft auch, einige Fehler zu vermeiden, die durch das Einschlafen des Server-Workers verursacht werden.
/**
* 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 });
};
Das Verwenden von 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;
};
Timer-Implementietung für MV3
Allerdings hat die Alarm-API eine ziemlich unangenehme Einschränkung: Es ist nicht möglich, einen Timer zu setzen, der in 3-5-10 Sekunden funktioniert, sondern nur in einer Minute oder mehr.
Service Worker wacht auf
Wenn der Service Worker in den Ruhezustand geht, werden die im RAM gespeicherten Daten entladen. Nach dem Aufwachen dauert es also eine Weile, bis die Erweiterung die Module initialisiert hat: im Durchschnitt 1,5-2 Sekunden. Wie Sie sehen können, gab es keine derartigen Probleme mit der Hintergrundseite, da sie nur neu geladen wurde, wenn der Browser neu gestartet wurde.
Die Lösung
Wir haben ein Modul hinzugefügt, das jede Zustandsänderung im RAM speichert. Der zuletzt gespeicherte Zustand wird von AdGuard VPN beim Aufwachen des Service Workers zur schnellen Wiederherstellung verwendet. Dadurch wird die Verzögerung beim Neustart des Service Workers auf 150-200 ms reduziert.
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>;
}
Schnittstelle für das Repository der Erweiterung
Die Umsetzung dieser Lösung erforderte die Verlagerung von änderbaren Feldern von Klassen in den Zustandsspeicher. Stattdessen fügten wir Getter und Setter zu den Klassen hinzu.
Wenn die Fallback-API zum Beispiel wie folgt aussah:
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;
}
}
Jetzt hat sich sein Aussehen verändert:
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;
}
}
Hier sind einige weitere Beispiele:
class Endpoints implements EndpointsInterface {
vpnInfo?: VpnExtensionInfoInterface;
}
Hinzufügen eines Status zur Klasse Endpoints in 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;
}
}
Hinzufügen eines Status zur Klasse Endpoints in Manifest V3
export class Credentials implements CredentialsInterface {
vpnToken: VpnTokenData | null;
vpnCredentials: CredentialsDataInterface | null;
}
Hinzufügen eines Status zur Klasse Credentials in 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();
}
}
Hinzufügen eines Status zur Klasse Credentials in Manifest V3
Implementierung von NetworkConnectionObserver
Der NetworkConnectionObserver
ist eine Klasse, die den Netzwerkstatus (online/offline
) überwacht. Sie wird verwendet, wenn eine Anwendung keine Antwort vom Server erhält und hilft, die Ursache des Problems zu ermitteln: keine Internetverbindung des Nutzers oder ein Problem auf der Serverseite. Außerdem überwacht der NetworkConnectionObserver
den Übergang des Nutzers von offline
zu online
. In diesem Fall werden die Zugriffsrechte überprüft, und die Verbindung wird wiederhergestellt, wenn sie aufgebaut wurde, bevor der Benutzer offline ging.
In MV2 ist NetworkConnectionObserver
sehr einfach implementiert. Er wartet auf das Event online
und ruft den ihm übergebenen callback
auf.
class NetworkConnectionObserver { constructor(callback: () => Promise<void>) { window.addEventListener('online', callback); } }
In MV3 hat der Service Worker keinen Zugriff auf window
, so dass die Implementierung komplizierter ist. Eine Lösung wäre die Verwendung eines Offscreen-Dokuments. Damit würde eine unsichtbare Seite mit Zugriff auf window
und document
und der Möglichkeit, dort Aktionen durchzuführen, die dem Service Worker nicht zur Verfügung stehen, geschaffen.
Google verspricht jedoch, dass das Offscreen-Dokument in Zukunft nach einer gewissen Zeit der Inaktivität einschlafen wird, ähnlich wie der Service Worker. Auf diese Weise wird der NetworkConnectionObserver
nicht in der Lage sein, die ganze Zeit auf das online/offline
-Event zu hören, was zu verschiedenen Problemen führen wird.
Jetzt müssen wir eine nicht ganz so subtile Lösung verwenden: jede halbe Sekunde überprüfen wir den Status von navigator.online
und rufen callback
auf, wenn er sich von offline
zu online
geändert hat.
/**
* 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;
}
Probleme mit der WebSocket-Unterstützung
In Manifest V3 werden Nachrichten, die von der Erweiterung über eine WebSocket-Verbindung empfangen werden, den schlafenden Service Worker nicht aufwecken. Infolgedessen verpasst die Erweiterung möglicherweise die Nachricht, dass die Zugriffsschlüssel des Backend-Servers aktualisiert werden müssen, und kann Fehlermeldungen über das Erreichen des Datenverkehrslimits, des Limits für angeschlossene Geräte und nicht routbare Domains nicht verarbeiten.
Die Lösung
Der erste Impuls war, Chromes Vision zu widersprechen und einen nicht schlafenden Service Worker zu schaffen. Tatsächlich haben wir das getan. Aber dann beschlossen wir, uns an die Regeln von Manifest V3 zu halten und nur dann eine Verbindung zum WebSocket herzustellen, wenn der Service Worker wach ist.
Trotz der gefundenen Lösung hoffen wir wirklich, dass Google das Problem, das wir gemeldet haben, löst und die Möglichkeit hinzufügt, die Lebensdauer des Service Workers mit über die WebSocket-Verbindung empfangenen Nachrichten zu verlängern.
Frühzeitiges Auslösen des onAuthRequired
-Handlers
Wir haben auch ein Problem mit einem Browser-Popup behoben, das eine Proxy-Autorisierung anforderte. Dieses Problem trat auf, wenn der Sitzungsspeicher nicht in der Lage war, die Anmeldedaten zu laden, bevor der onAuthRequired
-Event-Handler beim Aufwachen des Service Workers ausgelöst wurde.
Die Lösung
Um dieses Problem zu beheben, haben wir einen asynchronen Callback für den Handler onAuthRequired
hinzugefügt. Er ist nun so eingestellt, dass er wartet, bis der Sitzungsspeicher die Anmeldedaten geladen und eingegeben hat.
/**
* Fügt Listener für das onAuthRequired Ereignis hinzu
*/
private addOnAuthRequiredListener = () => {
chrome.webRequest.onAuthRequired.addListener(
this.onAuthRequiredHandler,
{ urls: [‘<all_urls>’] },
[‘blocking’],
);
};
onAuthRequired
-Handler mit einem synchronen Callback
/**
* Fügt Listener für das onAuthRequired Ereignis hinzu
*/
private addOnAuthRequiredListener = () => {
chrome.webRequest.onAuthRequired.addListener(
this.onAuthRequiredHandler,
{ urls: [‘<all_urls>’] },
[‘asyncBlocking’],
);
};
onAuthRequired
-Handler mit einem asynchronen Callback
Fazit
Manifest V3 ist weit davon entfernt, die intelligenteste und stabilste Umgebung zu sein. Dennoch können Sie die AdGuard VPN MV3 Browsererweiterung bereits installieren und ausprobieren. Wie immer freuen wir uns auf Feedback: Wenn Sie einen Fehler finden, melden Sie ihn bitte auf GitHub.