AdGuard VPN ブラウザ拡張機能、Manifest V3 に対応
Chrome API の新しいバージョンである「Manifest V3」は、多くの開発者にとって障害のようなものとなっています。
この新バージョンで、ブラウザ拡張機能らへの要件が大幅に変更されたからなのです。
開発が多少不便になるだけでなく、機能の一部が失われることにもつながります。
2022年8月、AdGuard は世界初の「Manifest V3」対応版広告ブロッカーブラウザ拡張機能をリリースしました。
開発は結構大変困難で、途中で多くの問題に直面しました。
しかしその時、「Manifest V3」の下でも広告ブロッカーに未来があるということを私たちは証明しました。
Googleが Manifest V2 対応の拡張機能の有効期限を少なくとも2024年1月まで延長したという事実にもかかわらず、私たちは前もって、Manifest V3 の要件を満たしている AdGuard VPN ブラウザ拡張機能の開発を行い、今、皆様にお届けする準備が整いました。
AdGuard VPN MV3対応版ブラウザ拡張機能の開発中に直面した問題
AdGuardのMV3版(MV3=Manifest V3)を開発した経験があるからといって、AdGuard VPN MV3版の開発がすごく簡単になることはない、という覚悟で取り組みんだこと大当たりでした。
開発中、私たちが遭遇した問題とその解決方法についてお話したいと思います。
サービスワーカーがスリープ状態になってしまう
旧バージョンのManifestにはバックグラウンドページというものがあり、インストール後またはブラウザが開かれたときに拡張機能が一度実行され、バックグラウンドで動作し続けるようになっていました。
Manifest V3 では、バックグラウンドページはサービスワーカー(service worker)に置き換えられました。
これにより、ユーザーが拡張機能を必要とするときだけバックグラウンド実行できるようになりました。
Google Chrome の背後にあるアイデアは、システムリソースの消費を大幅に削減することです。そして、使用頻度の低いブラウザ拡張機能には、この対策はもちろん有効です。
しかし、AdGuard VPN のように常にユーザーに機能を提供する必要がある拡張機能の場合(この場合はVPN接続を維持していないといけない)、頻繁にスリープとスリープ解除を繰り返すサービスワーカーは、システムの負荷を減らすというよりむしろ増やす可能性が高い。
それだけではありません。
Chrome はサービスワーカーを見守っています。
そのため、拡張機能が30秒以内にAPIコールやイベントを行わず、サービスワーカーのスリープを防げなかった場合、Chromeは単にスクリプトを停止します。
サービスワーカーの停止は、AdGuard VPN ブラウザ拡張機能の多数エラーのリスクの原因となります。
例えば、利用可能なロケーション(サーバー)、残りの無料トラフィック、除外されているサービス、アカウントに接続されている他のユーザーデバイス、利用可能なボーナスに関する最新の情報を受け取ることができなくなり、アクセスキーやトレイアイコンの拡張機能ステータスを更新することができなくなります。
この問題への解決方法
イベントやAPIコールは、サービスワーカーがハイバネーションモードに入るのを防ぎます。
イベントやAPIコールには次のようなものがあります:
- 拡張機能のポップアップ、オプションページ、コンテンツスクリプトからのメッセージ
- プロキシ認証リクエスト
- 拡張機能のポップアップが開く
- 新しいブラウザのタブを開く、既存のタブを更新する、ブラウザのウィンドウを切り替える
- ページ上のコンテキストメニューからのアクションを選択(対象外リストへのウェブサイト追加、除外モードの変更)
- Alarm API 経由で設定されたタイマーのトリガー(これについての詳細は後述します)
この仕組みを簡単に説明すると、以下のようになります。
サービスワーカーはイベントリスナー(event listener)を記録し、ブラウザはイベントを記録します。
イベントが発生すると、ブラウザはこのイベントに応答して起動する必要がある拡張機能があるかどうかを確認します。
もしあれば、拡張機能のサービスワーカーを起こし、イベントをイベントリスナーに渡します。
Manifest V2環境では、イベントリスナーがコード内のどこに位置しても問題ありませんでした。
バックグラウンドページは常に実行され、再起動されることがなかったため、リスナーの記録は非同期に行われることが可能でした。
Manifest V3 の場合、イベントが発生するとサービスワーカーが再起動されます。
この場合、イベントがイベントリスナーにヒットするためには、リスナーはコードのトップレベルで(同期的に)登録される必要があります。
以下の例では、ポップアップメッセージ、オプションページ、コンテンツスクリプトのリスナーがどのようにセットされているかがわかります:
// add the listener for messages from popup, options page and userscripts
chrome.runtime.onMessage.addListener((event) => console.log(event));
そしてこちらの例では、コンテキストメニューのアクションイベントのリスナーがどのようにセットされるかを示しています:
// add the listener for context menu actions
chrome.contextMenus.onClicked.addListener((event) => console.log(event));
こちらは、タブアクションイベントのリスナーがセットされる方法を示しています:
// 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));
setInterval と setTimeout に加え、Manifest V3 で登場した新しい Alarm API も、サーバーワーカーのスリープによるエラーを回避するのに役立ちます。
Alarm API を使用:
/**
* 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 });
};
MV3 環境でタイマーを実装:
/**
* 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;
};
しかし、Alarm APIにはかなり不便な制限があります。
3-5-10秒で動作するタイマーを設定することはできず、1分以上で動作するタイマーを設定することしかできません。
サービスワーカーのウェイクアップ(スリープ状態からの起動)
サービスワーカーがスリープモードに入ると、RAMに保存されていたデータをアンロードします。
そのため、目覚めた後、拡張機能がモジュールを初期化するのに時間がかかってしまいます(平均で1.5〜2秒というどうしようもなく長い時間です)。
Manifest V2のバックグラウンドページの場合はブラウザの再起動時にのみリロードされるため、このような問題は発生しませんでした。
解決策
各状態の変更をRAMから保存するモジュールを追加しました。
AdGuard VPN が最後に保存した状態は、サービスワーカーが起動したときに使用され、すばやく回復します。
これにより、サービスワーカーの再起動の遅延が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>;
}
このソリューションを実装するには、変更可能なフィールドをクラスからステートストアに移す必要がありました。
その代わりに、ゲッター(getters)とセッター(setters)をクラスに追加しました。
例えば、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;
}
}
現在での Fallback API はこちら:
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;
}
}
もう少し例を挙げよう。
Manifest V2 におけるEndpointsクラスにstateを追加:
class Endpoints implements EndpointsInterface {
vpnInfo?: VpnExtensionInfoInterface;
}
Manifest V3 におけるEndpointsクラスにstateを追加:
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;
}
}
Manifest V2 におけるCredentialsクラスにstateを追加:
export class Credentials implements CredentialsInterface {
vpnToken: VpnTokenData | null;
vpnCredentials: CredentialsDataInterface | null;
}
Manifest V3 におけるCredentialsクラスにstateを追加:
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();
}
}
NetworkConnectionObserver
の実装
NetworkConnectionObserver
はネットワークの状態(オンライン/オフライン)を監視するクラスです。
このクラスは、アプリがサーバから応答を得られない場合に使用され、問題の原因(ユーザからインターネット接続がないか、サーバ側の問題か)を特定するのに役立ちます。
また、NetworkConnectionObserver
はユーザが offline
から online
に遷移するのを監視します。この場合、アクセス権がチェックされ、ユーザーがオフラインになる前に接続が確立されていた場合は接続が復元されます。
MV2では、NetworkConnectionObserver
は非常にシンプルに実装できます。
online
イベントをリッスンし、渡された callback
を呼び出します。
class NetworkConnectionObserver {
constructor(callback: () => Promise<void>) {
window.addEventListener('online', callback);
}
}
MV3では、サービスワーカーはwindow
にアクセスできないので、実装が複雑になります。
解決策としては、オフスクリーンドキュメント(offscreen document)を使用することです。
これにより、window
とdocument
にアクセスできる不可視のページが作成され、サービスワーカーには利用できないアクションをそこで実行できるようになります。
しかし、Googleは将来的に、オフスクリーンドキュメントもサービスワーカーと同様に、一定時間操作されないとスリープ状態になるようにするようです。
そうなると、NetworkConnectionObserver
は常にonline/offline
イベントをリッスンすることができなくなり、様々な問題が発生することになります。
今のところ、私たちは少し雑な解決策を使わなければなりません。
半秒ごとに navigator.online
の状態をチェックし、offline
から online
に変化したら callback
を呼び出すという対策です。
/**
* 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 では、ブラウザ拡張機能がWebソケット接続を介して受信したメッセージは、スリープ状態のサービスワーカーを目覚めさせません。
その結果、ブラウザ拡張機能はバックエンドサーバーのアクセスキーを更新する必要性に関するメッセージを見逃す可能性があり、データ通信量の上限達成、接続デバイス台数の上限達成、およびルーティング不可能なドメインに関するエラーメッセージを処理できなくなる可能性があります。
解決策
最初に出てきたアイデアは、Chrome のビジョンに逆らって、スリープしないサービスワーカーを作ることでした。
しかしそのあと、Manifest V3 のルールに従いながらの方法はないかということで、サービスワーカーが起きているときだけウェブソケットに接続することにしました。
とりあえず解決策は見つかったものの、私たちがGoogleに報告した問題をGoogleが解決し、ウェブソケット接続で受信したメッセージでサービスワーカーの寿命を延ばす可能性を追加してくれることを切に願います。
onAuthRequired
ハンドラの早期起動
また、プロキシ認証リクエストでブラウザのポップアップが表示される問題を解決しました。
これは、サービスワーカーのウェイクアップ時に onAuthRequired
イベントハンドラが発生する前に、セッションストレージが認証情報をロードできなかった場合に発生していました。
解決策
この問題を解決するために、onAuthRequired
ハンドラに非同期(asynchronous)コールバックを追加しました。
セッションストレージが認証情報をロードして入力するまで待機するよう、このハンドラを設定しました。
同期(synchronous)コールバックのonAuthRequired
ハンドラ:
/**
* Adds listener for the onAuthRequired event
*/
private addOnAuthRequiredListener = () => {
chrome.webRequest.onAuthRequired.addListener(
this.onAuthRequiredHandler,
{ urls: ['<all_urls>'] },
['blocking'],
);
};
(今回の対策)非同期(synchronous)コールバックのonAuthRequired
ハンドラ:
/**
* Adds listener for the onAuthRequired event
*/
private addOnAuthRequiredListener = () => {
chrome.webRequest.onAuthRequired.addListener(
this.onAuthRequiredHandler,
{ urls: ['<all_urls>'] },
['asyncBlocking'],
);
};
結論
Manifest V3 は、一番スマートで安定した環境とは言えません。
しかし、AdGuard VPN 「ManifestV3」対応版ブラウザ拡張機能はもうインストールして試すことができます。
いつものように、私たちはフィードバックに大きく依存しており、もしバグなどを見つけたら、GitHubでご報告いただければ大変助かります。