在 MAIN world 里弹出 Chrome 权限授权框
2025年12月25日 · 2484 字
在 MAIN world 里弹出 Chrome 权限授权框
核心做法:用户手势会随
runtime.sendMessage传播。让持有手势的那个 frame 发消息,接收方的onMessage处理器就带着手势执行,可以在里面合法调用chrome.permissions.request()。下面是原理、实现,以及几个容易写错的地方。
Chrome 扩展的 optional 权限要按需申请,而 chrome.permissions.request() 有一条硬约束:必须在有用户手势时调用,否则直接抛 This function must be called during a user gesture。
按钮的 click 回调里调一下就完事——如果按钮和 API 在同一个地方的话。但只要 UI 不在能拿到 chrome.* 的上下文里,这个约束就变得很难满足。
典型情况是把 UI 注入到网页的 MAIN world:和页面共享同一个 JS 环境,能直接复用页面自身的框架、路由和样式体系,代价是 chrome.* 完全不可见,chrome.permissions 根本不存在。于是用户点的那个按钮,和能调 permissions.request() 的上下文,被彻底分开了。
两个必须先建立的概念
在讲解法之前,有两个事实需要先说清楚,否则后面的设计看起来会像是绕远路。
第一:用户激活是 frame 级的,不是 world 级的。
一个 frame 就是一份网页文档加上它的渲染上下文。用户在这个 frame 里点一下、按一下键,这个 frame 就获得一个约 5 秒的瞬态用户激活(transient user activation)。
关键在于,同一个 frame 里的 MAIN world 和 ISOLATED 内容脚本共享这份激活。它们 DOM 共享、JS 变量互不可见,但在用户激活这件事上是同一个 frame,同进同退。
第二:chrome.tabs.create 不会把用户激活传给新开的标签页。
这一条很反直觉,而且是很多人第一次尝试时会撞上的墙。最自然的想法是:既然 MAIN world 没有 chrome.permissions,那就 tabs.create 打开一个扩展自己的页面,那里 API 齐全,加载完直接请求。
结果一定抛错。新开的标签页打印 navigator.userActivation.hasBeenActive 是 false,而且不管是谁开的都一样——background 开、内容脚本在真实 click 回调里同步开,都是 false。这个 API 就是不传激活。
所以「让另一个页面自己拥有手势」这条路是堵死的。
关键机制:手势可以借,走的是消息
真正的解法在另一个维度上:
permissions.request() 的手势要求,可以由扩展消息传播过来的用户激活满足,和调用方页面自身的 navigator.userActivation 没有关系。
runtime.sendMessage / tabs.sendMessage 会随消息传播用户手势——只要发送方当前有激活,接收方的 onMessage 处理器就带着手势执行,在里面可以合法调用需要手势的 API。
把这一条和前面两个概念拼起来,路就通了:
用户点在网页上,这个 frame 得到激活 → 同 frame 的内容脚本(有 chrome.*)也持有这份激活 → 它在 5 秒窗口内发出的扩展消息,把手势一路借给了接收方。
有一个推论要记住:手势必须从一个有激活的 frame 起源。background 不是 frame,它没有激活可传,所以从 background 广播的消息带不上手势。
整体链路
用户点击触发按钮
├─ MAIN: 业务流程 → ensurePermission() → 挂 onReady/onResult ─┐
│ │ 同一次点击
└─ cs(同 frame,有激活): 捕获真实点击 → sendMessage(带手势)┘
▼
bg(收到带手势的消息): 同步 tabs.create 打开授权页
▼
授权页加载完 → broadcast('page-ready') ← 此时先不请求
▼
MAIN(frame 仍在 5s 激活窗口内)收到 → broadcast('do-request') ★手势起源
▼ 经 cs → bg → 授权页,sendMessage 沿途传播手势
授权页 onMessage(带手势)→ 同步 permissions.request() → 系统授权框
▼
用户点「允许」→ broadcast('result') → MAIN resolve → 继续原来的业务
同一次点击分出两条并行的支线:MAIN world 那边开始等待结果,内容脚本那边把「要开授权页」的消息发给 background。两边都需要,因为 MAIN 没法直接开页,而内容脚本不参与 UI 的业务流程。
下面逐段看代码。示例里的 $bus 是一个跨上下文的消息总线(MAIN ↔ 内容脚本 ↔ background ↔ 扩展页),底层就是 postMessage 加 runtime.sendMessage,用什么实现不重要。
1. 内容脚本捕获真实点击
MAIN world 里给按钮打一个属性标记:
// MAIN world 的 UI
<Button onClick={() => void submit()} data-perm-trigger>Continue</Button>
内容脚本在同一个 frame 里用捕获型监听盯着 document:
// content script —— ISOLATED world,有 chrome.*,与 MAIN 同 frame
document.addEventListener('click', e => {
const path = (e.composedPath?.() ?? []) as EventTarget[]
const hit = path.some(n => n instanceof HTMLElement && n.hasAttribute('data-perm-trigger'))
if (hit) void chrome.runtime.sendMessage({ type: 'ext:request-permission' })
}, true /* capture */)
两个细节:用 composedPath() 是为了穿透 Shadow DOM——UI 如果挂在 shadow root 里,document 层看到的 e.target 只是宿主元素;用 capture 是为了即使页面自己 stopPropagation,这个监听也照样能看到。
这条 sendMessage 在真实 click 事件的同步执行栈里发出,手势跟着一起走。
2. background 在带手势的 onMessage 里同步开页
// background.ts
chrome.runtime.onMessage.addListener(msg => {
if (msg?.type === 'ext:request-permission' && !permGranted) {
void chrome.tabs.create({
url: chrome.runtime.getURL('permissions.html'),
active: false, // 不抢焦点,用户停留的页面保持在前台
pinned: true, // 收成一个小标签,视觉干扰最小
})
}
return undefined
})
active: false 是有意的:授权页本身不需要被看到,系统权限框会浮在用户当前那个页面上,体验上就像是原地弹出来的。
permGranted 这个标志也不能省——已经授权过的用户再点,不该再闪一个标签页出来。
3. MAIN world 挂监听等待
async function ensurePermission(): Promise<boolean> {
if (await rpc<boolean>('isGranted')) return true // 已授权,直接放行
return new Promise<boolean>(resolve => {
const onReady = (): void => {
$bus.broadcast('permissions.do-request') // ★ 见第 5 步
}
const onResult = (granted: boolean): void => {
cleanup()
resolve(!!granted)
}
const timer = window.setTimeout(() => { cleanup(); resolve(false) }, 120_000)
function cleanup(): void {
window.clearTimeout(timer)
$bus.off('permissions.page-ready', onReady)
$bus.off('permissions.result', onResult)
}
$bus.on('permissions.page-ready', onReady)
$bus.on('permissions.result', onResult)
})
}
对业务代码来说,这一切被收敛成一个普通的 await:
if (!(await ensurePermission())) {
setErr('Permission is required.')
return
}
await rpc('startWork', payload)
4. 授权页:宣布就绪,但不请求
这是最容易写错的一步。授权页自己没有激活,加载即请求必然抛错。所以它只广播一条 page-ready,然后等别人告诉它什么时候请求:
// permissions/main.ts
$bus.on('permissions.do-request', () => void doRequest()) // 收到消息才请求
btn?.addEventListener('click', () => void doRequest()) // Allow 按钮兜底(真手势)
$bus.broadcast('permissions.page-ready') // 宣布就绪
5. ★ MAIN 收到 page-ready → 广播 do-request
const onReady = () => { $bus.broadcast('permissions.do-request') }
一行代码,但它是整条链路的核心。
此刻距离用户点击不到 1 秒,网页那个 frame 仍在 5 秒激活窗口内。MAIN world 广播 → 同 frame 的内容脚本(有激活)runtime.sendMessage → background → 授权页,每一跳都把手势带上。
这一步也解释了为什么另外两种写法一定失败:让授权页加载即请求(根本没有消息,也就没有手势),或者从 background 发起 do-request(background 不是 frame,没有激活可传)。手势必须从有激活的那个 frame 起源。
6. 授权页带着手势请求
async function doRequest(): Promise<void> {
try {
if (await chrome.permissions.contains(PERMS)) { // 竞态:已经授权了就直接回报
$bus.broadcast('permissions.result', true)
window.close()
return
}
const granted = await chrome.permissions.request(PERMS) // ← 带着传播来的手势
$bus.broadcast('permissions.result', granted)
setTimeout(() => window.close(), 300)
} catch {
// 手势没传到 —— 留下 Allow 按钮兜底,不要自动关页
setStatus('Click Allow to grant the permission.')
}
}
注意 catch 分支:万一手势因为某种边界情况没落地,页面不自动关闭,把 Allow 按钮留给用户。用户在这个页面上真实点一下,就是一个货真价实的本地手势,一定能成。这是一条便宜的兜底,不该省。
7. 结果回传
授权页广播 permissions.result,MAIN world 的 onResult resolve,ensurePermission 返回 true,业务流程继续往下走。
几个容易想省掉的环节
这条链路看起来绕,容易让人想砍掉一两步。三个常见的想法:
能不能不要内容脚本那一跳,MAIN 直接通过消息总线让 background 开页? 不行。总线上通常有序列化和额外的异步 hop,等消息到 background 时,开页动作已经不在手势窗口的同步栈里了。内容脚本直接在 click 的同步执行栈里 runtime.sendMessage,是最短、最可靠的一跳。
能不能授权页开好之后,由 background 直接通知它请求? 不行。background 没有激活,这条消息带不上手势。
能不能不等 page-ready,点击后延时几百毫秒直接广播 do-request? 理论上激活窗口有 5 秒,凑一凑也许能行,但这是在赌页面加载速度。用 page-ready 握手既准确,又天然处理了慢加载的情况——只要页面在 5 秒内起来就一定成功。
另外,permissions.request() 必须在 onMessage 处理器里同步发起。在它前面 await 任何东西,都可能把手势窗口丢掉。上面第 6 步里那个 contains() 检查是可以接受的例外(它命中时直接返回、根本不走 request),但如果不确定,宁可把检查放到别处。
小结
一句话概括:手势诞生在用户点击的那个 frame;同 frame 的 MAIN world 和内容脚本都持有它;在激活窗口内从这个 frame 发出扩展消息,就把手势一路借给了另一个 frame 的 API 调用。
tabs.create 给不给新页面激活其实完全不重要——手势走的是消息,不是页面本身。
同样的思路适用于其他要求用户手势的扩展 API:只要调用方拿不到手势,而某个 frame 拿得到,就让那个 frame 在激活窗口内发消息,把手势带过去。