区块链钱包开发(九)—— 构建后台主服务background.js

103 阅读5分钟

前言

我们在钱包的初始化中提到,钱包的后台入口文件是 app-init.js,它会加载很多文件:

  • sentry-install.js(日志记录工具)
  • init-globals.js(初始化全局变量)
  • lockdown-***(Secure ECMAScript)
  • runtime-cjs.js(LavaMoat)
  • common-* /background-*(后台代码)

其中background-* 都是由background.js打包生成,它是钱包业务的后台主服务。

脚本概览

源码:github.com/MetaMask/me…

// 简化的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 后台会根据发起方的不同采取不同的处理策略:

  1. 内部程序(popup/notification/fullscreen)

    • 首先判断连接请求是否来自钱包的内部页面(如弹窗、通知、全屏界面)。
    • 对于这些内部程序,会建立可信通信通道setupTrustedCommunication(具体实现将在下一章讲解)。
    • 同时,系统会记录内部页面的打开情况。当检测到没有活跃的内部页面时,会触发清理程序,销毁如 gas 监听器、余额监听器等不必要的资源,防止资源浪费。
  2. 钓鱼警告页面

    • 如果连接请求来自钓鱼警告页面,则会为其建立专门的通信通道,限制其通信能力,确保安全。
  3. 内容脚本(通常由 DApp 发起)

    • 对于内容脚本发起的连接(即 DApp 连接),由于用户可能同时打开多个 DApp 页面,因此需要记录每个连接的 tabIdorigin。在用户发起连接钱包请求时,将 tabIdorigin 关联起来,这样在后续的签名授权等操作中,能够准确地将操作结果返回给发起请求的正确页面。
    • 如果连接请求来自特定的营销页面,则会为其建立专门的通信通道,进行特殊处理。
    • 最后,对于内容脚本发起的所有连接(包括普通 DApp 和营销页面),都会统一建立不可信通信通道setupUntrustedCommunicationEip1193),以保证与 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();
}
  1. 创建 Offscreen Document
    首先会创建 Offscreen Document,主要用于与硬件钱包(如 Ledger、Trezor 等)进行通信。这样做是因为如果直接使用 Service Worker,可能会因其生命周期短暂而导致通信中断。关于 Offscreen Document 的详细介绍,可参考 Offscreen Document API

  2. 恢复钱包状态
    随后,通过读取持久化存储的数据,恢复上一次打开钱包时的状态(关于持久化存储的机制将在后续章节详细讲解)。

  3. Service Worker 保活
    通过定时写入时间戳,确保 Service Worker 不会被浏览器自动回收,从而保证后台服务的持续可用性。

  4. 初始化主控制器
    完成上述准备后,初始化 MetaMask 的主控制器,加载核心业务逻辑(下一章会详细讲解)。

  5. 钓鱼检测
    启动钓鱼检测机制。如果用户访问的是已知钓鱼网站,会自动重定向到警告页面(钓鱼检测的细节可能会在后续章节进一步讲解)。

  6. 通知页面加载完成
    最后,向所有页面发送“加载完成”的消息,便于 DApp 能够及时检测到钱包已准备好并建立连接。

总结

简单来说background.js主要做了两件事:

  • 连接监听器会根据不同来源(内部页面、钓鱼警告、DApp、营销页面)分流到不同的通信通道。
  • 初始化主控制器(metamask-controller.js)。

下章我们会深入讲讲主控制器 metamask-controller.js,带你看看钱包的“中枢神经”到底是怎么运作的。

学习交流请添加vx: gh313061

本教程配套视频教程:space.bilibili.com/382494787/l…

下期预告:构建主控制器metamask-controller.js