diff --git a/src/app/service/service_worker/popup.test.ts b/src/app/service/service_worker/popup.test.ts index fdf4f5750..7cd26c15e 100644 --- a/src/app/service/service_worker/popup.test.ts +++ b/src/app/service/service_worker/popup.test.ts @@ -273,6 +273,110 @@ describe("PopupService addScriptRunNumber 页面脚本执行计数", () => { expect(mA?.runNum).toBe(2); expect(mB?.runNum).toBe(4); }); + + // tabScript:-1 是后台脚本的命名空间。旧实现在 frameId 为 0 时会先清空列表,所以不止「多出一个普通脚本」, + // 还会把真正的后台脚本整批挤掉(#1774)。 + it.each([-1, 0])("tabId 为 %i(非真实标签页)时不得改动 tabScript:-1 缓存", async (tabId) => { + const backgroundMenu = createMenu("bg-script", { runNum: 1, runStatus: SCRIPT_RUN_STATUS_RUNNING }); + await cacheInstance.set(`${CACHE_KEY_TAB_SCRIPT}${-1}`, [backgroundMenu]); + const { service } = createService(); + + for (const frameId of [0, undefined, 3]) { + await service.addScriptRunNumber({ + tabId, + frameId, + url: "https://example.com/", + scriptmenus: [createMenu("normal-script", { runNum: 0 })], + }); + } + + await expect(service.getScriptMenu(-1)).resolves.toEqual([backgroundMenu]); + await expect(service.getScriptMenu(tabId)).resolves.toEqual(tabId === -1 ? [backgroundMenu] : []); + }); + + // 消息/事件边界的运行时数据不一定符合类型,undefined / NaN 也不能落成 tabScript:undefined / tabScript:NaN。 + it.each([undefined, Number.NaN])("tabId 为 %s(越过类型的运行时数据)时不写入任何 tabScript 缓存", async (tabId) => { + const { service } = createService(); + + await service.addScriptRunNumber({ + tabId: tabId as never, + frameId: 0, + url: "https://example.com/", + scriptmenus: [createMenu("normal-script", { runNum: 0 })], + }); + + const keys = await cacheInstance.list(); + expect(keys.filter((key) => key.startsWith(CACHE_KEY_TAB_SCRIPT))).toEqual([]); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── + +describe("PopupService tabScript:-1 命名空间契约:页面事件不得触碰后台脚本列表", () => { + const backgroundUuid = "bg-script"; + const normalUuid = "normal-script"; + const backgroundScript = createScript(backgroundUuid, { type: SCRIPT_TYPE_BACKGROUND }); + const normalScript = createScript(normalUuid, { type: SCRIPT_TYPE_NORMAL }); + const byUuid = (uuid: string) => [backgroundScript, normalScript].find((script) => script.uuid === uuid); + + beforeEach(async () => { + await cacheInstance.clear(); + }); + + it("无标签页的页面加载、iframe、菜单注册/注销、启停与运行状态事件之后,tabScript:-1 仍只含后台脚本", async () => { + const { service, subscriptions } = createService({ + scriptDAO: { + get: vi.fn(async (uuid: string) => byUuid(uuid)), + gets: vi.fn(async (uuids: string[]) => uuids.map(byUuid)), + }, + }); + service.dealBackgroundScriptInstall(); + const [installHandler] = subscriptions.get("installScript") || []; + await installHandler!({ + script: backgroundScript, + update: false, + } as TInstallScript); + // session 缓存的读取不拷贝,直接持有引用的话原地修改会连 expected 一起改掉,断言恒真。 + const expected = structuredClone(await service.getScriptMenu(-1)); + expect(expected.map((menu) => menu.uuid)).toEqual([backgroundUuid]); + + const expectNamespaceUntouched = async () => { + await flushAsync(); + await expect(service.getScriptMenu(-1)).resolves.toEqual(expected); + }; + + // 无标签页的主 frame / iframe / tabId 0 页面加载 + for (const tabId of [-1, 0]) { + for (const frameId of [0, 2]) { + await service.addScriptRunNumber({ + tabId, + frameId, + url: "https://example.com/", + scriptmenus: [createMenu(normalUuid, { runNum: 0 })], + }); + await expectNamespaceUntouched(); + } + } + + // 普通脚本在无标签页页面里的 GM_registerMenuCommand / GM_unregisterMenuCommand(tabId 会落成 -1) + const menuMessage = { uuid: normalUuid, key: "menu", name: "Menu", options: {}, tabId: -1 }; + await (service as any).updateRegisterMenuCommand(menuMessage, 1 /* REGISTER */); + await expectNamespaceUntouched(); + await (service as any).updateRegisterMenuCommand(menuMessage, 2 /* UNREGISTER */); + await expectNamespaceUntouched(); + + // 普通脚本的启用 / 禁用 / 运行状态事件 + const [enableHandler] = subscriptions.get("enableScripts") || []; + await enableHandler!([{ uuid: normalUuid, enable: true }] satisfies TEnableScript[]); + await expectNamespaceUntouched(); + await enableHandler!([{ uuid: normalUuid, enable: false }] satisfies TEnableScript[]); + await expectNamespaceUntouched(); + const [runStatusHandler] = subscriptions.get("scriptRunStatus") || []; + runStatusHandler!({ uuid: normalUuid, runStatus: SCRIPT_RUN_STATUS_RUNNING } satisfies TScriptRunStatus); + await expectNamespaceUntouched(); + runStatusHandler!({ uuid: normalUuid, runStatus: SCRIPT_RUN_STATUS_COMPLETE } satisfies TScriptRunStatus); + await expectNamespaceUntouched(); + }); }); // ───────────────────────────────────────────────────────────────────────────── diff --git a/src/app/service/service_worker/popup.ts b/src/app/service/service_worker/popup.ts index f921137e3..33331444c 100644 --- a/src/app/service/service_worker/popup.ts +++ b/src/app/service/service_worker/popup.ts @@ -618,6 +618,10 @@ export class PopupService { async addScriptRunNumber(o: TPopupPageLoadInfo) { const { tabId, frameId, scriptmenus } = o; + // tabScript:-1 是后台脚本的命名空间,只允许后台脚本的 install/enable/runStatus 事件写入。 + // 页面执行计数若落到 tabId <= 0,frameId 为 0 时还会先清空整个列表(#1774);生产者(RuntimeService.pageLoad)已过滤,这里是第二道防线。 + // 写成 !(tabId > 0) 而非 tabId <= 0:undefined / NaN 这类越过类型的运行时数据同样要拒绝。 + if (!(tabId > 0)) return; // 设置数据 await cacheInstance.tx(`${CACHE_KEY_TAB_SCRIPT}${tabId}`, (data: ScriptMenu[] | undefined, tx) => { const isPrevDataEmpty = !data?.length; diff --git a/src/app/service/service_worker/runtime.test.ts b/src/app/service/service_worker/runtime.test.ts index 667c6643f..5ccd3ee8f 100644 --- a/src/app/service/service_worker/runtime.test.ts +++ b/src/app/service/service_worker/runtime.test.ts @@ -1121,6 +1121,61 @@ describe("pageLoad 按消息发送方标签页区分隐身上下文", () => { }); }); +// 弹窗把 tabId -1 当作「后台脚本」命名空间;不属于任何标签页的页面若也记到 -1, +// 普通脚本就会出现在「开启和运行的后台脚本」里(#1774)。 +describe("pageLoad 页面运行计数只记到真实标签页", () => { + const pageUrl = "https://www.example.com/page"; + const scriptsForTab = { + injectScriptList: [], + contentScriptList: [], + envInfo: {}, + scriptmenus: [], + } as unknown as Awaited>; + const popupPageLoadEmits = (emit: ReturnType) => + emit.mock.calls.filter(([topic]) => topic === "popupPageLoadUpdate"); + + it.each([ + ["没有 tab", {}], + ["tab.id 为 TAB_ID_NONE(-1)", { tab: { id: -1 } }], + ])("%s 时照常下发脚本,但不记入任何标签页的运行计数", async (_label, senderTab) => { + const { runtime, mockGroup } = _createRuntimeContext(); + vi.spyOn(runtime, "getScriptsForTab").mockResolvedValue(scriptsForTab); + + const res = await runtime.pageLoad( + undefined, + new SenderRuntime({ + id: "scriptcat-test", + url: pageUrl, + frameId: 0, + ...senderTab, + } as chrome.runtime.MessageSender) + ); + + expect(res.ok).toBe(true); + expect(popupPageLoadEmits(mockGroup.emit)).toEqual([]); + }); + + it("真实标签页照常记入该标签页", async () => { + const tabId = 11; + const { runtime, mockGroup } = _createRuntimeContext(); + vi.spyOn(runtime, "getScriptsForTab").mockResolvedValue(scriptsForTab); + + await runtime.pageLoad( + undefined, + new SenderRuntime({ + id: "scriptcat-test", + url: pageUrl, + frameId: 0, + tab: { id: tabId, incognito: false }, + } as chrome.runtime.MessageSender) + ); + + expect(popupPageLoadEmits(mockGroup.emit)).toEqual([ + ["popupPageLoadUpdate", { tabId, frameId: 0, url: pageUrl, scriptmenus: [] }], + ]); + }); +}); + describe("sandbox verified 初始化重放", () => { it("忽略 fallback 通知,并且真实握手与重复握手只初始化一次脚本和语言监听", async () => { const { runtime, mockSystemConfig, mockScriptDAO } = _createRuntimeContext(); diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index 957d80e1e..5dfa33d01 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -1279,17 +1279,21 @@ export class RuntimeService { // 异常加载 return { ok: false }; } - const tabId = chromeSender.tab?.id || -1; + const tabId = chromeSender.tab?.id; const frameId = chromeSender.frameId; const incognito = chromeSender.tab?.incognito ?? false; const res = await this.getScriptsForTab({ url, tabId, frameId, incognito }); - this.mq.emit("popupPageLoadUpdate", { - tabId: tabId, - frameId: frameId, - url: url, - scriptmenus: res?.scriptmenus || [], // 对于 popup, resources那些不需要 - }); + // 页面可能不属于任何标签页(sender.tab 缺失或为 TAB_ID_NONE),脚本照常下发,但不能记运行计数: + // 弹窗把 tabId -1 当作后台脚本的命名空间,记进去普通脚本就会混进后台脚本列表(#1774)。 + if (tabId !== undefined && tabId > 0) { + this.mq.emit("popupPageLoadUpdate", { + tabId: tabId, + frameId: frameId, + url: url, + scriptmenus: res?.scriptmenus || [], // 对于 popup, resources那些不需要 + }); + } if (res) { // 返回脚本资料,在页面加载