Site Logo

在 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.hasBeenActivefalse,而且不管是谁开的都一样——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 ↔ 扩展页),底层就是 postMessageruntime.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 在激活窗口内发消息,把手势带过去。