前言
我们在钱包的初始化中提到,钱包的后台入口文件是 app-init.js,它会加载很多文件:
- sentry-install.js(日志记录工具)
- init-globals.js(初始化全局变量)
- lockdown-***(Secure ECMAScript)
- runtime-cjs.js(LavaMoat)
- common-* /background-*(后台代码)
其中background-* 都是由background.js打包生成,它是钱包业务的后台主服务。
脚本概览
// 简化的background.js
// 设置安装事件监听器(首次安装钱包打开用户注册界面)
if (globalThis.stateHooks.onInstalledListener) {
globalThis.stateHooks.onInstalledListener.then(handleOnInstalled);
} else {
browser.runtime.onInstalled.addListener(function listener(details) {
browser.runtime.onInstalled.removeListener(listener);
handleOnInstalled(details);
});
}
let isInitialized;
let resolveInitialization;
let rejectInitialization;
// 用于跟踪后台的初始化状态
function setGlobalInitializers() {
const deferred = Promise.withResolvers();
isInitialized = deferred.promise;
resolveInitialization = deferred.resolve;
rejectInitialization = deferred.reject;
}
// 设置全局初始化器
setGlobalInitializers();
// 设置连接监听器
const corruptionHandler = new CorruptionHandler();
browser.runtime.onConnect.addListener(async (...args) => {
try {
await isInitialized;
connectWindowPostMessage(...args);
} catch (error) {
...
}
}
});
browser.runtime.onConnectExternal.addListener(async (...args) => {
await isInitialized;
connectExternallyConnectable(...args);
});
// 设置更新可用监听器
browser.runtime.onUpdateAvailable.addListener(onUpdateAvailable);
// 启动后台初始化
if (!process.env.SKIP_BACKGROUND_INITIALIZATION) {
initBackground(null);
}
主要的作用有两个:
- 设置连接监听器
- 后台初始化
我们将针对这两部分详细讨论。
脚本详解
设置连接监听器
browser.runtime.onConnect用于处理内部连接(内容脚本,popup页面,全屏窗口等)
主要处理逻辑位于connectWindowPostMessage中:
// 简化后的代码
connectWindowPostMessage = (remotePort) => {
const processName = remotePort.name;
const senderUrl = remotePort.sender?.url ? new URL(remotePort.sender.url) : null;
let isMetaMaskInternalProcess = senderUrl?.origin === `chrome-extension://${browser.runtime.id}`;
if (isMetaMaskInternalProcess) {
// 处理内部窗口(popup/notification/fullscreen)与后台的通信
const portStream = new PortStream(remotePort);
controller.isClientOpen = true;
controller.setupTrustedCommunication(portStream, remotePort.sender);
// 根据窗口类型,管理连接计数和关闭时的清理
if (processName === ENVIRONMENT_TYPE_POPUP) {
openPopupCount += 1;
finished(portStream, () => {
openPopupCount -= 1;
controller.isClientOpen = isClientOpenStatus();
onCloseEnvironmentInstances(controller.isClientOpen, ENVIRONMENT_TYPE_POPUP);
});
}
if (processName === ENVIRONMENT_TYPE_NOTIFICATION) {
notificationIsOpen = true;
finished(portStream, () => {
notificationIsOpen = false;
controller.isClientOpen = isClientOpenStatus();
onCloseEnvironmentInstances(controller.isClientOpen, ENVIRONMENT_TYPE_NOTIFICATION);
});
}
if (processName === ENVIRONMENT_TYPE_FULLSCREEN) {
const tabId = remotePort.sender.tab.id;
openMetamaskTabsIDs[tabId] = true;
finished(portStream, () => {
delete openMetamaskTabsIDs[tabId];
controller.isClientOpen = isClientOpenStatus();
onCloseEnvironmentInstances(controller.isClientOpen, ENVIRONMENT_TYPE_FULLSCREEN);
});
}
return;
}
// 处理钓鱼警告页面的通信
if (
senderUrl &&
senderUrl.origin === phishingPageUrl.origin &&
senderUrl.pathname === phishingPageUrl.pathname
) {
const portStream = new PortStream(remotePort);
controller.setupPhishingCommunication({ connectionStream: portStream });
return;
}
// 处理普通网页(DApp)或特殊页面的通信
if (remotePort.sender && remotePort.sender.tab && remotePort.sender.url) {
const tabId = remotePort.sender.tab.id;
const url = new URL(remotePort.sender.url);
const { origin } = url;
// 监听账户请求
remotePort.onMessage.addListener((msg) => {
if (msg.data && msg.data.method === MESSAGE_TYPE.ETH_REQUEST_ACCOUNTS) {
requestAccountTabIds[origin] = tabId;
}
});
}
// 处理营销白名单页面的cookie通信
if (
senderUrl &&
COOKIE_ID_MARKETING_WHITELIST_ORIGINS.some((origin) => origin === senderUrl.origin)
) {
const portStream = new PortStream(remotePort);
controller.setUpCookieHandlerCommunication({ connectionStream: portStream });
}
// 默认:建立EIP-1193 provider通信
const portStream = new PortStream(remotePort);
controller.setupUntrustedCommunicationEip1193({portStream, remotePort.sender});
};
在处理连接请求时,MetaMask 后台会根据发起方的不同采取不同的处理策略:
-
内部程序(popup/notification/fullscreen)
- 首先判断连接请求是否来自钱包的内部页面(如弹窗、通知、全屏界面)。
- 对于这些内部程序,会建立可信通信通道
setupTrustedCommunication(具体实现将在下一章讲解)。 - 同时,系统会记录内部页面的打开情况。当检测到没有活跃的内部页面时,会触发清理程序,销毁如 gas 监听器、余额监听器等不必要的资源,防止资源浪费。
-
钓鱼警告页面
- 如果连接请求来自钓鱼警告页面,则会为其建立专门的通信通道,限制其通信能力,确保安全。
-
内容脚本(通常由 DApp 发起)
- 对于内容脚本发起的连接(即 DApp 连接),由于用户可能同时打开多个 DApp 页面,因此需要记录每个连接的
tabId和origin。在用户发起连接钱包请求时,将tabId与origin关联起来,这样在后续的签名授权等操作中,能够准确地将操作结果返回给发起请求的正确页面。 - 如果连接请求来自特定的营销页面,则会为其建立专门的通信通道,进行特殊处理。
- 最后,对于内容脚本发起的所有连接(包括普通 DApp 和营销页面),都会统一建立不可信通信通道(
setupUntrustedCommunicationEip1193),以保证与 DApp 的标准以太坊交互。
- 对于内容脚本发起的连接(即 DApp 连接),由于用户可能同时打开多个 DApp 页面,因此需要记录每个连接的
browser.runtime.onConnectExternal用于处理外部连接(硬件钱包,外部扩展,外部应用等),这里不做深入讲解。
后台初始化流程
async function initBackground() {
onNavigateToTab();
try {
await initialize();
persistenceManager.cleanUpMostRecentRetrievedState();
log.info('initialization complete.');
resolveInitialization();
} catch (error) {
log.error(error);
rejectInitialization(error);
}
}
// 初始化主入口
async function initialize(backup) {
// 创建Offscreen Document用于后台任务
const offscreenPromise = createOffscreen();
// 加载持久化存储的状态
const initData = await loadStateFromPersistence();
const initState = initData.data;
let isFirstMetaMaskControllerSetup;
// Service Worker 保活机制(定时写入时间戳,防止被浏览器回收)
if (initState.PreferencesController?.enableMV3TimestampSave !== false) {
const SAVE_TIMESTAMP_INTERVAL_MS = 2 * 1000;
// 定义保活函数
const saveTimestamp = () => {
const timestamp = new Date().toISOString();
browser.storage.session.set({ timestamp });
};
// 定时执行
setInterval(saveTimestamp, SAVE_TIMESTAMP_INTERVAL_MS);
}
// 检查是否首次启动,设置标记
const sessionData = await browser.storage.session.get([
'isFirstMetaMaskControllerSetup',
]);
isFirstMetaMaskControllerSetup = sessionData?.isFirstMetaMaskControllerSetup === undefined;
await browser.storage.session.set({ isFirstMetaMaskControllerSetup });
// 获取状态更新和安全重载工具
const { update, requestSafeReload } = getRequestSafeReload(persistenceManager);
// 初始化主控制器,加载核心业务逻辑
setupController(
initState,
isFirstMetaMaskControllerSetup,
initData.meta,
offscreenPromise,
requestSafeReload,
);
// 订阅状态更新和错误事件
controller.store.on('update', update);
controller.store.on('error', (error) => {
log.error('MetaMask controller.store error:', error);
sentry?.captureException(error);
});
// 启动钓鱼检测功能
maybeDetectPhishing(controller);
// 通知所有内容脚本后台已准备好
await sendReadyMessageToTabs();
}
-
创建 Offscreen Document
首先会创建 Offscreen Document,主要用于与硬件钱包(如 Ledger、Trezor 等)进行通信。这样做是因为如果直接使用 Service Worker,可能会因其生命周期短暂而导致通信中断。关于 Offscreen Document 的详细介绍,可参考 Offscreen Document API。 -
恢复钱包状态
随后,通过读取持久化存储的数据,恢复上一次打开钱包时的状态(关于持久化存储的机制将在后续章节详细讲解)。 -
Service Worker 保活
通过定时写入时间戳,确保 Service Worker 不会被浏览器自动回收,从而保证后台服务的持续可用性。 -
初始化主控制器
完成上述准备后,初始化 MetaMask 的主控制器,加载核心业务逻辑(下一章会详细讲解)。 -
钓鱼检测
启动钓鱼检测机制。如果用户访问的是已知钓鱼网站,会自动重定向到警告页面(钓鱼检测的细节可能会在后续章节进一步讲解)。 -
通知页面加载完成
最后,向所有页面发送“加载完成”的消息,便于 DApp 能够及时检测到钱包已准备好并建立连接。
总结
简单来说background.js主要做了两件事:
- 连接监听器会根据不同来源(内部页面、钓鱼警告、DApp、营销页面)分流到不同的通信通道。
- 初始化主控制器(metamask-controller.js)。
下章我们会深入讲讲主控制器 metamask-controller.js,带你看看钱包的“中枢神经”到底是怎么运作的。
学习交流请添加vx: gh313061
本教程配套视频教程:space.bilibili.com/382494787/l…
下期预告:构建主控制器metamask-controller.js