区块链钱包开发(三)—— 钱包的初始化(入口文件加载)
前言
钱包完整的初始化是一个很复杂的过程,包括文件加载,各种消息通道的建立,各种事件的监听,控制器的初始化,UI的初始化等等,这些后面会讲但是不在本文讨论的范围,本文只讨论初始化时加载的第一个文件,也是入口文件app-init.js:
"background": {
"service_worker": "scripts/app-init.js"
},
入口文件解析
//app-init.js
let scriptsLoadInitiated = false;
//所有通过importScripts加载的脚本都会立即同步阻塞执行
function importAllScripts() {
if (scriptsLoadInitiated) {
return;
}
scriptsLoadInitiated = true;
importScripts(...fileNames);
}
// 首次安装时会触发此事件,触发时会同步加载所有预设的脚本文件
self.addEventListener('install', importAllScripts);
// 注册一个保活事件,在MV3中,如果Service Worker长时间不活跃会被浏览器强制关闭
// UI 会定期发送消息(通过 setInterval)以保持 Service Worker 活跃
// 每次收到消息时重新加载脚本,确保环境完整
chrome.runtime.onMessage.addListener(() => {
importAllScripts();
return false;
});
// 如果service worker状态为 'activated'(已激活),说明是重启而非首次安装
// 这是一种默认机制, 浏览器强制关闭service worker后再重新激活,service worker不会经历从install到activated的转变,而是总是activated
if (self.serviceWorker.state === 'activated') {
importAllScripts();
}
// 动态注册content-script脚本
const registerInPageContentScript = async () => {
try {
await chrome.scripting.registerContentScripts([
{
id: 'inpage',
matches: ['file://*/*', 'http://*/*', 'https://*/*'],
js: ['scripts/inpage.js'],
runAt: 'document_start',
world: 'MAIN',
allFrames: true,
},
]);
} catch (err) {
console.warn(`Dropped attempt to register inpage content script. ${err}`);
}
};
const deferredOnInstalledListener = Promise.withResolvers();
globalThis.stateHooks.onInstalledListener = deferredOnInstalledListener.promise;
/**
* 这里需要监听onInstalled事件,将事件的返回值details存到全局变量中,因为后续需要用details.reason来判
* 这是什么原因触发了onInstalled事件。details.reason是一个浏览器内置的枚举类型,有四种状态
*(install/update/chrome_update/shared_module_update)不同的状态有不同的处理方式
*/
chrome.runtime.onInstalled.addListener(function listener(details) {
chrome.runtime.onInstalled.removeListener(listener);
deferredOnInstalledListener.resolve(details);
delete globalThis.stateHooks.onInstalledListener;
});
registerInPageContentScript();
关键点解读
1. 为什么要动态注册content-script脚本
文件最后执行registerInPageContentScript()将inpage.js注入到了content_scripts,而不是在manifest.json文件中直接声明,这里是由于浏览器的一个bug: bugs.chromium.org/p/chromium/…
因为inpage.js负责在每个网页中注入window.ethereum provider,所以需要访问网页上下文环境,Chrome扩展可以在两种执行环境中注入内容脚本:
- ISOLATED world(默认):脚本在独立环境中运行,有自己的JavaScript执行上下文
- MAIN world:脚本直接在页面的主环境中运行,可以直接访问页面的JavaScript上下文
由于bug的存在导致在manifest.json声明world: 'MAIN'无法生效,所以只能在安装的初始阶段动态注册。
2. 保活机制的实现
我们希望钱包插件一直处于工作状态,但在MV3中,如果一段时间(通常约30秒)内没有事件触发,浏览器会自动终止Service Worker。
在app-init.js中的如下代码严格来说不应该叫保活机制,而是复活机制,只有在service worker重新启动时才需要重新importAllScripts:
chrome.runtime.onMessage.addListener(() => {
importAllScripts();
return false;
});
真正的保活机制实际上是在background.js初始化时做的:
// 每两秒触发saveTimestamp将当前时间写入浏览器存储
const SAVE_TIMESTAMP_INTERVAL_MS = 2 * 1000;
saveTimestamp();
setInterval(saveTimestamp, SAVE_TIMESTAMP_INTERVAL_MS);
function saveTimestamp() {
const timestamp = new Date().toISOString();
browser.storage.session.set({ timestamp });
}
真实情况要更加复杂,我们可以发现在app-init.js中还有如下代码:
// service worker被意外关闭再重启时,其状态一直保持activated,这段代码保证了
// service worker重启时importAllScripts能被执行
if (self.serviceWorker.state === 'activated') {
importAllScripts();
}
3. 多层保障机制的设计思路
为了保证在任何情况下service worker都可以正常运行,MetaMask在初始化时采用了多层保障机制:
首先我们要达成一种共识:在插件首次安装时,我们必须要在service worker处于installing状态以后才去执行importAllScripts,具体原因可能是会导致一些初始化顺序问题,或者不同浏览器兼容问题等。如果不需要保证这一点,我们可以不加任何判断和事件直接在app-init.js中调用importAllScripts,这样最简单而且保证importAllScripts在任何情况下百分百运行。
我们在前面提到,service worker重启时会直接进入activated状态,因此self.addEventListener('install', importAllScripts);是不会被触发的,我们需要保证重启时importAllScripts能被执行才添加了:
if (self.serviceWorker.state === 'activated') {
importAllScripts();
}
理论上这样就可以保证任何情况下service worker的活跃,但MetaMask又添加了:
chrome.runtime.onMessage.addListener(() => {
importAllScripts();
return false;
});
在监听器中被触发一定是在直接在app-init.js调用触发之后,看起来有些冗余,这是一种防御性编程策略。MetaMask团队一定做了大量测试,覆盖各种极端场景、不同浏览器和操作系统等,这里看似冗余的代码实际上是一种兜底机制,尽最大努力保证钱包的正常运行。
一句话总结:为了使service worker一直正常运行,我们在background.js中通过每隔两秒触发事件的方式保活,如果出现意外情况我们可以通过判断self.serviceWorker的状态是否是activated复活service worker,如果这里意外没有触发复活,我们通过监听chrome.runtime.onMessage事件重新触发复活。
执行初始化流程图
flowchart TD
N["浏览器启动Service Worker"] --> O{"首次安装?"}
O -- 是 --> P["触发install事件"]
P --> Q["执行importAllScripts"]
O -- 否-也就是重启 --> R{"Service Worker状态?"}
R -- activated --> S["直接执行importAllScripts"]
R -- 其他状态 --> T["等待消息激活"]
T --> U["收到UI保活消息"]
U --> V["执行importAllScripts"]
Q --> W["注册onInstalled监听器"]
S --> W
V --> W
W --> X["注册inpage内容脚本"]
X --> Y["结束"]
最终加载的文件集合
最终加载的文件列表包括:
commonxx/backgroundxx
这些是被webpack分块打包的公共代码块和后台脚本分块,我们可以把它们统一看成是background.js,这部分后面的章节会详细讲解。
sentry-install.js
负责初始化实时错误监控和日志记录工具Sentry,用于收集生产环境中的错误信息。
init-globals.js
初始化全局变量,确保基本API在不同环境中的一致性:
const keys = ['XMLHttpRequest'];
keys.forEach((key) => {
if (!Reflect.has(globalThis, key)) {
globalThis[key] = undefined;
}
});
if (!Reflect.has(globalThis, 'window')) {
globalThis.window = globalThis;
}
安全相关文件
MetaMask采用了多层次的安全架构,包括以下几个关键文件:
lockdown-install.js
导入SES (Secure ECMAScript) 库,这是一个用于创建安全执行环境的JavaScript库:
import 'ses';
export {};
lockdown-run.js
执行SES库的lockdown()函数,冻结JavaScript环境中的所有内置对象:
try {
lockdown({
consoleTaming: 'unsafe',
errorTaming: 'unsafe',
domainTaming: 'unsafe',
overrideTaming: 'severe',
});
} catch (error) {
console.error('Lockdown failed:', error);
if (globalThis.sentry && globalThis.sentry.captureException) {
globalThis.sentry.captureException(
new Error(`Lockdown failed: ${error.message}`),
);
}
}
配置参数解释:
consoleTaming: 'unsafe':保留控制台功能的原始行为errorTaming: 'unsafe':保留错误对象的原始行为domainTaming: 'unsafe':不限制domain相关功能overrideTaming: 'severe':严格防止对内置原型的修改
lockdown-more.js
在lockdown()之后执行,进一步增强全局对象的安全性:
// 使globalThis上所有的"object"和"function"类型的自有属性变为不可配置和不可写
try {
/**
* 立即执行函数,用于保护JavaScript内置对象
*/
(function protectIntrinsics() {
// 获取新隔离区全局对象的所有自有键名
const namedIntrinsics = Reflect.ownKeys(new Compartment().globalThis);
// 这些命名的内置对象不会被`lockdown`自动硬化,需要手动处理
const shouldHardenManually = new Set(['eval', 'Function', 'Symbol']);
// 收集需要保护的全局属性
const globalProperties = new Set([
// 包含所有命名的内置对象
...namedIntrinsics,
]);
// 遍历所有全局属性并进行保护
globalProperties.forEach((propertyName) => {
// 实现代码省略...
// 对需要手动硬化的特殊内置对象进行处理
if (shouldHardenManually.has(propertyName)) {
try {
// 尝试硬化对象
harden(globalThis[propertyName]);
} catch (err) {
// 错误处理代码省略...
}
}
});
})();
} catch (error) {
console.error('Protecting intrinsics failed:', error);
}
runtime-cjs.js
导入LavaMoat的Lavapack运行时库,LavaMoat是metamask团队开发的安全工具,限制每个依赖包只能访问其功能所需的最小资源集。防止供应链攻击(指黑客通过入侵软件依赖库,在库软件中植入恶意代码,间接攻击目标系统)
import '@lavamoat/lavapack/src/runtime-cjs';
export {};
安全架构的重要性
MetaMask采用了SES和LavaMoat两套互补的安全系统:
-
SES (Secure ECMAScript):
- 工作在JavaScript引擎层面
- 冻结所有内置对象的原型,防止原型污染
- 创建一个确定性、不可变的JavaScript基础环境
-
LavaMoat:
- 工作在模块系统层面
- 为每个依赖包创建独立的沙箱环境
- 基于策略文件控制模块可访问的资源
- 防止供应链攻击(指黑客通过入侵软件依赖库,在库软件中植入恶意代码)
这种多层次安全架构对于处理加密资产的应用至关重要,确保即使一层防御被突破,其他层仍能提供保护。SES和LavaMoat为钱包提供了安全的运行时环境,在语言层面防止原型链攻击和全局对象污染,在模块层面防止未授权的API访问和模块间越权。
学习交流请添加vx: gh313061
下期预告:搭建stream风格的通信框架