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..0ba4fd5c5 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,308 @@ 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("扩展商店页未注入时报 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(); + + 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..0932cb055 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, isExtensionStoreUrl, 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,113 @@ 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"; + // 以下都是「确认没注入」,只为给出更准确的原因:两项判据都与浏览器有关 + // (Edge 商店在 Chrome 里是普通网页;Firefox 的文件访问开关语义也不同), + // 放在注入证据之后才不会误伤实际能运行的页面。 + if (isExtensionStoreUrl(url)) return "restricted"; + 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 +593,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 +801,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 +923,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..6c4b0d466 --- /dev/null +++ b/src/pkg/utils/page_access.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; +import { getPageAccessKind, isExtensionStoreUrl, 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("商店页在协议层面仍是普通 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/"])( + "普通 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("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")); + }); + + 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..205c63757 --- /dev/null +++ b/src/pkg/utils/page_access.ts @@ -0,0 +1,50 @@ +/** 页面对扩展的可注入性分类。`file` 单独成类:浏览器另有「允许访问文件网址」开关。 */ +export type TPageAccessKind = "web" | "file" | "restricted"; + +// 浏览器强制保留、任何扩展都注入不了的页面。about:blank 也在内:它没有内容可注入。 +const INJECTABLE_PROTOCOLS = new Set(["http:", "https:"]); + +export const getPageAccessKind = (url: string): TPageAccessKind => { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return "restricted"; + } + if (parsed.protocol === "file:") return "file"; + 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")) + ); +}; + +/** + * 取用于判定「同一注入前提」的 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 ""; + } +};