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..6f73fdbf3 100644 --- a/src/app/service/content/create_context.ts +++ b/src/app/service/content/create_context.ts @@ -52,6 +52,8 @@ export const createContext = ( setInvalidContext() { if (invalid) return; invalid = true; + 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 945d5e936..d908504ba 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)}`; @@ -215,6 +219,24 @@ export default class GMApi extends GM_Base { */ notificationTagMap?: Map; + @GMContext.protected() + audioStateChange?: { + listeners: Set; + connection?: MessageConnect; + registration?: Promise; + // 是否曾经成功收到过 "registered":一旦为 true,后续任何连接尝试(包括恢复期间 + // 尚未收到 registered 就又断线的重连尝试)都视为暂时性故障并保留监听器重试, + // 而非当作首次注册失败而放弃 + everRegistered?: boolean; + // 恢复重连的退避延迟(毫秒),每次收到 registered 后重置,每次退避重试后倍增并封顶 + retryDelay?: number; + // 当前这一轮 episode 的 resolve 函数:监听器归零(显式移除)时,若此时连接已建立但尚未 + // 收到 registered,disconnect() 前会先置空 state.connection,导致 onDisconnect 的守卫 + // 直接 return、无法感知这是"移除触发的断线"而结算 Promise;因此需要在这里直接结算, + // 避免 addStateChangeListener 返回的 Promise/回调永久悬挂 + settleRegistration?: () => void; + }; + constructor( public prefix: string, public message: Message, @@ -237,6 +259,8 @@ export default class GMApi extends GM_Base { setInvalidContext() { if (invalid) return; invalid = true; + this.audioStateChange?.connection?.disconnect(true); + this.audioStateChange = undefined; this.valueChangeListener.clear(); this.EE.removeAllListeners(); // 释放记忆 @@ -651,6 +675,241 @@ 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_audioSafeInvoke(callback: ((...args: A) => void) | undefined, ...args: A): void { + if (!callback) return; + try { + callback(...args); + } catch (error) { + console.error(error); + } + } + + // 建立(或在 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 { + // 若在本轮 episode 期间监听器被清空(remove 归零 / context 失效),audioStateChange + // 整体会被替换为新对象或置空,届时应丢弃这个迟到的连接,避免连接泄漏 + const isCurrentAttempt = () => a.audioStateChange === state; + + let resolveEpisode!: () => void; + let rejectEpisode!: (error: unknown) => void; + const episode = new Promise((resolve, reject) => { + resolveEpisode = resolve; + rejectEpisode = reject; + }); + state.registration = episode; + // 暴露给 _GM_audioRemoveStateChangeListener:监听器归零时,即使连接已建立但尚未收到 + // registered,也能直接结算本轮 episode,而不必依赖 onDisconnect 的重入(见下方说明, + // 那条路径在这种情况下会被守卫提前 return,永远不会结算) + state.settleRegistration = resolveEpisode; + + // 彻底放弃:丢弃整个 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.connection = undefined; + state.registration = undefined; + state.settleRegistration = undefined; + state.listeners.clear(); + a.audioStateChange = undefined; + connection?.disconnect(true); + } + }; + + const scheduleRetry = () => { + const delay = state.retryDelay || GM_AUDIO_RETRY_BASE_DELAY; + state.retryDelay = Math.min(delay * 2, GM_AUDIO_RETRY_MAX_DELAY); + setTimeout(() => { + if (!isCurrentAttempt() || !state.listeners.size) { + resolveEpisode(); + return; + } + 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"); + }); + }, + (error) => { + if (isCurrentAttempt() && state.everRegistered && state.listeners.size) { + scheduleRetry(); + return; + } + giveUp(error); + } + ); + }; + + attemptOnce(); + return episode; + } + + 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() }); + 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); + if (state?.listeners.size) return Promise.resolve(); + + if (state) { + const connection = state.connection; + const settleRegistration = state.settleRegistration; + 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(); + } + + @GMContext.API({ follow: "GM_audio" }) + public "GM_audio.setMute"(details: GMTypes.AudioMuteDetails, callback?: GMTypes.AudioErrorCallback): void { + 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) => _GM_audioSafeInvoke(callback, state), + () => _GM_audioSafeInvoke(callback, undefined) + ); + } + + @GMContext.API({ follow: "GM_audio" }) + public "GM_audio.addStateChangeListener"( + listener: GMTypes.AudioStateChangeListener, + callback?: GMTypes.AudioErrorCallback + ): void { + void _GM_audioAddStateChangeListener(this, listener).then( + () => _GM_audioSafeInvoke(callback), + (error) => _GM_audioSafeInvoke(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(() => _GM_audioSafeInvoke(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 +1873,15 @@ 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_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 new file mode 100644 index 000000000..291d3d042 --- /dev/null +++ b/src/app/service/content/gm_api/gm_audio.test.ts @@ -0,0 +1,661 @@ +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("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(); + 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("注册失败"); + }); + + 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("恢复期间的重连尝试在收到 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("恢复期间 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(); + + 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); + }); + + 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(); + } + }); + + 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 回调不应在成功回调抛出异常后被重复调用", () => { + 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); + }); + + 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 状态变化监听器互相隔离", () => { + 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 }); + }); +}); 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..10a07a321 --- /dev/null +++ b/src/app/service/content/gm_api/gm_audio_cancellation.test.ts @@ -0,0 +1,57 @@ +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); + // 与 setMute/getState/addStateChangeListener 等其余成功路径一致:不带参数调用回调 + // (GMTypes.AudioErrorCallback 的 error 为可选参数,无参调用与显式传入 undefined 等价) + expect(registered).toHaveBeenCalledWith(); + expect(disconnect).toHaveBeenCalledTimes(1); + expect(connect).toHaveBeenCalledTimes(1); + }); +}); 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 7617e3fa6..3ebb3d048 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 GMApi, { ConnectMatch, getConnectMatched, getExtensionSiteAccessOriginPattern } from "./gm_api"; +import GMApi, { + ConnectMatch, + getConnectMatched, + getExtensionSiteAccessOriginPattern, + MockGMExternalDependencies, +} from "./gm_api"; import { PermissionVerifyApiGet, type ConfirmParam } from "../permission_verify"; -import type { GMApiRequest } from "../types"; +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"; +import { type GMApiRequest } from "../types"; + +initTestEnv(); // 小工具:建立假的 IGetSender const makeSender = (url?: string): IGetSender => ({ @@ -210,3 +224,55 @@ describe.concurrent("native GM_download 的 @connect 校验(verifyXhrConnect await expect(xhrConfirm(req, makeConnSender(), makeGmApi())).rejects.toThrow(/not a part of the @connect list/); }); }); + +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 31eaae2c9..3d92b2e8c 100644 --- a/src/app/service/service_worker/gm_api/gm_api.ts +++ b/src/app/service/service_worker/gm_api/gm_api.ts @@ -1243,6 +1243,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?.muted && 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/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 = { 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 = {