From 62bab4a297733fa19b8ad1340baa85497c5a5bad Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Tue, 7 Jul 2026 03:04:12 +0900 Subject: [PATCH 01/15] =?UTF-8?q?=E2=9C=A8=20=E6=96=B0=E5=A2=9E=20GM=5Faud?= =?UTF-8?q?io=20=E4=B8=8E=20GM.audio?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/content/create_context.test.ts | 16 ++ src/app/service/content/create_context.ts | 4 + src/app/service/content/gm_api/gm_api.ts | 150 ++++++++++++++++- .../service/content/gm_api/gm_audio.test.ts | 158 ++++++++++++++++++ .../service/service_worker/gm_api/gm_api.ts | 64 +++++++ .../service_worker/gm_api/gm_audio.test.ts | 118 +++++++++++++ src/pkg/utils/monaco-editor/langs.ts | 7 + src/types/scriptcat.d.ts | 45 +++++ src/types/scriptcat.zh-CN.d.ts | 49 ++++++ 9 files changed, 610 insertions(+), 1 deletion(-) create mode 100644 src/app/service/content/gm_api/gm_audio.test.ts create mode 100644 src/app/service/service_worker/gm_api/gm_audio.test.ts diff --git a/src/app/service/content/create_context.test.ts b/src/app/service/content/create_context.test.ts index 54961d107..dfbaee057 100644 --- a/src/app/service/content/create_context.test.ts +++ b/src/app/service/content/create_context.test.ts @@ -102,6 +102,22 @@ describe.concurrent("createContext", () => { expect(context.GM_cookie.delete.name).toBe("bound GM_cookie.delete"); }); + it.concurrent("按 @grant 注入 GM_audio 与 GM.audio 的完整方法", () => { + const legacyContext = createTestContext(["GM_audio"]); + const promiseContext = createTestContext(["GM.audio"]); + + for (const context of [legacyContext, promiseContext]) { + expect(context.GM_audio.setMute.name).toBe("bound GM_audio.setMute"); + expect(context.GM_audio.getState.name).toBe("bound GM_audio.getState"); + expect(context.GM_audio.addStateChangeListener.name).toBe("bound GM_audio.addStateChangeListener"); + expect(context.GM_audio.removeStateChangeListener.name).toBe("bound GM_audio.removeStateChangeListener"); + expect(context.GM.audio.setMute.name).toBe("bound GM.audio.setMute"); + expect(context.GM.audio.getState.name).toBe("bound GM.audio.getState"); + expect(context.GM.audio.addStateChangeListener.name).toBe("bound GM.audio.addStateChangeListener"); + expect(context.GM.audio.removeStateChangeListener.name).toBe("bound GM.audio.removeStateChangeListener"); + } + }); + it.concurrent("window grant 先挂到 context.window,再由代理沙盒暴露为 window 方法", () => { const context = createTestContext(["window.close", "window.focus"]); const sandbox = createProxyContext(context); diff --git a/src/app/service/content/create_context.ts b/src/app/service/content/create_context.ts index 539fbd225..24c1e4751 100644 --- a/src/app/service/content/create_context.ts +++ b/src/app/service/content/create_context.ts @@ -52,6 +52,10 @@ export const createContext = ( setInvalidContext() { if (invalid) return; invalid = true; + this.audioStateChangeConnection?.disconnect(true); + this.audioStateChangeConnection = undefined; + this.audioStateChangeRegistration = undefined; + this.audioStateChangeListeners?.clear(); this.valueChangeListener.clear(); this.EE.removeAllListeners(); this.runFlag = `${uuidv4()}(invalid)`; // 更改 uuid 防止 runFlag 相关操作 diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index 945d5e936..1b84ae194 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -215,6 +215,15 @@ export default class GMApi extends GM_Base { */ notificationTagMap?: Map; + @GMContext.protected() + audioStateChangeListeners?: Set; + + @GMContext.protected() + audioStateChangeConnection?: MessageConnect; + + @GMContext.protected() + audioStateChangeRegistration?: Promise; + constructor( public prefix: string, public message: Message, @@ -237,6 +246,10 @@ export default class GMApi extends GM_Base { setInvalidContext() { if (invalid) return; invalid = true; + this.audioStateChangeConnection?.disconnect(true); + this.audioStateChangeConnection = undefined; + this.audioStateChangeRegistration = undefined; + this.audioStateChangeListeners?.clear(); this.valueChangeListener.clear(); this.EE.removeAllListeners(); // 释放记忆 @@ -651,6 +664,131 @@ export default class GMApi extends GM_Base { _GM_cookie(this, action, details, done); } + static _GM_audioError(error: unknown): string { + if (typeof error === "string") return error; + if (error instanceof Error) return error.message; + return `${error}`; + } + + static _GM_audioAddStateChangeListener(a: GMApi, listener: GMTypes.AudioStateChangeListener): Promise { + if (typeof listener !== "function") { + return Promise.reject("GM_audio.addStateChangeListener: Invalid argument"); + } + + a.audioStateChangeListeners ??= new Set(); + if (a.audioStateChangeListeners.has(listener)) { + return a.audioStateChangeRegistration ?? Promise.resolve(); + } + a.audioStateChangeListeners.add(listener); + if (a.audioStateChangeRegistration) return a.audioStateChangeRegistration; + + const registration = a.connect("GM_audio", ["addStateChangeListener"]).then( + (connection) => + new Promise((resolve, reject) => { + if (!a.audioStateChangeListeners?.size) { + connection.disconnect(true); + resolve(); + return; + } + + let registered = false; + a.audioStateChangeConnection = connection; + connection.onMessage((message) => { + if (message.code) { + reject(message.message); + return; + } + if (message.action === "registered") { + registered = true; + resolve(); + return; + } + if (message.action === "stateChange") { + for (const stateListener of a.audioStateChangeListeners ?? []) { + stateListener(message.data as GMTypes.AudioStateChangeInfo); + } + } + }); + connection.onDisconnect(() => { + if (a.audioStateChangeConnection !== connection) return; + a.audioStateChangeConnection = undefined; + a.audioStateChangeRegistration = undefined; + a.audioStateChangeListeners?.clear(); + if (!registered) reject("GM_audio.addStateChangeListener: Connection disconnected"); + }); + }) + ); + a.audioStateChangeRegistration = registration.catch((error) => { + const connection = a.audioStateChangeConnection; + a.audioStateChangeConnection = undefined; + a.audioStateChangeRegistration = undefined; + a.audioStateChangeListeners?.clear(); + connection?.disconnect(true); + throw error; + }); + return a.audioStateChangeRegistration; + } + + static _GM_audioRemoveStateChangeListener(a: GMApi, listener: GMTypes.AudioStateChangeListener): Promise { + a.audioStateChangeListeners?.delete(listener); + if (a.audioStateChangeListeners?.size) return Promise.resolve(); + + const connection = a.audioStateChangeConnection; + a.audioStateChangeConnection = undefined; + a.audioStateChangeRegistration = undefined; + connection?.disconnect(true); + return Promise.resolve(); + } + + @GMContext.API({ follow: "GM_audio" }) + public "GM_audio.setMute"(details: GMTypes.AudioMuteDetails, callback?: GMTypes.AudioErrorCallback): void { + void this.sendMessage("GM_audio", ["setMute", details]) + .then(() => callback?.()) + .catch((error) => callback?.(_GM_audioError(error))); + } + + @GMContext.API({ follow: "GM_audio" }) + public "GM_audio.getState"(callback: GMTypes.AudioStateCallback): void { + void this.sendMessage("GM_audio", ["getState"]) + .then((state: GMTypes.AudioState) => callback(state)) + .catch(() => callback(undefined)); + } + + @GMContext.API({ follow: "GM_audio" }) + public "GM_audio.addStateChangeListener"( + listener: GMTypes.AudioStateChangeListener, + callback?: GMTypes.AudioErrorCallback + ): void { + void _GM_audioAddStateChangeListener(this, listener) + .then(() => callback?.()) + .catch((error) => callback?.(_GM_audioError(error))); + } + + @GMContext.API({ follow: "GM_audio" }) + public "GM_audio.removeStateChangeListener"(listener: GMTypes.AudioStateChangeListener, callback?: () => void): void { + void _GM_audioRemoveStateChangeListener(this, listener).then(() => callback?.()); + } + + @GMContext.API({ follow: "GM.audio" }) + public "GM.audio.setMute"(details: GMTypes.AudioMuteDetails): Promise { + return this.sendMessage("GM_audio", ["setMute", details]); + } + + @GMContext.API({ follow: "GM.audio" }) + public "GM.audio.getState"(): Promise { + return this.sendMessage("GM_audio", ["getState"]); + } + + @GMContext.API({ follow: "GM.audio" }) + public "GM.audio.addStateChangeListener"(listener: GMTypes.AudioStateChangeListener): Promise { + return _GM_audioAddStateChangeListener(this, listener); + } + + @GMContext.API({ follow: "GM.audio" }) + public "GM.audio.removeStateChangeListener"(listener: GMTypes.AudioStateChangeListener): Promise { + return _GM_audioRemoveStateChangeListener(this, listener); + } + // 已注册的「菜单唯一键」集合,用于去重与解除绑定。 // 唯一键格式:{contentEnvKey}.t{注册ID},由 execEnvInit() 建立/维护。 menuKeyRegistered: Set | undefined; @@ -1614,4 +1752,14 @@ export default class GMApi extends GM_Base { export const { createGMBase } = GM_Base; // 从 GMApi 对象中解构出内部函数,用于后续本地使用,不导出 -const { _GM_getValue, _GM_cookie, _GM_setValue, _GM_setValues, _GM_download, _GM_notification } = GMApi; +const { + _GM_getValue, + _GM_cookie, + _GM_setValue, + _GM_setValues, + _GM_download, + _GM_notification, + _GM_audioError, + _GM_audioAddStateChangeListener, + _GM_audioRemoveStateChangeListener, +} = GMApi; diff --git a/src/app/service/content/gm_api/gm_audio.test.ts b/src/app/service/content/gm_api/gm_audio.test.ts new file mode 100644 index 000000000..cd923fc6a --- /dev/null +++ b/src/app/service/content/gm_api/gm_audio.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it, vi } from "vitest"; +import EventEmitter from "eventemitter3"; +import type { ScriptRunResource } from "@App/app/repo/scripts"; +import { MockMessageConnect } from "@Packages/message/mock_message"; +import type { Message, TMessage } from "@Packages/message/types"; +import GMApi from "./gm_api"; + +const audioState: GMTypes.AudioState = { + isMuted: true, + muteReason: "extension", + isAudible: false, +}; + +const createAudioApi = () => { + const connection = new MockMessageConnect(new EventEmitter()); + const sendMessage = vi.fn<(message: TMessage) => Promise>(async (message: TMessage) => { + const action = message.data.params[0]; + if (action === "getState") { + return { code: 0, data: audioState }; + } + return { code: 0 }; + }); + const connect = vi.fn(async () => connection); + const message = { sendMessage, connect } as unknown as Message; + const api = new GMApi("serviceWorker", message, message, { + uuid: "gm-audio-test", + value: {}, + } as ScriptRunResource); + return { api, connect, connection, sendMessage }; +}; + +describe("GM_audio 回调接口", () => { + it("设置静音成功或失败时以 TM 格式调用回调", async () => { + const { api, sendMessage } = createAudioApi(); + const success = vi.fn(); + + await new Promise((resolve) => { + api["GM_audio.setMute"]({ isMuted: true }, (error) => { + success(error); + resolve(); + }); + }); + expect(success).toHaveBeenCalledWith(undefined); + expect(sendMessage.mock.calls[0][0].data.params).toEqual(["setMute", { isMuted: true }]); + + sendMessage.mockResolvedValueOnce({ code: -1, message: "静音失败" }); + const failure = vi.fn(); + await new Promise((resolve) => { + api["GM_audio.setMute"]({ isMuted: false }, (error) => { + failure(error); + resolve(); + }); + }); + expect(failure).toHaveBeenCalledWith("静音失败"); + }); + + it("读取状态成功时传入状态,失败时传入 undefined", async () => { + const { api, sendMessage } = createAudioApi(); + const success = vi.fn(); + + await new Promise((resolve) => { + api["GM_audio.getState"]((state) => { + success(state); + resolve(); + }); + }); + expect(success).toHaveBeenCalledWith(audioState); + + sendMessage.mockResolvedValueOnce({ code: -1, message: "读取失败" }); + const failure = vi.fn(); + await new Promise((resolve) => { + api["GM_audio.getState"]((state) => { + failure(state); + resolve(); + }); + }); + expect(failure).toHaveBeenCalledWith(undefined); + }); +}); + +describe("GM.audio Promise 接口", () => { + it("设置静音与读取状态均返回 Promise", async () => { + const { api, sendMessage } = createAudioApi(); + + await expect(api["GM.audio.setMute"]({ isMuted: false })).resolves.toBeUndefined(); + await expect(api["GM.audio.getState"]()).resolves.toEqual(audioState); + expect(sendMessage.mock.calls.map(([message]) => message.data.params)).toEqual([ + ["setMute", { isMuted: false }], + ["getState"], + ]); + }); +}); + +describe("GM_audio 状态变化监听", () => { + it("多个监听器共享连接,并按原函数移除且在最后一个移除时断连", async () => { + const { api, connect, connection } = createAudioApi(); + const first = vi.fn(); + const second = vi.fn(); + + const firstRegistration = api["GM.audio.addStateChangeListener"](first); + await Promise.resolve(); + connection.sendMessage({ action: "registered" }); + await firstRegistration; + await api["GM.audio.addStateChangeListener"](second); + + connection.sendMessage({ action: "stateChange", data: { muted: "user", audible: true } }); + expect(first).toHaveBeenCalledWith({ muted: "user", audible: true }); + expect(second).toHaveBeenCalledWith({ muted: "user", audible: true }); + expect(connect).toHaveBeenCalledTimes(1); + + await api["GM.audio.removeStateChangeListener"](first); + connection.sendMessage({ action: "stateChange", data: { audible: false } }); + expect(first).toHaveBeenCalledTimes(1); + expect(second).toHaveBeenCalledTimes(2); + + const disconnect = vi.spyOn(connection, "disconnect"); + await api["GM.audio.removeStateChangeListener"](second); + expect(disconnect).toHaveBeenCalledTimes(1); + }); + + it("回调风格在注册成功后回调,注册失败时回传错误字符串", async () => { + const { api, connection } = createAudioApi(); + const listener = vi.fn(); + const registered = vi.fn(); + + const registration = new Promise((resolve) => { + api["GM_audio.addStateChangeListener"](listener, (error) => { + registered(error); + resolve(); + }); + }); + await Promise.resolve(); + connection.sendMessage({ action: "registered" }); + await registration; + expect(registered).toHaveBeenCalledWith(undefined); + + await new Promise((resolve) => { + api["GM_audio.removeStateChangeListener"](listener, () => { + registered(); + resolve(); + }); + }); + expect(registered).toHaveBeenCalledTimes(2); + + const failedApi = createAudioApi(); + const failure = vi.fn(); + const failedRegistration = new Promise((resolve) => { + failedApi.api["GM_audio.addStateChangeListener"](vi.fn(), (error) => { + failure(error); + resolve(); + }); + }); + await Promise.resolve(); + failedApi.connection.sendMessage({ code: -1, message: "注册失败" }); + await failedRegistration; + expect(failure).toHaveBeenCalledWith("注册失败"); + }); +}); diff --git a/src/app/service/service_worker/gm_api/gm_api.ts b/src/app/service/service_worker/gm_api/gm_api.ts index 0b93071a9..5dedbcee5 100644 --- a/src/app/service/service_worker/gm_api/gm_api.ts +++ b/src/app/service/service_worker/gm_api/gm_api.ts @@ -1232,6 +1232,70 @@ export default class GMApi { }); } + @PermissionVerify.API() + async GM_audio(request: GMApiRequest<[string, GMTypes.AudioMuteDetails?]>, sender: IGetSender): Promise { + const tabId = sender.getExtMessageSender().tabId; + if (tabId < 0) { + throw new Error("GM_audio is not available in this context"); + } + + const [action, details] = request.params; + switch (action) { + case "setMute": { + if (typeof details?.isMuted !== "boolean") { + throw new Error("GM_audio.setMute: Invalid argument"); + } + await chrome.tabs.update(tabId, { muted: details.isMuted }); + return undefined; + } + case "getState": { + const tab = await chrome.tabs.get(tabId); + const state: GMTypes.AudioState = {}; + if (typeof tab.mutedInfo?.muted === "boolean") state.isMuted = tab.mutedInfo.muted; + if (tab.mutedInfo?.reason) state.muteReason = tab.mutedInfo.reason; + if (typeof tab.audible === "boolean") state.isAudible = tab.audible; + return state; + } + case "addStateChangeListener": { + if (!sender.isType(GetSenderType.CONNECT)) { + throw new Error("GM_audio.addStateChangeListener requires a connection"); + } + const connection = sender.getConnect(); + if (!connection) { + throw new Error("GM_audio.addStateChangeListener requires a connection"); + } + + let connected = true; + const listener = (updatedTabId: number, changeInfo: chrome.tabs.OnUpdatedInfo) => { + if (!connected || updatedTabId !== tabId) return; + const stateChange: GMTypes.AudioStateChangeInfo = {}; + if (changeInfo.mutedInfo) { + stateChange.muted = changeInfo.mutedInfo.muted ? (changeInfo.mutedInfo.reason ?? "extension") : false; + } + if (typeof changeInfo.audible === "boolean") stateChange.audible = changeInfo.audible; + if (stateChange.muted === undefined && stateChange.audible === undefined) return; + connection.sendMessage({ action: "stateChange", data: stateChange }); + }; + const cleanup = () => { + if (!connected) return; + connected = false; + chrome.tabs.onUpdated.removeListener(listener); + }; + connection.onDisconnect(cleanup); + chrome.tabs.onUpdated.addListener(listener); + try { + connection.sendMessage({ action: "registered" }); + } catch (error) { + cleanup(); + throw error; + } + return undefined; + } + default: + throw new Error(`GM_audio: Unknown action ${action}`); + } + } + @PermissionVerify.API({}) async GM_notification(request: GMApiRequest<[GMTypes.NotificationDetails, string | undefined]>, sender: IGetSender) { const details: GMTypes.NotificationDetails = request.params[0]; diff --git a/src/app/service/service_worker/gm_api/gm_audio.test.ts b/src/app/service/service_worker/gm_api/gm_audio.test.ts new file mode 100644 index 000000000..ce8001afd --- /dev/null +++ b/src/app/service/service_worker/gm_api/gm_audio.test.ts @@ -0,0 +1,118 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { IGetSender } from "@Packages/message/server"; +import { GetSenderType } from "@Packages/message/server"; +import type { MessageConnect, TMessage } from "@Packages/message/types"; +import type { GMApiRequest } from "../types"; +import GMApi from "./gm_api"; +import chromeMock from "@Packages/chrome-extension-mock"; + +class TestConnection implements MessageConnect { + readonly messages: TMessage[] = []; + private disconnectListener?: (isSelfDisconnected: boolean) => void; + + onMessage(): void {} + + sendMessage(data: TMessage): void { + this.messages.push(data); + } + + disconnect(): void { + this.disconnectListener?.(true); + } + + onDisconnect(callback: (isSelfDisconnected: boolean) => void): void { + this.disconnectListener = callback; + } +} + +const createRequest = (params: unknown[]) => + ({ + uuid: "gm-audio-test", + runFlag: "run-flag", + api: "GM_audio", + params, + script: { metadata: { grant: ["GM_audio"] } }, + }) as unknown as GMApiRequest; + +const createSender = (tabId: number, connection?: MessageConnect): IGetSender => ({ + getType: () => (connection ? GetSenderType.CONNECT : GetSenderType.RUNTIME), + isType: (type) => Boolean(connection) && type === GetSenderType.CONNECT, + getSender: () => undefined, + getExtMessageSender: () => ({ tabId }), + getConnect: () => connection, +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("Service Worker GM_audio", () => { + it("设置并读取当前标签页的静音与音频状态", async () => { + const update = vi.fn(async () => ({ id: 7 })); + const get = vi.fn(async () => ({ + id: 7, + audible: true, + mutedInfo: { muted: true, reason: "extension" }, + })); + vi.stubGlobal("chrome", { + ...chromeMock, + tabs: { ...chromeMock.tabs, update, get }, + }); + const api = Object.create(GMApi.prototype) as GMApi; + const sender = createSender(7); + + await expect(api.GM_audio(createRequest(["setMute", { isMuted: true }]), sender)).resolves.toBeUndefined(); + await expect(api.GM_audio(createRequest(["getState"]), sender)).resolves.toEqual({ + isMuted: true, + muteReason: "extension", + isAudible: true, + }); + expect(update).toHaveBeenCalledWith(7, { muted: true }); + expect(get).toHaveBeenCalledWith(7); + }); + + it("仅转发当前标签页的 muted 与 audible 变化,并在连接断开时清理", async () => { + let onUpdated: ((tabId: number, changeInfo: chrome.tabs.OnUpdatedInfo) => void) | undefined; + const addListener = vi.fn((listener: typeof onUpdated) => { + onUpdated = listener; + }); + const removeListener = vi.fn(); + vi.stubGlobal("chrome", { + ...chromeMock, + tabs: { + ...chromeMock.tabs, + onUpdated: { addListener, removeListener }, + }, + }); + const api = Object.create(GMApi.prototype) as GMApi; + const connection = new TestConnection(); + + await api.GM_audio(createRequest(["addStateChangeListener"]), createSender(7, connection)); + expect(connection.messages).toEqual([{ action: "registered" }]); + + onUpdated?.(8, { audible: true }); + onUpdated?.(7, { status: "complete" }); + onUpdated?.(7, { mutedInfo: { muted: true, reason: "user" }, audible: false }); + expect(connection.messages).toEqual([ + { action: "registered" }, + { action: "stateChange", data: { muted: "user", audible: false } }, + ]); + + connection.disconnect(); + expect(removeListener).toHaveBeenCalledWith(onUpdated); + }); + + it("拒绝后台上下文、无效参数和非长连接监听注册", async () => { + const api = Object.create(GMApi.prototype) as GMApi; + + await expect(api.GM_audio(createRequest(["getState"]), createSender(-1))).rejects.toThrow( + "GM_audio is not available in this context" + ); + await expect(api.GM_audio(createRequest(["setMute", { isMuted: "yes" }]), createSender(7))).rejects.toThrow( + "GM_audio.setMute: Invalid argument" + ); + await expect(api.GM_audio(createRequest(["addStateChangeListener"]), createSender(7))).rejects.toThrow( + "GM_audio.addStateChangeListener requires a connection" + ); + }); +}); diff --git a/src/pkg/utils/monaco-editor/langs.ts b/src/pkg/utils/monaco-editor/langs.ts index 7805a4923..5e34aadd0 100644 --- a/src/pkg/utils/monaco-editor/langs.ts +++ b/src/pkg/utils/monaco-editor/langs.ts @@ -31,6 +31,7 @@ const grantValuePromptsZhCN = { GM_getResourceText: "读取 @resource 声明资源的文本内容。", GM_getResourceURL: "获取 @resource 声明资源的 URL。", GM_cookie: "访问 Cookie API,用于读取、写入或删除 Cookie。", + GM_audio: "控制并监听当前浏览器标签页的静音与发声状态。", CAT_fetchBlob: "ScriptCat 内部扩展 API:读取扩展侧可访问资源并返回 Blob。", CAT_fileStorage: "ScriptCat 扩展 API:访问脚本文件存储能力。", CAT_userConfig: "ScriptCat 扩展 API:访问脚本用户配置。", @@ -73,6 +74,7 @@ const grantValuePromptsEnUS = { GM_getResourceText: "Read the text content of a resource declared with @resource.", GM_getResourceURL: "Get the URL of a resource declared with @resource.", GM_cookie: "Access the Cookie API to read, write, or delete cookies.", + GM_audio: "Control and observe the current browser tab's mute and audible state.", CAT_fetchBlob: "ScriptCat internal API: read an extension-side accessible resource and return a Blob.", CAT_fileStorage: "ScriptCat API: access script file storage.", CAT_userConfig: "ScriptCat API: access script user configuration.", @@ -115,6 +117,7 @@ const grantValuePromptsZhTW = { GM_getResourceText: "讀取 @resource 宣告資源的文字內容。", GM_getResourceURL: "取得 @resource 宣告資源的 URL。", GM_cookie: "存取 Cookie API,用於讀取、寫入或刪除 Cookie。", + GM_audio: "控制並監聽目前瀏覽器分頁的靜音與音訊播放狀態。", CAT_fetchBlob: "ScriptCat 內部擴充 API:讀取擴充側可存取資源並回傳 Blob。", CAT_fileStorage: "ScriptCat 擴充 API:存取腳本檔案儲存能力。", CAT_userConfig: "ScriptCat 擴充 API:存取腳本使用者設定。", @@ -158,6 +161,7 @@ const grantValuePromptsJaJP = { GM_getResourceText: "@resource で宣言されたリソースのテキスト内容を読み取ります。", GM_getResourceURL: "@resource で宣言されたリソースの URL を取得します。", GM_cookie: "Cookie API にアクセスし、Cookie の読み取り、書き込み、削除を行います。", + GM_audio: "現在のブラウザータブのミュート状態と音声再生状態を制御・監視します。", CAT_fetchBlob: "ScriptCat 内部 API: 拡張機能側でアクセス可能なリソースを読み取り Blob を返します。", CAT_fileStorage: "ScriptCat API: スクリプトのファイルストレージへアクセスします。", CAT_userConfig: "ScriptCat API: スクリプトのユーザー設定へアクセスします。", @@ -201,6 +205,7 @@ const grantValuePromptsDeDE = { GM_getResourceText: "Liest den Textinhalt einer mit @resource deklarierten Ressource.", GM_getResourceURL: "Ruft die URL einer mit @resource deklarierten Ressource ab.", GM_cookie: "Greift auf die Cookie-API zu, um Cookies zu lesen, zu schreiben oder zu löschen.", + GM_audio: "Steuert und überwacht den Stumm- und Audiowiedergabestatus des aktuellen Browser-Tabs.", CAT_fetchBlob: "Interne ScriptCat-API: Liest eine erweiterungsseitig verfügbare Ressource und gibt einen Blob zurück.", CAT_fileStorage: "ScriptCat-API: Zugriff auf den Dateispeicher des Skripts.", @@ -244,6 +249,7 @@ const grantValuePromptsViVN = { GM_getResourceText: "Đọc nội dung văn bản của tài nguyên khai báo bằng @resource.", GM_getResourceURL: "Lấy URL của tài nguyên khai báo bằng @resource.", GM_cookie: "Truy cập Cookie API để đọc, ghi hoặc xóa cookie.", + GM_audio: "Điều khiển và theo dõi trạng thái tắt tiếng và phát âm thanh của tab trình duyệt hiện tại.", CAT_fetchBlob: "API nội bộ ScriptCat: đọc tài nguyên có thể truy cập từ phía tiện ích và trả về Blob.", CAT_fileStorage: "API ScriptCat: truy cập bộ nhớ tệp của script.", CAT_userConfig: "API ScriptCat: truy cập cấu hình người dùng của script.", @@ -286,6 +292,7 @@ const grantValuePromptsRuRU = { GM_getResourceText: "Читает текстовое содержимое ресурса, объявленного через @resource.", GM_getResourceURL: "Получает URL ресурса, объявленного через @resource.", GM_cookie: "Доступ к Cookie API для чтения, записи или удаления cookie.", + GM_audio: "Управляет звуком текущей вкладки браузера и отслеживает состояние воспроизведения аудио.", CAT_fetchBlob: "Внутренний API ScriptCat: читает доступный со стороны расширения ресурс и возвращает Blob.", CAT_fileStorage: "API ScriptCat: доступ к файловому хранилищу скрипта.", CAT_userConfig: "API ScriptCat: доступ к пользовательской конфигурации скрипта.", diff --git a/src/types/scriptcat.d.ts b/src/types/scriptcat.d.ts index 85a45b9d0..f6c35f28e 100644 --- a/src/types/scriptcat.d.ts +++ b/src/types/scriptcat.d.ts @@ -250,6 +250,18 @@ declare function GM_cookie( ondone: (cookie: GMTypes.Cookie[], error: unknown | undefined) => void ): void; +/** Control and observe the current browser tab's audio state. */ +declare const GM_audio: { + /** Mute or unmute the current tab. */ + setMute(details: GMTypes.AudioMuteDetails, callback?: GMTypes.AudioErrorCallback): void; + /** Read the current tab's mute and audible state. */ + getState(callback: GMTypes.AudioStateCallback): void; + /** Listen for mute or audible state changes. */ + addStateChangeListener(listener: GMTypes.AudioStateChangeListener, callback?: GMTypes.AudioErrorCallback): void; + /** Remove a previously registered state-change listener. */ + removeStateChangeListener(listener: GMTypes.AudioStateChangeListener, callback?: () => void): void; +}; + // =========================================================================== // GM.* object (Greasemonkey 4 / Tampermonkey 4+ Promise-style API) // =========================================================================== @@ -365,6 +377,18 @@ declare const GM: { /** Delete a cookie. */ delete(details: GMTypes.CookieDetails): Promise; }; + + /** Control and observe the current browser tab's audio state. */ + audio: { + /** Mute or unmute the current tab. */ + setMute(details: GMTypes.AudioMuteDetails): Promise; + /** Read the current tab's mute and audible state. */ + getState(): Promise; + /** Listen for mute or audible state changes. */ + addStateChangeListener(listener: GMTypes.AudioStateChangeListener): Promise; + /** Remove a previously registered state-change listener. */ + removeStateChangeListener(listener: GMTypes.AudioStateChangeListener): Promise; + }; }; // =========================================================================== @@ -516,6 +540,27 @@ declare namespace CATType { declare namespace GMTypes { type CookieAction = "list" | "delete" | "set"; + type AudioMuteReason = "user" | "capture" | "extension"; + + interface AudioMuteDetails { + isMuted: boolean; + } + + interface AudioState { + isMuted?: boolean; + muteReason?: AudioMuteReason; + isAudible?: boolean; + } + + interface AudioStateChangeInfo { + muted?: AudioMuteReason | false; + audible?: boolean; + } + + type AudioErrorCallback = (error?: string) => void; + type AudioStateCallback = (info: AudioState | undefined) => void; + type AudioStateChangeListener = (info: AudioStateChangeInfo) => void; + type LoggerLevel = "debug" | "info" | "warn" | "error"; type LoggerLabel = { diff --git a/src/types/scriptcat.zh-CN.d.ts b/src/types/scriptcat.zh-CN.d.ts index 4c99999cb..9c30c2573 100644 --- a/src/types/scriptcat.zh-CN.d.ts +++ b/src/types/scriptcat.zh-CN.d.ts @@ -256,6 +256,18 @@ declare function GM_cookie( ondone: (cookie: GMTypes.Cookie[], error: unknown | undefined) => void ): void; +/** 控制并监听当前浏览器标签页的音频状态。 */ +declare const GM_audio: { + /** 将当前标签页静音或取消静音。 */ + setMute(details: GMTypes.AudioMuteDetails, callback?: GMTypes.AudioErrorCallback): void; + /** 读取当前标签页的静音与发声状态。 */ + getState(callback: GMTypes.AudioStateCallback): void; + /** 监听静音或发声状态变化。 */ + addStateChangeListener(listener: GMTypes.AudioStateChangeListener, callback?: GMTypes.AudioErrorCallback): void; + /** 移除之前注册的状态变化监听器。 */ + removeStateChangeListener(listener: GMTypes.AudioStateChangeListener, callback?: () => void): void; +}; + // =========================================================================== // GM.* 对象(Greasemonkey 4 / Tampermonkey 4+ Promise 风格 API) // =========================================================================== @@ -371,6 +383,18 @@ declare const GM: { /** 删除 Cookie。 */ delete(details: GMTypes.CookieDetails): Promise; }; + + /** 控制并监听当前浏览器标签页的音频状态。 */ + audio: { + /** 将当前标签页静音或取消静音。 */ + setMute(details: GMTypes.AudioMuteDetails): Promise; + /** 读取当前标签页的静音与发声状态。 */ + getState(): Promise; + /** 监听静音或发声状态变化。 */ + addStateChangeListener(listener: GMTypes.AudioStateChangeListener): Promise; + /** 移除之前注册的状态变化监听器。 */ + removeStateChangeListener(listener: GMTypes.AudioStateChangeListener): Promise; + }; }; // =========================================================================== @@ -522,6 +546,31 @@ declare namespace CATType { declare namespace GMTypes { type CookieAction = "list" | "delete" | "set"; + /** 标签页静音原因。 */ + type AudioMuteReason = "user" | "capture" | "extension"; + + /** 设置标签页静音状态的参数。 */ + interface AudioMuteDetails { + isMuted: boolean; + } + + /** 标签页当前音频状态。 */ + interface AudioState { + isMuted?: boolean; + muteReason?: AudioMuteReason; + isAudible?: boolean; + } + + /** 标签页音频状态变化。 */ + interface AudioStateChangeInfo { + muted?: AudioMuteReason | false; + audible?: boolean; + } + + type AudioErrorCallback = (error?: string) => void; + type AudioStateCallback = (info: AudioState | undefined) => void; + type AudioStateChangeListener = (info: AudioStateChangeInfo) => void; + type LoggerLevel = "debug" | "info" | "warn" | "error"; type LoggerLabel = { From 776057c0aa7b49e7f25162046690b41377895c22 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Tue, 7 Jul 2026 07:34:01 +0900 Subject: [PATCH 02/15] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20=E5=90=88=E5=B9=B6?= =?UTF-8?q?=20GM=5Faudio=20=E7=8A=B6=E6=80=81=E5=8F=98=E5=8C=96=E7=9A=84?= =?UTF-8?q?=E4=B8=89=E4=B8=AA=E5=8F=98=E9=87=8F=E4=B8=BA=E5=8D=95=E4=B8=80?= =?UTF-8?q?=E5=AF=B9=E8=B1=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit audioStateChangeListeners/Connection/Registration 三者生命周期始终一致, 合并为单一 audioStateChange 对象,减少一处需要同步维护三个字段的心智负担。 --- src/app/service/content/create_context.ts | 6 +- src/app/service/content/gm_api/gm_api.ts | 69 +++++++++++------------ 2 files changed, 36 insertions(+), 39 deletions(-) diff --git a/src/app/service/content/create_context.ts b/src/app/service/content/create_context.ts index 24c1e4751..6f73fdbf3 100644 --- a/src/app/service/content/create_context.ts +++ b/src/app/service/content/create_context.ts @@ -52,10 +52,8 @@ export const createContext = ( setInvalidContext() { if (invalid) return; invalid = true; - this.audioStateChangeConnection?.disconnect(true); - this.audioStateChangeConnection = undefined; - this.audioStateChangeRegistration = undefined; - this.audioStateChangeListeners?.clear(); + this.audioStateChange?.connection?.disconnect(true); + this.audioStateChange = undefined; this.valueChangeListener.clear(); this.EE.removeAllListeners(); this.runFlag = `${uuidv4()}(invalid)`; // 更改 uuid 防止 runFlag 相关操作 diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index 1b84ae194..85f9f76b5 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -216,13 +216,11 @@ export default class GMApi extends GM_Base { notificationTagMap?: Map; @GMContext.protected() - audioStateChangeListeners?: Set; - - @GMContext.protected() - audioStateChangeConnection?: MessageConnect; - - @GMContext.protected() - audioStateChangeRegistration?: Promise; + audioStateChange?: { + listeners: Set; + connection?: MessageConnect; + registration?: Promise; + }; constructor( public prefix: string, @@ -246,10 +244,8 @@ export default class GMApi extends GM_Base { setInvalidContext() { if (invalid) return; invalid = true; - this.audioStateChangeConnection?.disconnect(true); - this.audioStateChangeConnection = undefined; - this.audioStateChangeRegistration = undefined; - this.audioStateChangeListeners?.clear(); + this.audioStateChange?.connection?.disconnect(true); + this.audioStateChange = undefined; this.valueChangeListener.clear(); this.EE.removeAllListeners(); // 释放记忆 @@ -675,24 +671,24 @@ export default class GMApi extends GM_Base { return Promise.reject("GM_audio.addStateChangeListener: Invalid argument"); } - a.audioStateChangeListeners ??= new Set(); - if (a.audioStateChangeListeners.has(listener)) { - return a.audioStateChangeRegistration ?? Promise.resolve(); + const state = (a.audioStateChange ??= { listeners: new Set() }); + if (state.listeners.has(listener)) { + return state.registration ?? Promise.resolve(); } - a.audioStateChangeListeners.add(listener); - if (a.audioStateChangeRegistration) return a.audioStateChangeRegistration; + state.listeners.add(listener); + if (state.registration) return state.registration; const registration = a.connect("GM_audio", ["addStateChangeListener"]).then( (connection) => new Promise((resolve, reject) => { - if (!a.audioStateChangeListeners?.size) { + if (!state.listeners.size) { connection.disconnect(true); resolve(); return; } let registered = false; - a.audioStateChangeConnection = connection; + state.connection = connection; connection.onMessage((message) => { if (message.code) { reject(message.message); @@ -704,38 +700,41 @@ export default class GMApi extends GM_Base { return; } if (message.action === "stateChange") { - for (const stateListener of a.audioStateChangeListeners ?? []) { + for (const stateListener of state.listeners) { stateListener(message.data as GMTypes.AudioStateChangeInfo); } } }); connection.onDisconnect(() => { - if (a.audioStateChangeConnection !== connection) return; - a.audioStateChangeConnection = undefined; - a.audioStateChangeRegistration = undefined; - a.audioStateChangeListeners?.clear(); + if (state.connection !== connection) return; + state.connection = undefined; + state.registration = undefined; + state.listeners.clear(); if (!registered) reject("GM_audio.addStateChangeListener: Connection disconnected"); }); }) ); - a.audioStateChangeRegistration = registration.catch((error) => { - const connection = a.audioStateChangeConnection; - a.audioStateChangeConnection = undefined; - a.audioStateChangeRegistration = undefined; - a.audioStateChangeListeners?.clear(); + state.registration = registration.catch((error) => { + const connection = state.connection; + state.connection = undefined; + state.registration = undefined; + state.listeners.clear(); connection?.disconnect(true); throw error; }); - return a.audioStateChangeRegistration; + return state.registration; } static _GM_audioRemoveStateChangeListener(a: GMApi, listener: GMTypes.AudioStateChangeListener): Promise { - a.audioStateChangeListeners?.delete(listener); - if (a.audioStateChangeListeners?.size) return Promise.resolve(); - - const connection = a.audioStateChangeConnection; - a.audioStateChangeConnection = undefined; - a.audioStateChangeRegistration = undefined; + const state = a.audioStateChange; + state?.listeners.delete(listener); + if (state?.listeners.size) return Promise.resolve(); + + const connection = state?.connection; + if (state) { + state.connection = undefined; + state.registration = undefined; + } connection?.disconnect(true); return Promise.resolve(); } From 01011717ecb3f19742a90d7a53ae2058ffb1c6d6 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Wed, 8 Jul 2026 06:59:00 +0900 Subject: [PATCH 03/15] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E5=A4=8D=20GM=5F?= =?UTF-8?q?audio=20=E7=8A=B6=E6=80=81=E7=9B=91=E5=90=AC=E5=9C=A8=20connect?= =?UTF-8?q?()=20=E6=9C=AA=E5=AE=8C=E6=88=90=E6=97=B6=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E5=86=8D=E6=B7=BB=E5=8A=A0=E5=AF=BC=E8=87=B4=E7=9A=84=E8=BF=9E?= =?UTF-8?q?=E6=8E=A5=E6=B3=84=E6=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit remove 归零后 connect() 仍可能在之后 resolve 并覆盖新连接,导致旧连接 (及其在 service worker 侧对应的 tabs.onUpdated 监听)未被断开。 引入 generation 世代号,每次归零/context 失效都会使旧世代的待定连接 在 resolve 时被判定为过期并立即断开。 --- src/app/service/content/gm_api/gm_api.ts | 30 +++++++---- .../service/content/gm_api/gm_audio.test.ts | 52 +++++++++++++++++++ 2 files changed, 73 insertions(+), 9 deletions(-) diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index 85f9f76b5..221614b5f 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -220,6 +220,9 @@ export default class GMApi extends GM_Base { listeners: Set; connection?: MessageConnect; registration?: Promise; + // 每次“最后一个监听器被移除”或 context 失效时递增, + // 用于识别并丢弃在此之后才resolve的过期 connect() 请求,避免其连接泄漏 + generation: number; }; constructor( @@ -671,17 +674,22 @@ export default class GMApi extends GM_Base { return Promise.reject("GM_audio.addStateChangeListener: Invalid argument"); } - const state = (a.audioStateChange ??= { listeners: new Set() }); + const state = (a.audioStateChange ??= { listeners: new Set(), generation: 0 }); if (state.listeners.has(listener)) { return state.registration ?? Promise.resolve(); } state.listeners.add(listener); if (state.registration) return state.registration; + // 记录本次尝试所属的世代:若在 connect() 完成前监听器被清空(remove 归零 / context 失效), + // 世代会被递增或 audioStateChange 整体被替换,届时应丢弃这个迟到的连接,避免连接泄漏 + const generation = state.generation; + const isCurrentAttempt = () => a.audioStateChange === state && state.generation === generation; + const registration = a.connect("GM_audio", ["addStateChangeListener"]).then( (connection) => new Promise((resolve, reject) => { - if (!state.listeners.size) { + if (!isCurrentAttempt() || !state.listeners.size) { connection.disconnect(true); resolve(); return; @@ -707,6 +715,7 @@ export default class GMApi extends GM_Base { }); connection.onDisconnect(() => { if (state.connection !== connection) return; + state.generation++; state.connection = undefined; state.registration = undefined; state.listeners.clear(); @@ -715,11 +724,13 @@ export default class GMApi extends GM_Base { }) ); state.registration = registration.catch((error) => { - const connection = state.connection; - state.connection = undefined; - state.registration = undefined; - state.listeners.clear(); - connection?.disconnect(true); + if (isCurrentAttempt()) { + state.connection?.disconnect(true); + state.generation++; + state.connection = undefined; + state.registration = undefined; + state.listeners.clear(); + } throw error; }); return state.registration; @@ -730,12 +741,13 @@ export default class GMApi extends GM_Base { state?.listeners.delete(listener); if (state?.listeners.size) return Promise.resolve(); - const connection = state?.connection; if (state) { + const connection = state.connection; + state.generation++; state.connection = undefined; state.registration = undefined; + connection?.disconnect(true); } - connection?.disconnect(true); return Promise.resolve(); } diff --git a/src/app/service/content/gm_api/gm_audio.test.ts b/src/app/service/content/gm_api/gm_audio.test.ts index cd923fc6a..63f798c4f 100644 --- a/src/app/service/content/gm_api/gm_audio.test.ts +++ b/src/app/service/content/gm_api/gm_audio.test.ts @@ -118,6 +118,58 @@ describe("GM_audio 状态变化监听", () => { expect(disconnect).toHaveBeenCalledTimes(1); }); + it("connect() 尚未 resolve 时移除再重新添加监听器,不应泄漏先前的连接", async () => { + const first = vi.fn(); + const second = vi.fn(); + + const connections = [ + new MockMessageConnect(new EventEmitter()), + new MockMessageConnect(new EventEmitter()), + ]; + const disconnects = connections.map((c) => vi.spyOn(c, "disconnect")); + const resolvers: Array<(connection: MockMessageConnect) => void> = []; + const connect = vi.fn( + () => + new Promise((resolve) => { + resolvers.push(resolve); + }) + ); + const message = { connect } as unknown as Message; + const api = new GMApi("serviceWorker", message, message, { + uuid: "gm-audio-test", + value: {}, + } as ScriptRunResource); + + // 1. 添加监听器,connect() 尚未 resolve + const firstAttempt = api["GM.audio.addStateChangeListener"](first); + // 2. 在 connect() resolve 之前移除该监听器(归零) + await api["GM.audio.removeStateChangeListener"](first); + // 3. 立即重新添加,触发第二次 connect() + const secondAttempt = api["GM.audio.addStateChangeListener"](second); + expect(connect).toHaveBeenCalledTimes(2); + + // 4. 两次 connect() 均 resolve:先是过期的第一次尝试,然后才是当前尝试 + resolvers[0](connections[0]); + await Promise.resolve(); + await Promise.resolve(); + resolvers[1](connections[1]); + await Promise.resolve(); + connections[1].sendMessage({ action: "registered" }); + await secondAttempt; + await firstAttempt; + + // 过期的第一个连接必须被立即断开,不能被当前连接覆盖后遗留 + expect(disconnects[0]).toHaveBeenCalledTimes(1); + expect(disconnects[1]).toHaveBeenCalledTimes(0); + + connections[1].sendMessage({ action: "stateChange", data: { muted: "user", audible: true } }); + expect(first).not.toHaveBeenCalled(); + expect(second).toHaveBeenCalledWith({ muted: "user", audible: true }); + + await api["GM.audio.removeStateChangeListener"](second); + expect(disconnects[1]).toHaveBeenCalledTimes(1); + }); + it("回调风格在注册成功后回调,注册失败时回传错误字符串", async () => { const { api, connection } = createAudioApi(); const listener = vi.fn(); From 28be9745f3e1cb4caa656891087c163c6c6e667d Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 11 Jul 2026 19:50:02 +0900 Subject: [PATCH 04/15] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E5=A4=8D=20GM=5F?= =?UTF-8?q?audio.getState()=20=E5=9C=A8=E6=9C=AA=E9=9D=99=E9=9F=B3?= =?UTF-8?q?=E6=97=B6=E4=BB=8D=E5=9B=9E=E4=BC=A0=20muteReason?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit chrome.tabs.Tab.mutedInfo.reason 记录的是最近一次静音/取消静音的原因, 取消静音后该字段仍可能残留旧值。之前的实现未检查 muted 即赋值给 muteReason,导致 isMuted: false 时仍出现如 { isMuted: false, muteReason: "extension" } 的不一致状态,与 Tampermonkey "仅在当前处于 静音状态时返回 muteReason" 的约定不符。 Co-Authored-By: Claude Sonnet 5 --- .../service_worker/gm_api/gm_api.test.ts | 70 ++++++++++++++++++- .../service/service_worker/gm_api/gm_api.ts | 2 +- 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/src/app/service/service_worker/gm_api/gm_api.test.ts b/src/app/service/service_worker/gm_api/gm_api.test.ts index 5bb7b6d4e..f6cdc10f7 100644 --- a/src/app/service/service_worker/gm_api/gm_api.test.ts +++ b/src/app/service/service_worker/gm_api/gm_api.test.ts @@ -1,11 +1,25 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { type IGetSender } from "@Packages/message/server"; import { type ExtMessageSender } from "@Packages/message/types"; -import { ConnectMatch, getConnectMatched, getExtensionSiteAccessOriginPattern } from "./gm_api"; +import GMApi, { + ConnectMatch, + getConnectMatched, + getExtensionSiteAccessOriginPattern, + MockGMExternalDependencies, +} from "./gm_api"; import { PermissionVerifyApiGet } from "../permission_verify"; +import { SystemConfig } from "@App/pkg/config/config"; +import PermissionVerify from "../permission_verify"; +import { Server } from "@Packages/message/server"; +import { MockMessage } from "@Packages/message/mock_message"; +import { MessageQueue } from "@Packages/message/message_queue"; +import EventEmitter from "eventemitter3"; +import { initTestEnv } from "@Tests/utils"; // 触发所有 GM API 装饰器注册(与 gm_api.ts 中的 import 保持同步) import "./gm_api"; +initTestEnv(); + // 小工具:建立假的 IGetSender const makeSender = (url?: string): IGetSender => ({ getSender: () => (url ? { url } : {}), @@ -129,3 +143,55 @@ describe.concurrent("getExtensionSiteAccessOriginPattern", () => { expect(getExtensionSiteAccessOriginPattern(new URL("file:///tmp/test.txt"))).toBeUndefined(); }); }); + +function createGMApi(): GMApi { + const ee = new EventEmitter(); + const message = new MockMessage(ee); + const messageQueue = new MessageQueue(); + const systemConfig = new SystemConfig(messageQueue); + const server = new Server("serviceWorker", message); + const permissionVerify = new PermissionVerify(server.group("permissionVerify"), messageQueue); + return new GMApi( + systemConfig, + permissionVerify, + server.group("runtime"), + message, + messageQueue, + {} as any, + new MockGMExternalDependencies() + ); +} + +const makeAudioSender = (tabId: number): IGetSender => ({ + getSender: () => ({}), + getType: () => 0, + isType: () => false, + getExtMessageSender: () => ({ tabId }) as ExtMessageSender, + getConnect: () => undefined, +}); + +describe("GM_audio getState", () => { + it("tab 未静音时不应返回 muteReason(即使 mutedInfo.reason 仍带有上次静音/取消静音的原因)", async () => { + const gmApi = createGMApi(); + (chrome.tabs as any).get = vi.fn().mockResolvedValueOnce({ + mutedInfo: { muted: false, reason: "extension" }, + audible: true, + } as chrome.tabs.Tab); + + const state = await gmApi.GM_audio({ params: ["getState"] } as any, makeAudioSender(1)); + + expect(state).toEqual({ isMuted: false, isAudible: true }); + }); + + it("tab 静音时应返回 muteReason", async () => { + const gmApi = createGMApi(); + (chrome.tabs as any).get = vi.fn().mockResolvedValueOnce({ + mutedInfo: { muted: true, reason: "user" }, + audible: false, + } as chrome.tabs.Tab); + + const state = await gmApi.GM_audio({ params: ["getState"] } as any, makeAudioSender(1)); + + expect(state).toEqual({ isMuted: true, muteReason: "user", isAudible: false }); + }); +}); diff --git a/src/app/service/service_worker/gm_api/gm_api.ts b/src/app/service/service_worker/gm_api/gm_api.ts index 5dedbcee5..cbf829522 100644 --- a/src/app/service/service_worker/gm_api/gm_api.ts +++ b/src/app/service/service_worker/gm_api/gm_api.ts @@ -1252,7 +1252,7 @@ export default class GMApi { const tab = await chrome.tabs.get(tabId); const state: GMTypes.AudioState = {}; if (typeof tab.mutedInfo?.muted === "boolean") state.isMuted = tab.mutedInfo.muted; - if (tab.mutedInfo?.reason) state.muteReason = tab.mutedInfo.reason; + if (tab.mutedInfo?.muted && tab.mutedInfo.reason) state.muteReason = tab.mutedInfo.reason; if (typeof tab.audible === "boolean") state.isAudible = tab.audible; return state; } From 4a143aa5d78ce4de4892572717b0111d5356d7a3 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 11 Jul 2026 19:50:25 +0900 Subject: [PATCH 05/15] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E5=A4=8D=20GM=5F?= =?UTF-8?q?audio=20=E7=8A=B6=E6=80=81=E7=9B=91=E5=90=AC=E5=9C=A8=E6=96=AD?= =?UTF-8?q?=E7=BA=BF=E9=87=8D=E8=BF=9E=E3=80=81=E5=9B=9E=E8=B0=83=E9=87=8D?= =?UTF-8?q?=E5=A4=8D=E8=B0=83=E7=94=A8=E4=B8=8E=E7=9B=91=E5=90=AC=E5=99=A8?= =?UTF-8?q?=E9=9A=94=E7=A6=BB=E4=B8=8A=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/app/service/content/gm_api/gm_api.ts | 76 ++++++++---- .../service/content/gm_api/gm_audio.test.ts | 113 ++++++++++++++++++ 2 files changed, 167 insertions(+), 22 deletions(-) diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index 221614b5f..409949b1f 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -669,18 +669,19 @@ export default class GMApi extends GM_Base { return `${error}`; } - static _GM_audioAddStateChangeListener(a: GMApi, listener: GMTypes.AudioStateChangeListener): Promise { - if (typeof listener !== "function") { - return Promise.reject("GM_audio.addStateChangeListener: Invalid argument"); - } - - const state = (a.audioStateChange ??= { listeners: new Set(), generation: 0 }); - if (state.listeners.has(listener)) { - return state.registration ?? Promise.resolve(); + // 调用脚本传入的回调,且吞掉回调自身抛出的异常,避免其被当作操作失败而重新以错误参数调用同一回调 + static _GM_audioSafeInvoke(callback: ((...args: A) => void) | undefined, ...args: A): void { + if (!callback) return; + try { + callback(...args); + } catch (error) { + console.error(error); } - state.listeners.add(listener); - if (state.registration) return state.registration; + } + // 建立(或在 service worker 意外断线后重新建立)GM_audio 状态变化连接。 + // 由 _GM_audioAddStateChangeListener 首次调用,也由断线重连逻辑自行递归调用。 + static _GM_audioConnect(a: GMApi, state: NonNullable): Promise { // 记录本次尝试所属的世代:若在 connect() 完成前监听器被清空(remove 归零 / context 失效), // 世代会被递增或 audioStateChange 整体被替换,届时应丢弃这个迟到的连接,避免连接泄漏 const generation = state.generation; @@ -709,15 +710,27 @@ export default class GMApi extends GM_Base { } if (message.action === "stateChange") { for (const stateListener of state.listeners) { - stateListener(message.data as GMTypes.AudioStateChangeInfo); + try { + stateListener(message.data as GMTypes.AudioStateChangeInfo); + } catch (error) { + console.error(error); + } } } }); - connection.onDisconnect(() => { + connection.onDisconnect((isSelfDisconnected) => { if (state.connection !== connection) return; - state.generation++; state.connection = undefined; state.registration = undefined; + if (registered && !isSelfDisconnected && a.audioStateChange === state && state.listeners.size) { + // service worker 意外终止(如 MV3 闲置回收)导致端口断开,而非脚本主动移除监听器, + // 此时应保留监听器并自动重连,而非静默丢弃——重新 connect() 会自动唤醒 service worker + const reconnect = GMApi._GM_audioConnect(a, state); + state.registration = reconnect; + reconnect.catch(() => {}); + return; + } + state.generation++; state.listeners.clear(); if (!registered) reject("GM_audio.addStateChangeListener: Connection disconnected"); }); @@ -736,6 +749,21 @@ export default class GMApi extends GM_Base { return state.registration; } + static _GM_audioAddStateChangeListener(a: GMApi, listener: GMTypes.AudioStateChangeListener): Promise { + if (typeof listener !== "function") { + return Promise.reject("GM_audio.addStateChangeListener: Invalid argument"); + } + + const state = (a.audioStateChange ??= { listeners: new Set(), generation: 0 }); + if (state.listeners.has(listener)) { + return state.registration ?? Promise.resolve(); + } + state.listeners.add(listener); + if (state.registration) return state.registration; + + return GMApi._GM_audioConnect(a, state); + } + static _GM_audioRemoveStateChangeListener(a: GMApi, listener: GMTypes.AudioStateChangeListener): Promise { const state = a.audioStateChange; state?.listeners.delete(listener); @@ -753,16 +781,18 @@ export default class GMApi extends GM_Base { @GMContext.API({ follow: "GM_audio" }) public "GM_audio.setMute"(details: GMTypes.AudioMuteDetails, callback?: GMTypes.AudioErrorCallback): void { - void this.sendMessage("GM_audio", ["setMute", details]) - .then(() => callback?.()) - .catch((error) => callback?.(_GM_audioError(error))); + void this.sendMessage("GM_audio", ["setMute", details]).then( + () => _GM_audioSafeInvoke(callback), + (error) => _GM_audioSafeInvoke(callback, _GM_audioError(error)) + ); } @GMContext.API({ follow: "GM_audio" }) public "GM_audio.getState"(callback: GMTypes.AudioStateCallback): void { - void this.sendMessage("GM_audio", ["getState"]) - .then((state: GMTypes.AudioState) => callback(state)) - .catch(() => callback(undefined)); + void this.sendMessage("GM_audio", ["getState"]).then( + (state: GMTypes.AudioState) => _GM_audioSafeInvoke(callback, state), + () => _GM_audioSafeInvoke(callback, undefined) + ); } @GMContext.API({ follow: "GM_audio" }) @@ -770,9 +800,10 @@ export default class GMApi extends GM_Base { listener: GMTypes.AudioStateChangeListener, callback?: GMTypes.AudioErrorCallback ): void { - void _GM_audioAddStateChangeListener(this, listener) - .then(() => callback?.()) - .catch((error) => callback?.(_GM_audioError(error))); + void _GM_audioAddStateChangeListener(this, listener).then( + () => _GM_audioSafeInvoke(callback), + (error) => _GM_audioSafeInvoke(callback, _GM_audioError(error)) + ); } @GMContext.API({ follow: "GM_audio" }) @@ -1771,6 +1802,7 @@ const { _GM_download, _GM_notification, _GM_audioError, + _GM_audioSafeInvoke, _GM_audioAddStateChangeListener, _GM_audioRemoveStateChangeListener, } = GMApi; diff --git a/src/app/service/content/gm_api/gm_audio.test.ts b/src/app/service/content/gm_api/gm_audio.test.ts index 63f798c4f..dff478231 100644 --- a/src/app/service/content/gm_api/gm_audio.test.ts +++ b/src/app/service/content/gm_api/gm_audio.test.ts @@ -207,4 +207,117 @@ describe("GM_audio 状态变化监听", () => { await failedRegistration; expect(failure).toHaveBeenCalledWith("注册失败"); }); + + it("service worker 意外断线(非本端主动断开)时应保留监听器并自动重连,而非静默丢弃", async () => { + const connections = [ + new MockMessageConnect(new EventEmitter()), + new MockMessageConnect(new EventEmitter()), + ]; + const connect = vi.fn().mockResolvedValueOnce(connections[0]).mockResolvedValueOnce(connections[1]); + const message = { connect } as unknown as Message; + const api = new GMApi("serviceWorker", message, message, { + uuid: "gm-audio-test", + value: {}, + } as ScriptRunResource); + const listener = vi.fn(); + + const registration = api["GM.audio.addStateChangeListener"](listener); + await Promise.resolve(); + connections[0].sendMessage({ action: "registered" }); + await registration; + expect(connect).toHaveBeenCalledTimes(1); + + // service worker 进入 idle 被 MV3 终止,端口在未经本端 disconnect() 调用的情况下断开 + connections[0].EE!.emit("disconnect", false); + // 监听器不应被清空——脚本从未调用 removeStateChangeListener + await Promise.resolve(); + await Promise.resolve(); + expect(connect).toHaveBeenCalledTimes(2); + + connections[1].sendMessage({ action: "registered" }); + await Promise.resolve(); + connections[1].sendMessage({ action: "stateChange", data: { audible: true } }); + expect(listener).toHaveBeenCalledWith({ audible: true }); + + await api["GM.audio.removeStateChangeListener"](listener); + }); + + it("本端主动 disconnect(如 removeStateChangeListener)不应触发自动重连", async () => { + const { api, connect, connection } = createAudioApi(); + const listener = vi.fn(); + + const registration = api["GM.audio.addStateChangeListener"](listener); + await Promise.resolve(); + connection.sendMessage({ action: "registered" }); + await registration; + expect(connect).toHaveBeenCalledTimes(1); + + await api["GM.audio.removeStateChangeListener"](listener); + await Promise.resolve(); + expect(connect).toHaveBeenCalledTimes(1); + }); +}); + +describe("GM_audio 回调不应在成功回调抛出异常后被重复调用", () => { + it("GM_audio.setMute 的回调抛出异常时只应被调用一次", async () => { + const { api } = createAudioApi(); + const callback = vi.fn(() => { + throw new Error("callback boom"); + }); + + api["GM_audio.setMute"]({ isMuted: true }, callback); + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it("GM_audio.getState 的回调抛出异常时不应以 undefined 重新调用", async () => { + const { api } = createAudioApi(); + const callback = vi.fn(() => { + throw new Error("callback boom"); + }); + + api["GM_audio.getState"](callback); + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it("GM_audio.addStateChangeListener 的注册回调抛出异常时只应被调用一次", async () => { + const { api, connection } = createAudioApi(); + const callback = vi.fn(() => { + throw new Error("callback boom"); + }); + + api["GM_audio.addStateChangeListener"](vi.fn(), callback); + await Promise.resolve(); + connection.sendMessage({ action: "registered" }); + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(callback).toHaveBeenCalledTimes(1); + }); +}); + +describe("GM_audio 状态变化监听器互相隔离", () => { + it("一个监听器抛出异常不应阻止其余监听器接收状态变化", async () => { + const { api, connection } = createAudioApi(); + const first = vi.fn(() => { + throw new Error("first listener boom"); + }); + const second = vi.fn(); + + const firstRegistration = api["GM.audio.addStateChangeListener"](first); + await Promise.resolve(); + connection.sendMessage({ action: "registered" }); + await firstRegistration; + await api["GM.audio.addStateChangeListener"](second); + + connection.sendMessage({ action: "stateChange", data: { audible: true } }); + + expect(first).toHaveBeenCalledWith({ audible: true }); + expect(second).toHaveBeenCalledWith({ audible: true }); + }); }); From 7c6d7e82dbe02e5e6f26597bd9796ba5dd15c407 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 11 Jul 2026 19:59:02 +0900 Subject: [PATCH 06/15] fix --- src/app/service/content/gm_api/gm_api.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index 409949b1f..2fb58abf1 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -730,7 +730,7 @@ export default class GMApi extends GM_Base { reconnect.catch(() => {}); return; } - state.generation++; + state.generation = state.generation > 1e9 ? 1 : state.generation + 1; state.listeners.clear(); if (!registered) reject("GM_audio.addStateChangeListener: Connection disconnected"); }); @@ -739,7 +739,7 @@ export default class GMApi extends GM_Base { state.registration = registration.catch((error) => { if (isCurrentAttempt()) { state.connection?.disconnect(true); - state.generation++; + state.generation = state.generation > 1e9 ? 1 : state.generation + 1; state.connection = undefined; state.registration = undefined; state.listeners.clear(); @@ -771,7 +771,7 @@ export default class GMApi extends GM_Base { if (state) { const connection = state.connection; - state.generation++; + state.generation = state.generation > 1e9 ? 1 : state.generation + 1; state.connection = undefined; state.registration = undefined; connection?.disconnect(true); From c93b379d1b87979802f1b9c95ac47f8f908c7db1 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 11 Jul 2026 20:29:58 +0900 Subject: [PATCH 07/15] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E5=A4=8D=20GM=5F?= =?UTF-8?q?audio=20=E6=81=A2=E5=A4=8D=E9=87=8D=E8=BF=9E=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E6=97=B6=E7=9B=91=E5=90=AC=E5=99=A8=E8=A2=AB=E9=9D=99=E9=BB=98?= =?UTF-8?q?=E6=B8=85=E7=A9=BA=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _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 --- src/app/service/content/gm_api/gm_api.ts | 43 ++++++++++++-- .../service/content/gm_api/gm_audio.test.ts | 58 +++++++++++++++++++ 2 files changed, 97 insertions(+), 4 deletions(-) diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index 2fb58abf1..6eb42902c 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -55,6 +55,10 @@ export interface GMRequestHandle { const integrity = {}; // 仅防止非法实例化 +// GM_audio 恢复重连的退避延迟基准值与上限(毫秒) +const GM_AUDIO_RETRY_BASE_DELAY = 250; +const GM_AUDIO_RETRY_MAX_DELAY = 8000; + let valChangeCounterId = 0; let valChangeRandomId = `${randNum(8e11, 2e12).toString(36)}`; @@ -223,6 +227,12 @@ export default class GMApi extends GM_Base { // 每次“最后一个监听器被移除”或 context 失效时递增, // 用于识别并丢弃在此之后才resolve的过期 connect() 请求,避免其连接泄漏 generation: number; + // 是否曾经成功收到过 "registered":一旦为 true,后续任何连接尝试(包括恢复期间 + // 尚未收到 registered 就又断线的重连尝试)都视为暂时性故障并保留监听器重试, + // 而非当作首次注册失败而放弃 + everRegistered?: boolean; + // 恢复重连的退避延迟(毫秒),每次收到 registered 后重置,每次退避重试后倍增并封顶 + retryDelay?: number; }; constructor( @@ -705,6 +715,8 @@ export default class GMApi extends GM_Base { } if (message.action === "registered") { registered = true; + state.everRegistered = true; + state.retryDelay = undefined; resolve(); return; } @@ -722,12 +734,18 @@ export default class GMApi extends GM_Base { if (state.connection !== connection) return; state.connection = undefined; state.registration = undefined; - if (registered && !isSelfDisconnected && a.audioStateChange === state && state.listeners.size) { - // service worker 意外终止(如 MV3 闲置回收)导致端口断开,而非脚本主动移除监听器, - // 此时应保留监听器并自动重连,而非静默丢弃——重新 connect() 会自动唤醒 service worker - const reconnect = GMApi._GM_audioConnect(a, state); + // 曾经成功收到过 registered(无论是本次连接还是更早的连接)时,此后任何非本端 + // 主动触发的断线——包括恢复期间尚未收到 registered 就又断线的重连尝试——都应视为 + // 暂时性故障并保留监听器重试,而非当作注册失败而放弃 + if (!isSelfDisconnected && isCurrentAttempt() && state.everRegistered && state.listeners.size) { + const reconnect = registered + ? // 已确认过的连接意外断线:立即重连,重新 connect() 本身即可唤醒被回收的 service worker + GMApi._GM_audioConnect(a, state) + : // 恢复期间的重连尝试在收到 registered 前又断线:退避后重试,避免持续故障时的忙等 + GMApi._GM_audioScheduleReconnect(a, state); state.registration = reconnect; reconnect.catch(() => {}); + resolve(); return; } state.generation = state.generation > 1e9 ? 1 : state.generation + 1; @@ -749,6 +767,23 @@ export default class GMApi extends GM_Base { return state.registration; } + // 恢复期间的重连尝试在收到 registered 前又断线时,退避一段时间后重试,直至重连成功、 + // 监听器被显式移除(listeners 归零)或 context 失效(audioStateChange 被替换) + static _GM_audioScheduleReconnect(a: GMApi, state: NonNullable): Promise { + const generation = state.generation; + const delay = state.retryDelay || GM_AUDIO_RETRY_BASE_DELAY; + state.retryDelay = Math.min(delay * 2, GM_AUDIO_RETRY_MAX_DELAY); + return new Promise((resolve, reject) => { + setTimeout(() => { + if (a.audioStateChange !== state || state.generation !== generation || !state.listeners.size) { + resolve(); + return; + } + GMApi._GM_audioConnect(a, state).then(resolve, reject); + }, delay); + }); + } + static _GM_audioAddStateChangeListener(a: GMApi, listener: GMTypes.AudioStateChangeListener): Promise { if (typeof listener !== "function") { return Promise.reject("GM_audio.addStateChangeListener: Invalid argument"); diff --git a/src/app/service/content/gm_api/gm_audio.test.ts b/src/app/service/content/gm_api/gm_audio.test.ts index dff478231..541af2f33 100644 --- a/src/app/service/content/gm_api/gm_audio.test.ts +++ b/src/app/service/content/gm_api/gm_audio.test.ts @@ -242,6 +242,64 @@ describe("GM_audio 状态变化监听", () => { await api["GM.audio.removeStateChangeListener"](listener); }); + it("恢复期间的重连尝试在收到 registered 前又断线时,应保留监听器并退避重试,而非放弃", async () => { + vi.useFakeTimers(); + try { + const connections = [ + new MockMessageConnect(new EventEmitter()), + new MockMessageConnect(new EventEmitter()), + new MockMessageConnect(new EventEmitter()), + ]; + const connect = vi + .fn() + .mockResolvedValueOnce(connections[0]) + .mockResolvedValueOnce(connections[1]) + .mockResolvedValueOnce(connections[2]); + const message = { connect } as unknown as Message; + const api = new GMApi("serviceWorker", message, message, { + uuid: "gm-audio-test", + value: {}, + } as ScriptRunResource); + const listener = vi.fn(); + + // 连接 1:初始注册成功 + const registration = api["GM.audio.addStateChangeListener"](listener); + await Promise.resolve(); + connections[0].sendMessage({ action: "registered" }); + await registration; + expect(connect).toHaveBeenCalledTimes(1); + + // 连接 1 意外断线(如 service worker 闲置回收),应立即发起连接 2 + connections[0].EE!.emit("disconnect", false); + await Promise.resolve(); + await Promise.resolve(); + expect(connect).toHaveBeenCalledTimes(2); + + // 连接 2 是恢复期间的重连尝试,在收到 registered 前又断线 + connections[1].EE!.emit("disconnect", false); + await Promise.resolve(); + await Promise.resolve(); + // 曾经成功注册过,监听器不应被清空,也不应停止重试 + expect(listener).not.toHaveBeenCalled(); + + // 退避期间不应立即重连 + expect(connect).toHaveBeenCalledTimes(2); + + // 退避到期后应发起连接 3 + await vi.advanceTimersByTimeAsync(1000); + expect(connect).toHaveBeenCalledTimes(3); + + connections[2].sendMessage({ action: "registered" }); + await Promise.resolve(); + connections[2].sendMessage({ action: "stateChange", data: { audible: true } }); + expect(listener).toHaveBeenCalledWith({ audible: true }); + + await api["GM.audio.removeStateChangeListener"](listener); + } finally { + vi.useRealTimers(); + } + }); + it("本端主动 disconnect(如 removeStateChangeListener)不应触发自动重连", async () => { const { api, connect, connection } = createAudioApi(); const listener = vi.fn(); From 49ad700116400ec1808621fa10e742b0278ed0d3 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 11 Jul 2026 20:42:46 +0900 Subject: [PATCH 08/15] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E5=A4=8D=20GM=5F?= =?UTF-8?q?audio=20everRegistered=20=E8=B7=A8=E6=B3=A8=E5=86=8C=E5=91=A8?= =?UTF-8?q?=E6=9C=9F=E6=AE=8B=E7=95=99=E4=B8=8E=E6=81=A2=E5=A4=8D=E6=9C=9F?= =?UTF-8?q?=E9=97=B4=E6=96=B0=E7=9B=91=E5=90=AC=E5=99=A8=E8=A2=AB=E6=8F=90?= =?UTF-8?q?=E5=89=8D=20resolve=20=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/app/service/content/gm_api/gm_api.ts | 9 +- .../service/content/gm_api/gm_audio.test.ts | 102 ++++++++++++++++++ 2 files changed, 109 insertions(+), 2 deletions(-) diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index 6eb42902c..3eafbd824 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -744,8 +744,10 @@ export default class GMApi extends GM_Base { : // 恢复期间的重连尝试在收到 registered 前又断线:退避后重试,避免持续故障时的忙等 GMApi._GM_audioScheduleReconnect(a, state); state.registration = reconnect; - reconnect.catch(() => {}); - resolve(); + // 若本次尝试已 registered,本 Promise 早已 resolve,链接到 reconnect 只是无操作; + // 若本次尝试尚未 registered(恢复期间的重连尝试又失败),则应等待替补连接的最终结果, + // 而不是在毫无连接、尚未收到 registered 的情况下就让调用方以为注册已经成功 + void reconnect.then(resolve, reject); return; } state.generation = state.generation > 1e9 ? 1 : state.generation + 1; @@ -809,6 +811,9 @@ export default class GMApi extends GM_Base { state.generation = state.generation > 1e9 ? 1 : state.generation + 1; state.connection = undefined; state.registration = undefined; + // 监听器归零:彻底丢弃 state,开启全新的注册生命周期,避免 everRegistered / retryDelay + // 残留到下一次 addStateChangeListener,导致全新的注册被误判为“恢复期间的重连” + a.audioStateChange = undefined; connection?.disconnect(true); } return Promise.resolve(); diff --git a/src/app/service/content/gm_api/gm_audio.test.ts b/src/app/service/content/gm_api/gm_audio.test.ts index 541af2f33..ea7cbd577 100644 --- a/src/app/service/content/gm_api/gm_audio.test.ts +++ b/src/app/service/content/gm_api/gm_audio.test.ts @@ -314,6 +314,108 @@ describe("GM_audio 状态变化监听", () => { await Promise.resolve(); expect(connect).toHaveBeenCalledTimes(1); }); + + it("完全移除监听器后重新添加,应开启全新的注册生命周期,而非继承旧的 everRegistered", async () => { + vi.useFakeTimers(); + try { + const connections = [ + new MockMessageConnect(new EventEmitter()), + new MockMessageConnect(new EventEmitter()), + ]; + const connect = vi.fn().mockResolvedValueOnce(connections[0]).mockResolvedValueOnce(connections[1]); + const message = { connect } as unknown as Message; + const api = new GMApi("serviceWorker", message, message, { + uuid: "gm-audio-test", + value: {}, + } as ScriptRunResource); + const listenerA = vi.fn(); + const listenerB = vi.fn(); + + // 监听器 A:注册成功 + const registrationA = api["GM.audio.addStateChangeListener"](listenerA); + await Promise.resolve(); + connections[0].sendMessage({ action: "registered" }); + await registrationA; + + // 移除 A(listeners 归零),随后添加全新的监听器 B + await api["GM.audio.removeStateChangeListener"](listenerA); + const registrationB = api["GM.audio.addStateChangeListener"](listenerB); + await Promise.resolve(); + + // B 的连接在收到 registered 之前就断线:这是一次全新的、从未成功过的注册, + // 应按“首次注册失败”处理并 reject,而不是被误判为“恢复期间的重连”而重试 + // (若被误判为恢复重连,会改为退避重试,本测试使用 fake timers 且不推进, + // 此时 registrationB 永远不会 settle,会以超时失败,同样能暴露该缺陷) + connections[1].EE!.emit("disconnect", false); + + await expect(registrationB).rejects.toBe("GM_audio.addStateChangeListener: Connection disconnected"); + expect(connect).toHaveBeenCalledTimes(2); + } finally { + vi.useRealTimers(); + } + }); + + it("恢复期间新增的监听器不应在替补连接尚未注册成功前就提前收到成功", async () => { + vi.useFakeTimers(); + try { + const connections = [ + new MockMessageConnect(new EventEmitter()), + new MockMessageConnect(new EventEmitter()), + new MockMessageConnect(new EventEmitter()), + ]; + const connect = vi + .fn() + .mockResolvedValueOnce(connections[0]) + .mockResolvedValueOnce(connections[1]) + .mockResolvedValueOnce(connections[2]); + const message = { connect } as unknown as Message; + const api = new GMApi("serviceWorker", message, message, { + uuid: "gm-audio-test", + value: {}, + } as ScriptRunResource); + const first = vi.fn(); + const second = vi.fn(); + + // 连接 1:初始注册成功 + const firstRegistration = api["GM.audio.addStateChangeListener"](first); + await Promise.resolve(); + connections[0].sendMessage({ action: "registered" }); + await firstRegistration; + + // 连接 1 意外断线,立即发起连接 2(恢复期间的重连尝试) + connections[0].EE!.emit("disconnect", false); + await Promise.resolve(); + await Promise.resolve(); + expect(connect).toHaveBeenCalledTimes(2); + + // 在连接 2 仍处于“已发起但尚未 registered”期间,添加监听器 second, + // 它应复用同一个仍在进行中的 state.registration + const secondRegistration = api["GM.audio.addStateChangeListener"](second); + let secondSettled = false; + void secondRegistration.then(() => { + secondSettled = true; + }); + + // 连接 2 在收到 registered 之前又断线:此时不应让 secondRegistration 提前 resolve, + // 因为既没有活跃连接,也没有收到过 registered 确认 + connections[1].EE!.emit("disconnect", false); + await Promise.resolve(); + await Promise.resolve(); + expect(secondSettled).toBe(false); + + // 退避到期、发起连接 3 之前,仍不应 resolve + await vi.advanceTimersByTimeAsync(1000); + expect(connect).toHaveBeenCalledTimes(3); + expect(secondSettled).toBe(false); + + // 直到连接 3(下一次重连)真正收到 registered,secondRegistration 才应 resolve + connections[2].sendMessage({ action: "registered" }); + await secondRegistration; + expect(secondSettled).toBe(true); + } finally { + vi.useRealTimers(); + } + }); }); describe("GM_audio 回调不应在成功回调抛出异常后被重复调用", () => { From 0ce1049359f50b0b3388a0ec8cff0072e34fcb04 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:02:56 +0900 Subject: [PATCH 09/15] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E5=A4=8D=20GM=5F?= =?UTF-8?q?audio=20=E6=81=A2=E5=A4=8D=E9=87=8D=E8=BF=9E=E4=B8=AD=20everReg?= =?UTF-8?q?istered=20=E6=AE=8B=E7=95=99=E4=B8=8E=20Promise=20=E9=93=BE?= =?UTF-8?q?=E6=97=A0=E7=95=8C=E5=A2=9E=E9=95=BF=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/app/service/content/gm_api/gm_api.ts | 181 ++++++++++-------- .../service/content/gm_api/gm_audio.test.ts | 135 +++++++++++++ 2 files changed, 235 insertions(+), 81 deletions(-) diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index 3eafbd824..12404d191 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -689,101 +689,120 @@ export default class GMApi extends GM_Base { } } - // 建立(或在 service worker 意外断线后重新建立)GM_audio 状态变化连接。 - // 由 _GM_audioAddStateChangeListener 首次调用,也由断线重连逻辑自行递归调用。 + // 建立(或在 service worker 意外断线后重新建立)GM_audio 状态变化连接,代表一轮"注册 episode"。 + // 由 _GM_audioAddStateChangeListener 首次调用;一个已成功 registered 的连接意外断线后, + // 也会递归调用本函数开启新的一轮 episode(此时旧 episode 早已 resolve,新 episode 让此后 + // 新增的监听器能感知"当前并非已连接"而非拿到一个陈旧的、早已成功的 Promise)。 + // 而"同一轮 episode 内、尚未收到 registered 就又断线"的重试则完全在本函数的闭包内以 + // setTimeout 迭代进行,共享同一个 deferred,不会像每次重试都新建 Promise 并递归 then() + // 链接那样、在长期故障时不断堆叠、无法被回收。 static _GM_audioConnect(a: GMApi, state: NonNullable): Promise { - // 记录本次尝试所属的世代:若在 connect() 完成前监听器被清空(remove 归零 / context 失效), + // 记录本轮 episode 所属的世代:若在其间监听器被清空(remove 归零 / context 失效), // 世代会被递增或 audioStateChange 整体被替换,届时应丢弃这个迟到的连接,避免连接泄漏 const generation = state.generation; const isCurrentAttempt = () => a.audioStateChange === state && state.generation === generation; - const registration = a.connect("GM_audio", ["addStateChangeListener"]).then( - (connection) => - new Promise((resolve, reject) => { - if (!isCurrentAttempt() || !state.listeners.size) { - connection.disconnect(true); - resolve(); - return; - } - - let registered = false; - state.connection = connection; - connection.onMessage((message) => { - if (message.code) { - reject(message.message); - return; - } - if (message.action === "registered") { - registered = true; - state.everRegistered = true; - state.retryDelay = undefined; - resolve(); - return; - } - if (message.action === "stateChange") { - for (const stateListener of state.listeners) { - try { - stateListener(message.data as GMTypes.AudioStateChangeInfo); - } catch (error) { - console.error(error); - } - } - } - }); - connection.onDisconnect((isSelfDisconnected) => { - if (state.connection !== connection) return; - state.connection = undefined; - state.registration = undefined; - // 曾经成功收到过 registered(无论是本次连接还是更早的连接)时,此后任何非本端 - // 主动触发的断线——包括恢复期间尚未收到 registered 就又断线的重连尝试——都应视为 - // 暂时性故障并保留监听器重试,而非当作注册失败而放弃 - if (!isSelfDisconnected && isCurrentAttempt() && state.everRegistered && state.listeners.size) { - const reconnect = registered - ? // 已确认过的连接意外断线:立即重连,重新 connect() 本身即可唤醒被回收的 service worker - GMApi._GM_audioConnect(a, state) - : // 恢复期间的重连尝试在收到 registered 前又断线:退避后重试,避免持续故障时的忙等 - GMApi._GM_audioScheduleReconnect(a, state); - state.registration = reconnect; - // 若本次尝试已 registered,本 Promise 早已 resolve,链接到 reconnect 只是无操作; - // 若本次尝试尚未 registered(恢复期间的重连尝试又失败),则应等待替补连接的最终结果, - // 而不是在毫无连接、尚未收到 registered 的情况下就让调用方以为注册已经成功 - void reconnect.then(resolve, reject); - return; - } - state.generation = state.generation > 1e9 ? 1 : state.generation + 1; - state.listeners.clear(); - if (!registered) reject("GM_audio.addStateChangeListener: Connection disconnected"); - }); - }) - ); - state.registration = registration.catch((error) => { - if (isCurrentAttempt()) { - state.connection?.disconnect(true); + let resolveEpisode!: () => void; + let rejectEpisode!: (error: unknown) => void; + const episode = new Promise((resolve, reject) => { + resolveEpisode = resolve; + rejectEpisode = reject; + }); + state.registration = episode; + + // 彻底放弃:丢弃整个 state(开启全新生命周期,避免 everRegistered / retryDelay 残留到 + // 下一次 addStateChangeListener,导致全新的注册被误判为"恢复期间的重连"),并以给定原因 + // 结束本轮 episode。 + // 注意:必须先 rejectEpisode() 锁定结果,再 disconnect() ——disconnect() 会同步重入触发 + // 自身的 onDisconnect(先置空 state.connection 令其提前 return),否则重入的 giveUp() 会 + // 抢先以另一个原因结算本已确定的 episode + const giveUp = (error: unknown) => { + const wasCurrentAttempt = isCurrentAttempt(); + rejectEpisode(error); + if (wasCurrentAttempt) { + const connection = state.connection; state.generation = state.generation > 1e9 ? 1 : state.generation + 1; state.connection = undefined; state.registration = undefined; state.listeners.clear(); + a.audioStateChange = undefined; + connection?.disconnect(true); } - throw error; - }); - return state.registration; - } + }; - // 恢复期间的重连尝试在收到 registered 前又断线时,退避一段时间后重试,直至重连成功、 - // 监听器被显式移除(listeners 归零)或 context 失效(audioStateChange 被替换) - static _GM_audioScheduleReconnect(a: GMApi, state: NonNullable): Promise { - const generation = state.generation; - const delay = state.retryDelay || GM_AUDIO_RETRY_BASE_DELAY; - state.retryDelay = Math.min(delay * 2, GM_AUDIO_RETRY_MAX_DELAY); - return new Promise((resolve, reject) => { + const scheduleRetry = () => { + const delay = state.retryDelay || GM_AUDIO_RETRY_BASE_DELAY; + state.retryDelay = Math.min(delay * 2, GM_AUDIO_RETRY_MAX_DELAY); setTimeout(() => { - if (a.audioStateChange !== state || state.generation !== generation || !state.listeners.size) { - resolve(); + if (!isCurrentAttempt() || !state.listeners.size) { + resolveEpisode(); return; } - GMApi._GM_audioConnect(a, state).then(resolve, reject); + attemptOnce(); }, delay); - }); + }; + + const attemptOnce = () => { + if (!isCurrentAttempt() || !state.listeners.size) { + resolveEpisode(); + return; + } + a.connect("GM_audio", ["addStateChangeListener"]).then((connection) => { + if (!isCurrentAttempt() || !state.listeners.size) { + connection.disconnect(true); + resolveEpisode(); + return; + } + + let registered = false; + state.connection = connection; + connection.onMessage((message) => { + if (message.code) { + giveUp(message.message); + return; + } + if (message.action === "registered") { + registered = true; + state.everRegistered = true; + state.retryDelay = undefined; + resolveEpisode(); + return; + } + if (message.action === "stateChange") { + for (const stateListener of state.listeners) { + try { + stateListener(message.data as GMTypes.AudioStateChangeInfo); + } catch (error) { + console.error(error); + } + } + } + }); + connection.onDisconnect((isSelfDisconnected) => { + if (state.connection !== connection) return; + state.connection = undefined; + // 曾经成功收到过 registered(无论是本次连接还是更早的连接)时,此后任何非本端 + // 主动触发的断线——包括恢复期间尚未收到 registered 就又断线的重连尝试——都应视为 + // 暂时性故障并保留监听器重试,而非当作注册失败而放弃 + if (!isSelfDisconnected && isCurrentAttempt() && state.everRegistered && state.listeners.size) { + if (registered) { + // 已确认过的连接意外断线:本轮 episode 早已 resolve,开启新的一轮 + // episode——重新 connect() 本身即可唤醒被回收的 service worker + GMApi._GM_audioConnect(a, state).catch(() => {}); + } else { + // 本轮 episode 内的重连尝试在收到 registered 前又断线:退避后在同一轮 + // episode 内重试,避免持续故障时的忙等,也避免每次重试都新建 Promise + scheduleRetry(); + } + return; + } + giveUp(registered ? undefined : "GM_audio.addStateChangeListener: Connection disconnected"); + }); + }, giveUp); + }; + + attemptOnce(); + return episode; } static _GM_audioAddStateChangeListener(a: GMApi, listener: GMTypes.AudioStateChangeListener): Promise { @@ -848,7 +867,7 @@ export default class GMApi extends GM_Base { @GMContext.API({ follow: "GM_audio" }) public "GM_audio.removeStateChangeListener"(listener: GMTypes.AudioStateChangeListener, callback?: () => void): void { - void _GM_audioRemoveStateChangeListener(this, listener).then(() => callback?.()); + void _GM_audioRemoveStateChangeListener(this, listener).then(() => _GM_audioSafeInvoke(callback)); } @GMContext.API({ follow: "GM.audio" }) diff --git a/src/app/service/content/gm_api/gm_audio.test.ts b/src/app/service/content/gm_api/gm_audio.test.ts index ea7cbd577..ad6ef868b 100644 --- a/src/app/service/content/gm_api/gm_audio.test.ts +++ b/src/app/service/content/gm_api/gm_audio.test.ts @@ -416,6 +416,122 @@ describe("GM_audio 状态变化监听", () => { vi.useRealTimers(); } }); + + it("恢复期间收到终止性错误(code)后,也应丢弃 state;此后新增的监听器不应继承旧的 everRegistered", async () => { + const connections = [ + new MockMessageConnect(new EventEmitter()), + new MockMessageConnect(new EventEmitter()), + new MockMessageConnect(new EventEmitter()), + ]; + const connect = vi + .fn() + .mockResolvedValueOnce(connections[0]) + .mockResolvedValueOnce(connections[1]) + .mockResolvedValueOnce(connections[2]); + const message = { connect } as unknown as Message; + const api = new GMApi("serviceWorker", message, message, { + uuid: "gm-audio-test", + value: {}, + } as ScriptRunResource); + const listenerA = vi.fn(); + const listenerB = vi.fn(); + + // 监听器 A:注册成功 + const registrationA = api["GM.audio.addStateChangeListener"](listenerA); + await Promise.resolve(); + connections[0].sendMessage({ action: "registered" }); + await registrationA; + + // 连接 1 意外断线,进入恢复期间,立即发起连接 2 + connections[0].EE!.emit("disconnect", false); + await Promise.resolve(); + await Promise.resolve(); + expect(connect).toHaveBeenCalledTimes(2); + + // 连接 2(恢复期间)在收到 registered 前收到终止性错误(如权限被收回),应彻底放弃, + // 而不是重试;且应像其他放弃路径一样丢弃 state,而不是让 everRegistered 残留下来 + connections[1].sendMessage({ code: -1, message: "恢复失败" }); + await Promise.resolve(); + // 此时不应再有监听器存活,A 从未被显式移除,但恢复失败后其注册已被放弃 + expect(connect).toHaveBeenCalledTimes(2); + + // 添加全新的监听器 B:这是一次全新的、从未成功过的注册 + const registrationB = api["GM.audio.addStateChangeListener"](listenerB); + await Promise.resolve(); + expect(connect).toHaveBeenCalledTimes(3); + + // B 的连接在收到 registered 前断线:应按“首次注册失败”处理并 reject, + // 而不是被残留的 everRegistered 误判为“恢复期间的重连”而重试 + connections[2].EE!.emit("disconnect", false); + await expect(registrationB).rejects.toBe("GM_audio.addStateChangeListener: Connection disconnected"); + expect(connect).toHaveBeenCalledTimes(3); + }); + + it("同一轮恢复 episode 内的多次重试应共享同一个 state.registration,直至成功或彻底放弃才结算", async () => { + vi.useFakeTimers(); + try { + const connections = [ + new MockMessageConnect(new EventEmitter()), + new MockMessageConnect(new EventEmitter()), + new MockMessageConnect(new EventEmitter()), + new MockMessageConnect(new EventEmitter()), + ]; + const connect = vi + .fn() + .mockResolvedValueOnce(connections[0]) + .mockResolvedValueOnce(connections[1]) + .mockResolvedValueOnce(connections[2]) + .mockResolvedValueOnce(connections[3]); + const message = { connect } as unknown as Message; + const api = new GMApi("serviceWorker", message, message, { + uuid: "gm-audio-test", + value: {}, + } as ScriptRunResource); + const listener = vi.fn(); + + // 连接 1:初始注册成功 + const registration = api["GM.audio.addStateChangeListener"](listener); + await Promise.resolve(); + connections[0].sendMessage({ action: "registered" }); + await registration; + + // 连接 1 意外断线,立即发起连接 2(开启新一轮 episode) + connections[0].EE!.emit("disconnect", false); + await Promise.resolve(); + await Promise.resolve(); + // 已注册过的 listener 复用 state.registration,借此取得本轮 episode 的 Promise 身份 + const episodeRegistration = api["GM.audio.addStateChangeListener"](listener); + let settled = false; + void episodeRegistration.then(() => { + settled = true; + }); + + // 连接 2、连接 3 均在收到 registered 前又断线:属于同一轮 episode 内的重试, + // state.registration 应保持同一身份,不应被替换成新的 Promise + connections[1].EE!.emit("disconnect", false); + await Promise.resolve(); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(1000); + expect(connect).toHaveBeenCalledTimes(3); + expect(api["GM.audio.addStateChangeListener"](listener)).toBe(episodeRegistration); + expect(settled).toBe(false); + + connections[2].EE!.emit("disconnect", false); + await Promise.resolve(); + await Promise.resolve(); + await vi.advanceTimersByTimeAsync(1000); + expect(connect).toHaveBeenCalledTimes(4); + expect(api["GM.audio.addStateChangeListener"](listener)).toBe(episodeRegistration); + expect(settled).toBe(false); + + // 连接 4 真正收到 registered,本轮 episode 才结算——且结算的正是最初取得的那个 Promise + connections[3].sendMessage({ action: "registered" }); + await episodeRegistration; + expect(settled).toBe(true); + } finally { + vi.useRealTimers(); + } + }); }); describe("GM_audio 回调不应在成功回调抛出异常后被重复调用", () => { @@ -459,6 +575,25 @@ describe("GM_audio 回调不应在成功回调抛出异常后被重复调用", ( expect(callback).toHaveBeenCalledTimes(1); }); + + it("GM_audio.removeStateChangeListener 的回调抛出异常时只应被调用一次", async () => { + const { api, connection } = createAudioApi(); + const listener = vi.fn(); + const callback = vi.fn(() => { + throw new Error("callback boom"); + }); + + const registration = api["GM.audio.addStateChangeListener"](listener); + await Promise.resolve(); + connection.sendMessage({ action: "registered" }); + await registration; + + api["GM_audio.removeStateChangeListener"](listener, callback); + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(callback).toHaveBeenCalledTimes(1); + }); }); describe("GM_audio 状态变化监听器互相隔离", () => { From da4e6ff1830fe771341d87019b9b05eb0ad7cbaa Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:38:09 +0900 Subject: [PATCH 10/15] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E5=A4=8D=20GM=5F?= =?UTF-8?q?audio=20=E6=B3=A8=E5=86=8C=E5=8F=96=E6=B6=88=E5=90=8E=20Promise?= =?UTF-8?q?=20=E6=82=AC=E6=8C=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gm_api/gm_audio_cancellation.test.ts | 55 ++++++++ src/app/service/content/gm_api/gm_context.ts | 120 +++++++++++++++++- 2 files changed, 173 insertions(+), 2 deletions(-) create mode 100644 src/app/service/content/gm_api/gm_audio_cancellation.test.ts diff --git a/src/app/service/content/gm_api/gm_audio_cancellation.test.ts b/src/app/service/content/gm_api/gm_audio_cancellation.test.ts new file mode 100644 index 000000000..4ada40fd5 --- /dev/null +++ b/src/app/service/content/gm_api/gm_audio_cancellation.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from "vitest"; +import EventEmitter from "eventemitter3"; +import type { ScriptRunResource } from "@App/app/repo/scripts"; +import { MockMessageConnect } from "@Packages/message/mock_message"; +import type { Message, TMessage } from "@Packages/message/types"; +import GMApi from "./gm_api"; + +const createPendingAudioApi = () => { + const connection = new MockMessageConnect(new EventEmitter()); + const connect = vi.fn(async () => connection); + const message = { connect } as unknown as Message; + const api = new GMApi("serviceWorker", message, message, { + uuid: "gm-audio-cancellation-test", + value: {}, + } as ScriptRunResource); + return { api, connect, connection }; +}; + +describe("GM_audio pending registration cancellation", () => { + it("resolves the Promise API when the last listener is removed before registered", async () => { + const { api, connect, connection } = createPendingAudioApi(); + const listener = vi.fn(); + const disconnect = vi.spyOn(connection, "disconnect"); + + const registration = api["GM.audio.addStateChangeListener"](listener); + await Promise.resolve(); + + await api["GM.audio.removeStateChangeListener"](listener); + await expect(registration).resolves.toBeUndefined(); + await Promise.resolve(); + + expect(disconnect).toHaveBeenCalledTimes(1); + expect(connect).toHaveBeenCalledTimes(1); + }); + + it("invokes the callback API once when removed before registered", async () => { + const { api, connect, connection } = createPendingAudioApi(); + const listener = vi.fn(); + const registered = vi.fn(); + const disconnect = vi.spyOn(connection, "disconnect"); + + api["GM_audio.addStateChangeListener"](listener, registered); + await Promise.resolve(); + + await new Promise((resolve) => { + api["GM_audio.removeStateChangeListener"](listener, resolve); + }); + await Promise.resolve(); + + expect(registered).toHaveBeenCalledTimes(1); + expect(registered).toHaveBeenCalledWith(undefined); + expect(disconnect).toHaveBeenCalledTimes(1); + expect(connect).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/service/content/gm_api/gm_context.ts b/src/app/service/content/gm_api/gm_context.ts index 25bfaa109..6b629c55e 100644 --- a/src/app/service/content/gm_api/gm_context.ts +++ b/src/app/service/content/gm_api/gm_context.ts @@ -2,6 +2,120 @@ import type { ApiParam, ApiValue } from "../types"; const apis: Map = new Map(); +type AudioListener = (...args: any[]) => unknown; +type AudioRegistrationCancel = () => void; + +const audioRegistrationCancels = new WeakMap>>(); + +function registerAudioCancellation( + context: object, + listener: AudioListener, + cancel: AudioRegistrationCancel +): void { + let registrations = audioRegistrationCancels.get(context); + if (!registrations) { + registrations = new Map(); + audioRegistrationCancels.set(context, registrations); + } + let cancellations = registrations.get(listener); + if (!cancellations) { + cancellations = new Set(); + registrations.set(listener, cancellations); + } + cancellations.add(cancel); +} + +function unregisterAudioCancellation( + context: object, + listener: AudioListener, + cancel: AudioRegistrationCancel +): void { + const registrations = audioRegistrationCancels.get(context); + const cancellations = registrations?.get(listener); + cancellations?.delete(cancel); + if (!cancellations?.size) registrations?.delete(listener); + if (!registrations?.size) audioRegistrationCancels.delete(context); +} + +function cancelAudioRegistrations(context: object, listener: unknown): void { + if (typeof listener !== "function") return; + const registrations = audioRegistrationCancels.get(context); + const cancellations = registrations?.get(listener as AudioListener); + if (!cancellations) return; + registrations!.delete(listener as AudioListener); + if (!registrations!.size) audioRegistrationCancels.delete(context); + for (const cancel of cancellations) cancel(); +} + +function safeInvoke(callback: unknown, ...args: unknown[]): void { + if (typeof callback !== "function") return; + try { + callback(...args); + } catch (error) { + console.error(error); + } +} + +function wrapAudioRegistrationApi(propertyName: string, api: (...args: any[]) => any): (...args: any[]) => any { + if (propertyName === "GM.audio.addStateChangeListener") { + return function (this: object, listener: unknown, ...args: unknown[]) { + let registration: Promise; + try { + registration = Promise.resolve(api.call(this, listener, ...args)); + } catch (error) { + return Promise.reject(error); + } + if (typeof listener !== "function") return registration; + + let cancel!: AudioRegistrationCancel; + const cancelled = new Promise((resolve) => { + cancel = resolve; + }); + registerAudioCancellation(this, listener as AudioListener, cancel); + return Promise.race([registration, cancelled]).finally(() => { + unregisterAudioCancellation(this, listener as AudioListener, cancel); + }); + }; + } + + if (propertyName === "GM_audio.addStateChangeListener") { + return function (this: object, listener: unknown, callback?: (...args: any[]) => void) { + if (typeof listener !== "function") return api.call(this, listener, callback); + + let settled = false; + let cancel!: AudioRegistrationCancel; + const finish = (error?: unknown) => { + if (settled) return; + settled = true; + unregisterAudioCancellation(this, listener as AudioListener, cancel); + safeInvoke(callback, error); + }; + cancel = () => { + void Promise.resolve().then(() => finish(undefined)); + }; + registerAudioCancellation(this, listener as AudioListener, cancel); + try { + return api.call(this, listener, finish); + } catch (error) { + unregisterAudioCancellation(this, listener as AudioListener, cancel); + throw error; + } + }; + } + + if ( + propertyName === "GM.audio.removeStateChangeListener" || + propertyName === "GM_audio.removeStateChangeListener" + ) { + return function (this: object, listener: unknown, ...args: unknown[]) { + cancelAudioRegistrations(this, listener); + return api.call(this, listener, ...args); + }; + } + + return api; +} + export function GMContextApiGet(name: string): ApiValue[] | undefined { // 回传 Api 列表 return apis.get(name); @@ -28,15 +142,17 @@ export default class GMContext { public static API(param: ApiParam = {}) { return (target: any, propertyName: string, descriptor: PropertyDescriptor) => { const key = propertyName; + const api = wrapAudioRegistrationApi(key, descriptor.value); + descriptor.value = api; let { follow } = param; const { alias } = param; if (!follow) { follow = key; // follow 是实际 @grant 的权限;使用follow时,不要使用alias以避免混乱 } - GMContextApiSet(follow, key, descriptor.value, param); + GMContextApiSet(follow, key, api, param); if (alias) { // 追加别名呼叫(参数和回传完全一致,为 GM_xxx 与 GM.xxx 等问题设计) - GMContextApiSet(alias, alias, descriptor.value, param); + GMContextApiSet(alias, alias, api, param); } }; } From 9929d03df9a26c4f990ce4464d7166ca65e8a167 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:41:35 +0900 Subject: [PATCH 11/15] Update gm_context.ts --- src/app/service/content/gm_api/gm_context.ts | 24 +++++--------------- 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/src/app/service/content/gm_api/gm_context.ts b/src/app/service/content/gm_api/gm_context.ts index 6b629c55e..7f6187cc7 100644 --- a/src/app/service/content/gm_api/gm_context.ts +++ b/src/app/service/content/gm_api/gm_context.ts @@ -7,11 +7,7 @@ type AudioRegistrationCancel = () => void; const audioRegistrationCancels = new WeakMap>>(); -function registerAudioCancellation( - context: object, - listener: AudioListener, - cancel: AudioRegistrationCancel -): void { +function registerAudioCancellation(context: object, listener: AudioListener, cancel: AudioRegistrationCancel): void { let registrations = audioRegistrationCancels.get(context); if (!registrations) { registrations = new Map(); @@ -25,11 +21,7 @@ function registerAudioCancellation( cancellations.add(cancel); } -function unregisterAudioCancellation( - context: object, - listener: AudioListener, - cancel: AudioRegistrationCancel -): void { +function unregisterAudioCancellation(context: object, listener: AudioListener, cancel: AudioRegistrationCancel): void { const registrations = audioRegistrationCancels.get(context); const cancellations = registrations?.get(listener); cancellations?.delete(cancel); @@ -83,16 +75,15 @@ function wrapAudioRegistrationApi(propertyName: string, api: (...args: any[]) => if (typeof listener !== "function") return api.call(this, listener, callback); let settled = false; - let cancel!: AudioRegistrationCancel; + const cancel: AudioRegistrationCancel = () => { + void Promise.resolve().then(() => finish(undefined)); + }; const finish = (error?: unknown) => { if (settled) return; settled = true; unregisterAudioCancellation(this, listener as AudioListener, cancel); safeInvoke(callback, error); }; - cancel = () => { - void Promise.resolve().then(() => finish(undefined)); - }; registerAudioCancellation(this, listener as AudioListener, cancel); try { return api.call(this, listener, finish); @@ -103,10 +94,7 @@ function wrapAudioRegistrationApi(propertyName: string, api: (...args: any[]) => }; } - if ( - propertyName === "GM.audio.removeStateChangeListener" || - propertyName === "GM_audio.removeStateChangeListener" - ) { + if (propertyName === "GM.audio.removeStateChangeListener" || propertyName === "GM_audio.removeStateChangeListener") { return function (this: object, listener: unknown, ...args: unknown[]) { cancelAudioRegistrations(this, listener); return api.call(this, listener, ...args); From ab02e5b79ad5154c7a10fc46edf8fa685683e31c Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 11 Jul 2026 22:30:14 +0900 Subject: [PATCH 12/15] fix --- src/app/service/content/gm_api/gm_api.ts | 17 +++ .../gm_api/gm_audio_cancellation.test.ts | 4 +- src/app/service/content/gm_api/gm_context.ts | 108 +----------------- 3 files changed, 22 insertions(+), 107 deletions(-) diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index 12404d191..285c841ed 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -233,6 +233,11 @@ export default class GMApi extends GM_Base { everRegistered?: boolean; // 恢复重连的退避延迟(毫秒),每次收到 registered 后重置,每次退避重试后倍增并封顶 retryDelay?: number; + // 当前这一轮 episode 的 resolve 函数:监听器归零(显式移除)时,若此时连接已建立但尚未 + // 收到 registered,disconnect() 前会先置空 state.connection,导致 onDisconnect 的守卫 + // 直接 return、无法感知这是"移除触发的断线"而结算 Promise;因此需要在这里直接结算, + // 避免 addStateChangeListener 返回的 Promise/回调永久悬挂 + settleRegistration?: () => void; }; constructor( @@ -709,6 +714,10 @@ export default class GMApi extends GM_Base { rejectEpisode = reject; }); state.registration = episode; + // 暴露给 _GM_audioRemoveStateChangeListener:监听器归零时,即使连接已建立但尚未收到 + // registered,也能直接结算本轮 episode,而不必依赖 onDisconnect 的重入(见下方说明, + // 那条路径在这种情况下会被守卫提前 return,永远不会结算) + state.settleRegistration = resolveEpisode; // 彻底放弃:丢弃整个 state(开启全新生命周期,避免 everRegistered / retryDelay 残留到 // 下一次 addStateChangeListener,导致全新的注册被误判为"恢复期间的重连"),并以给定原因 @@ -724,6 +733,7 @@ export default class GMApi extends GM_Base { state.generation = state.generation > 1e9 ? 1 : state.generation + 1; state.connection = undefined; state.registration = undefined; + state.settleRegistration = undefined; state.listeners.clear(); a.audioStateChange = undefined; connection?.disconnect(true); @@ -827,13 +837,20 @@ export default class GMApi extends GM_Base { if (state) { const connection = state.connection; + const settleRegistration = state.settleRegistration; state.generation = state.generation > 1e9 ? 1 : state.generation + 1; state.connection = undefined; state.registration = undefined; + state.settleRegistration = undefined; // 监听器归零:彻底丢弃 state,开启全新的注册生命周期,避免 everRegistered / retryDelay // 残留到下一次 addStateChangeListener,导致全新的注册被误判为“恢复期间的重连” a.audioStateChange = undefined; connection?.disconnect(true); + // 若此时连接已建立但尚未收到 registered,上面的 disconnect() 不会让 onDisconnect + // 结算 episode(state.connection 已被置空,守卫会直接 return);若连接尚未建立 + // (connect() 仍在进行中),attemptOnce() 的 resolve 也要等到 connect() 完成才会触发。 + // 因此这里直接结算,确保 addStateChangeListener 返回的 Promise/回调不会永久悬挂 + settleRegistration?.(); } return Promise.resolve(); } diff --git a/src/app/service/content/gm_api/gm_audio_cancellation.test.ts b/src/app/service/content/gm_api/gm_audio_cancellation.test.ts index 4ada40fd5..10a07a321 100644 --- a/src/app/service/content/gm_api/gm_audio_cancellation.test.ts +++ b/src/app/service/content/gm_api/gm_audio_cancellation.test.ts @@ -48,7 +48,9 @@ describe("GM_audio pending registration cancellation", () => { await Promise.resolve(); expect(registered).toHaveBeenCalledTimes(1); - expect(registered).toHaveBeenCalledWith(undefined); + // 与 setMute/getState/addStateChangeListener 等其余成功路径一致:不带参数调用回调 + // (GMTypes.AudioErrorCallback 的 error 为可选参数,无参调用与显式传入 undefined 等价) + expect(registered).toHaveBeenCalledWith(); expect(disconnect).toHaveBeenCalledTimes(1); expect(connect).toHaveBeenCalledTimes(1); }); diff --git a/src/app/service/content/gm_api/gm_context.ts b/src/app/service/content/gm_api/gm_context.ts index 7f6187cc7..25bfaa109 100644 --- a/src/app/service/content/gm_api/gm_context.ts +++ b/src/app/service/content/gm_api/gm_context.ts @@ -2,108 +2,6 @@ import type { ApiParam, ApiValue } from "../types"; const apis: Map = new Map(); -type AudioListener = (...args: any[]) => unknown; -type AudioRegistrationCancel = () => void; - -const audioRegistrationCancels = new WeakMap>>(); - -function registerAudioCancellation(context: object, listener: AudioListener, cancel: AudioRegistrationCancel): void { - let registrations = audioRegistrationCancels.get(context); - if (!registrations) { - registrations = new Map(); - audioRegistrationCancels.set(context, registrations); - } - let cancellations = registrations.get(listener); - if (!cancellations) { - cancellations = new Set(); - registrations.set(listener, cancellations); - } - cancellations.add(cancel); -} - -function unregisterAudioCancellation(context: object, listener: AudioListener, cancel: AudioRegistrationCancel): void { - const registrations = audioRegistrationCancels.get(context); - const cancellations = registrations?.get(listener); - cancellations?.delete(cancel); - if (!cancellations?.size) registrations?.delete(listener); - if (!registrations?.size) audioRegistrationCancels.delete(context); -} - -function cancelAudioRegistrations(context: object, listener: unknown): void { - if (typeof listener !== "function") return; - const registrations = audioRegistrationCancels.get(context); - const cancellations = registrations?.get(listener as AudioListener); - if (!cancellations) return; - registrations!.delete(listener as AudioListener); - if (!registrations!.size) audioRegistrationCancels.delete(context); - for (const cancel of cancellations) cancel(); -} - -function safeInvoke(callback: unknown, ...args: unknown[]): void { - if (typeof callback !== "function") return; - try { - callback(...args); - } catch (error) { - console.error(error); - } -} - -function wrapAudioRegistrationApi(propertyName: string, api: (...args: any[]) => any): (...args: any[]) => any { - if (propertyName === "GM.audio.addStateChangeListener") { - return function (this: object, listener: unknown, ...args: unknown[]) { - let registration: Promise; - try { - registration = Promise.resolve(api.call(this, listener, ...args)); - } catch (error) { - return Promise.reject(error); - } - if (typeof listener !== "function") return registration; - - let cancel!: AudioRegistrationCancel; - const cancelled = new Promise((resolve) => { - cancel = resolve; - }); - registerAudioCancellation(this, listener as AudioListener, cancel); - return Promise.race([registration, cancelled]).finally(() => { - unregisterAudioCancellation(this, listener as AudioListener, cancel); - }); - }; - } - - if (propertyName === "GM_audio.addStateChangeListener") { - return function (this: object, listener: unknown, callback?: (...args: any[]) => void) { - if (typeof listener !== "function") return api.call(this, listener, callback); - - let settled = false; - const cancel: AudioRegistrationCancel = () => { - void Promise.resolve().then(() => finish(undefined)); - }; - const finish = (error?: unknown) => { - if (settled) return; - settled = true; - unregisterAudioCancellation(this, listener as AudioListener, cancel); - safeInvoke(callback, error); - }; - registerAudioCancellation(this, listener as AudioListener, cancel); - try { - return api.call(this, listener, finish); - } catch (error) { - unregisterAudioCancellation(this, listener as AudioListener, cancel); - throw error; - } - }; - } - - if (propertyName === "GM.audio.removeStateChangeListener" || propertyName === "GM_audio.removeStateChangeListener") { - return function (this: object, listener: unknown, ...args: unknown[]) { - cancelAudioRegistrations(this, listener); - return api.call(this, listener, ...args); - }; - } - - return api; -} - export function GMContextApiGet(name: string): ApiValue[] | undefined { // 回传 Api 列表 return apis.get(name); @@ -130,17 +28,15 @@ export default class GMContext { public static API(param: ApiParam = {}) { return (target: any, propertyName: string, descriptor: PropertyDescriptor) => { const key = propertyName; - const api = wrapAudioRegistrationApi(key, descriptor.value); - descriptor.value = api; let { follow } = param; const { alias } = param; if (!follow) { follow = key; // follow 是实际 @grant 的权限;使用follow时,不要使用alias以避免混乱 } - GMContextApiSet(follow, key, api, param); + GMContextApiSet(follow, key, descriptor.value, param); if (alias) { // 追加别名呼叫(参数和回传完全一致,为 GM_xxx 与 GM.xxx 等问题设计) - GMContextApiSet(alias, alias, api, param); + GMContextApiSet(alias, alias, descriptor.value, param); } }; } From ed94bd88cbf1110609eb36f333d19505ee533972 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 11 Jul 2026 23:20:00 +0900 Subject: [PATCH 13/15] Update gm_api.ts --- src/app/service/content/gm_api/gm_api.ts | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index 285c841ed..0ce5afcdd 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -224,9 +224,6 @@ export default class GMApi extends GM_Base { listeners: Set; connection?: MessageConnect; registration?: Promise; - // 每次“最后一个监听器被移除”或 context 失效时递增, - // 用于识别并丢弃在此之后才resolve的过期 connect() 请求,避免其连接泄漏 - generation: number; // 是否曾经成功收到过 "registered":一旦为 true,后续任何连接尝试(包括恢复期间 // 尚未收到 registered 就又断线的重连尝试)都视为暂时性故障并保留监听器重试, // 而非当作首次注册失败而放弃 @@ -702,10 +699,9 @@ export default class GMApi extends GM_Base { // setTimeout 迭代进行,共享同一个 deferred,不会像每次重试都新建 Promise 并递归 then() // 链接那样、在长期故障时不断堆叠、无法被回收。 static _GM_audioConnect(a: GMApi, state: NonNullable): Promise { - // 记录本轮 episode 所属的世代:若在其间监听器被清空(remove 归零 / context 失效), - // 世代会被递增或 audioStateChange 整体被替换,届时应丢弃这个迟到的连接,避免连接泄漏 - const generation = state.generation; - const isCurrentAttempt = () => a.audioStateChange === state && state.generation === generation; + // 若在本轮 episode 期间监听器被清空(remove 归零 / context 失效),audioStateChange + // 整体会被替换为新对象或置空,届时应丢弃这个迟到的连接,避免连接泄漏 + const isCurrentAttempt = () => a.audioStateChange === state; let resolveEpisode!: () => void; let rejectEpisode!: (error: unknown) => void; @@ -730,7 +726,6 @@ export default class GMApi extends GM_Base { rejectEpisode(error); if (wasCurrentAttempt) { const connection = state.connection; - state.generation = state.generation > 1e9 ? 1 : state.generation + 1; state.connection = undefined; state.registration = undefined; state.settleRegistration = undefined; @@ -820,7 +815,7 @@ export default class GMApi extends GM_Base { return Promise.reject("GM_audio.addStateChangeListener: Invalid argument"); } - const state = (a.audioStateChange ??= { listeners: new Set(), generation: 0 }); + const state = (a.audioStateChange ??= { listeners: new Set() }); if (state.listeners.has(listener)) { return state.registration ?? Promise.resolve(); } @@ -838,7 +833,6 @@ export default class GMApi extends GM_Base { if (state) { const connection = state.connection; const settleRegistration = state.settleRegistration; - state.generation = state.generation > 1e9 ? 1 : state.generation + 1; state.connection = undefined; state.registration = undefined; state.settleRegistration = undefined; From cde17920b896400508caa4a0113e4c3c7988590c Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 12 Jul 2026 03:30:47 +0900 Subject: [PATCH 14/15] =?UTF-8?q?=F0=9F=90=9B=20preserve=20GM=5Faudio=20li?= =?UTF-8?q?steners=20after=20reconnect=20failure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/gm_api/gm_api.ts | 97 ++++++++++--------- .../service/content/gm_api/gm_audio.test.ts | 43 ++++++++ 2 files changed, 96 insertions(+), 44 deletions(-) diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index 0ce5afcdd..d908504ba 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -752,58 +752,67 @@ export default class GMApi extends GM_Base { resolveEpisode(); return; } - a.connect("GM_audio", ["addStateChangeListener"]).then((connection) => { - if (!isCurrentAttempt() || !state.listeners.size) { - connection.disconnect(true); - resolveEpisode(); - return; - } - - let registered = false; - state.connection = connection; - connection.onMessage((message) => { - if (message.code) { - giveUp(message.message); - return; - } - if (message.action === "registered") { - registered = true; - state.everRegistered = true; - state.retryDelay = undefined; + a.connect("GM_audio", ["addStateChangeListener"]).then( + (connection) => { + if (!isCurrentAttempt() || !state.listeners.size) { + connection.disconnect(true); resolveEpisode(); return; } - if (message.action === "stateChange") { - for (const stateListener of state.listeners) { - try { - stateListener(message.data as GMTypes.AudioStateChangeInfo); - } catch (error) { - console.error(error); + + let registered = false; + state.connection = connection; + connection.onMessage((message) => { + if (message.code) { + giveUp(message.message); + return; + } + if (message.action === "registered") { + registered = true; + state.everRegistered = true; + state.retryDelay = undefined; + resolveEpisode(); + return; + } + if (message.action === "stateChange") { + for (const stateListener of state.listeners) { + try { + stateListener(message.data as GMTypes.AudioStateChangeInfo); + } catch (error) { + console.error(error); + } } } - } - }); - connection.onDisconnect((isSelfDisconnected) => { - if (state.connection !== connection) return; - state.connection = undefined; - // 曾经成功收到过 registered(无论是本次连接还是更早的连接)时,此后任何非本端 - // 主动触发的断线——包括恢复期间尚未收到 registered 就又断线的重连尝试——都应视为 - // 暂时性故障并保留监听器重试,而非当作注册失败而放弃 - if (!isSelfDisconnected && isCurrentAttempt() && state.everRegistered && state.listeners.size) { - if (registered) { - // 已确认过的连接意外断线:本轮 episode 早已 resolve,开启新的一轮 - // episode——重新 connect() 本身即可唤醒被回收的 service worker - GMApi._GM_audioConnect(a, state).catch(() => {}); - } else { - // 本轮 episode 内的重连尝试在收到 registered 前又断线:退避后在同一轮 - // episode 内重试,避免持续故障时的忙等,也避免每次重试都新建 Promise - scheduleRetry(); + }); + connection.onDisconnect((isSelfDisconnected) => { + if (state.connection !== connection) return; + state.connection = undefined; + // 曾经成功收到过 registered(无论是本次连接还是更早的连接)时,此后任何非本端 + // 主动触发的断线——包括恢复期间尚未收到 registered 就又断线的重连尝试——都应视为 + // 暂时性故障并保留监听器重试,而非当作注册失败而放弃 + if (!isSelfDisconnected && isCurrentAttempt() && state.everRegistered && state.listeners.size) { + if (registered) { + // 已确认过的连接意外断线:本轮 episode 早已 resolve,开启新的一轮 + // episode——重新 connect() 本身即可唤醒被回收的 service worker + GMApi._GM_audioConnect(a, state).catch(() => {}); + } else { + // 本轮 episode 内的重连尝试在收到 registered 前又断线:退避后在同一轮 + // episode 内重试,避免持续故障时的忙等,也避免每次重试都新建 Promise + scheduleRetry(); + } + return; } + giveUp(registered ? undefined : "GM_audio.addStateChangeListener: Connection disconnected"); + }); + }, + (error) => { + if (isCurrentAttempt() && state.everRegistered && state.listeners.size) { + scheduleRetry(); return; } - giveUp(registered ? undefined : "GM_audio.addStateChangeListener: Connection disconnected"); - }); - }, giveUp); + giveUp(error); + } + ); }; attemptOnce(); diff --git a/src/app/service/content/gm_api/gm_audio.test.ts b/src/app/service/content/gm_api/gm_audio.test.ts index ad6ef868b..291d3d042 100644 --- a/src/app/service/content/gm_api/gm_audio.test.ts +++ b/src/app/service/content/gm_api/gm_audio.test.ts @@ -300,6 +300,49 @@ describe("GM_audio 状态变化监听", () => { } }); + it("恢复期间 connect() rejects 时,应保留监听器并退避重试", async () => { + vi.useFakeTimers(); + try { + const connections = [ + new MockMessageConnect(new EventEmitter()), + new MockMessageConnect(new EventEmitter()), + ]; + const connect = vi + .fn() + .mockResolvedValueOnce(connections[0]) + .mockRejectedValueOnce(new Error("temporary connection failure")) + .mockResolvedValueOnce(connections[1]); + const message = { connect } as unknown as Message; + const api = new GMApi("serviceWorker", message, message, { + uuid: "gm-audio-test", + value: {}, + } as ScriptRunResource); + const listener = vi.fn(); + + const registration = api["GM.audio.addStateChangeListener"](listener); + await Promise.resolve(); + connections[0].sendMessage({ action: "registered" }); + await registration; + + connections[0].EE!.emit("disconnect", false); + await Promise.resolve(); + await Promise.resolve(); + expect(connect).toHaveBeenCalledTimes(2); + + await vi.advanceTimersByTimeAsync(250); + expect(connect).toHaveBeenCalledTimes(3); + + connections[1].sendMessage({ action: "registered" }); + await Promise.resolve(); + connections[1].sendMessage({ action: "stateChange", data: { audible: true } }); + expect(listener).toHaveBeenCalledWith({ audible: true }); + + await api["GM.audio.removeStateChangeListener"](listener); + } finally { + vi.useRealTimers(); + } + }); + it("本端主动 disconnect(如 removeStateChangeListener)不应触发自动重连", async () => { const { api, connect, connection } = createAudioApi(); const listener = vi.fn(); From 460ddff383fe3b65f5c652bfea08c8c0ec4fbd69 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 12 Jul 2026 03:30:57 +0900 Subject: [PATCH 15/15] =?UTF-8?q?=F0=9F=93=84=20add=20GM=5Faudio=20declara?= =?UTF-8?q?tions=20to=20editor=20template?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/template/scriptcat.d.tpl | 45 ++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/src/template/scriptcat.d.tpl b/src/template/scriptcat.d.tpl index e89cb2b05..b25e43eea 100644 --- a/src/template/scriptcat.d.tpl +++ b/src/template/scriptcat.d.tpl @@ -206,6 +206,18 @@ declare function GM_cookie( details: GMTypes.CookieDetails, ondone: (cookie: GMTypes.Cookie[], error: unknown | undefined) => void ): void; + +/** 控制并监听当前浏览器标签页的音频状态。 */ +declare const GM_audio: { + /** 将当前标签页静音或取消静音。 */ + setMute(details: GMTypes.AudioMuteDetails, callback?: GMTypes.AudioErrorCallback): void; + /** 读取当前标签页的静音与发声状态。 */ + getState(callback: GMTypes.AudioStateCallback): void; + /** 监听静音或发声状态变化。 */ + addStateChangeListener(listener: GMTypes.AudioStateChangeListener, callback?: GMTypes.AudioErrorCallback): void; + /** 移除之前注册的状态变化监听器。 */ + removeStateChangeListener(listener: GMTypes.AudioStateChangeListener, callback?: () => void): void; +}; /** * GM.* API (兼容 Greasemonkey4/Tampermonkey 4+ 的 Promise 风格) @@ -304,6 +316,18 @@ declare const GM: { /** Cookie 操作 */ cookie(action: GMTypes.CookieAction, details: GMTypes.CookieDetails): Promise; + + /** 控制并监听当前浏览器标签页的音频状态。 */ + audio: { + /** 将当前标签页静音或取消静音。 */ + setMute(details: GMTypes.AudioMuteDetails): Promise; + /** 读取当前标签页的静音与发声状态。 */ + getState(): Promise; + /** 监听静音或发声状态变化。 */ + addStateChangeListener(listener: GMTypes.AudioStateChangeListener): Promise; + /** 移除之前注册的状态变化监听器。 */ + removeStateChangeListener(listener: GMTypes.AudioStateChangeListener): Promise; + }; }; /** @@ -451,6 +475,27 @@ declare namespace CATType { declare namespace GMTypes { type CookieAction = "list" | "delete" | "set"; + type AudioMuteReason = "user" | "capture" | "extension"; + + interface AudioMuteDetails { + isMuted: boolean; + } + + interface AudioState { + isMuted?: boolean; + muteReason?: AudioMuteReason; + isAudible?: boolean; + } + + interface AudioStateChangeInfo { + muted?: AudioMuteReason | false; + audible?: boolean; + } + + type AudioErrorCallback = (error?: string) => void; + type AudioStateCallback = (info: AudioState | undefined) => void; + type AudioStateChangeListener = (info: AudioStateChangeInfo) => void; + type LoggerLevel = "debug" | "info" | "warn" | "error"; type LoggerLabel = {