From 451c8bd80962dbb01d791ff1f04f68665c233b62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Mon, 24 Aug 2026 16:45:44 +0800 Subject: [PATCH 1/8] =?UTF-8?q?=F0=9F=90=9B=20popup=20=E5=BD=93=E5=89=8D?= =?UTF-8?q?=E9=A1=B5=E8=84=9A=E6=9C=AC=E6=8C=89=E5=AE=9E=E9=99=85=E6=B3=A8?= =?UTF-8?q?=E5=85=A5=E5=B1=95=E7=A4=BA=EF=BC=8C=E8=80=8C=E9=9D=9E=20patter?= =?UTF-8?q?n=20=E5=91=BD=E4=B8=AD=20(#1687)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit popup「当前页运行脚本」原本只按顶层网址做 pattern 匹配,带来两类失真: 1. iframe 内运行的脚本整条消失。#1666 为了让「排除本站」后该行立即消失, 删掉了 #1511 加的「运行过但未匹配」合并,代价是 @match 只命中 iframe 的 脚本连同它在 iframe 注册的 GM 菜单都不再出现。现改为「本 tab 跑过 ∧ 仍匹配 某个子 frame」——排除本站后脚本对所有 frame 都不再匹配,仍会立即消失。 仅匹配子 frame 的行标记 matchesTopFrame=false,UI 据此隐藏按顶层 host 生成规则的站点范围操作(否则会写出 *://settings/* 这类垃圾规则)。 2. chrome:// 等脚本猫触及不到的页面上照样列出脚本。新增页面状态判定: 协议/商店名单给出准确原因,「本 tab 有没有 content script 报到」作为 运行时证据兜住白名单漏掉的情况(企业策略等)。file:// 的权限查询只用于 给未注入的情况一个更准确的原因,不反过来否定已注入的事实。 黑名单页并入同一状态——它同样不会注入,此前却照常列脚本。 GetPopupDataRes.isBlacklist 相应替换为 pageStatus。 e2e/popup-matching-regressions.spec.ts 原先用不授予 userScripts 权限的 fixture,脚本从未真正注入,验证的只是 pattern 命中这一层(即本 issue 的假象 在测试套件里的镜像)。改用带权限与 .test host 解析的 fixture + 本地 mock 页。 --- e2e/popup-matching-regressions.spec.ts | 40 ++- packages/chrome-extension-mock/extension.ts | 5 + packages/chrome-extension-mock/index.ts | 2 + .../chrome-extension-mock/web_navigation.ts | 8 + src/app/cache_key.ts | 2 + src/app/service/service_worker/client.ts | 6 +- src/app/service/service_worker/popup.test.ts | 284 +++++++++++++++++- src/app/service/service_worker/popup.ts | 123 +++++++- .../service_worker/popup_scriptmenu.ts | 2 +- src/app/service/service_worker/runtime.ts | 1 + src/app/service/service_worker/types.ts | 12 + src/locales/de-DE/popup.json | 3 + src/locales/en-US/popup.json | 3 + src/locales/ja-JP/popup.json | 3 + src/locales/ko-KR/popup.json | 3 + src/locales/pt-BR/popup.json | 3 + src/locales/ru-RU/popup.json | 3 + src/locales/tr-TR/popup.json | 3 + src/locales/vi-VN/popup.json | 3 + src/locales/zh-CN/popup.json | 3 + src/locales/zh-TW/popup.json | 3 + src/pages/popup/App.test.tsx | 39 ++- src/pages/popup/App.tsx | 39 ++- src/pages/popup/preload.ts | 12 +- src/pages/popup/usePopupData.ts | 10 +- src/pkg/utils/page_access.test.ts | 65 ++++ src/pkg/utils/page_access.ts | 36 +++ 27 files changed, 656 insertions(+), 60 deletions(-) create mode 100644 packages/chrome-extension-mock/web_navigation.ts create mode 100644 src/pkg/utils/page_access.test.ts create mode 100644 src/pkg/utils/page_access.ts diff --git a/e2e/popup-matching-regressions.spec.ts b/e2e/popup-matching-regressions.spec.ts index b00bcf137..a47982a89 100644 --- a/e2e/popup-matching-regressions.spec.ts +++ b/e2e/popup-matching-regressions.spec.ts @@ -1,4 +1,4 @@ -import { test, expect } from "./fixtures"; +import { test, expect, startMockServer, type MockServer } from "./server-fixtures"; import { installScriptByCode } from "./utils"; import type { Page } from "@playwright/test"; @@ -7,19 +7,19 @@ function scriptCode(name: string, rule: "match" | "include") { // @name ${name} // @namespace issue-1591-e2e // @version 1.0.0 -// @${rule} https://example.com/* +// @${rule} http://sitea.test/* // @grant none // ==/UserScript== console.log("${name}");`; } -async function getTargetTab(extensionPage: Page) { - return extensionPage.evaluate(async () => { +async function getTargetTab(extensionPage: Page, targetUrl: string) { + return extensionPage.evaluate(async (targetUrl) => { const tabs = await chrome.tabs.query({}); - const tab = tabs.find((item) => item.url?.startsWith("https://example.com/")); + const tab = tabs.find((item) => item.url === targetUrl); if (!tab?.id || !tab.url) throw new Error("target tab not found"); return { tabId: tab.id, url: tab.url }; - }); + }, targetUrl); } async function verifyExcludeRoundTrip( @@ -41,7 +41,7 @@ async function verifyExcludeRoundTrip( const exclude = await chrome.runtime.sendMessage({ action: "serviceWorker/script/excludeUrl", - data: { uuid: script.uuid, excludePattern: "*://example.com/*", remove: false }, + data: { uuid: script.uuid, excludePattern: "*://sitea.test/*", remove: false }, }); if (exclude.code) throw new Error(`exclude failed: ${JSON.stringify(exclude)}`); @@ -51,7 +51,7 @@ async function verifyExcludeRoundTrip( const unexclude = await chrome.runtime.sendMessage({ action: "serviceWorker/script/excludeUrl", - data: { uuid: script.uuid, excludePattern: "*://example.com/*", remove: true }, + data: { uuid: script.uuid, excludePattern: "*://sitea.test/*", remove: true }, }); if (unexclude.code) throw new Error(`unexclude failed: ${JSON.stringify(unexclude)}`); @@ -66,6 +66,18 @@ async function verifyExcludeRoundTrip( } test.describe("Issue 1591: Popup exclusion regression", () => { + let server: MockServer; + let targetUrl: string; + + test.beforeEach(async () => { + server = await startMockServer(); + targetUrl = server.url("sitea.test", "/page"); + }); + + test.afterEach(async () => { + await server.close(); + }); + test("@match script remains visible and reversible after excluding the current site", async ({ context, extensionId, @@ -75,9 +87,9 @@ test.describe("Issue 1591: Popup exclusion regression", () => { const target = await context.newPage(); const extensionPage = await context.newPage(); try { - await target.goto("https://example.com/", { waitUntil: "domcontentloaded" }); + await target.goto(targetUrl, { waitUntil: "domcontentloaded" }); await extensionPage.goto(`chrome-extension://${extensionId}/src/options.html`); - const result = await verifyExcludeRoundTrip(extensionPage, await getTargetTab(extensionPage), name); + const result = await verifyExcludeRoundTrip(extensionPage, await getTargetTab(extensionPage, targetUrl), name); expect(result).toEqual({ excludedIsEffective: false, restoredIsEffective: true }); } finally { await extensionPage.close(); @@ -94,9 +106,9 @@ test.describe("Issue 1591: Popup exclusion regression", () => { const target = await context.newPage(); const extensionPage = await context.newPage(); try { - await target.goto("https://example.com/", { waitUntil: "domcontentloaded" }); + await target.goto(targetUrl, { waitUntil: "domcontentloaded" }); await extensionPage.goto(`chrome-extension://${extensionId}/src/options.html`); - const result = await verifyExcludeRoundTrip(extensionPage, await getTargetTab(extensionPage), name); + const result = await verifyExcludeRoundTrip(extensionPage, await getTargetTab(extensionPage, targetUrl), name); expect(result).toEqual({ excludedIsEffective: false, restoredIsEffective: true }); } finally { await extensionPage.close(); @@ -117,9 +129,9 @@ test.describe("Issue 1591: Popup exclusion regression", () => { const target = await context.newPage(); const extensionPage = await context.newPage(); try { - await target.goto("https://example.com/", { waitUntil: "domcontentloaded" }); + await target.goto(targetUrl, { waitUntil: "domcontentloaded" }); await extensionPage.goto(`chrome-extension://${extensionId}/src/options.html`); - const targetTab = await getTargetTab(extensionPage); + const targetTab = await getTargetTab(extensionPage, targetUrl); const popupData = await extensionPage.evaluate( ({ tabId, url }) => chrome.runtime.sendMessage({ action: "serviceWorker/popup/getPopupData", data: { tabId, url } }), diff --git a/packages/chrome-extension-mock/extension.ts b/packages/chrome-extension-mock/extension.ts index d77829b92..e570fae10 100644 --- a/packages/chrome-extension-mock/extension.ts +++ b/packages/chrome-extension-mock/extension.ts @@ -1,3 +1,8 @@ export default class Extension { inIncognitoContext = false; + + // 默认已授权访问 file://;需要未授权场景的测试自行 spyOn 覆写。 + isAllowedFileSchemeAccess(): Promise { + return Promise.resolve(true); + } } diff --git a/packages/chrome-extension-mock/index.ts b/packages/chrome-extension-mock/index.ts index 6122b4f96..0dc768992 100644 --- a/packages/chrome-extension-mock/index.ts +++ b/packages/chrome-extension-mock/index.ts @@ -11,6 +11,7 @@ import Permissions from "./permissions"; import Extension from "./extension"; import MockUserScripts from "./user_scripts"; import Action from "./action"; +import WebNavigation from "./web_navigation"; const chromeMock = { tabs: new MockTab(), @@ -26,6 +27,7 @@ const chromeMock = { extension: new Extension(), userScripts: new MockUserScripts(), action: new Action(), + webNavigation: new WebNavigation(), init() { this.downloads.reset(); this.permissions.reset(); diff --git a/packages/chrome-extension-mock/web_navigation.ts b/packages/chrome-extension-mock/web_navigation.ts new file mode 100644 index 000000000..7f5f801f7 --- /dev/null +++ b/packages/chrome-extension-mock/web_navigation.ts @@ -0,0 +1,8 @@ +export default class WebNavigation { + // 默认无框架资料;需要框架的测试自行 spyOn 覆写返回值。 + getAllFrames( + _details: chrome.webNavigation.GetAllFrameDetails + ): Promise { + return Promise.resolve([]); + } +} diff --git a/src/app/cache_key.ts b/src/app/cache_key.ts index ddfa4587c..7cd921201 100644 --- a/src/app/cache_key.ts +++ b/src/app/cache_key.ts @@ -1,5 +1,7 @@ export const CACHE_KEY_IMPORT_FILE = "importFile:"; // importFile 导入文件 export const CACHE_KEY_TAB_SCRIPT = "tabScript:"; +// 记录某 tab 最近一次 content script 报到的 origin,用于判定「本页扩展是否触及得到」 +export const CACHE_KEY_TAB_LOADED = "tabLoaded:"; export const CACHE_KEY_SET_VALUE = "setValue:"; export const CACHE_KEY_PERMISSION = "permission:"; export const CACHE_KEY_SKILL_INSTALL = "skillInstall:"; // Skill ZIP 待安装数据缓存 diff --git a/src/app/service/service_worker/client.ts b/src/app/service/service_worker/client.ts index 4e33062ce..557af5495 100644 --- a/src/app/service/service_worker/client.ts +++ b/src/app/service/service_worker/client.ts @@ -3,7 +3,7 @@ import { type Resource } from "@App/app/repo/resource"; import { type Subscribe } from "@App/app/repo/subscribe"; import { type Logger } from "@App/app/repo/logger"; import { type Permission } from "@App/app/repo/permission"; -import type { InstallSource, ScriptMenu, ScriptMenuItem, TBatchUpdateListAction } from "./types"; +import type { InstallSource, ScriptMenu, ScriptMenuItem, TBatchUpdateListAction, TPopupPageStatus } from "./types"; import { Client } from "@Packages/message/client"; import type { MessageSend } from "@Packages/message/types"; import type PermissionVerify from "./permission_verify"; @@ -245,8 +245,8 @@ export type GetPopupDataReq = { }; export type GetPopupDataRes = { - // 在黑名单 - isBlacklist: boolean; + // 当前页状态:非 ok 时 scriptList 为空,由 Popup 说明原因 + pageStatus: TPopupPageStatus; scriptList: ScriptMenu[]; backScriptList: ScriptMenu[]; }; diff --git a/src/app/service/service_worker/popup.test.ts b/src/app/service/service_worker/popup.test.ts index 5134ae579..70391ead4 100644 --- a/src/app/service/service_worker/popup.test.ts +++ b/src/app/service/service_worker/popup.test.ts @@ -1,7 +1,7 @@ import { initTestEnv } from "@Tests/utils"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { cacheInstance } from "@App/app/cache"; -import { CACHE_KEY_TAB_SCRIPT } from "@App/app/cache_key"; +import { CACHE_KEY_TAB_LOADED, CACHE_KEY_TAB_SCRIPT } from "@App/app/cache_key"; import { PopupService } from "./popup"; import type { ScriptMenu } from "./types"; import type { RuntimeService } from "./runtime"; @@ -19,6 +19,8 @@ import type { IMessageQueue } from "@Packages/message/message_queue"; import type { Group } from "@Packages/message/server"; import type { SystemConfig } from "@App/pkg/config/config"; import type { TDeleteScript, TEnableScript, TInstallScript, TScriptRunStatus } from "../queue"; +import type WebNavigationMock from "@Packages/chrome-extension-mock/web_navigation"; +import type ExtensionMock from "@Packages/chrome-extension-mock/extension"; initTestEnv(); @@ -103,6 +105,7 @@ const flushAsync = (tabId: number = -1) => cacheInstance.tx(`${CACHE_KEY_TAB_SCR describe("PopupService 删除脚本后 Popup 菜单残留清理", () => { beforeEach(async () => { await cacheInstance.clear(); + await cacheInstance.set(`${CACHE_KEY_TAB_LOADED}${1}`, "https://example.com"); }); it("getPopupData 读取 Popup 数据时,不应显示 runScripts 缓存中的未匹配脚本", async () => { @@ -191,6 +194,7 @@ describe("PopupService addScriptRunNumber 页面脚本执行计数", () => { await service.addScriptRunNumber({ tabId: 1, frameId: 0, + url: "https://example.com/", scriptmenus: [createMenu(newUuid, { runNum: 0 })], }); @@ -208,6 +212,7 @@ describe("PopupService addScriptRunNumber 页面脚本执行计数", () => { await service.addScriptRunNumber({ tabId: 1, frameId: 10, // subframe id + url: "https://frame.example.com/", scriptmenus: [createMenu(uuid, { runNum: 0 })], }); @@ -223,6 +228,7 @@ describe("PopupService addScriptRunNumber 页面脚本执行计数", () => { await service.addScriptRunNumber({ tabId: 1, frameId: 0, + url: "https://example.com/", scriptmenus: [createMenu(uuid, { runNum: 0, isEffective: true })], }); @@ -236,7 +242,7 @@ describe("PopupService addScriptRunNumber 页面脚本执行计数", () => { it("scriptmenus 为空且缓存也为空时,不应写入 session 缓存(避免无谓的 storage 写入)", async () => { const { service } = createService(); - await service.addScriptRunNumber({ tabId: 1, frameId: 0, scriptmenus: [] }); + await service.addScriptRunNumber({ tabId: 1, frameId: 0, url: "https://example.com/", scriptmenus: [] }); // 不应产生任何缓存记录 await expect(service.getScriptMenu(1)).resolves.toEqual([]); @@ -255,6 +261,7 @@ describe("PopupService addScriptRunNumber 页面脚本执行计数", () => { await service.addScriptRunNumber({ tabId: 1, frameId: 5, // subframe,非 0 → 保留旧缓存叠加 + url: "https://frame.example.com/", scriptmenus: [createMenu(uuidA, { runNum: 0 }), createMenu(uuidB, { runNum: 0 })], }); @@ -271,6 +278,8 @@ describe("PopupService addScriptRunNumber 页面脚本执行计数", () => { describe("PopupService getPopupData Popup 数据获取与合并", () => { beforeEach(async () => { await cacheInstance.clear(); + // 这些用例只关心「匹配结果如何合并」,统一预置为「本页 content script 已报到」 + await cacheInstance.set(`${CACHE_KEY_TAB_LOADED}${1}`, "https://example.com"); }); it("URL 匹配的脚本(无运行缓存)应出现在 scriptList,isEffective 与 enable 按脚本状态设置", async () => { @@ -294,7 +303,7 @@ describe("PopupService getPopupData Popup 数据获取与合并", () => { expect(result.scriptList[0].isEffective).toBe(true); expect(result.scriptList[0].enable).toBe(true); expect(result.scriptList[0].hasMatchOverride).toBe(true); - expect(result.isBlacklist).toBe(false); + expect(result.pageStatus).toBe("ok"); }); it("无 match 覆盖的脚本(无运行缓存)hasMatchOverride 应为 false", async () => { @@ -382,37 +391,282 @@ describe("PopupService getPopupData Popup 数据获取与合并", () => { expect(result.backScriptList[0].uuid).toBe(bgUuid); }); - it("isBlacklist 由 runtime.isUrlBlacklist 决定,黑名单 URL 应返回 true", async () => { + it("未匹配当前 URL 但仍在运行的脚本,若脚本在 DAO 中已被删除,不应出现在 scriptList", async () => { + const deletedUuid = "deleted-running"; + // 在运行缓存中有记录(模拟脚本曾经运行),但 DAO 返回 undefined(脚本已被删除) + await cacheInstance.set(`${CACHE_KEY_TAB_SCRIPT}${1}`, [createMenu(deletedUuid)]); + const { service } = createService({ runtime: { getPopupPageScriptMatchingResultByUrl: vi.fn().mockResolvedValue(new Map()), + isUrlBlacklist: vi.fn().mockReturnValue(false), + }, + scriptDAO: { + gets: vi.fn().mockResolvedValue([undefined]), // 脚本已删除 + }, + }); + + const result = await service.getPopupData({ tabId: 1, url: "https://example.com/" }); + + expect(result.scriptList.map((s) => s.uuid)).not.toContain(deletedUuid); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── + +describe("PopupService getPopupData 页面可达性(脚本猫无法触及的页面)", () => { + const WEB_URL = "https://example.com/"; + // 与 webNavigation 同理:@types/chrome 的 callback 重载会让 vi.spyOn 取到返回 void 的那一个 + const extensionMock = chrome.extension as unknown as ExtensionMock; + const matchOne = (uuid: string) => vi.fn().mockResolvedValue(new Map([[uuid, { uuid, effective: true }]])); + + /** 模拟 content script 报到:顶层 frame 载入事件 */ + const firePageLoad = (service: PopupService, tabId: number, url: string) => + service.markTabInjected({ tabId, frameId: 0, url, scriptmenus: [] }); + + beforeEach(async () => { + await cacheInstance.clear(); + vi.restoreAllMocks(); + }); + + it("浏览器内部页应返回 restricted,且不列出仅 pattern 命中的脚本", async () => { + const uuid = "allsite"; + const { service } = createService({ + runtime: { getPopupPageScriptMatchingResultByUrl: matchOne(uuid) }, + scriptDAO: { gets: vi.fn().mockResolvedValue([createScript(uuid)]) }, + }); + + const result = await service.getPopupData({ tabId: 1, url: "chrome://settings/" }); + + expect(result.pageStatus).toBe("restricted"); + expect(result.scriptList).toEqual([]); + }); + + it("受限页仍应返回后台脚本清单(后台脚本与当前页无关)", async () => { + const bgUuid = "bg"; + await cacheInstance.set(`${CACHE_KEY_TAB_SCRIPT}${-1}`, [createMenu(bgUuid)]); + const { service } = createService({ + scriptDAO: { gets: vi.fn(async (uuids: string[]) => uuids.map((uuid) => createScript(uuid))) }, + }); + + const result = await service.getPopupData({ tabId: 1, url: "chrome://settings/" }); + + expect(result.backScriptList.map((s) => s.uuid)).toEqual([bgUuid]); + }); + + it("黑名单页应返回 blacklist,且不列出脚本(黑名单页同样不会注入)", async () => { + const uuid = "allsite"; + const { service } = createService({ + runtime: { + getPopupPageScriptMatchingResultByUrl: matchOne(uuid), isUrlBlacklist: vi.fn().mockReturnValue(true), }, + scriptDAO: { gets: vi.fn().mockResolvedValue([createScript(uuid)]) }, }); + await firePageLoad(service, 1, WEB_URL); - const result = await service.getPopupData({ tabId: 1, url: "https://blocked.com/" }); + const result = await service.getPopupData({ tabId: 1, url: WEB_URL }); - expect(result.isBlacklist).toBe(true); + expect(result.pageStatus).toBe("blacklist"); + expect(result.scriptList).toEqual([]); }); - it("未匹配当前 URL 但仍在运行的脚本,若脚本在 DAO 中已被删除,不应出现在 scriptList", async () => { - const deletedUuid = "deleted-running"; - // 在运行缓存中有记录(模拟脚本曾经运行),但 DAO 返回 undefined(脚本已被删除) - await cacheInstance.set(`${CACHE_KEY_TAB_SCRIPT}${1}`, [createMenu(deletedUuid)]); + it("可注入页收到 content script 报到后返回 ok,正常列出脚本", async () => { + const uuid = "allsite"; + const { service } = createService({ + runtime: { getPopupPageScriptMatchingResultByUrl: matchOne(uuid) }, + scriptDAO: { gets: vi.fn().mockResolvedValue([createScript(uuid)]) }, + }); + await firePageLoad(service, 1, WEB_URL); + + const result = await service.getPopupData({ tabId: 1, url: WEB_URL }); + + expect(result.pageStatus).toBe("ok"); + expect(result.scriptList.map((s) => s.uuid)).toEqual([uuid]); + }); + + it("可注入页但从未收到报到(页面比扩展旧 / 被策略拦下)应返回 not-injected", async () => { + const uuid = "allsite"; + const { service } = createService({ + runtime: { getPopupPageScriptMatchingResultByUrl: matchOne(uuid) }, + scriptDAO: { gets: vi.fn().mockResolvedValue([createScript(uuid)]) }, + }); + + const result = await service.getPopupData({ tabId: 1, url: WEB_URL }); + + expect(result.pageStatus).toBe("not-injected"); + expect(result.scriptList).toEqual([]); + }); + + it("同 origin 内的后续导航(SPA 换页)仍算已注入", async () => { + const { service } = createService(); + await firePageLoad(service, 1, "https://example.com/a"); + + const result = await service.getPopupData({ tabId: 1, url: "https://example.com/b?c=1" }); + + expect(result.pageStatus).toBe("ok"); + }); + + it("跳到另一个 origin 后,旧报到记录不应让新页面被判为已注入", async () => { + const { service } = createService(); + await firePageLoad(service, 1, "https://example.com/a"); + + const result = await service.getPopupData({ tabId: 1, url: "https://other.com/a" }); + + expect(result.pageStatus).toBe("not-injected"); + }); + + it("file:// 页未授权文件访问时应返回 file-access-denied", async () => { + vi.spyOn(extensionMock, "isAllowedFileSchemeAccess").mockResolvedValue(false); + const { service } = createService(); + + const result = await service.getPopupData({ tabId: 1, url: "file:///tmp/a.html" }); + + expect(result.pageStatus).toBe("file-access-denied"); + }); + + it("file:// 页已实际注入时按 ok 处理,不因权限查询结果误报", async () => { + vi.spyOn(extensionMock, "isAllowedFileSchemeAccess").mockResolvedValue(false); + const { service } = createService(); + await firePageLoad(service, 1, "file:///tmp/a.html"); + + const result = await service.getPopupData({ tabId: 1, url: "file:///tmp/a.html" }); + + expect(result.pageStatus).toBe("ok"); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── + +describe("PopupService getPopupData 子 frame(iframe)内运行的脚本", () => { + const TOP_URL = "https://top.example.com/"; + const FRAME_URL = "https://embed.example.org/player"; + + // @types/chrome 的 getAllFrames 以 callback 重载收尾,vi.spyOn 会取到返回 void 的那一个, + // 因此改用 mock 实作的类型来 spy。 + const webNavigationMock = chrome.webNavigation as unknown as WebNavigationMock; + + /** 让 chrome.webNavigation.getAllFrames 返回指定的子 frame 网址(frameId 从 1 起) */ + const mockFrames = (urls: string[]) => + vi + .spyOn(webNavigationMock, "getAllFrames") + .mockResolvedValue([ + { frameId: 0, url: TOP_URL }, + ...urls.map((url, i) => ({ frameId: i + 1, url })), + ] as chrome.webNavigation.GetAllFrameResultDetails[]); + + /** 匹配器:只有 matchedUrls 里的网址会命中 uuid */ + const matcherFor = (uuid: string, matchedUrls: string[], effective = true) => + vi.fn(async (url: string) => (matchedUrls.includes(url) ? new Map([[uuid, { uuid, effective }]]) : new Map())); + + beforeEach(async () => { + await cacheInstance.clear(); + await cacheInstance.set(`${CACHE_KEY_TAB_LOADED}${1}`, "https://top.example.com"); + vi.restoreAllMocks(); + }); + + it("只匹配 iframe 网址并已在该 frame 运行过的脚本,应出现在当前页脚本列表", async () => { + const uuid = "iframe-only"; + await cacheInstance.set(`${CACHE_KEY_TAB_SCRIPT}${1}`, [createMenu(uuid, { runNum: 1, runNumByIframe: 1 })]); + mockFrames([FRAME_URL]); + + const { service } = createService({ + runtime: { getPopupPageScriptMatchingResultByUrl: matcherFor(uuid, [FRAME_URL]) }, + scriptDAO: { gets: vi.fn().mockResolvedValue([createScript(uuid)]) }, + }); + + const result = await service.getPopupData({ tabId: 1, url: TOP_URL }); + + expect(result.scriptList.map((s) => s.uuid)).toContain(uuid); + expect(result.scriptList[0].runNumByIframe).toBe(1); + expect(result.scriptList[0].isEffective).toBe(true); + }); + + it("仅匹配 iframe 的脚本应标记 matchesTopFrame = false,顶层匹配的脚本为 true", async () => { + const topUuid = "top-script"; + const frameUuid = "iframe-script"; + await cacheInstance.set(`${CACHE_KEY_TAB_SCRIPT}${1}`, [createMenu(frameUuid, { runNumByIframe: 1 })]); + mockFrames([FRAME_URL]); const { service } = createService({ runtime: { - getPopupPageScriptMatchingResultByUrl: vi.fn().mockResolvedValue(new Map()), - isUrlBlacklist: vi.fn().mockReturnValue(false), + getPopupPageScriptMatchingResultByUrl: vi.fn(async (url: string) => + url === TOP_URL + ? new Map([[topUuid, { uuid: topUuid, effective: true }]]) + : new Map([[frameUuid, { uuid: frameUuid, effective: true }]]) + ), }, scriptDAO: { - gets: vi.fn().mockResolvedValue([undefined]), // 脚本已删除 + gets: vi.fn(async (uuids: string[]) => uuids.map((uuid) => createScript(uuid))), }, }); - const result = await service.getPopupData({ tabId: 1, url: "https://example.com/" }); + const result = await service.getPopupData({ tabId: 1, url: TOP_URL }); - expect(result.scriptList.map((s) => s.uuid)).not.toContain(deletedUuid); + const byUuid = new Map(result.scriptList.map((s) => [s.uuid, s])); + expect(byUuid.get(topUuid)?.matchesTopFrame).toBe(true); + expect(byUuid.get(frameUuid)?.matchesTopFrame).toBe(false); + }); + + it("运行过但已不匹配任何 frame 的脚本(例如刚被排除本站),不应出现在列表", async () => { + const uuid = "just-excluded"; + await cacheInstance.set(`${CACHE_KEY_TAB_SCRIPT}${1}`, [createMenu(uuid, { runNum: 1 })]); + mockFrames([FRAME_URL]); + + const { service } = createService({ + runtime: { getPopupPageScriptMatchingResultByUrl: matcherFor(uuid, []) }, + scriptDAO: { gets: vi.fn().mockResolvedValue([createScript(uuid)]) }, + }); + + const result = await service.getPopupData({ tabId: 1, url: TOP_URL }); + + expect(result.scriptList).toHaveLength(0); + }); + + it("匹配 iframe 但已从 DAO 删除的脚本,不应出现在列表", async () => { + const uuid = "deleted-iframe-script"; + await cacheInstance.set(`${CACHE_KEY_TAB_SCRIPT}${1}`, [createMenu(uuid, { runNumByIframe: 1 })]); + mockFrames([FRAME_URL]); + + const { service } = createService({ + runtime: { getPopupPageScriptMatchingResultByUrl: matcherFor(uuid, [FRAME_URL]) }, + scriptDAO: { gets: vi.fn().mockResolvedValue([undefined]) }, + }); + + const result = await service.getPopupData({ tabId: 1, url: TOP_URL }); + + expect(result.scriptList).toHaveLength(0); + }); + + it("getAllFrames 失败(标签页已关闭等)时降级为只看顶层匹配,不影响顶层脚本列表", async () => { + const topUuid = "top-script"; + const frameUuid = "iframe-script"; + await cacheInstance.set(`${CACHE_KEY_TAB_SCRIPT}${1}`, [createMenu(frameUuid, { runNumByIframe: 1 })]); + vi.spyOn(webNavigationMock, "getAllFrames").mockRejectedValue(new Error("No tab with id")); + + const { service } = createService({ + runtime: { getPopupPageScriptMatchingResultByUrl: matcherFor(topUuid, [TOP_URL]) }, + scriptDAO: { gets: vi.fn(async (uuids: string[]) => uuids.map((uuid) => createScript(uuid))) }, + }); + + const result = await service.getPopupData({ tabId: 1, url: TOP_URL }); + + expect(result.scriptList.map((s) => s.uuid)).toEqual([topUuid]); + }); + + it("顶层匹配已覆盖的脚本不应触发 getAllFrames 查询", async () => { + const uuid = "top-script"; + await cacheInstance.set(`${CACHE_KEY_TAB_SCRIPT}${1}`, [createMenu(uuid)]); + const getAllFrames = mockFrames([FRAME_URL]); + + const { service } = createService({ + runtime: { getPopupPageScriptMatchingResultByUrl: matcherFor(uuid, [TOP_URL]) }, + scriptDAO: { gets: vi.fn().mockResolvedValue([createScript(uuid)]) }, + }); + + await service.getPopupData({ tabId: 1, url: TOP_URL }); + + expect(getAllFrames).not.toHaveBeenCalled(); }); }); diff --git a/src/app/service/service_worker/popup.ts b/src/app/service/service_worker/popup.ts index 7026ac36b..58d62b7f9 100644 --- a/src/app/service/service_worker/popup.ts +++ b/src/app/service/service_worker/popup.ts @@ -1,7 +1,7 @@ import { type IMessageQueue } from "@Packages/message/message_queue"; import { type Group } from "@Packages/message/server"; import { type RuntimeService } from "./runtime"; -import type { ScriptMenu, TPopupScript } from "./types"; +import type { ScriptMenu, TPopupPageStatus, TPopupScript } from "./types"; import type { GetPopupDataReq, GetPopupDataRes, MenuClickParams } from "./client"; import { cacheInstance } from "@App/app/cache"; import type { ScriptDAO } from "@App/app/repo/scripts"; @@ -17,9 +17,12 @@ import type { } from "../queue"; import { getCurrentTab } from "@App/pkg/utils/utils"; import { type SystemConfig } from "@App/pkg/config/config"; -import { CACHE_KEY_TAB_SCRIPT } from "@App/app/cache_key"; +import { CACHE_KEY_TAB_LOADED, CACHE_KEY_TAB_SCRIPT } from "@App/app/cache_key"; import { timeoutExecution } from "@App/pkg/utils/timer"; import { v5 as uuidv5 } from "uuid"; +import { getPageAccessKind, toOrigin } from "@App/pkg/utils/page_access"; +import LoggerCore from "@App/app/logger/core"; +import Logger from "@App/app/logger/logger"; const enum ScriptMenuRegisterType { REGISTER = 1, @@ -367,6 +370,16 @@ export class PopupService { // 获取popup页面数据 async getPopupData(req: GetPopupDataReq): Promise { const { url, tabId } = req; + const pageStatus = await this.getPageStatus(tabId, url); + if (pageStatus !== "ok") { + // 页面上不会有任何脚本运行,列出「匹配到的」脚本只会让人以为它们在跑(#1687); + // 后台脚本与当前页无关,照常返回。 + return { + pageStatus, + scriptList: [], + backScriptList: await this.attachScriptDisplayInfo(await this.getScriptMenu(-1)), + }; + } const [matchingResult, runScripts, backScriptList] = await Promise.all([ this.runtime.getPopupPageScriptMatchingResultByUrl(url), this.getScriptMenu(tabId), @@ -405,19 +418,109 @@ export class PopupService { run = scriptToMenu(script); run.isEffective = o.effective!; } + run.matchesTopFrame = true; scriptMenuMap.set(uuid, run); } + await this.mergeSubFrameRunScripts(tabId, url, runScripts, scriptMenuMap); + const scriptMenu = [...scriptMenuMap.values()]; - // 检查是否在黑名单中 - const isBlacklist = this.runtime.isUrlBlacklist(url); // 即时附加图标与本地化脚本名(仅写入响应,不回写 session 缓存,避免 icon64 等占用过大) const [scriptListWithInfo, backScriptListWithInfo] = await Promise.all([ this.attachScriptDisplayInfo(scriptMenu), this.attachScriptDisplayInfo(backScriptList), ]); // 后台脚本只显示开启或者运行中的脚本 - return { isBlacklist, scriptList: scriptListWithInfo, backScriptList: backScriptListWithInfo }; + return { pageStatus, scriptList: scriptListWithInfo, backScriptList: backScriptListWithInfo }; + } + + /** + * 判断当前页脚本猫是否触及得到。 + * + * 顺序有意为之:浏览器保留页与黑名单是「无论如何都不会注入」的确定结论,先判; + * 其余情况以「本 tab 有没有 content script 报到」为准 —— 它是运行时证据, + * 比协议白名单准(企业策略、扩展商店等都拦不住白名单)。file:// 的权限查询只用来 + * 给未注入的情况一个更准确的原因,不能反过来否定已经注入成功的事实(Firefox 上该 + * 查询与实际可注入性并不总是一致)。 + */ + private async getPageStatus(tabId: number, url: string): Promise { + const kind = getPageAccessKind(url); + if (kind === "restricted") return "restricted"; + if (this.runtime.isUrlBlacklist(url)) return "blacklist"; + if (await this.isTabInjected(tabId, url)) return "ok"; + if (kind === "file" && !(await chrome.extension.isAllowedFileSchemeAccess())) return "file-access-denied"; + return "not-injected"; + } + + /** 本 tab 是否收到过当前 origin 的 content script 报到。 */ + private async isTabInjected(tabId: number, url: string) { + const origin = await cacheInstance.get(`${CACHE_KEY_TAB_LOADED}${tabId}`); + return !!origin && origin === toOrigin(url); + } + + /** + * 把「只在子 frame(iframe)里跑起来」的脚本并回当前页清单。 + * + * 清单主体按顶层网址匹配,因此 @match 只命中 iframe 的脚本连同它在 iframe 注册的 GM 菜单 + * 都会整条消失(#1687)。判定条件是「本 tab 跑过 ∧ 现在仍匹配某个子 frame」而非单纯「跑过」: + * 用户在 Popup 排除本站后脚本对所有 frame 都不再匹配,该行仍会立即消失。 + */ + private async mergeSubFrameRunScripts( + tabId: number, + topUrl: string, + runScripts: ScriptMenu[], + scriptMenuMap: Map + ) { + const unmatched = runScripts.filter((script) => !scriptMenuMap.has(script.uuid)); + if (!unmatched.length) return; + + const frameUrls = await this.getSubFrameUrls(tabId, topUrl); + if (!frameUrls.length) return; + + // effective 取「任一 frame 生效」:脚本只要在某个 frame 上没有被排除,它就确实会在该页运行。 + const frameMatching = new Map(); + for (const frameUrl of frameUrls) { + const matchingResult = await this.runtime.getPopupPageScriptMatchingResultByUrl(frameUrl); + for (const [uuid, o] of matchingResult) { + frameMatching.set(uuid, frameMatching.get(uuid) || o.effective); + } + } + + const matchedRunScripts = unmatched.filter((script) => frameMatching.has(script.uuid)); + if (!matchedRunScripts.length) return; + + // 运行记录来自 tabScript: session cache,脚本删除事件与 Popup 读取可能交错, + // 因此要用 DAO 结果做读侧防护,避免已删除脚本残留在 Popup 清单。 + const scripts = await this.scriptDAO.gets(matchedRunScripts.map((script) => script.uuid)); + for (let idx = 0, l = matchedRunScripts.length; idx < l; idx++) { + const script = scripts[idx]; + if (!script) continue; + const run = matchedRunScripts[idx]; + run.enable = script.status === SCRIPT_STATUS_ENABLE; + run.isEffective = frameMatching.get(run.uuid)!; + run.hasMatchOverride = script.selfMetadata?.match !== undefined; + run.hasUserConfig = !!script.config; + run.matchesTopFrame = false; + scriptMenuMap.set(run.uuid, run); + } + } + + /** 取本 tab 全部子 frame 的网址(去重、排除顶层网址)。标签页已关闭或不可访问时返回空数组。 */ + private async getSubFrameUrls(tabId: number, topUrl: string): Promise { + let frames: chrome.webNavigation.GetAllFrameResultDetails[] | null; + try { + frames = await chrome.webNavigation.getAllFrames({ tabId }); + } catch (e) { + // 取不到框架资料时退化为「只看顶层匹配」,与本功能加入前的行为一致。 + LoggerCore.logger().warn("getAllFrames failed", { tabId }, Logger.E(e)); + return []; + } + const urls = new Set(); + for (const frame of frames || []) { + if (!frame.frameId || !frame.url || frame.url === topUrl) continue; + urls.add(frame.url); + } + return [...urls]; } /** 为 ScriptMenu 列表即时附加图标 URL 与本地化脚本名(返回浅拷贝,不修改缓存中的原对象) */ @@ -486,6 +589,14 @@ export class PopupService { return changed; } + // popupPageLoadUpdate 的处理之一:顶层 frame 报到即说明本页扩展触及得到。 + // 记 origin 而非完整网址,SPA 换页不会失效,跳到另一个 origin 则自然失效。 + async markTabInjected({ tabId, frameId, url }: TPopupPageLoadInfo) { + if (frameId || tabId <= 0) return; + const origin = toOrigin(url); + if (origin) await cacheInstance.set(`${CACHE_KEY_TAB_LOADED}${tabId}`, origin); + } + async addScriptRunNumber(o: TPopupPageLoadInfo) { const { tabId, frameId, scriptmenus } = o; // 设置数据 @@ -686,6 +797,7 @@ export class PopupService { const clearData = async (tabId: number) => { runCountMap.delete(tabId); scriptCountMap.delete(tabId); + cacheInstance.del(`${CACHE_KEY_TAB_LOADED}${tabId}`); const list = this.updateMenuCommands.get(tabId); if (list) { // 避免 menuCommand 更新在 Tab 移除后触发 @@ -807,6 +919,7 @@ export class PopupService { // 监听运行次数 // 监听页面载入事件以更新脚本执行计数;若为当前活动 tab,同步刷新 badge。 this.mq.subscribe("popupPageLoadUpdate", async (o) => { + await this.markTabInjected(o); await this.addScriptRunNumber(o); // 设置角标 (chrome.tabs.onActivated 切换后) if (o.tabId === lastActiveTabId) { diff --git a/src/app/service/service_worker/popup_scriptmenu.ts b/src/app/service/service_worker/popup_scriptmenu.ts index fe3391c55..0830c1181 100644 --- a/src/app/service/service_worker/popup_scriptmenu.ts +++ b/src/app/service/service_worker/popup_scriptmenu.ts @@ -4,7 +4,7 @@ import type { Script } from "@App/app/repo/scripts"; import { getIcon, getStorageName } from "@App/pkg/utils/utils"; import { i18nName } from "@App/locales/locales"; -export type TPopupPageLoadInfo = { tabId: number; frameId?: number; scriptmenus: ScriptMenu[] }; +export type TPopupPageLoadInfo = { tabId: number; frameId?: number; url: string; scriptmenus: ScriptMenu[] }; // 将 Script 转为 ScriptMenu 并初始化其在该 tab 的菜单暂存(menus 空阵列、计数归零)。 export const scriptToMenu = (script: Script): ScriptMenu => { diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index 88ed68dea..30614ef38 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -1265,6 +1265,7 @@ export class RuntimeService { this.mq.emit("popupPageLoadUpdate", { tabId: tabId, frameId: frameId, + url: url, scriptmenus: res?.scriptmenus || [], // 对于 popup, resources那些不需要 }); diff --git a/src/app/service/service_worker/types.ts b/src/app/service/service_worker/types.ts index 8c63b27b1..29df3c741 100644 --- a/src/app/service/service_worker/types.ts +++ b/src/app/service/service_worker/types.ts @@ -183,6 +183,15 @@ export type GMRegisterMenuCommandParam = [TScriptMenuItemKey, TScriptMenuItemNam */ export type GMUnRegisterMenuCommandParam = [TScriptMenuItemKey]; +/** + * Popup 当前页的状态。除 `ok` 外都代表「脚本不会在此页面运行」,Popup 据此改为说明原因而不是列脚本: + * - restricted: 浏览器保留页(chrome:// / 扩展页 / 扩展商店等),任何扩展都注入不了 + * - blacklist: 命中用户配置的网址黑名单 + * - file-access-denied: 本地文件页,但未开启「允许访问文件网址」 + * - not-injected: 可注入但本 tab 没有 content script 报到(页面比扩展旧、被企业策略拦下等),刷新即可 + */ +export type TPopupPageStatus = "ok" | "restricted" | "blacklist" | "file-access-denied" | "not-injected"; + /** 脚本菜单的完整信息 */ export type ScriptMenu = { uuid: string; // 脚本uuid @@ -201,6 +210,9 @@ export type ScriptMenu = { menus: ScriptMenuItem[]; // 脚本菜单 isEffective: boolean | null; // 是否在当前网址启动 hasMatchOverride: boolean; // 是否存在 match 覆盖(selfMetadata.match !== undefined),用于区分 S1/S3 与 S2/S4 + // 是否匹配顶层页面网址。false 代表只匹配到某个子 frame(iframe),此时 Popup 的站点范围操作 + // (以顶层 host 生成规则)对该脚本无意义,不应显示。由 getPopupData 即时计算,不写回 session 缓存。 + matchesTopFrame?: boolean; }; /** 批量更新记录 */ diff --git a/src/locales/de-DE/popup.json b/src/locales/de-DE/popup.json index f63f32c2e..9545ccfac 100644 --- a/src/locales/de-DE/popup.json +++ b/src/locales/de-DE/popup.json @@ -6,6 +6,9 @@ "lower_version_browser_guide": "Ihr Browser ist zu veraltet, daher können die Skripte nicht richtig ausgeführt werden. 👉Hier klicken, um mehr zu erfahren", "click_to_reload": "👉Zum Neuladen klicken", "page_in_blacklist": "Die aktuelle Seite ist auf der Blacklist und kann keine Skripte verwenden", + "page_restricted": "Der Browser erlaubt Erweiterungen nicht, auf dieser Seite Skripte auszuführen", + "page_file_access_denied": "Um Skripte auf lokalen Dateien auszuführen, aktivieren Sie „Zugriff auf Datei-URLs zulassen“ auf der Detailseite der Erweiterung", + "page_not_injected": "Auf dieser Seite läuft noch kein Skript – laden Sie die Seite neu", "ext_update_notification": "ScriptCat-Erweiterung wurde aktualisiert", "ext_update_notification_desc": "Aktuelle Version: {{version}}, Details finden Sie im Changelog", "script_menu_display": "Von Skript registrierte Menüs", diff --git a/src/locales/en-US/popup.json b/src/locales/en-US/popup.json index ead07e2c9..021990175 100644 --- a/src/locales/en-US/popup.json +++ b/src/locales/en-US/popup.json @@ -6,6 +6,9 @@ "lower_version_browser_guide": "Your browser is too outdated, so the scripts cannot run properly. 👉Click me to learn more", "click_to_reload": "👉Click to Reload", "page_in_blacklist": "The current page is blacklisted, cannot use script", + "page_restricted": "The browser does not allow extensions to run scripts on this page", + "page_file_access_denied": "To run scripts on local files, enable “Allow access to file URLs” on the extension details page", + "page_not_injected": "No script is running on this page yet — reload the page to take effect", "ext_update_notification": "Scriptcat extension updated", "ext_update_notification_desc": "Current version: {{version}}, please see the update log for details", "script_menu_display": "Script Registered Menu", diff --git a/src/locales/ja-JP/popup.json b/src/locales/ja-JP/popup.json index 6645893b7..2e99c2ad0 100644 --- a/src/locales/ja-JP/popup.json +++ b/src/locales/ja-JP/popup.json @@ -6,6 +6,9 @@ "lower_version_browser_guide": "ご使用のブラウザは古すぎるため、スクリプトは正常に動作しません。👉詳しくはこちら", "click_to_reload": "👉再読み込みする", "page_in_blacklist": "現在のページはブラックリストにあり、スクリプトを使用できません", + "page_restricted": "ブラウザーはこのページでの拡張機能によるスクリプト実行を許可していません", + "page_file_access_denied": "ローカルファイルでスクリプトを実行するには、拡張機能の詳細ページで「ファイルの URL へのアクセスを許可する」を有効にしてください", + "page_not_injected": "このページではまだスクリプトが実行されていません。ページを再読み込みしてください", "ext_update_notification": "ScriptCat拡張機能が更新されました", "ext_update_notification_desc": "現在のバージョン: {{version}}、詳細は更新ログをご覧ください", "script_menu_display": "スクリプトが登録したメニュー", diff --git a/src/locales/ko-KR/popup.json b/src/locales/ko-KR/popup.json index a94baa567..ce661f2e0 100644 --- a/src/locales/ko-KR/popup.json +++ b/src/locales/ko-KR/popup.json @@ -6,6 +6,9 @@ "lower_version_browser_guide": "브라우저 버전이 너무 낮아 스크립트가 정상적으로 실행되지 않습니다. 👉자세히 알아보기", "click_to_reload": "👉클릭하여 새로고침", "page_in_blacklist": "현재 페이지는 차단 목록에 있어 스크립트를 사용할 수 없습니다", + "page_restricted": "브라우저가 이 페이지에서 확장 프로그램의 스크립트 실행을 허용하지 않습니다", + "page_file_access_denied": "로컬 파일에서 스크립트를 실행하려면 확장 프로그램 세부정보 페이지에서 '파일 URL에 대한 액세스 허용'을 켜세요", + "page_not_injected": "이 페이지에서는 아직 스크립트가 실행되지 않았습니다. 페이지를 새로 고치세요", "ext_update_notification": "ScriptCat 확장 프로그램이 업데이트되었습니다", "ext_update_notification_desc": "현재 버전: {{version}}, 자세한 내용은 업데이트 로그를 확인하세요", "script_menu_display": "스크립트가 등록한 메뉴", diff --git a/src/locales/pt-BR/popup.json b/src/locales/pt-BR/popup.json index ee33dcc57..8c5dfab2d 100644 --- a/src/locales/pt-BR/popup.json +++ b/src/locales/pt-BR/popup.json @@ -6,6 +6,9 @@ "lower_version_browser_guide": "Seu navegador está muito desatualizado, então os scripts não podem ser executados corretamente. 👉Clique aqui para saber mais", "click_to_reload": "👉Clique para recarregar", "page_in_blacklist": "A página atual está na lista de bloqueio, não é possível usar scripts", + "page_restricted": "O navegador não permite que extensões executem scripts nesta página", + "page_file_access_denied": "Para executar scripts em arquivos locais, ative “Permitir acesso a URLs de arquivo” na página de detalhes da extensão", + "page_not_injected": "Nenhum script está em execução nesta página — recarregue a página para aplicar", "ext_update_notification": "Extensão ScriptCat atualizada", "ext_update_notification_desc": "Versão atual: {{version}}, por favor, veja o log de atualizações para mais detalhes", "script_menu_display": "Menu criado do script", diff --git a/src/locales/ru-RU/popup.json b/src/locales/ru-RU/popup.json index b74dae3b6..12f3294b7 100644 --- a/src/locales/ru-RU/popup.json +++ b/src/locales/ru-RU/popup.json @@ -6,6 +6,9 @@ "lower_version_browser_guide": "Ваш браузер слишком устарел, поэтому скрипты не могут работать корректно. 👉Нажмите, чтобы узнать подробнее", "click_to_reload": "👉Нажмите для перезагрузки", "page_in_blacklist": "Текущая страница находится в черном списке, невозможно использовать скрипты", + "page_restricted": "Браузер не разрешает расширениям выполнять скрипты на этой странице", + "page_file_access_denied": "Чтобы выполнять скрипты в локальных файлах, включите «Разрешить доступ к файлам URL» на странице сведений о расширении", + "page_not_injected": "На этой странице ещё не выполняется ни один скрипт — обновите страницу", "ext_update_notification": "Расширение ScriptCat обновлено", "ext_update_notification_desc": "Текущая версия: {{version}}, подробности смотрите в журнале обновлений", "script_menu_display": "Меню, зарегистрированные скриптом", diff --git a/src/locales/tr-TR/popup.json b/src/locales/tr-TR/popup.json index e9d8c84ca..dead0fae2 100644 --- a/src/locales/tr-TR/popup.json +++ b/src/locales/tr-TR/popup.json @@ -6,6 +6,9 @@ "lower_version_browser_guide": "Tarayıcınız çok eski, bu nedenle betikler düzgün çalışamaz. 👉Daha fazla bilgi edinmek için tıklayın", "click_to_reload": "👉Yeniden Yüklemek İçin Tıklayın", "page_in_blacklist": "Geçerli sayfa kara listeye alındığından betik kullanılamaz", + "page_restricted": "Tarayıcı, uzantıların bu sayfada betik çalıştırmasına izin vermiyor", + "page_file_access_denied": "Yerel dosyalarda betik çalıştırmak için uzantı ayrıntıları sayfasında “Dosya URL’lerine erişime izin ver” seçeneğini açın", + "page_not_injected": "Bu sayfada henüz betik çalışmıyor — sayfayı yenileyin", "ext_update_notification": "ScriptCat uzantısı güncellendi", "ext_update_notification_desc": "Geçerli sürüm: {{version}}, ayrıntılar için güncelleme günlüğüne bakın", "script_menu_display": "Betik Menüsü", diff --git a/src/locales/vi-VN/popup.json b/src/locales/vi-VN/popup.json index 94f65427d..a412289f6 100644 --- a/src/locales/vi-VN/popup.json +++ b/src/locales/vi-VN/popup.json @@ -6,6 +6,9 @@ "lower_version_browser_guide": "Trình duyệt của bạn quá cũ, nên các script không thể hoạt động đúng cách. 👉Nhấn để xem thêm", "click_to_reload": "👉Nhấp chuột để tải lại", "page_in_blacklist": "Trang hiện tại nằm trong danh sách đen, không thể sử dụng script", + "page_restricted": "Trình duyệt không cho phép tiện ích chạy script trên trang này", + "page_file_access_denied": "Để chạy script trên tệp cục bộ, hãy bật “Cho phép truy cập URL tệp” trong trang chi tiết tiện ích", + "page_not_injected": "Chưa có script nào chạy trên trang này — hãy tải lại trang", "ext_update_notification": "Tiện ích scriptcat đã cập nhật", "ext_update_notification_desc": "Phiên bản hiện tại: {{version}}, vui lòng xem nhật ký cập nhật để biết chi tiết", "script_menu_display": "Menu đã đăng ký script", diff --git a/src/locales/zh-CN/popup.json b/src/locales/zh-CN/popup.json index ef6dc1e14..9eec8fd6d 100644 --- a/src/locales/zh-CN/popup.json +++ b/src/locales/zh-CN/popup.json @@ -6,6 +6,9 @@ "lower_version_browser_guide": "您的浏览器版本过低,脚本无法正常运行。👉点击了解更多", "click_to_reload": "👉点击重新加载", "page_in_blacklist": "当前页面在黑名单中,无法使用脚本", + "page_restricted": "浏览器不允许扩展在此页面运行脚本", + "page_file_access_denied": "要在本地文件上运行脚本,请在扩展详情页开启「允许访问文件网址」", + "page_not_injected": "脚本尚未在此页面运行,刷新页面后生效", "ext_update_notification": "脚本猫扩展已更新", "ext_update_notification_desc": "当前版本:{{version}},详情请查看更新日志", "script_menu_display": "脚本注册的菜单", diff --git a/src/locales/zh-TW/popup.json b/src/locales/zh-TW/popup.json index 755b1f76e..9c06bbf8b 100644 --- a/src/locales/zh-TW/popup.json +++ b/src/locales/zh-TW/popup.json @@ -6,6 +6,9 @@ "lower_version_browser_guide": "您的瀏覽器版本過舊,腳本無法正常執行。👉點擊了解更多", "click_to_reload": "👉點擊重新載入", "page_in_blacklist": "目前頁面在黑名單中,無法使用腳本", + "page_restricted": "瀏覽器不允許擴充功能在此頁面執行腳本", + "page_file_access_denied": "要在本機檔案上執行腳本,請在擴充功能詳細資料頁開啟「允許存取檔案網址」", + "page_not_injected": "腳本尚未在此頁面執行,重新整理頁面後生效", "ext_update_notification": "腳本貓擴充功能已更新", "ext_update_notification_desc": "目前版本:{{version}},詳情請查看更新日誌", "script_menu_display": "腳本註冊的選單", diff --git a/src/pages/popup/App.test.tsx b/src/pages/popup/App.test.tsx index 779b4e0a6..e34bab9e2 100644 --- a/src/pages/popup/App.test.tsx +++ b/src/pages/popup/App.test.tsx @@ -21,7 +21,7 @@ import App from "./App"; function makeData(overrides: Record = {}) { return { loading: false, - isBlacklist: false, + pageStatus: "ok", host: "example.com", scriptList: [], backScriptList: [], @@ -163,6 +163,27 @@ describe("Popup 紧凑布局", () => { }); }); +describe("Popup 当前页状态提示(脚本猫触及不到的页面)", () => { + it.each([ + ["restricted", "浏览器不允许扩展在此页面运行脚本"], + ["blacklist", "当前页面在黑名单中,无法使用脚本"], + ["file-access-denied", "要在本地文件上运行脚本,请在扩展详情页开启「允许访问文件网址」"], + ["not-injected", "脚本尚未在此页面运行,刷新页面后生效"], + ])("pageStatus=%s 时说明本页不运行脚本的原因", (pageStatus, message) => { + mockData = makeData({ pageStatus, scriptList: [], fullScriptCount: 0 }); + render(); + + expect(screen.getByText(message)).toBeInTheDocument(); + }); + + it("pageStatus=ok 时不显示任何状态提示", () => { + mockData = makeData({ scriptList: [makeScriptMenu()], fullScriptCount: 1 }); + render(); + + expect(screen.queryByText(/浏览器不允许|黑名单|允许访问文件网址|刷新页面后生效/)).not.toBeInTheDocument(); + }); +}); + describe("Popup 脚本快捷设置与站点范围操作", () => { it.each([false, true])( "开关关闭时有效脚本始终保留排除并回落黑名单动作(hasMatchOverride=%s)", @@ -255,6 +276,22 @@ describe("Popup 脚本快捷设置与站点范围操作", () => { expect(handleAllowUrl).toHaveBeenCalledWith("u1"); }); + it("只匹配到 iframe 的脚本隐藏站点范围动作(规则按顶层 host 生成,对它不成立)", () => { + mockData = makeData({ + popupSiteScopeActions: true, + scriptList: [makeScriptMenu({ isEffective: true, hasMatchOverride: false, matchesTopFrame: false })], + fullScriptCount: 1, + }); + render(); + + fireEvent.click(screen.getByRole("button", { name: /Script A/ })); + + expect(screen.getByRole("button", { name: "脚本设置" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "仅在 example.com 执行" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "允许在 example.com 执行" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "排除在 example.com 上执行" })).not.toBeInTheDocument(); + }); + it("开关关闭且本站不生效时隐藏包含与排除动作", () => { mockData = makeData({ scriptList: [makeScriptMenu({ isEffective: false, hasMatchOverride: true })], diff --git a/src/pages/popup/App.tsx b/src/pages/popup/App.tsx index fa0840d3e..ecc877dc9 100644 --- a/src/pages/popup/App.tsx +++ b/src/pages/popup/App.tsx @@ -45,7 +45,7 @@ import { versionCompare, type ScriptProvider, } from "./usePopupData"; -import type { ScriptMenu, ScriptMenuItem } from "@App/app/service/service_worker/types"; +import type { ScriptMenu, ScriptMenuItem, TPopupPageStatus } from "@App/app/service/service_worker/types"; import { ScriptIcon } from "@App/pages/options/routes/ScriptList/components"; import PopupWarnings from "./PopupWarnings"; import { SCRIPT_RUN_STATUS_RUNNING, SCRIPT_RUN_STATUS_ERROR } from "@App/app/repo/scripts"; @@ -107,10 +107,10 @@ export default function App() {
{/* 顶部警告区:UserScripts API 不可用引导 / 申请权限 / Edge 移动端二维码 / 黑名单 */} - {/* 黑名单警告 */} - {data.isBlacklist && ( + {/* 本页不会运行脚本时说明原因,取代「列出一堆并没有在跑的脚本」 */} + {data.pageStatus !== "ok" && (
- {t("popup:page_in_blacklist")} + {getPageStatusMessage(data.pageStatus, t)}
)}
onExcludeUrl(script.uuid, true) : undefined; + // 只匹配到子 frame(iframe)的脚本:站点范围操作按顶层 host 生成规则,对它不成立,故不显示 + const siteHost = isPageScript && script.matchesTopFrame !== false ? host : undefined; const statusBadge = getStatusBadge(script, isPageScript, t); const displayName = script.name; @@ -594,27 +596,26 @@ function ScriptRow({ > {t("editor:script_setting")} - {isPageScript && host && showSiteScopeActions && script.isEffective === false && onAllowUrl && ( + {siteHost && showSiteScopeActions && script.isEffective === false && onAllowUrl && ( } primary onClick={() => onAllowUrl(script.uuid)}> - {t("allow_on_site").replace("$0", host)} + {t("allow_on_site").replace("$0", siteHost)} )} - {isPageScript && - host && + {siteHost && showSiteScopeActions && script.isEffective === true && !script.hasMatchOverride && onOnlyRunOnUrl && ( onOnlyRunOnUrl(script.uuid)}> } primary> - {t("only_on_site").replace("$0", host)} + {t("only_on_site").replace("$0", siteHost)} )} {/* 排除 host 无需确认;站点范围操作开启时同步维护 match 与 exclude 覆盖。 */} - {isPageScript && host && script.isEffective === true && excludeSite && ( + {siteHost && script.isEffective === true && excludeSite && ( } warn onClick={excludeSite}> - {t("exclude_off").replace("$0", host)} + {t("exclude_off").replace("$0", siteHost)} )} {/* 删除(AlertDialog 二次确认) */} @@ -691,6 +692,22 @@ function ScriptRow({ ); } +/** 当前页不运行脚本的原因说明;`ok` 不显示提示。 */ +function getPageStatusMessage(pageStatus: TPopupPageStatus, t: TFunction): string { + switch (pageStatus) { + case "blacklist": + return t("popup:page_in_blacklist"); + case "restricted": + return t("popup:page_restricted"); + case "file-access-denied": + return t("popup:page_file_access_denied"); + case "not-injected": + return t("popup:page_not_injected"); + case "ok": + return ""; + } +} + function getStatusBadge(script: ScriptMenu, isPageScript: boolean, t: TFunction): React.ReactNode { if (script.runStatus === SCRIPT_RUN_STATUS_RUNNING) { // 与设计稿一致:页面脚本运行中=蓝色(info),后台脚本运行中=绿色(success) diff --git a/src/pages/popup/preload.ts b/src/pages/popup/preload.ts index e59e9b14b..00974b0d4 100644 --- a/src/pages/popup/preload.ts +++ b/src/pages/popup/preload.ts @@ -1,4 +1,5 @@ -import type { ScriptMenu } from "@App/app/service/service_worker/types"; +import type { ScriptMenu, TPopupPageStatus } from "@App/app/service/service_worker/types"; +import type { GetPopupDataRes } from "@App/app/service/service_worker/client"; import { ExtVersion } from "@App/app/const"; import { cacheInstance } from "@App/app/cache"; import { sanitizeHTML } from "@App/pkg/utils/sanitize"; @@ -19,7 +20,7 @@ export type PopupInitialData = { popupCompactLayout: boolean; popupSiteScopeActions: boolean; defaultScriptProvider: ScriptProvider; - isBlacklist: boolean; + pageStatus: TPopupPageStatus; scriptList: ScriptMenu[]; backScriptList: ScriptMenu[]; }; @@ -58,10 +59,11 @@ const popupDataQuery = createPreloadableQuery<"popup", PopupInitialData>({ const tabId = tab?.id ?? -1; const url = tab?.url ?? ""; - const popupData = + // 取不到标签页(例如开发者工具窗口)时,同样按「脚本猫触及不到」处理 + const popupData: GetPopupDataRes = tabId >= 0 && url ? await popupClient.getPopupData({ tabId, url }) - : { isBlacklist: false, scriptList: [], backScriptList: [] }; + : { pageStatus: "restricted", scriptList: [], backScriptList: [] }; if (signal.aborted) throw new DOMException("Popup preload aborted", "AbortError"); @@ -75,7 +77,7 @@ const popupDataQuery = createPreloadableQuery<"popup", PopupInitialData>({ popupCompactLayout, popupSiteScopeActions, defaultScriptProvider: provider ?? "scriptcat", - isBlacklist: popupData.isBlacklist, + pageStatus: popupData.pageStatus, scriptList: popupData.scriptList.sort(scriptListSorter), backScriptList: popupData.backScriptList, }; diff --git a/src/pages/popup/usePopupData.ts b/src/pages/popup/usePopupData.ts index 779f11d0f..6664e14d4 100644 --- a/src/pages/popup/usePopupData.ts +++ b/src/pages/popup/usePopupData.ts @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback, useRef, useMemo } from "react"; -import type { ScriptMenu, ScriptMenuItem, TPopupScript } from "@App/app/service/service_worker/types"; +import type { ScriptMenu, ScriptMenuItem, TPopupPageStatus, TPopupScript } from "@App/app/service/service_worker/types"; import type { TDeleteScript, TEnableScript, TScriptRunStatus } from "@App/app/service/queue"; import { popupClient, scriptClient, runtimeClient, requestOpenBatchUpdatePage } from "../store/features/script"; import { subscribeMessage, systemConfig } from "../store/global"; @@ -83,7 +83,7 @@ export function usePopupData() { const [initialized, setInitialized] = useState(!!initialData); const [scriptList, setScriptList] = useState(initialData?.scriptList ?? []); const [backScriptList, setBackScriptList] = useState(initialData?.backScriptList ?? []); - const [isBlacklist, setIsBlacklist] = useState(initialData?.isBlacklist ?? false); + const [pageStatus, setPageStatus] = useState(initialData?.pageStatus ?? "ok"); const [currentUrl, setCurrentUrl] = useState(initialData?.url ?? ""); const [currentTabId, setCurrentTabId] = useState(initialData?.tabId ?? -1); const [searchQuery, setSearchQuery] = useState(""); @@ -122,7 +122,7 @@ export function usePopupData() { res.scriptList.sort(scriptListSorter); setScriptList(res.scriptList); setBackScriptList(res.backScriptList); - setIsBlacklist(res.isBlacklist); + setPageStatus(res.pageStatus); } catch (e) { console.error("Failed to fetch popup data:", e); } @@ -133,7 +133,7 @@ export function usePopupData() { if (initialData && !initialized) { setScriptList(initialData.scriptList); setBackScriptList(initialData.backScriptList); - setIsBlacklist(initialData.isBlacklist); + setPageStatus(initialData.pageStatus); setCurrentUrl(initialData.url); setCurrentTabId(initialData.tabId); setIsEnableScript(initialData.isEnableScript); @@ -439,7 +439,7 @@ export function usePopupData() { return { loading: !initialized && !popupData.isError, - isBlacklist, + pageStatus, host, scriptList: displayScriptList, backScriptList: displayBackScriptList, diff --git a/src/pkg/utils/page_access.test.ts b/src/pkg/utils/page_access.test.ts new file mode 100644 index 000000000..21ba91d1a --- /dev/null +++ b/src/pkg/utils/page_access.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { getPageAccessKind, toOrigin } from "./page_access"; + +describe("getPageAccessKind 页面可注入性分类", () => { + it.each([ + "chrome://settings/", + "chrome://flags/", + "chrome-untrusted://terminal/", + "edge://settings/", + "about:addons", + "devtools://devtools/bundled/inspector.html", + "view-source:https://example.com/", + "chrome-extension://abcdefghijklmnopabcdefghijklmnop/popup.html", + "moz-extension://11111111-2222-3333-4444-555555555555/popup.html", + ])("浏览器内部页 / 扩展页不可注入:%s", (url) => { + expect(getPageAccessKind(url)).toBe("restricted"); + }); + + it.each([ + "https://chromewebstore.google.com/detail/abc", + "https://chrome.google.com/webstore/detail/abc", + "https://addons.mozilla.org/zh-CN/firefox/addon/abc/", + ])("扩展商店页浏览器不允许注入:%s", (url) => { + expect(getPageAccessKind(url)).toBe("restricted"); + }); + + it("chrome.google.com 上非商店路径仍是普通网页", () => { + expect(getPageAccessKind("https://chrome.google.com/intl/zh-CN/chrome/")).toBe("web"); + }); + + it.each(["https://example.com/", "http://example.com/a?b=1", "https://xn--fiq228c.tld/"])( + "普通 http(s) 页可注入:%s", + (url) => { + expect(getPageAccessKind(url)).toBe("web"); + } + ); + + it.each(["file:///Users/me/a.html", "file:///D:/tmp/b.htm"])("本地文件另成一类(需额外授权):%s", (url) => { + expect(getPageAccessKind(url)).toBe("file"); + }); + + it.each(["", "not a url", "about:blank"])("无法解析或无内容的地址按不可注入处理:%s", (url) => { + expect(getPageAccessKind(url)).toBe("restricted"); + }); +}); + +describe("toOrigin 注入前提的同一性", () => { + it("同源不同路径/查询视为同一 origin(SPA 换页不应失效)", () => { + expect(toOrigin("https://example.com/a?b=1")).toBe(toOrigin("https://example.com/c")); + }); + + it("不同 host 或不同端口不是同一 origin", () => { + expect(toOrigin("https://example.com/")).not.toBe(toOrigin("https://other.com/")); + expect(toOrigin("http://example.com:8080/")).not.toBe(toOrigin("http://example.com/")); + }); + + it("本地文件页之间共享同一授权前提", () => { + expect(toOrigin("file:///a.html")).toBe("file://"); + expect(toOrigin("file:///b/c.html")).toBe("file://"); + }); + + it("无法解析的地址返回空字符串", () => { + expect(toOrigin("not a url")).toBe(""); + }); +}); diff --git a/src/pkg/utils/page_access.ts b/src/pkg/utils/page_access.ts new file mode 100644 index 000000000..e027e25ed --- /dev/null +++ b/src/pkg/utils/page_access.ts @@ -0,0 +1,36 @@ +/** 页面对扩展的可注入性分类。`file` 单独成类:浏览器另有「允许访问文件网址」开关。 */ +export type TPageAccessKind = "web" | "file" | "restricted"; + +// 浏览器强制保留、任何扩展都注入不了的页面。about:blank 也在内:它没有内容可注入。 +const INJECTABLE_PROTOCOLS = new Set(["http:", "https:"]); + +// 商店页由浏览器单独保护,scheme 是 https 但同样注入不了。 +const isExtensionStore = (url: URL) => + url.hostname === "chromewebstore.google.com" || + url.hostname === "addons.mozilla.org" || + (url.hostname === "chrome.google.com" && url.pathname.startsWith("/webstore")); + +export const getPageAccessKind = (url: string): TPageAccessKind => { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return "restricted"; + } + if (parsed.protocol === "file:") return "file"; + if (!INJECTABLE_PROTOCOLS.has(parsed.protocol)) return "restricted"; + return isExtensionStore(parsed) ? "restricted" : "web"; +}; + +/** + * 取用于判定「同一注入前提」的 origin。解析失败返回空字符串。 + * file:// 的 origin 在各浏览器多为 "null",改用 scheme 代替:本地文件页之间共享同一个授权开关。 + */ +export const toOrigin = (url: string): string => { + try { + const parsed = new URL(url); + return parsed.protocol === "file:" ? "file://" : parsed.origin; + } catch { + return ""; + } +}; From 359d4f9643a977a8e804bbca7b9d95329a27bbb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Mon, 24 Aug 2026 17:01:42 +0800 Subject: [PATCH 2/8] =?UTF-8?q?=F0=9F=90=9B=20=E6=89=A9=E5=B1=95=E5=95=86?= =?UTF-8?q?=E5=BA=97=E5=88=A4=E5=AE=9A=E7=A7=BB=E5=88=B0=E6=B3=A8=E5=85=A5?= =?UTF-8?q?=E8=AF=81=E6=8D=AE=E4=B9=8B=E5=90=8E=EF=BC=8C=E5=B9=B6=E8=A1=A5?= =?UTF-8?q?=E4=B8=8A=20Edge=20=E5=95=86=E5=BA=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 商店域是浏览器相关的:Edge 商店在 Chrome 里就是普通网页,Chrome 商店在 Firefox 里也一样。原实现把商店域并进 getPageAccessKind 的硬名单、优先于 注入证据判定,会在「别家浏览器」上误报脚本不能运行——而它其实在跑。 改为与 file:// 权限查询同一处理:只用来给「已确认没注入」的页面一个更准确 的原因,不反过来否定已注入的事实。顺带补上 microsoftedge.microsoft.com/addons。 --- src/app/service/service_worker/popup.test.ts | 26 +++++++++++++ src/app/service/service_worker/popup.ts | 6 ++- src/pkg/utils/page_access.test.ts | 39 ++++++++++++++------ src/pkg/utils/page_access.ts | 30 +++++++++++---- 4 files changed, 81 insertions(+), 20 deletions(-) diff --git a/src/app/service/service_worker/popup.test.ts b/src/app/service/service_worker/popup.test.ts index 70391ead4..0ba4fd5c5 100644 --- a/src/app/service/service_worker/popup.test.ts +++ b/src/app/service/service_worker/popup.test.ts @@ -516,6 +516,32 @@ describe("PopupService getPopupData 页面可达性(脚本猫无法触及的 expect(result.pageStatus).toBe("not-injected"); }); + it("扩展商店页未注入时报 restricted(浏览器保护自家商店)", async () => { + const { service } = createService(); + + const result = await service.getPopupData({ + tabId: 1, + url: "https://microsoftedge.microsoft.com/addons/detail/abcdefgh", + }); + + expect(result.pageStatus).toBe("restricted"); + }); + + it("扩展商店页若实际已注入则按 ok 处理:各浏览器只保护自家商店,别家商店在本浏览器是普通网页", async () => { + const uuid = "allsite"; + const storeUrl = "https://microsoftedge.microsoft.com/addons/detail/abcdefgh"; + const { service } = createService({ + runtime: { getPopupPageScriptMatchingResultByUrl: matchOne(uuid) }, + scriptDAO: { gets: vi.fn().mockResolvedValue([createScript(uuid)]) }, + }); + await firePageLoad(service, 1, storeUrl); + + const result = await service.getPopupData({ tabId: 1, url: storeUrl }); + + expect(result.pageStatus).toBe("ok"); + expect(result.scriptList.map((s) => s.uuid)).toEqual([uuid]); + }); + it("file:// 页未授权文件访问时应返回 file-access-denied", async () => { vi.spyOn(extensionMock, "isAllowedFileSchemeAccess").mockResolvedValue(false); const { service } = createService(); diff --git a/src/app/service/service_worker/popup.ts b/src/app/service/service_worker/popup.ts index 58d62b7f9..0932cb055 100644 --- a/src/app/service/service_worker/popup.ts +++ b/src/app/service/service_worker/popup.ts @@ -20,7 +20,7 @@ import { type SystemConfig } from "@App/pkg/config/config"; import { CACHE_KEY_TAB_LOADED, CACHE_KEY_TAB_SCRIPT } from "@App/app/cache_key"; import { timeoutExecution } from "@App/pkg/utils/timer"; import { v5 as uuidv5 } from "uuid"; -import { getPageAccessKind, toOrigin } from "@App/pkg/utils/page_access"; +import { getPageAccessKind, isExtensionStoreUrl, toOrigin } from "@App/pkg/utils/page_access"; import LoggerCore from "@App/app/logger/core"; import Logger from "@App/app/logger/logger"; @@ -448,6 +448,10 @@ export class PopupService { if (kind === "restricted") return "restricted"; if (this.runtime.isUrlBlacklist(url)) return "blacklist"; if (await this.isTabInjected(tabId, url)) return "ok"; + // 以下都是「确认没注入」,只为给出更准确的原因:两项判据都与浏览器有关 + // (Edge 商店在 Chrome 里是普通网页;Firefox 的文件访问开关语义也不同), + // 放在注入证据之后才不会误伤实际能运行的页面。 + if (isExtensionStoreUrl(url)) return "restricted"; if (kind === "file" && !(await chrome.extension.isAllowedFileSchemeAccess())) return "file-access-denied"; return "not-injected"; } diff --git a/src/pkg/utils/page_access.test.ts b/src/pkg/utils/page_access.test.ts index 21ba91d1a..6c4b0d466 100644 --- a/src/pkg/utils/page_access.test.ts +++ b/src/pkg/utils/page_access.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { getPageAccessKind, toOrigin } from "./page_access"; +import { getPageAccessKind, isExtensionStoreUrl, toOrigin } from "./page_access"; describe("getPageAccessKind 页面可注入性分类", () => { it.each([ @@ -16,16 +16,8 @@ describe("getPageAccessKind 页面可注入性分类", () => { expect(getPageAccessKind(url)).toBe("restricted"); }); - it.each([ - "https://chromewebstore.google.com/detail/abc", - "https://chrome.google.com/webstore/detail/abc", - "https://addons.mozilla.org/zh-CN/firefox/addon/abc/", - ])("扩展商店页浏览器不允许注入:%s", (url) => { - expect(getPageAccessKind(url)).toBe("restricted"); - }); - - it("chrome.google.com 上非商店路径仍是普通网页", () => { - expect(getPageAccessKind("https://chrome.google.com/intl/zh-CN/chrome/")).toBe("web"); + it("商店页在协议层面仍是普通 https 网页——是否注入得了由浏览器决定,不在此判定", () => { + expect(getPageAccessKind("https://chromewebstore.google.com/detail/abc")).toBe("web"); }); it.each(["https://example.com/", "http://example.com/a?b=1", "https://xn--fiq228c.tld/"])( @@ -44,6 +36,31 @@ describe("getPageAccessKind 页面可注入性分类", () => { }); }); +describe("isExtensionStoreUrl 扩展商店页识别", () => { + it.each([ + "https://chromewebstore.google.com/detail/abc", + "https://chrome.google.com/webstore/detail/abc", + "https://addons.mozilla.org/zh-CN/firefox/addon/abc/", + "https://microsoftedge.microsoft.com/addons/detail/abcdefgh", + "https://microsoftedge.microsoft.com/addons/Microsoft-Edge-Extensions-Home", + ])("各浏览器的扩展商店:%s", (url) => { + expect(isExtensionStoreUrl(url)).toBe(true); + }); + + it.each([ + "https://chrome.google.com/intl/zh-CN/chrome/", + "https://microsoftedge.microsoft.com/", + "https://www.microsoft.com/edge", + "https://example.com/addons/detail", + ])("同域下的非商店路径与同名路径不算商店:%s", (url) => { + expect(isExtensionStoreUrl(url)).toBe(false); + }); + + it("无法解析的地址不算商店", () => { + expect(isExtensionStoreUrl("not a url")).toBe(false); + }); +}); + describe("toOrigin 注入前提的同一性", () => { it("同源不同路径/查询视为同一 origin(SPA 换页不应失效)", () => { expect(toOrigin("https://example.com/a?b=1")).toBe(toOrigin("https://example.com/c")); diff --git a/src/pkg/utils/page_access.ts b/src/pkg/utils/page_access.ts index e027e25ed..205c63757 100644 --- a/src/pkg/utils/page_access.ts +++ b/src/pkg/utils/page_access.ts @@ -4,12 +4,6 @@ export type TPageAccessKind = "web" | "file" | "restricted"; // 浏览器强制保留、任何扩展都注入不了的页面。about:blank 也在内:它没有内容可注入。 const INJECTABLE_PROTOCOLS = new Set(["http:", "https:"]); -// 商店页由浏览器单独保护,scheme 是 https 但同样注入不了。 -const isExtensionStore = (url: URL) => - url.hostname === "chromewebstore.google.com" || - url.hostname === "addons.mozilla.org" || - (url.hostname === "chrome.google.com" && url.pathname.startsWith("/webstore")); - export const getPageAccessKind = (url: string): TPageAccessKind => { let parsed: URL; try { @@ -18,8 +12,28 @@ export const getPageAccessKind = (url: string): TPageAccessKind => { return "restricted"; } if (parsed.protocol === "file:") return "file"; - if (!INJECTABLE_PROTOCOLS.has(parsed.protocol)) return "restricted"; - return isExtensionStore(parsed) ? "restricted" : "web"; + return INJECTABLE_PROTOCOLS.has(parsed.protocol) ? "web" : "restricted"; +}; + +/** + * 是否为扩展商店页。各浏览器只保护「自家」商店:Edge 商店在 Chrome 里就是普通网页, + * 反之亦然。因此调用方只能拿它给「已确认没注入」的页面一个更准确的原因, + * 不能反过来断定注入不了——否则会误伤在别家浏览器里正常运行的脚本。 + */ +export const isExtensionStoreUrl = (url: string): boolean => { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return false; + } + const { hostname, pathname } = parsed; + return ( + hostname === "chromewebstore.google.com" || + hostname === "addons.mozilla.org" || + (hostname === "chrome.google.com" && pathname.startsWith("/webstore")) || + (hostname === "microsoftedge.microsoft.com" && pathname.startsWith("/addons")) + ); }; /** From 67270db507a05a0cfa1e80b44cddc88cecfaed1d Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:49:45 +0900 Subject: [PATCH 3/8] =?UTF-8?q?=F0=9F=90=9B=20popup=20=E5=8F=AA=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E5=AE=9E=E9=99=85=E6=B3=A8=E5=85=A5=E7=9A=84=E8=84=9A?= =?UTF-8?q?=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/chrome-extension-mock/tab.ts | 6 +++ src/app/service/service_worker/popup.test.ts | 53 ++++++++++++++------ src/app/service/service_worker/popup.ts | 41 ++++++++------- 3 files changed, 64 insertions(+), 36 deletions(-) diff --git a/packages/chrome-extension-mock/tab.ts b/packages/chrome-extension-mock/tab.ts index 8c4cb8f5a..3cc154b90 100644 --- a/packages/chrome-extension-mock/tab.ts +++ b/packages/chrome-extension-mock/tab.ts @@ -3,6 +3,12 @@ import EventEmitter from "eventemitter3"; export default class MockTab { hook = new EventEmitter(); + get(tabId: number, callback?: (tab: chrome.tabs.Tab) => void) { + const tab = { id: tabId, url: "https://example.com/" } as chrome.tabs.Tab; + callback?.(tab); + return Promise.resolve(tab); + } + query(queryInfo?: chrome.tabs.QueryInfo, callback?: (tabs: chrome.tabs.Tab[]) => void) { const mockTab = { id: 1, diff --git a/src/app/service/service_worker/popup.test.ts b/src/app/service/service_worker/popup.test.ts index 0ba4fd5c5..855b89981 100644 --- a/src/app/service/service_worker/popup.test.ts +++ b/src/app/service/service_worker/popup.test.ts @@ -21,9 +21,12 @@ import type { SystemConfig } from "@App/pkg/config/config"; import type { TDeleteScript, TEnableScript, TInstallScript, TScriptRunStatus } from "../queue"; import type WebNavigationMock from "@Packages/chrome-extension-mock/web_navigation"; import type ExtensionMock from "@Packages/chrome-extension-mock/extension"; +import type TabMock from "@Packages/chrome-extension-mock/tab"; initTestEnv(); +const tabsMock = chrome.tabs as unknown as TabMock; + // ── 公共测试辅助(跨 describe 复用) ────────────────────────────────────────── /** 构造最小可用 ScriptMenu 对象 */ @@ -282,7 +285,7 @@ describe("PopupService getPopupData Popup 数据获取与合并", () => { await cacheInstance.set(`${CACHE_KEY_TAB_LOADED}${1}`, "https://example.com"); }); - it("URL 匹配的脚本(无运行缓存)应出现在 scriptList,isEffective 与 enable 按脚本状态设置", async () => { + it("仅命中 URL pattern 但没有实际运行记录的脚本不应出现在 scriptList", async () => { const uuid = "match-uuid"; const matchMap = new Map([[uuid, { uuid, effective: true }]]); @@ -298,17 +301,14 @@ describe("PopupService getPopupData Popup 数据获取与合并", () => { const result = await service.getPopupData({ tabId: 1, url: "https://example.com/" }); - expect(result.scriptList).toHaveLength(1); - expect(result.scriptList[0].uuid).toBe(uuid); - expect(result.scriptList[0].isEffective).toBe(true); - expect(result.scriptList[0].enable).toBe(true); - expect(result.scriptList[0].hasMatchOverride).toBe(true); + expect(result.scriptList).toEqual([]); expect(result.pageStatus).toBe("ok"); }); - it("无 match 覆盖的脚本(无运行缓存)hasMatchOverride 应为 false", async () => { + it("实际运行脚本无 match 覆盖时 hasMatchOverride 应为 false", async () => { const uuid = "no-match-uuid"; const matchMap = new Map([[uuid, { uuid, effective: true }]]); + await cacheInstance.set(`${CACHE_KEY_TAB_SCRIPT}${1}`, [createMenu(uuid)]); const { service } = createService({ runtime: { @@ -421,8 +421,11 @@ describe("PopupService getPopupData 页面可达性(脚本猫无法触及的 const matchOne = (uuid: string) => vi.fn().mockResolvedValue(new Map([[uuid, { uuid, effective: true }]])); /** 模拟 content script 报到:顶层 frame 载入事件 */ - const firePageLoad = (service: PopupService, tabId: number, url: string) => - service.markTabInjected({ tabId, frameId: 0, url, scriptmenus: [] }); + const firePageLoad = async (service: PopupService, tabId: number, url: string, scriptmenus: ScriptMenu[] = []) => { + vi.spyOn(tabsMock, "get").mockResolvedValue({ id: tabId, url } as chrome.tabs.Tab); + await service.markTabInjected({ tabId, frameId: 0, url, scriptmenus }); + await service.addScriptRunNumber({ tabId, frameId: 0, url, scriptmenus }); + }; beforeEach(async () => { await cacheInstance.clear(); @@ -463,7 +466,7 @@ describe("PopupService getPopupData 页面可达性(脚本猫无法触及的 }, scriptDAO: { gets: vi.fn().mockResolvedValue([createScript(uuid)]) }, }); - await firePageLoad(service, 1, WEB_URL); + await firePageLoad(service, 1, WEB_URL, [createMenu(uuid)]); const result = await service.getPopupData({ tabId: 1, url: WEB_URL }); @@ -477,7 +480,7 @@ describe("PopupService getPopupData 页面可达性(脚本猫无法触及的 runtime: { getPopupPageScriptMatchingResultByUrl: matchOne(uuid) }, scriptDAO: { gets: vi.fn().mockResolvedValue([createScript(uuid)]) }, }); - await firePageLoad(service, 1, WEB_URL); + await firePageLoad(service, 1, WEB_URL, [createMenu(uuid)]); const result = await service.getPopupData({ tabId: 1, url: WEB_URL }); @@ -516,6 +519,20 @@ describe("PopupService getPopupData 页面可达性(脚本猫无法触及的 expect(result.pageStatus).toBe("not-injected"); }); + it("旧页面的迟到报到不能覆盖当前 tab 的新 origin", async () => { + const { service } = createService(); + const getTab = vi + .spyOn(tabsMock, "get") + .mockResolvedValue({ id: 1, url: "https://new.example.com/page" } as chrome.tabs.Tab); + + await service.markTabInjected({ tabId: 1, frameId: 0, url: "https://old.example.com/page", scriptmenus: [] }); + await expect(cacheInstance.get(`${CACHE_KEY_TAB_LOADED}${1}`)).resolves.toBeUndefined(); + + await service.markTabInjected({ tabId: 1, frameId: 0, url: "https://new.example.com/page", scriptmenus: [] }); + await expect(cacheInstance.get(`${CACHE_KEY_TAB_LOADED}${1}`)).resolves.toBe("https://new.example.com"); + expect(getTab).toHaveBeenCalledTimes(2); + }); + it("扩展商店页未注入时报 restricted(浏览器保护自家商店)", async () => { const { service } = createService(); @@ -534,7 +551,7 @@ describe("PopupService getPopupData 页面可达性(脚本猫无法触及的 runtime: { getPopupPageScriptMatchingResultByUrl: matchOne(uuid) }, scriptDAO: { gets: vi.fn().mockResolvedValue([createScript(uuid)]) }, }); - await firePageLoad(service, 1, storeUrl); + await firePageLoad(service, 1, storeUrl, [createMenu(uuid)]); const result = await service.getPopupData({ tabId: 1, url: storeUrl }); @@ -554,7 +571,7 @@ describe("PopupService getPopupData 页面可达性(脚本猫无法触及的 it("file:// 页已实际注入时按 ok 处理,不因权限查询结果误报", async () => { vi.spyOn(extensionMock, "isAllowedFileSchemeAccess").mockResolvedValue(false); const { service } = createService(); - await firePageLoad(service, 1, "file:///tmp/a.html"); + await firePageLoad(service, 1, "file:///tmp/a.html", [createMenu("file-script")]); const result = await service.getPopupData({ tabId: 1, url: "file:///tmp/a.html" }); @@ -611,7 +628,10 @@ describe("PopupService getPopupData 子 frame(iframe)内运行的脚本", () it("仅匹配 iframe 的脚本应标记 matchesTopFrame = false,顶层匹配的脚本为 true", async () => { const topUuid = "top-script"; const frameUuid = "iframe-script"; - await cacheInstance.set(`${CACHE_KEY_TAB_SCRIPT}${1}`, [createMenu(frameUuid, { runNumByIframe: 1 })]); + await cacheInstance.set(`${CACHE_KEY_TAB_SCRIPT}${1}`, [ + createMenu(topUuid), + createMenu(frameUuid, { runNumByIframe: 1 }), + ]); mockFrames([FRAME_URL]); const { service } = createService({ @@ -667,7 +687,10 @@ describe("PopupService getPopupData 子 frame(iframe)内运行的脚本", () it("getAllFrames 失败(标签页已关闭等)时降级为只看顶层匹配,不影响顶层脚本列表", async () => { const topUuid = "top-script"; const frameUuid = "iframe-script"; - await cacheInstance.set(`${CACHE_KEY_TAB_SCRIPT}${1}`, [createMenu(frameUuid, { runNumByIframe: 1 })]); + await cacheInstance.set(`${CACHE_KEY_TAB_SCRIPT}${1}`, [ + createMenu(topUuid), + createMenu(frameUuid, { runNumByIframe: 1 }), + ]); vi.spyOn(webNavigationMock, "getAllFrames").mockRejectedValue(new Error("No tab with id")); const { service } = createService({ diff --git a/src/app/service/service_worker/popup.ts b/src/app/service/service_worker/popup.ts index 0932cb055..9f049f324 100644 --- a/src/app/service/service_worker/popup.ts +++ b/src/app/service/service_worker/popup.ts @@ -386,13 +386,12 @@ export class PopupService { this.getScriptMenu(-1), ]); - const uuids = [...matchingResult.keys()]; - - const scripts = await this.scriptDAO.gets(uuids); - // 与运行时脚本进行合并 // 以已运行脚本建立快取(uuid→ScriptMenu),供后续合并与覆盖状态。 const runMap = new Map(runScripts.map((script) => [script.uuid, script])); + // Popup 展示实际已注入的脚本;仅命中 URL pattern 但没有本次 tab 运行记录的脚本不应出现。 + const uuids = [...matchingResult.keys()].filter((uuid) => runMap.has(uuid)); + const scripts = await this.scriptDAO.gets(uuids); // 合并后结果 const scriptMenuMap = new Map(); // 合并数据 @@ -401,23 +400,14 @@ export class PopupService { const script = scripts[idx]; const o = matchingResult.get(uuid); - if (!script || !o) continue; + const run = runMap.get(uuid); + if (!script || !o || !run) continue; - let run = runMap.get(uuid); - if (run) { - // 如果脚本已经存在,则不添加,更新信息 - run.enable = script.status === SCRIPT_STATUS_ENABLE; - run.isEffective = o.effective!; - run.hasMatchOverride = script.selfMetadata?.match !== undefined; - run.hasUserConfig = !!script.config; - } else { - // 由于目前没有在 Popup 显示 @match @include @exclude, 所以以下代码暂不需要 - // if (script.selfMetadata) { - // script.metadata = getCombinedMeta(script.metadata, script.selfMetadata); - // } - run = scriptToMenu(script); - run.isEffective = o.effective!; - } + // 如果脚本已经存在,则不添加,更新信息 + run.enable = script.status === SCRIPT_STATUS_ENABLE; + run.isEffective = o.effective!; + run.hasMatchOverride = script.selfMetadata?.match !== undefined; + run.hasUserConfig = !!script.config; run.matchesTopFrame = true; scriptMenuMap.set(uuid, run); } @@ -598,7 +588,16 @@ export class PopupService { async markTabInjected({ tabId, frameId, url }: TPopupPageLoadInfo) { if (frameId || tabId <= 0) return; const origin = toOrigin(url); - if (origin) await cacheInstance.set(`${CACHE_KEY_TAB_LOADED}${tabId}`, origin); + if (!origin) return; + try { + const tab = await chrome.tabs.get(tabId); + // pageLoad 的 service-worker 处理可能晚于下一次导航;只接受仍属于当前 origin 的报到。 + if (toOrigin(tab.url || "") !== origin) return; + } catch (e) { + LoggerCore.logger().warn("Ignoring page-load update for unavailable tab", { tabId }, Logger.E(e)); + return; + } + await cacheInstance.set(`${CACHE_KEY_TAB_LOADED}${tabId}`, origin); } async addScriptRunNumber(o: TPopupPageLoadInfo) { From 9d341d4fe2f2d14f9fdc581587fea2cb953111c9 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:16:32 +0900 Subject: [PATCH 4/8] =?UTF-8?q?Revert=20"=F0=9F=90=9B=20popup=20=E5=8F=AA?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E5=AE=9E=E9=99=85=E6=B3=A8=E5=85=A5=E7=9A=84?= =?UTF-8?q?=E8=84=9A=E6=9C=AC"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 67270db507a05a0cfa1e80b44cddc88cecfaed1d. --- packages/chrome-extension-mock/tab.ts | 6 --- src/app/service/service_worker/popup.test.ts | 53 ++++++-------------- src/app/service/service_worker/popup.ts | 41 +++++++-------- 3 files changed, 36 insertions(+), 64 deletions(-) diff --git a/packages/chrome-extension-mock/tab.ts b/packages/chrome-extension-mock/tab.ts index 3cc154b90..8c4cb8f5a 100644 --- a/packages/chrome-extension-mock/tab.ts +++ b/packages/chrome-extension-mock/tab.ts @@ -3,12 +3,6 @@ import EventEmitter from "eventemitter3"; export default class MockTab { hook = new EventEmitter(); - get(tabId: number, callback?: (tab: chrome.tabs.Tab) => void) { - const tab = { id: tabId, url: "https://example.com/" } as chrome.tabs.Tab; - callback?.(tab); - return Promise.resolve(tab); - } - query(queryInfo?: chrome.tabs.QueryInfo, callback?: (tabs: chrome.tabs.Tab[]) => void) { const mockTab = { id: 1, diff --git a/src/app/service/service_worker/popup.test.ts b/src/app/service/service_worker/popup.test.ts index 855b89981..0ba4fd5c5 100644 --- a/src/app/service/service_worker/popup.test.ts +++ b/src/app/service/service_worker/popup.test.ts @@ -21,12 +21,9 @@ import type { SystemConfig } from "@App/pkg/config/config"; import type { TDeleteScript, TEnableScript, TInstallScript, TScriptRunStatus } from "../queue"; import type WebNavigationMock from "@Packages/chrome-extension-mock/web_navigation"; import type ExtensionMock from "@Packages/chrome-extension-mock/extension"; -import type TabMock from "@Packages/chrome-extension-mock/tab"; initTestEnv(); -const tabsMock = chrome.tabs as unknown as TabMock; - // ── 公共测试辅助(跨 describe 复用) ────────────────────────────────────────── /** 构造最小可用 ScriptMenu 对象 */ @@ -285,7 +282,7 @@ describe("PopupService getPopupData Popup 数据获取与合并", () => { await cacheInstance.set(`${CACHE_KEY_TAB_LOADED}${1}`, "https://example.com"); }); - it("仅命中 URL pattern 但没有实际运行记录的脚本不应出现在 scriptList", async () => { + it("URL 匹配的脚本(无运行缓存)应出现在 scriptList,isEffective 与 enable 按脚本状态设置", async () => { const uuid = "match-uuid"; const matchMap = new Map([[uuid, { uuid, effective: true }]]); @@ -301,14 +298,17 @@ describe("PopupService getPopupData Popup 数据获取与合并", () => { const result = await service.getPopupData({ tabId: 1, url: "https://example.com/" }); - expect(result.scriptList).toEqual([]); + expect(result.scriptList).toHaveLength(1); + expect(result.scriptList[0].uuid).toBe(uuid); + expect(result.scriptList[0].isEffective).toBe(true); + expect(result.scriptList[0].enable).toBe(true); + expect(result.scriptList[0].hasMatchOverride).toBe(true); expect(result.pageStatus).toBe("ok"); }); - it("实际运行脚本无 match 覆盖时 hasMatchOverride 应为 false", async () => { + it("无 match 覆盖的脚本(无运行缓存)hasMatchOverride 应为 false", async () => { const uuid = "no-match-uuid"; const matchMap = new Map([[uuid, { uuid, effective: true }]]); - await cacheInstance.set(`${CACHE_KEY_TAB_SCRIPT}${1}`, [createMenu(uuid)]); const { service } = createService({ runtime: { @@ -421,11 +421,8 @@ describe("PopupService getPopupData 页面可达性(脚本猫无法触及的 const matchOne = (uuid: string) => vi.fn().mockResolvedValue(new Map([[uuid, { uuid, effective: true }]])); /** 模拟 content script 报到:顶层 frame 载入事件 */ - const firePageLoad = async (service: PopupService, tabId: number, url: string, scriptmenus: ScriptMenu[] = []) => { - vi.spyOn(tabsMock, "get").mockResolvedValue({ id: tabId, url } as chrome.tabs.Tab); - await service.markTabInjected({ tabId, frameId: 0, url, scriptmenus }); - await service.addScriptRunNumber({ tabId, frameId: 0, url, scriptmenus }); - }; + const firePageLoad = (service: PopupService, tabId: number, url: string) => + service.markTabInjected({ tabId, frameId: 0, url, scriptmenus: [] }); beforeEach(async () => { await cacheInstance.clear(); @@ -466,7 +463,7 @@ describe("PopupService getPopupData 页面可达性(脚本猫无法触及的 }, scriptDAO: { gets: vi.fn().mockResolvedValue([createScript(uuid)]) }, }); - await firePageLoad(service, 1, WEB_URL, [createMenu(uuid)]); + await firePageLoad(service, 1, WEB_URL); const result = await service.getPopupData({ tabId: 1, url: WEB_URL }); @@ -480,7 +477,7 @@ describe("PopupService getPopupData 页面可达性(脚本猫无法触及的 runtime: { getPopupPageScriptMatchingResultByUrl: matchOne(uuid) }, scriptDAO: { gets: vi.fn().mockResolvedValue([createScript(uuid)]) }, }); - await firePageLoad(service, 1, WEB_URL, [createMenu(uuid)]); + await firePageLoad(service, 1, WEB_URL); const result = await service.getPopupData({ tabId: 1, url: WEB_URL }); @@ -519,20 +516,6 @@ describe("PopupService getPopupData 页面可达性(脚本猫无法触及的 expect(result.pageStatus).toBe("not-injected"); }); - it("旧页面的迟到报到不能覆盖当前 tab 的新 origin", async () => { - const { service } = createService(); - const getTab = vi - .spyOn(tabsMock, "get") - .mockResolvedValue({ id: 1, url: "https://new.example.com/page" } as chrome.tabs.Tab); - - await service.markTabInjected({ tabId: 1, frameId: 0, url: "https://old.example.com/page", scriptmenus: [] }); - await expect(cacheInstance.get(`${CACHE_KEY_TAB_LOADED}${1}`)).resolves.toBeUndefined(); - - await service.markTabInjected({ tabId: 1, frameId: 0, url: "https://new.example.com/page", scriptmenus: [] }); - await expect(cacheInstance.get(`${CACHE_KEY_TAB_LOADED}${1}`)).resolves.toBe("https://new.example.com"); - expect(getTab).toHaveBeenCalledTimes(2); - }); - it("扩展商店页未注入时报 restricted(浏览器保护自家商店)", async () => { const { service } = createService(); @@ -551,7 +534,7 @@ describe("PopupService getPopupData 页面可达性(脚本猫无法触及的 runtime: { getPopupPageScriptMatchingResultByUrl: matchOne(uuid) }, scriptDAO: { gets: vi.fn().mockResolvedValue([createScript(uuid)]) }, }); - await firePageLoad(service, 1, storeUrl, [createMenu(uuid)]); + await firePageLoad(service, 1, storeUrl); const result = await service.getPopupData({ tabId: 1, url: storeUrl }); @@ -571,7 +554,7 @@ describe("PopupService getPopupData 页面可达性(脚本猫无法触及的 it("file:// 页已实际注入时按 ok 处理,不因权限查询结果误报", async () => { vi.spyOn(extensionMock, "isAllowedFileSchemeAccess").mockResolvedValue(false); const { service } = createService(); - await firePageLoad(service, 1, "file:///tmp/a.html", [createMenu("file-script")]); + await firePageLoad(service, 1, "file:///tmp/a.html"); const result = await service.getPopupData({ tabId: 1, url: "file:///tmp/a.html" }); @@ -628,10 +611,7 @@ describe("PopupService getPopupData 子 frame(iframe)内运行的脚本", () it("仅匹配 iframe 的脚本应标记 matchesTopFrame = false,顶层匹配的脚本为 true", async () => { const topUuid = "top-script"; const frameUuid = "iframe-script"; - await cacheInstance.set(`${CACHE_KEY_TAB_SCRIPT}${1}`, [ - createMenu(topUuid), - createMenu(frameUuid, { runNumByIframe: 1 }), - ]); + await cacheInstance.set(`${CACHE_KEY_TAB_SCRIPT}${1}`, [createMenu(frameUuid, { runNumByIframe: 1 })]); mockFrames([FRAME_URL]); const { service } = createService({ @@ -687,10 +667,7 @@ describe("PopupService getPopupData 子 frame(iframe)内运行的脚本", () it("getAllFrames 失败(标签页已关闭等)时降级为只看顶层匹配,不影响顶层脚本列表", async () => { const topUuid = "top-script"; const frameUuid = "iframe-script"; - await cacheInstance.set(`${CACHE_KEY_TAB_SCRIPT}${1}`, [ - createMenu(topUuid), - createMenu(frameUuid, { runNumByIframe: 1 }), - ]); + await cacheInstance.set(`${CACHE_KEY_TAB_SCRIPT}${1}`, [createMenu(frameUuid, { runNumByIframe: 1 })]); vi.spyOn(webNavigationMock, "getAllFrames").mockRejectedValue(new Error("No tab with id")); const { service } = createService({ diff --git a/src/app/service/service_worker/popup.ts b/src/app/service/service_worker/popup.ts index 9f049f324..0932cb055 100644 --- a/src/app/service/service_worker/popup.ts +++ b/src/app/service/service_worker/popup.ts @@ -386,12 +386,13 @@ export class PopupService { this.getScriptMenu(-1), ]); + const uuids = [...matchingResult.keys()]; + + const scripts = await this.scriptDAO.gets(uuids); + // 与运行时脚本进行合并 // 以已运行脚本建立快取(uuid→ScriptMenu),供后续合并与覆盖状态。 const runMap = new Map(runScripts.map((script) => [script.uuid, script])); - // Popup 展示实际已注入的脚本;仅命中 URL pattern 但没有本次 tab 运行记录的脚本不应出现。 - const uuids = [...matchingResult.keys()].filter((uuid) => runMap.has(uuid)); - const scripts = await this.scriptDAO.gets(uuids); // 合并后结果 const scriptMenuMap = new Map(); // 合并数据 @@ -400,14 +401,23 @@ export class PopupService { const script = scripts[idx]; const o = matchingResult.get(uuid); - const run = runMap.get(uuid); - if (!script || !o || !run) continue; + if (!script || !o) continue; - // 如果脚本已经存在,则不添加,更新信息 - run.enable = script.status === SCRIPT_STATUS_ENABLE; - run.isEffective = o.effective!; - run.hasMatchOverride = script.selfMetadata?.match !== undefined; - run.hasUserConfig = !!script.config; + let run = runMap.get(uuid); + if (run) { + // 如果脚本已经存在,则不添加,更新信息 + run.enable = script.status === SCRIPT_STATUS_ENABLE; + run.isEffective = o.effective!; + run.hasMatchOverride = script.selfMetadata?.match !== undefined; + run.hasUserConfig = !!script.config; + } else { + // 由于目前没有在 Popup 显示 @match @include @exclude, 所以以下代码暂不需要 + // if (script.selfMetadata) { + // script.metadata = getCombinedMeta(script.metadata, script.selfMetadata); + // } + run = scriptToMenu(script); + run.isEffective = o.effective!; + } run.matchesTopFrame = true; scriptMenuMap.set(uuid, run); } @@ -588,16 +598,7 @@ export class PopupService { async markTabInjected({ tabId, frameId, url }: TPopupPageLoadInfo) { if (frameId || tabId <= 0) return; const origin = toOrigin(url); - if (!origin) return; - try { - const tab = await chrome.tabs.get(tabId); - // pageLoad 的 service-worker 处理可能晚于下一次导航;只接受仍属于当前 origin 的报到。 - if (toOrigin(tab.url || "") !== origin) return; - } catch (e) { - LoggerCore.logger().warn("Ignoring page-load update for unavailable tab", { tabId }, Logger.E(e)); - return; - } - await cacheInstance.set(`${CACHE_KEY_TAB_LOADED}${tabId}`, origin); + if (origin) await cacheInstance.set(`${CACHE_KEY_TAB_LOADED}${tabId}`, origin); } async addScriptRunNumber(o: TPopupPageLoadInfo) { From bac5f1cdfd88062a1d97b96d56e30c6936e38ba3 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:49:45 +0900 Subject: [PATCH 5/8] =?UTF-8?q?=F0=9F=90=9B=20popup=20=E5=8F=AA=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E5=AE=9E=E9=99=85=E6=B3=A8=E5=85=A5=E7=9A=84=E8=84=9A?= =?UTF-8?q?=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/chrome-extension-mock/tab.ts | 6 +++ src/app/service/service_worker/popup.test.ts | 53 ++++++++++++++------ src/app/service/service_worker/popup.ts | 41 ++++++++------- 3 files changed, 64 insertions(+), 36 deletions(-) diff --git a/packages/chrome-extension-mock/tab.ts b/packages/chrome-extension-mock/tab.ts index 8c4cb8f5a..3cc154b90 100644 --- a/packages/chrome-extension-mock/tab.ts +++ b/packages/chrome-extension-mock/tab.ts @@ -3,6 +3,12 @@ import EventEmitter from "eventemitter3"; export default class MockTab { hook = new EventEmitter(); + get(tabId: number, callback?: (tab: chrome.tabs.Tab) => void) { + const tab = { id: tabId, url: "https://example.com/" } as chrome.tabs.Tab; + callback?.(tab); + return Promise.resolve(tab); + } + query(queryInfo?: chrome.tabs.QueryInfo, callback?: (tabs: chrome.tabs.Tab[]) => void) { const mockTab = { id: 1, diff --git a/src/app/service/service_worker/popup.test.ts b/src/app/service/service_worker/popup.test.ts index 0ba4fd5c5..855b89981 100644 --- a/src/app/service/service_worker/popup.test.ts +++ b/src/app/service/service_worker/popup.test.ts @@ -21,9 +21,12 @@ import type { SystemConfig } from "@App/pkg/config/config"; import type { TDeleteScript, TEnableScript, TInstallScript, TScriptRunStatus } from "../queue"; import type WebNavigationMock from "@Packages/chrome-extension-mock/web_navigation"; import type ExtensionMock from "@Packages/chrome-extension-mock/extension"; +import type TabMock from "@Packages/chrome-extension-mock/tab"; initTestEnv(); +const tabsMock = chrome.tabs as unknown as TabMock; + // ── 公共测试辅助(跨 describe 复用) ────────────────────────────────────────── /** 构造最小可用 ScriptMenu 对象 */ @@ -282,7 +285,7 @@ describe("PopupService getPopupData Popup 数据获取与合并", () => { await cacheInstance.set(`${CACHE_KEY_TAB_LOADED}${1}`, "https://example.com"); }); - it("URL 匹配的脚本(无运行缓存)应出现在 scriptList,isEffective 与 enable 按脚本状态设置", async () => { + it("仅命中 URL pattern 但没有实际运行记录的脚本不应出现在 scriptList", async () => { const uuid = "match-uuid"; const matchMap = new Map([[uuid, { uuid, effective: true }]]); @@ -298,17 +301,14 @@ describe("PopupService getPopupData Popup 数据获取与合并", () => { const result = await service.getPopupData({ tabId: 1, url: "https://example.com/" }); - expect(result.scriptList).toHaveLength(1); - expect(result.scriptList[0].uuid).toBe(uuid); - expect(result.scriptList[0].isEffective).toBe(true); - expect(result.scriptList[0].enable).toBe(true); - expect(result.scriptList[0].hasMatchOverride).toBe(true); + expect(result.scriptList).toEqual([]); expect(result.pageStatus).toBe("ok"); }); - it("无 match 覆盖的脚本(无运行缓存)hasMatchOverride 应为 false", async () => { + it("实际运行脚本无 match 覆盖时 hasMatchOverride 应为 false", async () => { const uuid = "no-match-uuid"; const matchMap = new Map([[uuid, { uuid, effective: true }]]); + await cacheInstance.set(`${CACHE_KEY_TAB_SCRIPT}${1}`, [createMenu(uuid)]); const { service } = createService({ runtime: { @@ -421,8 +421,11 @@ describe("PopupService getPopupData 页面可达性(脚本猫无法触及的 const matchOne = (uuid: string) => vi.fn().mockResolvedValue(new Map([[uuid, { uuid, effective: true }]])); /** 模拟 content script 报到:顶层 frame 载入事件 */ - const firePageLoad = (service: PopupService, tabId: number, url: string) => - service.markTabInjected({ tabId, frameId: 0, url, scriptmenus: [] }); + const firePageLoad = async (service: PopupService, tabId: number, url: string, scriptmenus: ScriptMenu[] = []) => { + vi.spyOn(tabsMock, "get").mockResolvedValue({ id: tabId, url } as chrome.tabs.Tab); + await service.markTabInjected({ tabId, frameId: 0, url, scriptmenus }); + await service.addScriptRunNumber({ tabId, frameId: 0, url, scriptmenus }); + }; beforeEach(async () => { await cacheInstance.clear(); @@ -463,7 +466,7 @@ describe("PopupService getPopupData 页面可达性(脚本猫无法触及的 }, scriptDAO: { gets: vi.fn().mockResolvedValue([createScript(uuid)]) }, }); - await firePageLoad(service, 1, WEB_URL); + await firePageLoad(service, 1, WEB_URL, [createMenu(uuid)]); const result = await service.getPopupData({ tabId: 1, url: WEB_URL }); @@ -477,7 +480,7 @@ describe("PopupService getPopupData 页面可达性(脚本猫无法触及的 runtime: { getPopupPageScriptMatchingResultByUrl: matchOne(uuid) }, scriptDAO: { gets: vi.fn().mockResolvedValue([createScript(uuid)]) }, }); - await firePageLoad(service, 1, WEB_URL); + await firePageLoad(service, 1, WEB_URL, [createMenu(uuid)]); const result = await service.getPopupData({ tabId: 1, url: WEB_URL }); @@ -516,6 +519,20 @@ describe("PopupService getPopupData 页面可达性(脚本猫无法触及的 expect(result.pageStatus).toBe("not-injected"); }); + it("旧页面的迟到报到不能覆盖当前 tab 的新 origin", async () => { + const { service } = createService(); + const getTab = vi + .spyOn(tabsMock, "get") + .mockResolvedValue({ id: 1, url: "https://new.example.com/page" } as chrome.tabs.Tab); + + await service.markTabInjected({ tabId: 1, frameId: 0, url: "https://old.example.com/page", scriptmenus: [] }); + await expect(cacheInstance.get(`${CACHE_KEY_TAB_LOADED}${1}`)).resolves.toBeUndefined(); + + await service.markTabInjected({ tabId: 1, frameId: 0, url: "https://new.example.com/page", scriptmenus: [] }); + await expect(cacheInstance.get(`${CACHE_KEY_TAB_LOADED}${1}`)).resolves.toBe("https://new.example.com"); + expect(getTab).toHaveBeenCalledTimes(2); + }); + it("扩展商店页未注入时报 restricted(浏览器保护自家商店)", async () => { const { service } = createService(); @@ -534,7 +551,7 @@ describe("PopupService getPopupData 页面可达性(脚本猫无法触及的 runtime: { getPopupPageScriptMatchingResultByUrl: matchOne(uuid) }, scriptDAO: { gets: vi.fn().mockResolvedValue([createScript(uuid)]) }, }); - await firePageLoad(service, 1, storeUrl); + await firePageLoad(service, 1, storeUrl, [createMenu(uuid)]); const result = await service.getPopupData({ tabId: 1, url: storeUrl }); @@ -554,7 +571,7 @@ describe("PopupService getPopupData 页面可达性(脚本猫无法触及的 it("file:// 页已实际注入时按 ok 处理,不因权限查询结果误报", async () => { vi.spyOn(extensionMock, "isAllowedFileSchemeAccess").mockResolvedValue(false); const { service } = createService(); - await firePageLoad(service, 1, "file:///tmp/a.html"); + await firePageLoad(service, 1, "file:///tmp/a.html", [createMenu("file-script")]); const result = await service.getPopupData({ tabId: 1, url: "file:///tmp/a.html" }); @@ -611,7 +628,10 @@ describe("PopupService getPopupData 子 frame(iframe)内运行的脚本", () it("仅匹配 iframe 的脚本应标记 matchesTopFrame = false,顶层匹配的脚本为 true", async () => { const topUuid = "top-script"; const frameUuid = "iframe-script"; - await cacheInstance.set(`${CACHE_KEY_TAB_SCRIPT}${1}`, [createMenu(frameUuid, { runNumByIframe: 1 })]); + await cacheInstance.set(`${CACHE_KEY_TAB_SCRIPT}${1}`, [ + createMenu(topUuid), + createMenu(frameUuid, { runNumByIframe: 1 }), + ]); mockFrames([FRAME_URL]); const { service } = createService({ @@ -667,7 +687,10 @@ describe("PopupService getPopupData 子 frame(iframe)内运行的脚本", () it("getAllFrames 失败(标签页已关闭等)时降级为只看顶层匹配,不影响顶层脚本列表", async () => { const topUuid = "top-script"; const frameUuid = "iframe-script"; - await cacheInstance.set(`${CACHE_KEY_TAB_SCRIPT}${1}`, [createMenu(frameUuid, { runNumByIframe: 1 })]); + await cacheInstance.set(`${CACHE_KEY_TAB_SCRIPT}${1}`, [ + createMenu(topUuid), + createMenu(frameUuid, { runNumByIframe: 1 }), + ]); vi.spyOn(webNavigationMock, "getAllFrames").mockRejectedValue(new Error("No tab with id")); const { service } = createService({ diff --git a/src/app/service/service_worker/popup.ts b/src/app/service/service_worker/popup.ts index 0932cb055..9f049f324 100644 --- a/src/app/service/service_worker/popup.ts +++ b/src/app/service/service_worker/popup.ts @@ -386,13 +386,12 @@ export class PopupService { this.getScriptMenu(-1), ]); - const uuids = [...matchingResult.keys()]; - - const scripts = await this.scriptDAO.gets(uuids); - // 与运行时脚本进行合并 // 以已运行脚本建立快取(uuid→ScriptMenu),供后续合并与覆盖状态。 const runMap = new Map(runScripts.map((script) => [script.uuid, script])); + // Popup 展示实际已注入的脚本;仅命中 URL pattern 但没有本次 tab 运行记录的脚本不应出现。 + const uuids = [...matchingResult.keys()].filter((uuid) => runMap.has(uuid)); + const scripts = await this.scriptDAO.gets(uuids); // 合并后结果 const scriptMenuMap = new Map(); // 合并数据 @@ -401,23 +400,14 @@ export class PopupService { const script = scripts[idx]; const o = matchingResult.get(uuid); - if (!script || !o) continue; + const run = runMap.get(uuid); + if (!script || !o || !run) continue; - let run = runMap.get(uuid); - if (run) { - // 如果脚本已经存在,则不添加,更新信息 - run.enable = script.status === SCRIPT_STATUS_ENABLE; - run.isEffective = o.effective!; - run.hasMatchOverride = script.selfMetadata?.match !== undefined; - run.hasUserConfig = !!script.config; - } else { - // 由于目前没有在 Popup 显示 @match @include @exclude, 所以以下代码暂不需要 - // if (script.selfMetadata) { - // script.metadata = getCombinedMeta(script.metadata, script.selfMetadata); - // } - run = scriptToMenu(script); - run.isEffective = o.effective!; - } + // 如果脚本已经存在,则不添加,更新信息 + run.enable = script.status === SCRIPT_STATUS_ENABLE; + run.isEffective = o.effective!; + run.hasMatchOverride = script.selfMetadata?.match !== undefined; + run.hasUserConfig = !!script.config; run.matchesTopFrame = true; scriptMenuMap.set(uuid, run); } @@ -598,7 +588,16 @@ export class PopupService { async markTabInjected({ tabId, frameId, url }: TPopupPageLoadInfo) { if (frameId || tabId <= 0) return; const origin = toOrigin(url); - if (origin) await cacheInstance.set(`${CACHE_KEY_TAB_LOADED}${tabId}`, origin); + if (!origin) return; + try { + const tab = await chrome.tabs.get(tabId); + // pageLoad 的 service-worker 处理可能晚于下一次导航;只接受仍属于当前 origin 的报到。 + if (toOrigin(tab.url || "") !== origin) return; + } catch (e) { + LoggerCore.logger().warn("Ignoring page-load update for unavailable tab", { tabId }, Logger.E(e)); + return; + } + await cacheInstance.set(`${CACHE_KEY_TAB_LOADED}${tabId}`, origin); } async addScriptRunNumber(o: TPopupPageLoadInfo) { From 4253a261dd647c29ab003303bbc05a0b584b1cc1 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:34:54 +0900 Subject: [PATCH 6/8] =?UTF-8?q?=F0=9F=90=9B=20=E4=B8=A2=E5=BC=83=E8=BF=87?= =?UTF-8?q?=E6=9C=9F=E7=9A=84=20Popup=20=E9=A1=B5=E9=9D=A2=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E8=AE=B0=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/popup.test.ts | 72 ++++++++++++++++++- src/app/service/service_worker/popup.ts | 40 +++++++---- .../service_worker/popup_scriptmenu.ts | 8 ++- src/app/service/service_worker/runtime.ts | 1 + 4 files changed, 104 insertions(+), 17 deletions(-) diff --git a/src/app/service/service_worker/popup.test.ts b/src/app/service/service_worker/popup.test.ts index 855b89981..b5b685524 100644 --- a/src/app/service/service_worker/popup.test.ts +++ b/src/app/service/service_worker/popup.test.ts @@ -93,7 +93,7 @@ const createService = (overrides: { runtime?: Partial; scriptDAO getBadgeBackgroundColor: vi.fn().mockResolvedValue("#000000"), getBadgeTextColor: vi.fn().mockResolvedValue("#ffffff"), } as unknown as SystemConfig; - const service = new PopupService({} as Group, mq, runtime, scriptDAO, systemConfig); + const service = new PopupService({ on: vi.fn() } as unknown as Group, mq, runtime, scriptDAO, systemConfig); return { service, subscriptions, runtime, scriptDAO }; }; @@ -418,6 +418,7 @@ describe("PopupService getPopupData 页面可达性(脚本猫无法触及的 const WEB_URL = "https://example.com/"; // 与 webNavigation 同理:@types/chrome 的 callback 重载会让 vi.spyOn 取到返回 void 的那一个 const extensionMock = chrome.extension as unknown as ExtensionMock; + const webNavigationMock = chrome.webNavigation as unknown as WebNavigationMock; const matchOne = (uuid: string) => vi.fn().mockResolvedValue(new Map([[uuid, { uuid, effective: true }]])); /** 模拟 content script 报到:顶层 frame 载入事件 */ @@ -519,6 +520,60 @@ describe("PopupService getPopupData 页面可达性(脚本猫无法触及的 expect(result.pageStatus).toBe("not-injected"); }); + it("全局关闭脚本后,即使旧的注入标记仍在,也不应显示页面脚本", async () => { + const uuid = "disabled-script"; + const { service } = createService({ + runtime: { + isLoadScripts: false, + getPopupPageScriptMatchingResultByUrl: matchOne(uuid), + }, + scriptDAO: { gets: vi.fn().mockResolvedValue([createScript(uuid)]) }, + }); + await firePageLoad(service, 1, WEB_URL, [createMenu(uuid)]); + + const result = await service.getPopupData({ tabId: 1, url: WEB_URL }); + + expect(result.pageStatus).toBe("not-injected"); + expect(result.scriptList).toEqual([]); + }); + + it("被判定为迟到的旧页面报到不应重新写入当前 tab 的运行缓存", async () => { + const { service } = createService(); + const getTab = vi + .spyOn(tabsMock, "get") + .mockResolvedValue({ id: 1, url: "https://new.example.com/page" } as chrome.tabs.Tab); + + await (service as any).handlePageLoadUpdate({ + tabId: 1, + frameId: 0, + url: "https://old.example.com/page", + scriptmenus: [createMenu("old-script")], + }); + await flushAsync(1); + + expect(getTab).toHaveBeenCalledWith(1); + await expect(cacheInstance.get(`${CACHE_KEY_TAB_SCRIPT}${1}`)).resolves.toBeUndefined(); + }); + + it("同 origin 的新 document 也不能接受旧 document 的报到", async () => { + const { service } = createService(); + vi.spyOn(tabsMock, "get").mockResolvedValue({ id: 1, url: "https://example.com/page" } as chrome.tabs.Tab); + vi.spyOn(webNavigationMock, "getAllFrames").mockResolvedValue([ + { frameId: 0, url: "https://example.com/page", documentId: "new-document" }, + ] as chrome.webNavigation.GetAllFrameResultDetails[]); + + await expect( + service.markTabInjected({ + tabId: 1, + frameId: 0, + documentId: "old-document", + url: "https://example.com/page", + scriptmenus: [], + }) + ).resolves.toBe(false); + await expect(cacheInstance.get(`${CACHE_KEY_TAB_LOADED}${1}`)).resolves.toBeUndefined(); + }); + it("旧页面的迟到报到不能覆盖当前 tab 的新 origin", async () => { const { service } = createService(); const getTab = vi @@ -669,6 +724,21 @@ describe("PopupService getPopupData 子 frame(iframe)内运行的脚本", () expect(result.scriptList).toHaveLength(0); }); + it("当前 iframe 已不再生效时,旧的 iframe 运行记录不应保留在列表", async () => { + const uuid = "iframe-disabled"; + await cacheInstance.set(`${CACHE_KEY_TAB_SCRIPT}${1}`, [createMenu(uuid, { runNumByIframe: 1 })]); + mockFrames([FRAME_URL]); + + const { service } = createService({ + runtime: { getPopupPageScriptMatchingResultByUrl: matcherFor(uuid, [FRAME_URL], false) }, + scriptDAO: { gets: vi.fn().mockResolvedValue([createScript(uuid)]) }, + }); + + const result = await service.getPopupData({ tabId: 1, url: TOP_URL }); + + expect(result.scriptList).toHaveLength(0); + }); + it("匹配 iframe 但已从 DAO 删除的脚本,不应出现在列表", async () => { const uuid = "deleted-iframe-script"; await cacheInstance.set(`${CACHE_KEY_TAB_SCRIPT}${1}`, [createMenu(uuid, { runNumByIframe: 1 })]); diff --git a/src/app/service/service_worker/popup.ts b/src/app/service/service_worker/popup.ts index 9f049f324..796c20bf8 100644 --- a/src/app/service/service_worker/popup.ts +++ b/src/app/service/service_worker/popup.ts @@ -437,6 +437,7 @@ export class PopupService { const kind = getPageAccessKind(url); if (kind === "restricted") return "restricted"; if (this.runtime.isUrlBlacklist(url)) return "blacklist"; + if (this.runtime.isLoadScripts === false) return "not-injected"; if (await this.isTabInjected(tabId, url)) return "ok"; // 以下都是「确认没注入」,只为给出更准确的原因:两项判据都与浏览器有关 // (Edge 商店在 Chrome 里是普通网页;Firefox 的文件访问开关语义也不同), @@ -476,7 +477,7 @@ export class PopupService { for (const frameUrl of frameUrls) { const matchingResult = await this.runtime.getPopupPageScriptMatchingResultByUrl(frameUrl); for (const [uuid, o] of matchingResult) { - frameMatching.set(uuid, frameMatching.get(uuid) || o.effective); + if (o.effective) frameMatching.set(uuid, true); } } @@ -585,19 +586,26 @@ export class PopupService { // popupPageLoadUpdate 的处理之一:顶层 frame 报到即说明本页扩展触及得到。 // 记 origin 而非完整网址,SPA 换页不会失效,跳到另一个 origin 则自然失效。 - async markTabInjected({ tabId, frameId, url }: TPopupPageLoadInfo) { - if (frameId || tabId <= 0) return; + async markTabInjected({ tabId, frameId, documentId, url }: TPopupPageLoadInfo): Promise { + if (tabId <= 0) return false; const origin = toOrigin(url); - if (!origin) return; + if (!origin) return false; try { const tab = await chrome.tabs.get(tabId); // pageLoad 的 service-worker 处理可能晚于下一次导航;只接受仍属于当前 origin 的报到。 - if (toOrigin(tab.url || "") !== origin) return; + if (!frameId && toOrigin(tab.url || "") !== origin) return false; + if (frameId || documentId) { + const frames = await chrome.webNavigation.getAllFrames({ tabId }); + const currentFrame = frames?.find((frame) => frame.frameId === (frameId || 0)); + if (!currentFrame || currentFrame.url !== url) return false; + if (documentId && currentFrame.documentId !== documentId) return false; + } } catch (e) { LoggerCore.logger().warn("Ignoring page-load update for unavailable tab", { tabId }, Logger.E(e)); - return; + return false; } - await cacheInstance.set(`${CACHE_KEY_TAB_LOADED}${tabId}`, origin); + if (!frameId) await cacheInstance.set(`${CACHE_KEY_TAB_LOADED}${tabId}`, origin); + return true; } async addScriptRunNumber(o: TPopupPageLoadInfo) { @@ -921,13 +929,15 @@ export class PopupService { // 监听运行次数 // 监听页面载入事件以更新脚本执行计数;若为当前活动 tab,同步刷新 badge。 - this.mq.subscribe("popupPageLoadUpdate", async (o) => { - await this.markTabInjected(o); - await this.addScriptRunNumber(o); - // 设置角标 (chrome.tabs.onActivated 切换后) - if (o.tabId === lastActiveTabId) { - await this.updateBadgeIcon(); - } - }); + this.mq.subscribe("popupPageLoadUpdate", this.handlePageLoadUpdate.bind(this)); + } + + private async handlePageLoadUpdate(o: TPopupPageLoadInfo) { + if (!(await this.markTabInjected(o))) return; + await this.addScriptRunNumber(o); + // 设置角标 (chrome.tabs.onActivated 切换后) + if (o.tabId === lastActiveTabId) { + await this.updateBadgeIcon(); + } } } diff --git a/src/app/service/service_worker/popup_scriptmenu.ts b/src/app/service/service_worker/popup_scriptmenu.ts index 0830c1181..fb8b2fee3 100644 --- a/src/app/service/service_worker/popup_scriptmenu.ts +++ b/src/app/service/service_worker/popup_scriptmenu.ts @@ -4,7 +4,13 @@ import type { Script } from "@App/app/repo/scripts"; import { getIcon, getStorageName } from "@App/pkg/utils/utils"; import { i18nName } from "@App/locales/locales"; -export type TPopupPageLoadInfo = { tabId: number; frameId?: number; url: string; scriptmenus: ScriptMenu[] }; +export type TPopupPageLoadInfo = { + tabId: number; + frameId?: number; + documentId?: string; + url: string; + scriptmenus: ScriptMenu[]; +}; // 将 Script 转为 ScriptMenu 并初始化其在该 tab 的菜单暂存(menus 空阵列、计数归零)。 export const scriptToMenu = (script: Script): ScriptMenu => { diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index 30614ef38..361d748b1 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -1265,6 +1265,7 @@ export class RuntimeService { this.mq.emit("popupPageLoadUpdate", { tabId: tabId, frameId: frameId, + documentId: chromeSender.documentId, url: url, scriptmenus: res?.scriptmenus || [], // 对于 popup, resources那些不需要 }); From ddddcdbbb7c37d51cd93f99eed10d8dc821a40f6 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:46:41 +0900 Subject: [PATCH 7/8] =?UTF-8?q?Revert=20"=F0=9F=90=9B=20=E4=B8=A2=E5=BC=83?= =?UTF-8?q?=E8=BF=87=E6=9C=9F=E7=9A=84=20Popup=20=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E8=AE=B0=E5=BD=95"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 4253a261dd647c29ab003303bbc05a0b584b1cc1. --- src/app/service/service_worker/popup.test.ts | 72 +------------------ src/app/service/service_worker/popup.ts | 40 ++++------- .../service_worker/popup_scriptmenu.ts | 8 +-- src/app/service/service_worker/runtime.ts | 1 - 4 files changed, 17 insertions(+), 104 deletions(-) diff --git a/src/app/service/service_worker/popup.test.ts b/src/app/service/service_worker/popup.test.ts index b5b685524..855b89981 100644 --- a/src/app/service/service_worker/popup.test.ts +++ b/src/app/service/service_worker/popup.test.ts @@ -93,7 +93,7 @@ const createService = (overrides: { runtime?: Partial; scriptDAO getBadgeBackgroundColor: vi.fn().mockResolvedValue("#000000"), getBadgeTextColor: vi.fn().mockResolvedValue("#ffffff"), } as unknown as SystemConfig; - const service = new PopupService({ on: vi.fn() } as unknown as Group, mq, runtime, scriptDAO, systemConfig); + const service = new PopupService({} as Group, mq, runtime, scriptDAO, systemConfig); return { service, subscriptions, runtime, scriptDAO }; }; @@ -418,7 +418,6 @@ describe("PopupService getPopupData 页面可达性(脚本猫无法触及的 const WEB_URL = "https://example.com/"; // 与 webNavigation 同理:@types/chrome 的 callback 重载会让 vi.spyOn 取到返回 void 的那一个 const extensionMock = chrome.extension as unknown as ExtensionMock; - const webNavigationMock = chrome.webNavigation as unknown as WebNavigationMock; const matchOne = (uuid: string) => vi.fn().mockResolvedValue(new Map([[uuid, { uuid, effective: true }]])); /** 模拟 content script 报到:顶层 frame 载入事件 */ @@ -520,60 +519,6 @@ describe("PopupService getPopupData 页面可达性(脚本猫无法触及的 expect(result.pageStatus).toBe("not-injected"); }); - it("全局关闭脚本后,即使旧的注入标记仍在,也不应显示页面脚本", async () => { - const uuid = "disabled-script"; - const { service } = createService({ - runtime: { - isLoadScripts: false, - getPopupPageScriptMatchingResultByUrl: matchOne(uuid), - }, - scriptDAO: { gets: vi.fn().mockResolvedValue([createScript(uuid)]) }, - }); - await firePageLoad(service, 1, WEB_URL, [createMenu(uuid)]); - - const result = await service.getPopupData({ tabId: 1, url: WEB_URL }); - - expect(result.pageStatus).toBe("not-injected"); - expect(result.scriptList).toEqual([]); - }); - - it("被判定为迟到的旧页面报到不应重新写入当前 tab 的运行缓存", async () => { - const { service } = createService(); - const getTab = vi - .spyOn(tabsMock, "get") - .mockResolvedValue({ id: 1, url: "https://new.example.com/page" } as chrome.tabs.Tab); - - await (service as any).handlePageLoadUpdate({ - tabId: 1, - frameId: 0, - url: "https://old.example.com/page", - scriptmenus: [createMenu("old-script")], - }); - await flushAsync(1); - - expect(getTab).toHaveBeenCalledWith(1); - await expect(cacheInstance.get(`${CACHE_KEY_TAB_SCRIPT}${1}`)).resolves.toBeUndefined(); - }); - - it("同 origin 的新 document 也不能接受旧 document 的报到", async () => { - const { service } = createService(); - vi.spyOn(tabsMock, "get").mockResolvedValue({ id: 1, url: "https://example.com/page" } as chrome.tabs.Tab); - vi.spyOn(webNavigationMock, "getAllFrames").mockResolvedValue([ - { frameId: 0, url: "https://example.com/page", documentId: "new-document" }, - ] as chrome.webNavigation.GetAllFrameResultDetails[]); - - await expect( - service.markTabInjected({ - tabId: 1, - frameId: 0, - documentId: "old-document", - url: "https://example.com/page", - scriptmenus: [], - }) - ).resolves.toBe(false); - await expect(cacheInstance.get(`${CACHE_KEY_TAB_LOADED}${1}`)).resolves.toBeUndefined(); - }); - it("旧页面的迟到报到不能覆盖当前 tab 的新 origin", async () => { const { service } = createService(); const getTab = vi @@ -724,21 +669,6 @@ describe("PopupService getPopupData 子 frame(iframe)内运行的脚本", () expect(result.scriptList).toHaveLength(0); }); - it("当前 iframe 已不再生效时,旧的 iframe 运行记录不应保留在列表", async () => { - const uuid = "iframe-disabled"; - await cacheInstance.set(`${CACHE_KEY_TAB_SCRIPT}${1}`, [createMenu(uuid, { runNumByIframe: 1 })]); - mockFrames([FRAME_URL]); - - const { service } = createService({ - runtime: { getPopupPageScriptMatchingResultByUrl: matcherFor(uuid, [FRAME_URL], false) }, - scriptDAO: { gets: vi.fn().mockResolvedValue([createScript(uuid)]) }, - }); - - const result = await service.getPopupData({ tabId: 1, url: TOP_URL }); - - expect(result.scriptList).toHaveLength(0); - }); - it("匹配 iframe 但已从 DAO 删除的脚本,不应出现在列表", async () => { const uuid = "deleted-iframe-script"; await cacheInstance.set(`${CACHE_KEY_TAB_SCRIPT}${1}`, [createMenu(uuid, { runNumByIframe: 1 })]); diff --git a/src/app/service/service_worker/popup.ts b/src/app/service/service_worker/popup.ts index 796c20bf8..9f049f324 100644 --- a/src/app/service/service_worker/popup.ts +++ b/src/app/service/service_worker/popup.ts @@ -437,7 +437,6 @@ export class PopupService { const kind = getPageAccessKind(url); if (kind === "restricted") return "restricted"; if (this.runtime.isUrlBlacklist(url)) return "blacklist"; - if (this.runtime.isLoadScripts === false) return "not-injected"; if (await this.isTabInjected(tabId, url)) return "ok"; // 以下都是「确认没注入」,只为给出更准确的原因:两项判据都与浏览器有关 // (Edge 商店在 Chrome 里是普通网页;Firefox 的文件访问开关语义也不同), @@ -477,7 +476,7 @@ export class PopupService { for (const frameUrl of frameUrls) { const matchingResult = await this.runtime.getPopupPageScriptMatchingResultByUrl(frameUrl); for (const [uuid, o] of matchingResult) { - if (o.effective) frameMatching.set(uuid, true); + frameMatching.set(uuid, frameMatching.get(uuid) || o.effective); } } @@ -586,26 +585,19 @@ export class PopupService { // popupPageLoadUpdate 的处理之一:顶层 frame 报到即说明本页扩展触及得到。 // 记 origin 而非完整网址,SPA 换页不会失效,跳到另一个 origin 则自然失效。 - async markTabInjected({ tabId, frameId, documentId, url }: TPopupPageLoadInfo): Promise { - if (tabId <= 0) return false; + async markTabInjected({ tabId, frameId, url }: TPopupPageLoadInfo) { + if (frameId || tabId <= 0) return; const origin = toOrigin(url); - if (!origin) return false; + if (!origin) return; try { const tab = await chrome.tabs.get(tabId); // pageLoad 的 service-worker 处理可能晚于下一次导航;只接受仍属于当前 origin 的报到。 - if (!frameId && toOrigin(tab.url || "") !== origin) return false; - if (frameId || documentId) { - const frames = await chrome.webNavigation.getAllFrames({ tabId }); - const currentFrame = frames?.find((frame) => frame.frameId === (frameId || 0)); - if (!currentFrame || currentFrame.url !== url) return false; - if (documentId && currentFrame.documentId !== documentId) return false; - } + if (toOrigin(tab.url || "") !== origin) return; } catch (e) { LoggerCore.logger().warn("Ignoring page-load update for unavailable tab", { tabId }, Logger.E(e)); - return false; + return; } - if (!frameId) await cacheInstance.set(`${CACHE_KEY_TAB_LOADED}${tabId}`, origin); - return true; + await cacheInstance.set(`${CACHE_KEY_TAB_LOADED}${tabId}`, origin); } async addScriptRunNumber(o: TPopupPageLoadInfo) { @@ -929,15 +921,13 @@ export class PopupService { // 监听运行次数 // 监听页面载入事件以更新脚本执行计数;若为当前活动 tab,同步刷新 badge。 - this.mq.subscribe("popupPageLoadUpdate", this.handlePageLoadUpdate.bind(this)); - } - - private async handlePageLoadUpdate(o: TPopupPageLoadInfo) { - if (!(await this.markTabInjected(o))) return; - await this.addScriptRunNumber(o); - // 设置角标 (chrome.tabs.onActivated 切换后) - if (o.tabId === lastActiveTabId) { - await this.updateBadgeIcon(); - } + this.mq.subscribe("popupPageLoadUpdate", async (o) => { + await this.markTabInjected(o); + await this.addScriptRunNumber(o); + // 设置角标 (chrome.tabs.onActivated 切换后) + if (o.tabId === lastActiveTabId) { + await this.updateBadgeIcon(); + } + }); } } diff --git a/src/app/service/service_worker/popup_scriptmenu.ts b/src/app/service/service_worker/popup_scriptmenu.ts index fb8b2fee3..0830c1181 100644 --- a/src/app/service/service_worker/popup_scriptmenu.ts +++ b/src/app/service/service_worker/popup_scriptmenu.ts @@ -4,13 +4,7 @@ import type { Script } from "@App/app/repo/scripts"; import { getIcon, getStorageName } from "@App/pkg/utils/utils"; import { i18nName } from "@App/locales/locales"; -export type TPopupPageLoadInfo = { - tabId: number; - frameId?: number; - documentId?: string; - url: string; - scriptmenus: ScriptMenu[]; -}; +export type TPopupPageLoadInfo = { tabId: number; frameId?: number; url: string; scriptmenus: ScriptMenu[] }; // 将 Script 转为 ScriptMenu 并初始化其在该 tab 的菜单暂存(menus 空阵列、计数归零)。 export const scriptToMenu = (script: Script): ScriptMenu => { diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index 361d748b1..30614ef38 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -1265,7 +1265,6 @@ export class RuntimeService { this.mq.emit("popupPageLoadUpdate", { tabId: tabId, frameId: frameId, - documentId: chromeSender.documentId, url: url, scriptmenus: res?.scriptmenus || [], // 对于 popup, resources那些不需要 }); From 815b8cbd3cd88eff820764a0d6522573d90845df Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Mon, 24 Aug 2026 20:46:46 +0900 Subject: [PATCH 8/8] =?UTF-8?q?Revert=20"=F0=9F=90=9B=20popup=20=E5=8F=AA?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E5=AE=9E=E9=99=85=E6=B3=A8=E5=85=A5=E7=9A=84?= =?UTF-8?q?=E8=84=9A=E6=9C=AC"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit bac5f1cdfd88062a1d97b96d56e30c6936e38ba3. --- packages/chrome-extension-mock/tab.ts | 6 --- src/app/service/service_worker/popup.test.ts | 53 ++++++-------------- src/app/service/service_worker/popup.ts | 41 +++++++-------- 3 files changed, 36 insertions(+), 64 deletions(-) diff --git a/packages/chrome-extension-mock/tab.ts b/packages/chrome-extension-mock/tab.ts index 3cc154b90..8c4cb8f5a 100644 --- a/packages/chrome-extension-mock/tab.ts +++ b/packages/chrome-extension-mock/tab.ts @@ -3,12 +3,6 @@ import EventEmitter from "eventemitter3"; export default class MockTab { hook = new EventEmitter(); - get(tabId: number, callback?: (tab: chrome.tabs.Tab) => void) { - const tab = { id: tabId, url: "https://example.com/" } as chrome.tabs.Tab; - callback?.(tab); - return Promise.resolve(tab); - } - query(queryInfo?: chrome.tabs.QueryInfo, callback?: (tabs: chrome.tabs.Tab[]) => void) { const mockTab = { id: 1, diff --git a/src/app/service/service_worker/popup.test.ts b/src/app/service/service_worker/popup.test.ts index 855b89981..0ba4fd5c5 100644 --- a/src/app/service/service_worker/popup.test.ts +++ b/src/app/service/service_worker/popup.test.ts @@ -21,12 +21,9 @@ import type { SystemConfig } from "@App/pkg/config/config"; import type { TDeleteScript, TEnableScript, TInstallScript, TScriptRunStatus } from "../queue"; import type WebNavigationMock from "@Packages/chrome-extension-mock/web_navigation"; import type ExtensionMock from "@Packages/chrome-extension-mock/extension"; -import type TabMock from "@Packages/chrome-extension-mock/tab"; initTestEnv(); -const tabsMock = chrome.tabs as unknown as TabMock; - // ── 公共测试辅助(跨 describe 复用) ────────────────────────────────────────── /** 构造最小可用 ScriptMenu 对象 */ @@ -285,7 +282,7 @@ describe("PopupService getPopupData Popup 数据获取与合并", () => { await cacheInstance.set(`${CACHE_KEY_TAB_LOADED}${1}`, "https://example.com"); }); - it("仅命中 URL pattern 但没有实际运行记录的脚本不应出现在 scriptList", async () => { + it("URL 匹配的脚本(无运行缓存)应出现在 scriptList,isEffective 与 enable 按脚本状态设置", async () => { const uuid = "match-uuid"; const matchMap = new Map([[uuid, { uuid, effective: true }]]); @@ -301,14 +298,17 @@ describe("PopupService getPopupData Popup 数据获取与合并", () => { const result = await service.getPopupData({ tabId: 1, url: "https://example.com/" }); - expect(result.scriptList).toEqual([]); + expect(result.scriptList).toHaveLength(1); + expect(result.scriptList[0].uuid).toBe(uuid); + expect(result.scriptList[0].isEffective).toBe(true); + expect(result.scriptList[0].enable).toBe(true); + expect(result.scriptList[0].hasMatchOverride).toBe(true); expect(result.pageStatus).toBe("ok"); }); - it("实际运行脚本无 match 覆盖时 hasMatchOverride 应为 false", async () => { + it("无 match 覆盖的脚本(无运行缓存)hasMatchOverride 应为 false", async () => { const uuid = "no-match-uuid"; const matchMap = new Map([[uuid, { uuid, effective: true }]]); - await cacheInstance.set(`${CACHE_KEY_TAB_SCRIPT}${1}`, [createMenu(uuid)]); const { service } = createService({ runtime: { @@ -421,11 +421,8 @@ describe("PopupService getPopupData 页面可达性(脚本猫无法触及的 const matchOne = (uuid: string) => vi.fn().mockResolvedValue(new Map([[uuid, { uuid, effective: true }]])); /** 模拟 content script 报到:顶层 frame 载入事件 */ - const firePageLoad = async (service: PopupService, tabId: number, url: string, scriptmenus: ScriptMenu[] = []) => { - vi.spyOn(tabsMock, "get").mockResolvedValue({ id: tabId, url } as chrome.tabs.Tab); - await service.markTabInjected({ tabId, frameId: 0, url, scriptmenus }); - await service.addScriptRunNumber({ tabId, frameId: 0, url, scriptmenus }); - }; + const firePageLoad = (service: PopupService, tabId: number, url: string) => + service.markTabInjected({ tabId, frameId: 0, url, scriptmenus: [] }); beforeEach(async () => { await cacheInstance.clear(); @@ -466,7 +463,7 @@ describe("PopupService getPopupData 页面可达性(脚本猫无法触及的 }, scriptDAO: { gets: vi.fn().mockResolvedValue([createScript(uuid)]) }, }); - await firePageLoad(service, 1, WEB_URL, [createMenu(uuid)]); + await firePageLoad(service, 1, WEB_URL); const result = await service.getPopupData({ tabId: 1, url: WEB_URL }); @@ -480,7 +477,7 @@ describe("PopupService getPopupData 页面可达性(脚本猫无法触及的 runtime: { getPopupPageScriptMatchingResultByUrl: matchOne(uuid) }, scriptDAO: { gets: vi.fn().mockResolvedValue([createScript(uuid)]) }, }); - await firePageLoad(service, 1, WEB_URL, [createMenu(uuid)]); + await firePageLoad(service, 1, WEB_URL); const result = await service.getPopupData({ tabId: 1, url: WEB_URL }); @@ -519,20 +516,6 @@ describe("PopupService getPopupData 页面可达性(脚本猫无法触及的 expect(result.pageStatus).toBe("not-injected"); }); - it("旧页面的迟到报到不能覆盖当前 tab 的新 origin", async () => { - const { service } = createService(); - const getTab = vi - .spyOn(tabsMock, "get") - .mockResolvedValue({ id: 1, url: "https://new.example.com/page" } as chrome.tabs.Tab); - - await service.markTabInjected({ tabId: 1, frameId: 0, url: "https://old.example.com/page", scriptmenus: [] }); - await expect(cacheInstance.get(`${CACHE_KEY_TAB_LOADED}${1}`)).resolves.toBeUndefined(); - - await service.markTabInjected({ tabId: 1, frameId: 0, url: "https://new.example.com/page", scriptmenus: [] }); - await expect(cacheInstance.get(`${CACHE_KEY_TAB_LOADED}${1}`)).resolves.toBe("https://new.example.com"); - expect(getTab).toHaveBeenCalledTimes(2); - }); - it("扩展商店页未注入时报 restricted(浏览器保护自家商店)", async () => { const { service } = createService(); @@ -551,7 +534,7 @@ describe("PopupService getPopupData 页面可达性(脚本猫无法触及的 runtime: { getPopupPageScriptMatchingResultByUrl: matchOne(uuid) }, scriptDAO: { gets: vi.fn().mockResolvedValue([createScript(uuid)]) }, }); - await firePageLoad(service, 1, storeUrl, [createMenu(uuid)]); + await firePageLoad(service, 1, storeUrl); const result = await service.getPopupData({ tabId: 1, url: storeUrl }); @@ -571,7 +554,7 @@ describe("PopupService getPopupData 页面可达性(脚本猫无法触及的 it("file:// 页已实际注入时按 ok 处理,不因权限查询结果误报", async () => { vi.spyOn(extensionMock, "isAllowedFileSchemeAccess").mockResolvedValue(false); const { service } = createService(); - await firePageLoad(service, 1, "file:///tmp/a.html", [createMenu("file-script")]); + await firePageLoad(service, 1, "file:///tmp/a.html"); const result = await service.getPopupData({ tabId: 1, url: "file:///tmp/a.html" }); @@ -628,10 +611,7 @@ describe("PopupService getPopupData 子 frame(iframe)内运行的脚本", () it("仅匹配 iframe 的脚本应标记 matchesTopFrame = false,顶层匹配的脚本为 true", async () => { const topUuid = "top-script"; const frameUuid = "iframe-script"; - await cacheInstance.set(`${CACHE_KEY_TAB_SCRIPT}${1}`, [ - createMenu(topUuid), - createMenu(frameUuid, { runNumByIframe: 1 }), - ]); + await cacheInstance.set(`${CACHE_KEY_TAB_SCRIPT}${1}`, [createMenu(frameUuid, { runNumByIframe: 1 })]); mockFrames([FRAME_URL]); const { service } = createService({ @@ -687,10 +667,7 @@ describe("PopupService getPopupData 子 frame(iframe)内运行的脚本", () it("getAllFrames 失败(标签页已关闭等)时降级为只看顶层匹配,不影响顶层脚本列表", async () => { const topUuid = "top-script"; const frameUuid = "iframe-script"; - await cacheInstance.set(`${CACHE_KEY_TAB_SCRIPT}${1}`, [ - createMenu(topUuid), - createMenu(frameUuid, { runNumByIframe: 1 }), - ]); + await cacheInstance.set(`${CACHE_KEY_TAB_SCRIPT}${1}`, [createMenu(frameUuid, { runNumByIframe: 1 })]); vi.spyOn(webNavigationMock, "getAllFrames").mockRejectedValue(new Error("No tab with id")); const { service } = createService({ diff --git a/src/app/service/service_worker/popup.ts b/src/app/service/service_worker/popup.ts index 9f049f324..0932cb055 100644 --- a/src/app/service/service_worker/popup.ts +++ b/src/app/service/service_worker/popup.ts @@ -386,12 +386,13 @@ export class PopupService { this.getScriptMenu(-1), ]); + const uuids = [...matchingResult.keys()]; + + const scripts = await this.scriptDAO.gets(uuids); + // 与运行时脚本进行合并 // 以已运行脚本建立快取(uuid→ScriptMenu),供后续合并与覆盖状态。 const runMap = new Map(runScripts.map((script) => [script.uuid, script])); - // Popup 展示实际已注入的脚本;仅命中 URL pattern 但没有本次 tab 运行记录的脚本不应出现。 - const uuids = [...matchingResult.keys()].filter((uuid) => runMap.has(uuid)); - const scripts = await this.scriptDAO.gets(uuids); // 合并后结果 const scriptMenuMap = new Map(); // 合并数据 @@ -400,14 +401,23 @@ export class PopupService { const script = scripts[idx]; const o = matchingResult.get(uuid); - const run = runMap.get(uuid); - if (!script || !o || !run) continue; + if (!script || !o) continue; - // 如果脚本已经存在,则不添加,更新信息 - run.enable = script.status === SCRIPT_STATUS_ENABLE; - run.isEffective = o.effective!; - run.hasMatchOverride = script.selfMetadata?.match !== undefined; - run.hasUserConfig = !!script.config; + let run = runMap.get(uuid); + if (run) { + // 如果脚本已经存在,则不添加,更新信息 + run.enable = script.status === SCRIPT_STATUS_ENABLE; + run.isEffective = o.effective!; + run.hasMatchOverride = script.selfMetadata?.match !== undefined; + run.hasUserConfig = !!script.config; + } else { + // 由于目前没有在 Popup 显示 @match @include @exclude, 所以以下代码暂不需要 + // if (script.selfMetadata) { + // script.metadata = getCombinedMeta(script.metadata, script.selfMetadata); + // } + run = scriptToMenu(script); + run.isEffective = o.effective!; + } run.matchesTopFrame = true; scriptMenuMap.set(uuid, run); } @@ -588,16 +598,7 @@ export class PopupService { async markTabInjected({ tabId, frameId, url }: TPopupPageLoadInfo) { if (frameId || tabId <= 0) return; const origin = toOrigin(url); - if (!origin) return; - try { - const tab = await chrome.tabs.get(tabId); - // pageLoad 的 service-worker 处理可能晚于下一次导航;只接受仍属于当前 origin 的报到。 - if (toOrigin(tab.url || "") !== origin) return; - } catch (e) { - LoggerCore.logger().warn("Ignoring page-load update for unavailable tab", { tabId }, Logger.E(e)); - return; - } - await cacheInstance.set(`${CACHE_KEY_TAB_LOADED}${tabId}`, origin); + if (origin) await cacheInstance.set(`${CACHE_KEY_TAB_LOADED}${tabId}`, origin); } async addScriptRunNumber(o: TPopupPageLoadInfo) {