A extensão de navegador AdGuard VPN migrou para o Manifest V3
O Manifest V3, a nova versão do API do Chrome, se tornou um grande incômodo para muitos desenvolvedores. Ele traz mudanças significativas que precisam ser seguidas pelas extensões de navegador. Isso não apenas causa alguns inconvenientes, mas também leva a uma perda parcial de funcionalidades.
Em Agosto de 2022, o AdGuard lançou a primeira extensão de navegador de bloqueio de anúncios com base no Manifest V3. Seu desenvolvimento foi um verdadeiro desafio: nós encaramos muitos problemas ao longo do caminho. Mesmo assim foi possível provar que existia um futuro para o bloqueio de anúncios mesmo dentro da realidade do Manifest V3.
Mesmo que o Google tenha estendido a vida útil das extensões Manifest V2 até Janeiro de 2024, nós não perdemos tempo e desenvolvemos uma extensão de navegador do AdGuard VPN que funciona de acordo com as regras do Manifest V3 e estamos prontos para apresentá-la agora.
Extensão de navegador AdGuard VPN MV3
Após desenvolver o bloqueador de anúncios AdGuard MV3, nós tínhamos a certeza de que trabalhar no AdGuard VPN MV3 não seria tarefa fácil. Estávamos certos. Vamos falar um pouquinho mais sobre os problemas que encontramos e como conseguimos resolvê-los.
Service worker suspendido
A versão anterior do Manifest tinha uma página de fundo que permitia que a extensão fosse executada logo após a instalação ou quando o navegador fosse aberto, continuando a funcionar em segundo plano. No Manifest V3, esta página em segundo plano é substituída pelo service worker.
Isso permite que as extensões sejam executadas em segundo plano apenas quando o usuário precisa delas. A ideia por trás disso é reduzir significativamente o consumo dos recursos do sistema. Para extensões de navegador pouco utilizadas, esta é uma ótima ideia e de fato funciona. Mas se uma extensão precisa ser usada constantemente pelo usuário, como é o caso do AdGuard VPN, um service worker que é suspendido e ativado com frequência pode sobrecarregar o sistema ao invés de otimizá-lo.
E não para por aí. O Chrome fica de olho do service worker. Assim, se a extensão não faz nenhuma chamada ao API ou não registra nenhum evento em 30 segundos para impedir que o service worker entre em modo suspenso, o navegador simplesmente interrompe o script.
Parar o service worker pode levar a vários erros na extensão de navegador AdGuard VPN. Por exemplo, ela não receberia as últimas informações sobre os locais disponíveis, quantidade de tráfego disponível, serviços excluídos, outros dispositivos conectados à conta e bônus disponíveis; não seria possível atualizar as chaves de acesso e o ícone de menu com o status da extensão.
Solução
Nós mencionamos acima que eventos e chamadas da API impedem que o service worker entre no modo de hibernação. Estas ações incluem:
- mensagens de uma popup da extensão, página de opções ou script de conteúdo
- solicitação de autenticação do proxy
- abertura de uma popup da extensão
- abertura de uma nova aba no navegador, atualização de uma aba já existente ou a mudança de uma janela para a outra
- a escolha de qualquer ação do menu na página (adição do site às exclusões, alterações no modo de exclusão)
- ativação do alarme através do Alarm API (mais sobre isso em breve)
Em resumo, funciona da seguinte maneira: o service worker registra o escutador de eventos e o navegador grava o evento. Quando o evento acontece, o navegador entende que a extensão precisa ser “acordada” para responder a ele. Assim, ele acorda o service worker da extensão e transmite o evento ao escutador.
No ambiente do Manifest V2, não importava onde o escutador de eventos estava localizado, seu registro podia ocorrer de forma assíncrona porque a página em segundo plano estava sempre funcionando e nunca era reiniciada.
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.
No exemplo abaixo, você pode ver como estão agora os escutadores das mensagens pop up, da página de opções e dos scripts de conteúdo:
// add the listener for messages from popup, options page and userscripts chrome.runtime.onMessage.addListener((event) => console.log(event));
Este exemplo ilustra o escutador para os eventos de ação do menu contextual:
// add the listener for context menu actions chrome.contextMenus.onClicked.addListener((event) => console.log(event));
Isso mostra como configurar o escutador para eventos de ação em abas:
// 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));
O novo Alarm API, que apareceu no Manifest V3 em adição ao setInterval e setTimeout, também ajuda a evitar alguns erros causados pela hibernação do worker do servidor.
/**
* 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 o 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;
};`
Implementação do Timer para MV3
No entanto, o Alarm API tem uma limitação um tanto quanto inconveniente: é impossível definir um temporizador que funcione em 3-5-10 segundos. O mínimo é 1 minuto ou mais.
Despertando o service worker
Quando o service worker entra no modo de hibernação, ele descarrega os dados armazenados na RAM. Assim, após despertar, demora um pouco para a extensão inicializar os seus módulos, 1,5 - 2 segundos em média. Como se pode ver, quando a página em segundo plano ainda existia, não havia este problema, já que ela apenas era recarregada quando o navegador era reiniciado.
Solução
Nós adicionamos um módulo para armazenar cada estado de mudança da RAM. O último estado salvo é usado pelo AdGuard VPN quando o service worker é despertado para uma recuperação rápida. Isso reduz o atraso na reinicialização do service worker para 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>;
}`
Repositório da interface do estado da extensão
Implementar esta solução exigiu a movimentação de campos mudáveis de classes para o estado de armazenamento. No lugar delas, nós adicionamos getters e setters às classes.
Por exemplo, o API Fallback era assim:
`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;
}
}`
E agora ele está assim:
`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;
}
}`
Aqui estão alguns outros exemplos:
`class Endpoints implements EndpointsInterface {
vpnInfo?: VpnExtensionInfoInterface;
}`
Adicionando um estado à classe Endpoints no 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;
}
}`
Adicionando um estado à classe Endpoints no Manifest V3
`export class Credentials implements CredentialsInterface {
vpnToken: VpnTokenData | null;
vpnCredentials: CredentialsDataInterface | null;
...
}`
*Adicionando um estado à classe Credentials no 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();
}
}`
Adicionando um estado à classe Credentials no Manifest V3
Implementação de NetworkConnectionObserver
O NetworkConnectionObserver
é uma classe que monitora o status de rede (online/offline
). Ele é usado quando o app não recebe uma resposta do servidor e ajuda a identificar a causa do problema: falta de conexão à internet por parte do usuários ou algum problema do lado do servidor. Além disso, o NetworkConnectionObserver
monitora a transição do usuário de offline
a online
. Neste caso, os direitos de acesso são checados e a conexão é restaurada se ela tiver sido estabelecida antes de o usuário ter ficado offline.
No MV2, o NetworkConnectionObserver
é implementado de forma muito simples. Ele ouve o evento online
e se comunica com o callback
que passou por ele.
`class NetworkConnectionObserver {
constructor(callback: () => Promise<void>) {
window.addEventListener('online', callback);
}
}`
Dentro do MV3, o service worker não tem acesso a window
, por isso sua implementação é mais complicada. Uma solução seria usar um documento offscreen. Isso criaria uma página invisível com acesso a window
e document
e a habilidade de realizar ações por lá que não estão disponíveis para o service worker.
No entanto, o Google promete que, no futuro, o documento offscreen entrará em modo de hibernação após um período de inatividade, parecido com o service worker. Desta forma, o NetworkConnectionObserver
não conseguirá escutar o evento online/offline
o tempo todo, o que pode levar a vários problemas.
Agora, nós precisamos utilizar uma solução não muito sutil: verificamos o estado do navigator.online
a cada meio segundo e chamamos o callback
se houve uma mudança de offline
para 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 compatibilidade Websocket
Agora, no Manifest V3, as mensagens recebidas pela extensão sobre uma conexão websocket não acordarão o service work hibernando. Como resultado, a extensão pode perder a mensagem sobre a necessidade de atualizar as chaves de acesso ao servidor backend e pode não conseguir lidar com mensagens de erro sobre o limite de tráfego atingido, limite de dispositivos conectados e domínios não roteados.
Solução
O impulso inicial foi ir contra a visão do Chrome e criar um service worker que não entra em modo de hibernação. Nós inclusive fizemos isso. Mas, então, nós decidimos jogar de acordo com as regras do Manifest V3 e conectar ao websocket apenas quando o service worker estiver desperto.
Apesar de termos encontrado uma solução, nós realmente esperamos que o Google resolva o problema que reportamos e adicione a possibilidade de extender a vida do service worker com mensagens recebidas através da conexão websocket.
Ativação antecipada do manipulador de eventos onAuthRequired
Nós também resolvemos um problema associado à aparição de uma popup no navegador com uma solicitação de autorização de proxy. Isso acontecia quando a sessão armazenamento não conseguia carregar as credenciais antes de que o manipulador de eventos onAuthRequired
era descartado após o despertar do service worker.
Solução
Para solucionar este problema, nós adicionamos um callback assíncrono para o manipulador onAuthRequired
. Ele está agora configurado para esperar até que o armazenamento da sessão tenha carregado as credenciais e as inserido.
/**
* Adds listener for the onAuthRequired event
*/
private addOnAuthRequiredListener = () => {
chrome.webRequest.onAuthRequired.addListener(
this.onAuthRequiredHandler,
{ urls: ['<all_urls>'] },
['blocking'],
);
};
Manipulador onAuthRequired
com um callback síncrono
/**
* Adds listener for the onAuthRequired event
*/
private addOnAuthRequiredListener = () => {
chrome.webRequest.onAuthRequired.addListener(
this.onAuthRequiredHandler,
{ urls: ['<all_urls>'] },
['asyncBlocking'],
);
};
Manipulador onAuthRequired
com um callback assíncrono
Em conclusão
O Manifest V3 está longe de ser um ambiente estável e inteligente. Ainda assim, você já pode instalar a extensão de navegador AdGuard VPN MV3 e experimentá-la. Como de costume, contamos com o seu feedback: se você encontrar um bug, pode reportá-lo no GitHub.