本文最后更新于:2024年11月22日 晚上
背景
最近使用了 ZenFS 在浏览器中模拟文件系统,以在浏览器中像使用 node fs api 一样存储一些文件。但想要可视化的检查当前存储的文件时,却没有一个可以直观的工具来完成。所以就创建了一个 Chrome Devtools Extension ZenFS Viewer,以实现这个目标。在此过程中就遇到了如何传递 ArrayBuffer 从网页到 devtools panel 线程的问题,一些尝试如下。
首先尝试了最简单的方法,browser.devtools.inspectedWindow.eval
可以在网页执行任意代码并得到结果,例如
1
| browser.devtools.inspectedWindow.eval(`__zenfs__.readdir('/')`)
|
然而 inspectedWindow.eval 并不支持 Promise 返回值,例如下面的表达式无法得到 Promise 结果
1
| browser.devtools.inspectedWindow.eval(`await __zenfs__.promises.readdir('/')`)
|
同样的,也无法支持 ArrayBuffer。所以这个显而易见的 API 被放弃了。
1 2 3
| browser.devtools.inspectedWindow.eval( `await __zenfs__.promises.readFile('/test.png')`, )
|
chrome.runtime.sendMessage
接下来就是思想体操的时候了,一开始考虑的方案就是通过 devtools panel => background script => content-script(isolation) => content-script(main) 进行中转通信,以此在 devtools panel 中调用网页的全局变量并传递和获取 ArrayBuffer 的响应。大概示意图如下
然而在使用 chrome.runtime.sendMessage
时也遇到了和 inspectedWindow.eval
类似的问题,无法传递 ArrayBuffer,仅支持 JSON Value。当然可以序列化 ArrayBuffer 为 JSON,但在传输视频之类的大型数据时并不现实。
解决
之后经过一些搜索和思考,找到了一种方法可以绕道 chrome.runtime.message,因为注入的 iframe 和 devtools panel 同源,所以可以使用 BroadcastChannel 通信,而 iframe 和注入的 content-script(main world) 之间尽管不同源,但仍然可以通过 postMessage/onMessage 来通信,并且两者都支持传递 ArrayBuffer,这样便可绕道成功。
Content-Script <=> Iframe
网页与注入的 iframe 之间,通信可以使用基本的 postMessage/onMessage 实现,为了减少冗余代码,这里使用 comlink 来实现。
先看看注入的 content-script,它主要是负责对 iframe 暴露一些 API 的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
| import { expose, windowEndpoint } from 'comlink'
export default defineUnlistedScript(() => { const map = new Map<string, ArrayBuffer>()
interface IFS { readFile: (path: string) => Promise<ArrayBuffer> writeFile: (path: string, data: ArrayBuffer) => Promise<void> readdir: (path: string) => Promise<string[]> }
expose( { readFile: async (path: string) => { return map.get(path) || new Uint8Array([1, 2, 3]).buffer }, writeFile: async (path: string, data: ArrayBuffer) => { map.set(path, data) }, readdir: async (path: string) => { return Array.from(map.keys()).filter((p) => p.startsWith(path)) }, } as IFS, windowEndpoint( (document.querySelector('#inject-iframe')! as HTMLIFrameElement) .contentWindow!, ), ) console.log('main-content') })
|
而在 iframe 中,则需要转发所有来自 BroadcastChannel 的请求通过 postMessage 传递到上层注入的 content-script 中,其中在每次传递 ArrayBuffer 时都需要使用 transfer 来转移对象到不同线程。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41
| import { expose, transfer, windowEndpoint, wrap } from 'comlink'
interface IFS { readFile: (path: string) => Promise<ArrayBuffer> writeFile: (path: string, data: ArrayBuffer) => Promise<void> readdir: (path: string) => Promise<string[]> }
async function main() { const tabId = (await browser.tabs.getCurrent())!.id if (!tabId) { return } const ipc = wrap<IFS>(windowEndpoint(globalThis.parent)) const bc = new BroadcastChannel( `${browser.runtime.getManifest().name}-iframe-${tabId}`, ) expose( { readFile: async (path: string) => { const r = await ipc.readFile(path) return transfer(r, [r]) }, writeFile: async (path: string, data: ArrayBuffer) => { await ipc.writeFile(path, transfer(data, [data])) }, readdir: async (path: string) => { console.log('readdir', path) return await ipc.readdir(path) }, } as IFS, bc, )
console.log('iframe main') }
main()
|
而在 Devtools 中,要做的事情有一点点多 🤏。首先需要注入两个 content-script,而其中 isolation-content.js 是用来创建 iframe 的 content-script。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| import { PublicPath } from 'wxt/browser'
async function injectScript() { const includeIframe = await new Promise((resolve) => { browser.devtools.inspectedWindow.eval( `!!document.querySelector('#inject-iframe')`, (result) => { resolve(result) }, ) }) if (includeIframe) { return } const tabId = browser.devtools.inspectedWindow.tabId if (!tabId) { return } await browser.scripting.executeScript({ target: { tabId }, files: ['/isolation-content.js' as PublicPath], world: 'ISOLATED', }) await browser.scripting.executeScript({ target: { tabId }, files: ['/main-content.js' as PublicPath], world: 'MAIN', }) }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| function createIframeUi() { const wrapper = document.createElement('div') wrapper.style.height = '0' wrapper.style.width = '0' const ifr = document.createElement('iframe') wrapper.appendChild(ifr) ifr.src = browser.runtime.getURL('/iframe.html') ifr.style.width = '0' ifr.style.height = '0' ifr.style.zIndex = '-9999' ifr.style.border = 'none' ifr.id = 'inject-iframe' document.body.appendChild(wrapper) return ifr }
export default defineUnlistedScript(() => { console.log('isolation-content', createIframeUi()) })
|
接下来就可以在 devtools-panel 中获取数据了,由于 iframe 的注入完成的时机并不能确定,所以需要加个简单的通知机制。
1 2 3 4 5 6 7 8 9 10 11 12
| import { wrap } from 'comlink'
async function main() { console.log('iframe main') await wrap<{ onReady: () => void }>(bc).onReady() }
main()
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| async function main() { await injectScript() interface IFS { readFile: (path: string) => Promise<ArrayBuffer> writeFile: (path: string, data: ArrayBuffer) => Promise<void> readdir: (path: string) => Promise<string[]> } const tabId = browser.devtools.inspectedWindow.tabId if (!tabId) { return } const bc = new BroadcastChannel( `${browser.runtime.getManifest().name}-iframe-${tabId}`, ) await new Promise<void>((resolve) => expose({ onReady: resolve }, bc)) console.log('onReady') const ipc = wrap<IFS>(bc) const r = await ipc.readdir('/') console.log(r) const data = new Uint8Array([1, 2, 3]).buffer await ipc.writeFile('/test.txt', transfer(data, [data])) const r2 = await ipc.readFile('/test.txt') console.log(r2) }
main()
|
完整代码参考: https://github.com/rxliuli/devtools-webpage-message-demo
总结
至今为止,仍然没有简单的方法来支持 Devtools Extension 与 Webpage 之间的通信来替代 inspectedWindow.eval
,确实是有点神奇。