AdGuard VPN 浏览器扩展迁移到 Manifest V3 了
Manifest V3, Chrome 的新 API 成为了不少开发者的阻碍。MV3 对浏览器扩展必须遵循的规则进行了重大更改,规则不仅会造成一些不便,而且还会导致部分功能的失效。
2022年8月,AdGuard 发布了首个基于 Manifest V3 的广告拦截浏览器扩展。在开发过程中,我们面临了很多挑战,有些问题我们已经解决好了,有些还在修复的过程中。然而,我们随后证明了,即使在 Manifest V3的引入后,广告拦截程序仍有未来。
虽然 Google 已经将 Manifest V2 扩展使用期限延期到2024年1月,但我们并没有因此懈怠下来,而是继续紧锣密鼓地研发新产品,因此现在我们已经可以给大家介绍新扩展了。
AdGuard VPN MV3 浏览器扩展
根据我们开发 AdGuard MV3 扩展的经验,我们并不指望 AdGuard VPN MV3 能够"一帆风顺"。让我们来谈谈一路上我们所遇到的问题,以及解决它们的方法吧。
Service worker 睡眠状态
Manifest 的上一个版本有一个后台页面(background page),允许扩展在安装后或打开浏览器后运行一次,并在后台运行。在 Manifest V3, Service worker 代替了之前的后台页面。
有了 Service worker,扩展程序可以只在用户需要时在后台运行。Google 浏览器的主要目标是,大大减少系统资源的消耗。在很少使用浏览器扩展的情况下,可以达成这一目标。但是,扩展程序应该一直工作,如 AdGuard VPN,频繁的唤醒和睡眠更有可能会增加系统的负担。
此外,Chrome 密切监控 Service worker。如果为了避免 Service worker 进入睡眠状态,在30秒内扩展中没有发生任何操作和 API 调用,那么浏览器就会暂停运行相应的脚本。
停止 Service worker 会导致 AdGuard VPN 扩展工作流程收到许多错误。例如,扩展程序将无法收到有关可用服务器位置、剩余免费流量、排除的服务、连接到账号的其他用户设备和可用优惠的最新信息;也无法更新许可密钥和扩展的托盘图标状态。
解决方案
我们在上面提到,操作和 API 调用会阻止 Service worker 进入睡眠状态,包括:
- 来自一个扩展的弹出窗口、选项页或内容脚本的信息
- 代理认证请求
- 一个扩展的弹出窗口的打开
- 打开一个新的浏览器标签,刷新一个现有的标签,或在浏览器窗口之间的切换
- 从页面的上下文菜单中选择任何操作(将网站添加到排除项,改变排除模式)
- 触发通过 Alarm API 设置的定时器(后面会有更多详情)
简而言之,工作原理如下:Service worker 注册一个事件监听器(event listener),浏览器会记录事件。当事件发生时,浏览器会检查它是否有一个扩展要响应该事件而被唤醒。如果有,它就会唤醒该扩展的 Service worker,并将事件传递给事件监听器。
在 Manifest V2 下,事件监听器在代码中的哪个位置并不重要,由于后台页面连续运行且没有重启,其注册记录可以异步发生。
在 Manifest V3 中,当一个事件发生时,Service worker 会被重新启动。在这种情况下,要通过监听 push 事件,事件必须被注册到代码的最高层(同步)。
下面的例子展示现在为来自弹出窗口、选项页和内容脚本的消息设置监听器的方式:
// 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,也有助于避免因 Server 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 });
};
试用 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 的定时器实现
然而,Alarm API 有一个相当不方便的限制,即无法设置一个在3-5-10秒内工作的定时器,而只能在1分钟或更长时间内工作。
Service worker 唤醒
当 Service worker 进入休眠模式时,它会卸载存储在 RAM 中的数据。因此,在唤醒后,扩展程序需要用一段时间来初始化模块,平均1.5-2秒的时间,令人难以理解很长的时间。正如用户可以看到,后台页面没有这样的问题,因为它只在浏览器重启时被重新进行加载。
解决方案
我们已经添加了一个模块,允许我们从 RAM 中存储每个状态的变化。当 Service worker 唤醒时,AdGuard VPN 使用最后保存的状态,以便快速恢复。这将重新启动 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>;
}
扩展状态存储库接口
实现上述解决方案需要将可改变的字段从类中搬移到状态存储中。取而代之的是,我们在类中添加了 Getters 和 Setters。
比方说,如果备份 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;
}
在 Manifest V2 的 Endpoints 类添加状态
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 V3 的 Endpoints 类添加状态
export class Credentials implements CredentialsInterface {
vpnToken: VpnTokenData | null;
vpnCredentials: CredentialsDataInterface | null;
}
在 Manifest V2 的 Credentials 类添加状态
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();
}
}
在 Manifest V3 的 Credentials 类添加状态
NetworkConnectionObserver
的实现
NetworkConnectionObserver
是一个观察网络状态(online/offline
)的类。当应用程序没有收到服务器的响应时,它就会帮助判断问题的原因:用户没有互联网连接或服务器端发发生错误。另外,NetworkConnectionObserver
监控用户从离线到在线的转变。在这种情况下,检查访问权限,如果连接是在用户离线前建立的,将恢复连接。
在 MV2 中,NetworkConnectionObserver
的实现非常简单。它监听 online
事件并调用传递给它的 callback
。
class NetworkConnectionObserver {
constructor(callback: () => Promise<void>) {
window.addEventListener('online', callback);
}
}
在 MV3 中,Service worker 无法访问 window
,所以实现流程比较复杂。一个解决方案是使用一个屏幕外的文档。这将创建一个不可见的页面,可以访问 window
和 document
,并能够执行 Service worker 无法使用的操作。
然而,Google 承诺,在未来,屏幕外的文件将在一段时间的不活动后陷入睡眠状态,与 Service worker 类似。这样一来,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;
}
支持 Websocket 的问题
现在在 Manifest V3 中,扩展通过网络 Websocket 连接收到的消息不会唤醒处于睡眠状态的 Service worker 。正因为如此,扩展可能会错过关于需要更新后端服务器访问密钥的消息,也可能会处理关于达到流量限制、连接设备限制和不可路由域的错误消息。
解决方案
起始脉冲是,要违背 Chrome 的设计,做一个未休眠的 Service worker。事实上,我们确实试过了这个方式。但后来我们决定尝试遵循 Manifest V3 的规则,只在 Service worker 唤醒的时候连接到网络 Websocket。
虽然已经找到了一个解决方案,但我们非常希望 Google 能够解决我们报告的问题,并增加通过网络 Websocket 连接收到的消息来延长 Service worker 的生命周期。
提前触发 onAuthRequired
事件处理程序
我们还解决了一个在代理中浏览器弹出请求授权的问题。这曾经发生在会话存储无法在 service worker 唤醒时 onAuthRequired
事件处理程序启动之前加载凭证。
解决方案
为了解决这个问题,我们为 onAuthRequired
处理程序添加了一个异步回调。它现在被设置为等待,直到会话存储加载并输入凭证。
/**
* Adds listener for the onAuthRequired event
*/
private addOnAuthRequiredListener = () => {
chrome.webRequest.onAuthRequired.addListener(
this.onAuthRequiredHandler,
{ urls: ['<all_urls>'] },
['blocking'],
);
};
onAuthRequired
处理程序,有同步回调
/**
* Adds listener for the onAuthRequired event
*/
private addOnAuthRequiredListener = () => {
chrome.webRequest.onAuthRequired.addListener(
this.onAuthRequiredHandler,
{ urls: ['<all_urls>'] },
['asyncBlocking'],
);
};
*onAuthRequired
处理程序,有异步回调
总结
Manifest V3 远不是一个最智能和最稳定的 API,特别是对于 AdGuard VPN 这类的扩展程序。尽管如此,用户已经可以安装并试用它了。一如既往,我们非常期待你的反馈意见!如果发现了错误,请在 GitHub 上报告它。