✨ 添加 GM_audio 与 GM.audio 支持 (TM) - #1550
Conversation
audioStateChangeListeners/Connection/Registration 三者生命周期始终一致, 合并为单一 audioStateChange 对象,减少一处需要同步维护三个字段的心智负担。
remove 归零后 connect() 仍可能在之后 resolve 并覆盖新连接,导致旧连接 (及其在 service worker 侧对应的 tabs.onUpdated 监听)未被断开。 引入 generation 世代号,每次归零/context 失效都会使旧世代的待定连接 在 resolve 时被判定为过期并立即断开。
chrome.tabs.Tab.mutedInfo.reason 记录的是最近一次静音/取消静音的原因,
取消静音后该字段仍可能残留旧值。之前的实现未检查 muted 即赋值给
muteReason,导致 isMuted: false 时仍出现如 { isMuted: false,
muteReason: "extension" } 的不一致状态,与 Tampermonkey "仅在当前处于
静音状态时返回 muteReason" 的约定不符。
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1. service worker 因 MV3 闲置回收而意外终止时,端口断开会被当作主动 移除处理,导致监听器集合被静默清空,脚本此后再也收不到状态变化。 现在通过 MessageConnect.onDisconnect 提供的 isSelfDisconnected 区分 主动断开与意外断开:仅在非本端主动断开且监听器集合仍非空时,保留 监听器并自动重新 connect()(该调用本身会唤醒被回收的 service worker),而不是丢弃注册。 2. GM_audio.setMute / getState / addStateChangeListener 的回调风格接口 原先使用 .then(success).catch(fail),若 success 回调自身抛出异常, 会被同一个 .catch() 捕获并以"操作失败"的形式重新调用一次回调。改为 两参数 then(onFulfilled, onRejected) 并通过 _GM_audioSafeInvoke 吞掉 回调自身抛出的异常,确保每次操作只回调一次。 3. 多个脚本的状态变化监听器共享同一条连接时,原先在同一个循环中直接 调用,一个监听器抛出异常会中断循环,导致其后注册的监听器收不到该 次状态变化。现在为每个监听器调用加上独立的 try/catch 隔离。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
_GM_audioConnect() 在已成功注册的连接意外断线后会自动重连,但若这次 恢复重连本身在收到 "registered" 之前又断开,之前的逻辑只依据本次连接 的局部 registered 变量判断,会当作"注册失败"清空 state.listeners 并 放弃重连——脚本因此在未调用 removeStateChangeListener 的情况下悄然失去 监听,且被吞掉的 reject 也无法通知脚本。 现在改为依据持久化在 state 上的 everRegistered 标记(一旦成功收到过 registered 即为 true,仅在监听器归零或 context 失效时随 state 一起 重置)来判断:只要曾经注册成功过,此后任何非本端主动触发的断线都保留 监听器并重试——已确认过的连接断线立即重连,恢复期间尚未确认的连接 断线则通过新增的 _GM_audioScheduleReconnect() 做指数退避(250ms 起、 封顶 8s)重试,直至重连成功、监听器被显式移除或 context 失效为止。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1. 移除最后一个监听器时只清空了 connection/registration,未丢弃整个 audioStateChange,导致 everRegistered/retryDelay 残留到下一次 addStateChangeListener。脚本移除全部监听器后重新添加,若新连接在 收到 registered 前断线,会被误判为"恢复期间的重连"而保留监听器 退避重试,而不是像正常首次注册失败那样 reject。现在移除到零时 直接丢弃 a.audioStateChange,让下一次注册开启全新生命周期。 2. 恢复期间的重连尝试断线时,原逻辑无论本次连接是否已收到 registered 都直接 resolve 当前 Promise 并把 reconnect.catch() 吞掉。这样一来, 在替补连接仍处于"已发起但未注册"期间新增的监听器,会拿到这个提前 resolve 的 Promise,被错误地告知注册已经成功——而实际上既没有活跃 连接,也未收到过 registered 确认。现在改为 reconnect.then(resolve, reject):已 registered 的情形下原 Promise 早已结算、链接为无操作; 尚未 registered 的情形下则等待替补连接的最终结果再结算。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1. 恢复期间收到终止性错误(服务端以 { code } 拒绝,如权限被收回)时,
原逻辑只清空监听器和连接,未丢弃 audioStateChange 本身,导致
everRegistered 残留到下一次 addStateChangeListener——一次全新的、
从未成功过的注册会被误判为"恢复期间的重连"而重试,而不是像正常
首次注册失败那样 reject。现在把所有"彻底放弃"路径(显式错误、
非重试断线)都收敛到同一个 giveUp(),统一丢弃 audioStateChange。
2. 每次恢复重连都会创建一层新的 Promise 并通过 reconnect.then(resolve,
reject) 链接到上一层,长期故障会不断堆叠、无法被回收。现在改为每
"一轮 episode"(即:一个已注册连接意外断线,到下一次成功注册或
彻底放弃为止)只使用一个稳定的 deferred;同一轮 episode 内、尚未
收到 registered 就又断线的重试全部在闭包内以 setTimeout 迭代进行,
共享同一个 Promise,不再逐次新建。
重构过程中发现 giveUp() 内先 disconnect() 连接、后 reject 的顺序会被
disconnect() 同步触发的自身 onDisconnect 重入,抢先以另一个原因结算
本应确定的结果;已改为先 reject 锁定结果、再做连接清理。
3. GM_audio.removeStateChangeListener 的回调此前未经过 _GM_audioSafeInvoke
包裹,回调自身抛出的异常会成为未处理的 Promise 拒绝,现已与其余三个
回调风格接口保持一致。
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
# Conflicts: # src/pkg/utils/monaco-editor/langs.ts
|
按 PickInvariant 的 DELTA_AUDIT,对 base 结论:正常路径与声明基本一致,但仍有以下生命周期缺口:
已检查:注入别名、类型声明、权限路由、正常共享连接/重连/移除和回调异常隔离。未验证:真实跨上下文桥接、关闭端口时序及 tab ID 0 的浏览器可达性;现有测试未覆盖上述 adverse races。 |
# Conflicts: # src/app/service/service_worker/gm_api/gm_api.test.ts
|
已将 PR 分支同步到最新
验证结果:
当前已发布 PR head: |
Checklist / 检查清单
背景
Tampermonkey 提供了用于控制和监听当前标签页音频状态的
GM_audioAPI。ScriptCat 此前缺少对应实现,用户脚本无法通过统一接口完成标签页静音、音频状态读取以及状态变化监听。https://www.tampermonkey.net/documentation.php?locale=en&q=GM_audio
本 PR 为 ScriptCat 补充回调风格的
GM_audio和 Promise 风格的GM.audio,并处理 Manifest V3 Service Worker 可能被回收、监听连接意外断开、异步连接迟到以及注册完成前取消等生命周期问题。本次改动
实现以下回调风格 API:
GM_audio.setMuteGM_audio.getStateGM_audio.addStateChangeListenerGM_audio.removeStateChangeListener实现对应的 Promise 风格 API:
GM.audio.setMuteGM.audio.getStateGM.audio.addStateChangeListenerGM.audio.removeStateChangeListenerService Worker 使用
chrome.tabs.update和chrome.tabs.get控制、读取当前标签页的音频状态。通过
chrome.tabs.onUpdated监听当前标签页的mutedInfo和audible变化。多个状态监听器复用同一个长连接,最后一个监听器移除后主动断开连接并清理后台监听。
Service Worker 意外断线时保留脚本监听器并自动恢复连接;恢复期间连续断线或连接创建失败时使用指数退避重试。
使用注册生命周期对象和世代边界丢弃迟到的异步连接,避免“移除后立即重新添加”产生连接泄漏或污染新一轮注册。
支持在注册完成前移除监听器,并保证待处理的 Promise 或回调能够立即、且仅一次地正常结束。
重复添加同一个监听函数不会创建重复监听或额外连接,并会复用当前注册结果。
脚本上下文失效时主动断开连接并清除全部音频监听状态。
隔离完成回调和状态监听器自身抛出的异常,避免影响 API 状态、连接恢复或其他监听器。
补充英文及简体中文类型声明,并为所有编辑器语言加入
@grant GM_audio提示。新增 Content、Context 和 Service Worker 测试,覆盖正常行为、错误路径、连接恢复、连续故障、取消竞态、迟到连接和资源释放。
实现考虑
1. 回调风格与 Promise 风格共用同一套实现
GM_audio和GM.audio共用底层消息及监听管理逻辑,避免两种调用风格出现行为差异。无论脚本声明
@grant GM_audio还是@grant GM.audio,上下文都会注入完整的GM_audio和GM.audio方法集合。回调风格遵循以下行为:
setMute成功时以undefined调用回调,失败时传入错误字符串;getState成功时传入状态对象,失败时传入undefined;addStateChangeListener在后台确认注册后调用完成回调,失败时传入错误字符串;removeStateChangeListener完成清理后调用回调。Promise 风格则直接 resolve 对应结果或 reject 原始操作错误。
2. API 只作用于当前脚本所在标签页
Service Worker 从消息发送者中取得当前标签页 ID,所有操作均限定在该标签页:
setMute使用chrome.tabs.update(tabId, { muted });getState使用chrome.tabs.get(tabId);tabId的chrome.tabs.onUpdated事件。没有有效标签页的后台上下文不能使用
GM_audio,以避免误操作其他标签页。setMute仅接受布尔类型的isMuted。无效参数会返回明确错误,而不是依赖浏览器 API 的隐式转换。addStateChangeListener仅接受函数类型的监听器,并要求通过长连接调用。3. 状态字段与浏览器标签页状态对应
getState返回以下可选字段:isMuted:标签页当前是否静音;muteReason:静音原因,可能为user、capture或extension;isAudible:标签页当前是否正在产生可听音频。状态变化监听使用与 Tampermonkey 接口相近的字段:
muted:取消静音时为false,静音时为对应的静音原因;audible:标签页的最新发声状态。后台会过滤其他标签页以及不包含
mutedInfo或audible的更新,避免向脚本发送无关事件。如果标签页处于静音状态但浏览器没有提供具体原因,状态变化事件会使用
"extension"作为回退值。4. 多个监听器共享一个长连接
同一脚本上下文中的全部音频状态监听器保存在一个
Set中,并共享一条与 Service Worker 的长连接。后台完成
chrome.tabs.onUpdated注册后发送registered消息。只有收到该消息后,addStateChangeListener的 Promise 或完成回调才会结束,避免把“连接对象已创建”错误地视为“后台监听已经注册”。移除监听器时:
chrome.tabs.onUpdated监听器;重复添加同一个监听函数不会创建重复监听或额外连接;注册仍在进行时,重复添加会复用当前注册 Promise。
5. 处理 Service Worker 意外断线
Manifest V3 Service Worker 可能因闲置而被浏览器终止,因此已经成功注册的连接意外断开时,不能直接丢弃脚本监听器。
实现会区分两类断线:
registered:保留监听器并自动重新连接。已确认过的连接意外断开后,会立即开启新一轮注册。
恢复连接如果在收到
registered前再次断开,或者恢复期间的connect()失败,则从 250 毫秒开始指数退避,并逐步增加到最多 8 秒,避免 Service Worker 持续不可用时发生忙等。每次成功收到
registered后,退避延迟都会重置。6. 每轮恢复使用独立的注册 episode
一个已经成功注册的连接断开后,旧的
addStateChangeListenerPromise 已经完成。恢复过程需要创建新一轮注册 episode,使恢复期间新增的监听器可以等待当前连接真正重新注册,而不是错误复用一个已经 resolve 的旧 Promise。
同一轮恢复过程中,如果连接在收到
registered前连续失败,重试会共享同一个 deferred Promise,并在闭包内通过定时器迭代。这样可以避免长期故障时每次重试都递归创建新的 Promise 链,造成不可回收的状态堆积。
7. 防止异步连接竞态和资源泄漏
connect()返回 Promise,在以下情况下可能产生迟到连接:实现以当前
audioStateChange状态对象作为生命周期边界。监听器归零或上下文失效时会丢弃整个旧状态;迟到连接发现自己不再属于当前生命周期后,会立即主动断开,不会覆盖或泄漏到新的监听生命周期中。
监听器全部移除后,包含
everRegistered、retryDelay、当前连接和注册 Promise 在内的状态都会被整体丢弃。之后重新添加监听器会创建全新的注册生命周期,而不会继承旧连接的恢复状态。
8. 允许取消尚未完成的注册
监听器可能在后台发送
registered前被移除,包括:connect()仍未返回;对于这种情况:
GM.audio.addStateChangeListener返回的待处理 Promise 会正常 resolve;GM_audio.addStateChangeListener的完成回调会以undefined调用一次;状态对象会保存当前注册 episode 的 settlement 函数,使最后一个监听器移除时可以直接结束待处理注册,而不必依赖可能已被连接守卫忽略的
onDisconnect回调。9. 首次注册失败与恢复故障使用不同策略
首次注册期间发生以下情况时,会结束当前注册并清除监听状态:
connect()创建失败;registered的情况下连接断开。只有当前监听生命周期至少成功注册过一次后,后续的连接失败或意外断线才会被视为暂时性故障并自动重试。
彻底放弃某一轮首次注册时,会先确定 Promise 的失败结果,再断开连接,避免同步触发的
onDisconnect抢先使用其他错误原因结算同一 Promise。10. 隔离用户脚本回调异常
脚本传入的完成回调或状态监听器可能自行抛出异常。
这些异常会被捕获并输出到控制台,但不会:
已知限制
1. 仅支持标签页脚本上下文
GM_audio依赖消息发送者的标签页 ID,因此不能在没有对应标签页的后台上下文中使用。本 PR 不提供指定任意标签页 ID 的能力,所有操作始终作用于当前用户脚本所在标签页。
2. 状态变化取决于浏览器事件
状态监听基于
chrome.tabs.onUpdated。浏览器只会提供本次实际发生变化的字段,因此状态变化对象中的
muted和audible都是可选的,脚本不应假设每次事件都会同时包含两个字段。3. 首次注册失败不会无限自动重试
只有至少成功收到过一次
registered后发生的非本端断线或连接失败,才会被视为暂时性故障并保留监听器恢复。首次注册失败、后台明确返回错误或从未完成注册的连接断开时,会结束当前注册并清理监听状态。脚本需要再次调用
addStateChangeListener才能重新注册。4. 兼容目标
本实现以当前 Tampermonkey
GM_audio的 API 形状和主要行为作为兼容目标,同时结合 ScriptCat 的消息系统和 Manifest V3 生命周期实现连接管理。不在类型声明或文档中承诺与某个特定 Tampermonkey 版本的所有边缘行为完全一致。
建议审查重点
@grant GM_audio与@grant GM.audio的注入范围和回调、Promise 行为是否符合项目现有约定;registered确认、监听器共享和最后一个监听器移除时的清理顺序;connect()迟到以及移除后重新添加的竞态;参考
https://developer.chrome.com/docs/extensions/reference/api/tabs#method-update
https://developer.chrome.com/docs/extensions/reference/api/tabs#method-get
https://developer.chrome.com/docs/extensions/reference/api/tabs#event-onUpdated