✨ 添加 GM_audio 与 GM.audio 支持 (TM)#1550
Draft
cyfung1031 wants to merge 16 commits into
Draft
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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