在 Chrome 插件中将 ArrayBuffer 从网页传递到 Devtools Panel

本文最后更新于:2024年11月22日 晚上

背景

最近使用了 ZenFS 在浏览器中模拟文件系统,以在浏览器中像使用 node fs api 一样存储一些文件。但想要可视化的检查当前存储的文件时,却没有一个可以直观的工具来完成。所以就创建了一个 Chrome Devtools Extension ZenFS Viewer,以实现这个目标。在此过程中就遇到了如何传递 ArrayBuffer 从网页到 devtools panel 线程的问题,一些尝试如下。

browser.devtools.inspectedWindow.eval

首先尝试了最简单的方法,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 的响应。大概示意图如下

before.excalidraw.svg

然而在使用 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,这样便可绕道成功。

after.excalidraw.svg

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
// entrypoints/main-content.ts
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
// entrypoints/iframe/main.ts
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)
// 将 ArrayBuffer 通过 transfer 传递回 devtools-panel 中
return transfer(r, [r])
},
writeFile: async (path: string, data: ArrayBuffer) => {
// 将 ArrayBuffer 通过 transfer 传递到 content-script 中
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()

Iframe <=> Devtools

而在 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
// entrypoints/devtools-panel/main.ts
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
// entrypoints/isolation-content.ts
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
// entrypoints/iframe/main.ts
import { wrap } from 'comlink'

async function main() {
// Other code...
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
// entrypoints/devtools-panel/main.ts
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')
// Test code...
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,确实是有点神奇。


在 Chrome 插件中将 ArrayBuffer 从网页传递到 Devtools Panel
https://blog.rxliuli.com/p/cc2c3f61d78346188ca4112ef4734184/
作者
rxliuli
发布于
2024年11月12日
许可协议