From ac4ba3edf75c6dbedaebd976f75d4694fd716212 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 17 Jul 2026 14:53:10 +0900 Subject: [PATCH 1/4] =?UTF-8?q?=E2=9C=A8=20add=20@site-access=20opt-in=20p?= =?UTF-8?q?opup=20section?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/chrome-extension-mock/runtime.ts | 1 + src/app/service/service_worker/client.ts | 5 ++ src/app/service/service_worker/popup.test.ts | 26 +++++++++ src/app/service/service_worker/popup.ts | 27 +++++++-- .../service_worker/popup_scriptmenu.ts | 2 + .../service/service_worker/runtime.test.ts | 25 +++++++++ src/app/service/service_worker/runtime.ts | 4 ++ src/app/service/service_worker/script.test.ts | 12 ++++ src/app/service/service_worker/script.ts | 22 ++++++++ src/app/service/service_worker/types.ts | 1 + src/app/service/service_worker/utils.test.ts | 23 ++++++++ src/app/service/service_worker/utils.ts | 16 ++++++ src/locales/de-DE/common.json | 1 + src/locales/de-DE/popup.json | 1 + src/locales/en-US/common.json | 1 + src/locales/en-US/popup.json | 1 + src/locales/ja-JP/common.json | 1 + src/locales/ja-JP/popup.json | 1 + src/locales/ru-RU/common.json | 1 + src/locales/ru-RU/popup.json | 1 + src/locales/tr-TR/common.json | 1 + src/locales/tr-TR/popup.json | 1 + src/locales/vi-VN/common.json | 1 + src/locales/vi-VN/popup.json | 1 + src/locales/zh-CN/common.json | 1 + src/locales/zh-CN/popup.json | 1 + src/locales/zh-TW/common.json | 1 + src/locales/zh-TW/popup.json | 1 + src/pages/popup/App.test.tsx | 34 ++++++++++++ src/pages/popup/App.tsx | 55 ++++++++++++++++++- src/pages/popup/preload.test.ts | 1 + src/pages/popup/preload.ts | 4 +- src/pages/popup/usePopupData.test.ts | 1 + src/pages/popup/usePopupData.ts | 48 ++++++++++++++-- 34 files changed, 311 insertions(+), 12 deletions(-) diff --git a/packages/chrome-extension-mock/runtime.ts b/packages/chrome-extension-mock/runtime.ts index 322543926..589cda114 100644 --- a/packages/chrome-extension-mock/runtime.ts +++ b/packages/chrome-extension-mock/runtime.ts @@ -99,6 +99,7 @@ export default class Runtime { updatetime: Date.now() - 1000, }, ], + optInScriptList: [], backScriptList: [], isBlacklist: false, }; diff --git a/src/app/service/service_worker/client.ts b/src/app/service/service_worker/client.ts index 720564940..bd21479b6 100644 --- a/src/app/service/service_worker/client.ts +++ b/src/app/service/service_worker/client.ts @@ -98,6 +98,10 @@ export class ScriptClient extends Client { return this.do("excludeUrl", { uuid, excludePattern, remove }); } + includeUrl(uuid: string, includePattern: string) { + return this.do("includeUrl", { uuid, includePattern }); + } + // 重置匹配项 resetMatch(uuid: string, match: string[] | undefined) { return this.do("resetMatch", { uuid, match }); @@ -229,6 +233,7 @@ export type GetPopupDataRes = { // 在黑名单 isBlacklist: boolean; scriptList: ScriptMenu[]; + optInScriptList: ScriptMenu[]; backScriptList: ScriptMenu[]; }; diff --git a/src/app/service/service_worker/popup.test.ts b/src/app/service/service_worker/popup.test.ts index 85b3b1721..207d82f1d 100644 --- a/src/app/service/service_worker/popup.test.ts +++ b/src/app/service/service_worker/popup.test.ts @@ -301,6 +301,32 @@ describe("PopupService getPopupData Popup 数据获取与合并", () => { expect(result.isBlacklist).toBe(false); }); + it("匹配但未加入当前网址白名单的 opt-in 脚本应单独返回,且不进入当前页脚本列表", async () => { + const uuid = "opt-in-uuid"; + const matchMap = new Map([[uuid, { uuid, effective: true }]]); + + const { service } = createService({ + runtime: { + getPopupPageScriptMatchingResultByUrl: vi.fn().mockResolvedValue(matchMap), + isUrlBlacklist: vi.fn().mockReturnValue(false), + }, + scriptDAO: { + gets: vi.fn().mockResolvedValue([ + createScript(uuid, { + metadata: { match: ["*://*/*"], "site-access": ["opt-in"] }, + }), + ]), + }, + }); + + const result = await service.getPopupData({ tabId: 1, url: "https://example.com/" }); + + expect(result.scriptList).toEqual([]); + expect(result.optInScriptList).toHaveLength(1); + expect(result.optInScriptList[0]?.uuid).toBe(uuid); + expect(result.optInScriptList[0]?.isEffective).toBe(false); + }); + it("脚本同时在匹配结果与运行缓存中,应复用缓存记录(保留 runNum)并更新 enable/isEffective/hasUserConfig", async () => { const uuid = "run-uuid"; const matchMap = new Map([[uuid, { uuid, effective: false }]]); diff --git a/src/app/service/service_worker/popup.ts b/src/app/service/service_worker/popup.ts index 0cc63b20a..ef4d23d43 100644 --- a/src/app/service/service_worker/popup.ts +++ b/src/app/service/service_worker/popup.ts @@ -6,6 +6,7 @@ import type { GetPopupDataReq, GetPopupDataRes, MenuClickParams } from "./client import { cacheInstance } from "@App/app/cache"; import type { ScriptDAO } from "@App/app/repo/scripts"; import { applyScriptDisplayInfo, scriptToMenu, type TPopupPageLoadInfo } from "./popup_scriptmenu"; +import { isSiteAccessAllowed, isSiteAccessOptIn } from "./utils"; import { SCRIPT_STATUS_ENABLE, SCRIPT_TYPE_NORMAL, SCRIPT_RUN_STATUS_RUNNING } from "@App/app/repo/scripts"; import type { TDeleteScript, @@ -382,6 +383,8 @@ export class PopupService { const runMap = new Map(runScripts.map((script) => [script.uuid, script])); // 合并后结果 const scriptMenuMap = new Map(); + const optInScriptList: ScriptMenu[] = []; + const optInScriptUuids = new Set(); // 合并数据 for (let idx = 0, l = uuids.length; idx < l; idx++) { const uuid = uuids[idx]; @@ -404,13 +407,23 @@ export class PopupService { run = scriptToMenu(script); run.isEffective = o.effective!; } - scriptMenuMap.set(uuid, run); + const siteAccessOptIn = isSiteAccessOptIn(script.metadata); + run.siteAccess = siteAccessOptIn ? "opt-in" : undefined; + if (siteAccessOptIn && !isSiteAccessAllowed(script, url)) { + run.isEffective = false; + optInScriptList.push(run); + optInScriptUuids.add(uuid); + } else { + scriptMenuMap.set(uuid, run); + } } // 将未匹配当前 url 但仍在运行的脚本,附加到清单末端,避免使用者找不到其菜单。 // 这些记录来自 tabScript: session cache;脚本删除事件与 Popup 读取可能交错, // 因此这里必须用 DAO 结果做读侧防护,避免已删除脚本残留在 Popup 清单。 - const unmatchedRunScripts = runScripts.filter((script) => !scriptMenuMap.has(script.uuid)); + const unmatchedRunScripts = runScripts.filter( + (script) => !scriptMenuMap.has(script.uuid) && !optInScriptUuids.has(script.uuid) + ); if (unmatchedRunScripts.length) { const existingRunScripts = await this.scriptDAO.gets(unmatchedRunScripts.map((script) => script.uuid)); for (let idx = 0, l = unmatchedRunScripts.length; idx < l; idx++) { @@ -424,12 +437,18 @@ export class PopupService { // 检查是否在黑名单中 const isBlacklist = this.runtime.isUrlBlacklist(url); // 即时附加图标与本地化脚本名(仅写入响应,不回写 session 缓存,避免 icon64 等占用过大) - const [scriptListWithInfo, backScriptListWithInfo] = await Promise.all([ + const [scriptListWithInfo, optInScriptListWithInfo, backScriptListWithInfo] = await Promise.all([ this.attachScriptDisplayInfo(scriptMenu), + this.attachScriptDisplayInfo(optInScriptList), this.attachScriptDisplayInfo(backScriptList), ]); // 后台脚本只显示开启或者运行中的脚本 - return { isBlacklist, scriptList: scriptListWithInfo, backScriptList: backScriptListWithInfo }; + return { + isBlacklist, + scriptList: scriptListWithInfo, + optInScriptList: optInScriptListWithInfo, + backScriptList: backScriptListWithInfo, + }; } /** 为 ScriptMenu 列表即时附加图标 URL 与本地化脚本名(返回浅拷贝,不修改缓存中的原对象) */ diff --git a/src/app/service/service_worker/popup_scriptmenu.ts b/src/app/service/service_worker/popup_scriptmenu.ts index dafa06f3f..6e0bd4fc0 100644 --- a/src/app/service/service_worker/popup_scriptmenu.ts +++ b/src/app/service/service_worker/popup_scriptmenu.ts @@ -3,6 +3,7 @@ import type { ScriptMenu } from "@App/app/service/service_worker/types"; import type { Script } from "@App/app/repo/scripts"; import { getIcon, getStorageName } from "@App/pkg/utils/utils"; import { i18nName } from "@App/locales/locales"; +import { isSiteAccessOptIn } from "./utils"; export type TPopupPageLoadInfo = { tabId: number; frameId?: number; scriptmenus: ScriptMenu[] }; @@ -15,6 +16,7 @@ export const scriptToMenu = (script: Script): ScriptMenu => { enable: script.status === SCRIPT_STATUS_ENABLE, updatetime: script.updatetime || 0, hasUserConfig: !!script.config, + siteAccess: isSiteAccessOptIn(script.metadata) ? "opt-in" : undefined, // 不需要完整 metadata。目前在 Popup 未使用 metadata。 // 有需要时请把 metadata 里需要的部份抽出 (例如 @match @include @exclude),避免 chrome.storage.session 储存量过大 // metadata: script.metadata, diff --git a/src/app/service/service_worker/runtime.test.ts b/src/app/service/service_worker/runtime.test.ts index 47f0d8365..b3e3fd2ec 100644 --- a/src/app/service/service_worker/runtime.test.ts +++ b/src/app/service/service_worker/runtime.test.ts @@ -824,6 +824,31 @@ describe("getScriptsForTab 附加边界场景", () => { expect(result).toBeNull(); }); + it("没有用户自定义 match 时,opt-in 脚本不应执行", async () => { + const { runtime, script, scriptRes } = createFullContext({ + metadata: { match: ["https://www.example.com/*"], "site-access": ["opt-in"] }, + }); + await runtime.applyScriptMatchInfo(scriptRes); + + const result = await runtime.getScriptsForTab({ url: pageUrl, tabId: undefined, frameId: undefined }); + + expect(result).toBeNull(); + expect((runtime as any).pageLoadCaches.has(script.uuid)).toBe(false); + }); + + it("用户自定义 match 命中时,opt-in 脚本应正常执行", async () => { + const { runtime, scriptRes } = createFullContext({ + metadata: { match: ["https://www.example.com/*"], "site-access": ["opt-in"] }, + selfMetadata: { match: ["*://www.example.com/*"] }, + }); + await runtime.applyScriptMatchInfo(scriptRes); + + const result = await runtime.getScriptsForTab({ url: pageUrl, tabId: undefined, frameId: undefined }); + + expect(result).not.toBeNull(); + expect(result!.injectScriptList.length + result!.contentScriptList.length).toBe(1); + }); + it("匹配结果中的脚本被 DAO 返回为 DISABLE 状态时,shouldSkipPageLoadScript 过滤后返回 null", async () => { const { runtime, script, scriptRes, mockScriptDAO } = createFullContext(); await runtime.applyScriptMatchInfo(scriptRes); // 启用时写入匹配器 diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index d2edbefdd..206857c38 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -14,6 +14,8 @@ import { buildScriptRunResourceBasic, compileInjectionCode, getUserScriptRegister, + isSiteAccessAllowed, + isSiteAccessOptIn, parseUrlSRI, scriptURLPatternResults, type RegisteredUserScriptWithJsCode, @@ -1509,6 +1511,8 @@ export class RuntimeService { for (let idx = 0, l = uuids.length; idx < l; idx++) { const script = scripts[idx]; if (!script) continue; + // opt-in 脚本只有在用户通过 Popup 将当前网址加入白名单后才能执行。 + if (isSiteAccessOptIn(script.metadata) && !isSiteAccessAllowed(script, url)) continue; const scriptRes = buildScriptRunResourceBasic(script); if (this.shouldSkipPageLoadScript(scriptRes, frameId)) continue; diff --git a/src/app/service/service_worker/script.test.ts b/src/app/service/service_worker/script.test.ts index 2159e336e..8f1d4a770 100644 --- a/src/app/service/service_worker/script.test.ts +++ b/src/app/service/service_worker/script.test.ts @@ -658,6 +658,18 @@ describe("ScriptService selfMetadata 用户覆盖", () => { }); }); + describe("includeUrl - popup 加入 opt-in 白名单", () => { + it("应追加当前网址到用户自定义 match 覆盖中", async () => { + const script = createMockScript({ selfMetadata: { match: ["*://existing.com/*"] } }); + vi.mocked(mockScriptDAO.get).mockResolvedValue(script); + + await scriptService.includeUrl({ uuid: script.uuid, includePattern: "*://user.com/*" }); + + expect(savedSelfMetadata()).toEqual({ match: ["*://existing.com/*", "*://user.com/*"] }); + expect(mockMessageQueue.publish).toHaveBeenCalled(); + }); + }); + describe("resetMatch / resetExclude - 编辑器匹配列表", () => { it("传入 undefined(重置)应删除用户覆盖", async () => { const script = createMockScript({ selfMetadata: { match: ["*://user.com/*"] } }); diff --git a/src/app/service/service_worker/script.ts b/src/app/service/service_worker/script.ts index 48f992422..c510d1729 100644 --- a/src/app/service/service_worker/script.ts +++ b/src/app/service/service_worker/script.ts @@ -919,6 +919,27 @@ export class ScriptService { }); } + // 将 opt-in 脚本的当前网址加入用户自定义 match 白名单。 + async includeUrl({ uuid, includePattern }: { uuid: string; includePattern: string }) { + let script = await this.scriptDAO.get(uuid); + if (!script) { + throw new Error("script not found"); + } + const matchSet = new Set(script.selfMetadata?.match || []); + matchSet.add(includePattern); + script = selfMetadataUpdate(script, "match", matchSet); + return this.scriptDAO + .update(uuid, script) + .then(() => { + this.publishInstallScript(script, { update: true }); + return true; + }) + .catch((e) => { + this.logger.error("include url error", Logger.E(e)); + throw e; + }); + } + async resetExclude({ uuid, exclude }: { uuid: string; exclude: string[] | undefined }) { let script = await this.scriptDAO.get(uuid); if (!script) { @@ -1662,6 +1683,7 @@ export class ScriptService { this.group.on("getFilterResult", this.getFilterResult.bind(this)); this.group.on("getScriptRunResourceByUUID", this.getScriptRunResourceByUUID.bind(this)); this.group.on("excludeUrl", this.excludeUrl.bind(this)); + this.group.on("includeUrl", this.includeUrl.bind(this)); this.group.on("resetMatch", this.resetMatch.bind(this)); this.group.on("resetExclude", this.resetExclude.bind(this)); this.group.on("requestCheckUpdate", this.requestCheckUpdate.bind(this)); diff --git a/src/app/service/service_worker/types.ts b/src/app/service/service_worker/types.ts index a672c8fbd..53c11d447 100644 --- a/src/app/service/service_worker/types.ts +++ b/src/app/service/service_worker/types.ts @@ -192,6 +192,7 @@ export type ScriptMenu = { updatetime: number; // 脚本更新时间 hasUserConfig: boolean; // 是否有用户配置 icon?: string; // 脚本图标 URL(由 getPopupData 即时附加在响应中,不写回 session 缓存) + siteAccess?: "opt-in"; // 脚本声明了 @site-access opt-in // 不需要完整 metadata。目前在 Popup 未使用 metadata。 // 有需要时请把 metadata 里需要的部份抽出 (例如 @match @include @exclude),避免 chrome.storage.session 储存量过大 // metadata: SCMetadata; // 脚本元数据 diff --git a/src/app/service/service_worker/utils.test.ts b/src/app/service/service_worker/utils.test.ts index 5f935d6e6..a50bca99a 100644 --- a/src/app/service/service_worker/utils.test.ts +++ b/src/app/service/service_worker/utils.test.ts @@ -4,6 +4,8 @@ import { parseUrlSRI, getCombinedMeta, selfMetadataUpdate, + isSiteAccessOptIn, + isSiteAccessAllowed, getUserScriptRegister, compileInjectionCode, shouldAutoOpenChangelog, @@ -253,6 +255,27 @@ describe.concurrent("selfMetadataUpdate", () => { }); }); +describe.concurrent("@site-access opt-in", () => { + it.concurrent("应识别 opt-in 声明而忽略其他 site-access 值", () => { + expect(isSiteAccessOptIn({ "site-access": ["opt-in"] })).toBe(true); + expect(isSiteAccessOptIn({ "site-access": ["opt-out"] })).toBe(false); + }); + + it.concurrent("只有当前网址命中用户自定义 match/include 时才允许 opt-in 脚本", () => { + const script = { selfMetadata: { match: ["*://example.com/*"] } }; + + expect(isSiteAccessAllowed(script, "https://example.com/page")).toBe(true); + expect(isSiteAccessAllowed(script, "https://other.com/page")).toBe(false); + expect( + isSiteAccessAllowed({ selfMetadata: { include: ["*://example.com/tools/*"] } }, "https://example.com/tools/a") + ).toBe(true); + expect( + isSiteAccessAllowed({ selfMetadata: { include: ["*example.com/tools*"] } }, "https://example.com/tools/a") + ).toBe(true); + expect(isSiteAccessAllowed({ selfMetadata: {} }, "https://example.com/page")).toBe(false); + }); +}); + describe.concurrent("getUserScriptRegister", () => { it.concurrent("should return a valid RegisteredUserScript object", () => { const mockScriptMatchInfo: ScriptMatchInfo = { diff --git a/src/app/service/service_worker/utils.ts b/src/app/service/service_worker/utils.ts index 2f141befb..d86ff99f8 100644 --- a/src/app/service/service_worker/utils.ts +++ b/src/app/service/service_worker/utils.ts @@ -15,7 +15,9 @@ import { import { extractUrlPatterns, getApiMatchesAndGlobs, + isUrlMatch, RuleType, + RuleTypeBit, toUniquePatternStrings, type URLRuleEntry, } from "@App/pkg/utils/url_matcher"; @@ -23,6 +25,20 @@ import { cacheInstance } from "@App/app/cache"; export type RegisteredUserScriptWithJsCode = RequireField; +export const isSiteAccessOptIn = (metadata: SCMetadata | undefined): boolean => + metadata?.["site-access"]?.some((value) => value.trim().toLowerCase() === "opt-in") ?? false; + +export const isSiteAccessAllowed = (script: Pick, url: string): boolean => { + const metadata = script.selfMetadata; + if (!metadata) return false; + + const patterns = extractUrlPatterns([ + ...(metadata.match || []).map((value) => `@match ${value}`), + ...(metadata.include || []).map((value) => `@include ${value}`), + ]); + return patterns.some((pattern) => pattern.ruleType & RuleTypeBit.INCLUSION && isUrlMatch(url, pattern)); +}; + export function getRunAt(runAts: string[]): chrome.extensionTypes.RunAt { // 没有 run-at 时为 undefined. Fallback 至 document_idle const runAt = runAts[0] as string | undefined; diff --git a/src/locales/de-DE/common.json b/src/locales/de-DE/common.json index df1003539..26797cc54 100644 --- a/src/locales/de-DE/common.json +++ b/src/locales/de-DE/common.json @@ -60,6 +60,7 @@ "copy": "Kopieren", "exclude_on": "Wiederherstellen auf $0 zur Ausführung", "exclude_off": "Ausschließen auf $0 zur Ausführung", + "include_on": "Ausführung auf $0 einschließen", "confirm_error": "Bestätigung fehlgeschlagen", "import": "Importieren", "error": "Fehler", diff --git a/src/locales/de-DE/popup.json b/src/locales/de-DE/popup.json index f63f32c2e..9ffb6f8b1 100644 --- a/src/locales/de-DE/popup.json +++ b/src/locales/de-DE/popup.json @@ -1,6 +1,7 @@ { "new_version_available": "Neue Version verfügbar", "current_page_scripts": "Aktuelle Seiten-Ausführungsskripte", + "opt_in_scripts": "Opt-in-Skripte", "enabled_background_scripts": "Aktivierte und laufende Hintergrundskripte", "develop_mode_guide": "Der 'Entwicklermodus' ist derzeit nicht aktiviert, daher können die Skripte nicht richtig ausgeführt werden. 👉Hier klicken, um zu erfahren, wie man ihn aktiviert", "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", diff --git a/src/locales/en-US/common.json b/src/locales/en-US/common.json index 7ce145b36..65a8a44f1 100644 --- a/src/locales/en-US/common.json +++ b/src/locales/en-US/common.json @@ -60,6 +60,7 @@ "copy": "Copy", "exclude_on": "Reinstate $0's execution", "exclude_off": "Exclude $0's execution", + "include_on": "Include $0's execution", "confirm_error": "Confirmation Failed", "import": "Import", "error": "Error", diff --git a/src/locales/en-US/popup.json b/src/locales/en-US/popup.json index ead07e2c9..82bfff2e8 100644 --- a/src/locales/en-US/popup.json +++ b/src/locales/en-US/popup.json @@ -1,6 +1,7 @@ { "new_version_available": "New Version Available", "current_page_scripts": "Current Page Running Scripts", + "opt_in_scripts": "Opt-in Scripts", "enabled_background_scripts": "Enabled and Running Background Scripts", "develop_mode_guide": "'Developer mode' is currently not enabled, so the scripts cannot run properly. 👉tap to learn how to enable", "lower_version_browser_guide": "Your browser is too outdated, so the scripts cannot run properly. 👉Click me to learn more", diff --git a/src/locales/ja-JP/common.json b/src/locales/ja-JP/common.json index 1919d18e8..168ea71b3 100644 --- a/src/locales/ja-JP/common.json +++ b/src/locales/ja-JP/common.json @@ -60,6 +60,7 @@ "copy": "コピー", "exclude_on": "$0の実行を復元", "exclude_off": "$0の実行を除外", + "include_on": "$0での実行を含める", "confirm_error": "確認に失敗しました", "import": "インポート", "error": "エラー", diff --git a/src/locales/ja-JP/popup.json b/src/locales/ja-JP/popup.json index 6645893b7..d6e00fb1f 100644 --- a/src/locales/ja-JP/popup.json +++ b/src/locales/ja-JP/popup.json @@ -1,6 +1,7 @@ { "new_version_available": "新しいバージョンが利用可能です", "current_page_scripts": "現在のページ実行スクリプト", + "opt_in_scripts": "オプトインスクリプト", "enabled_background_scripts": "有効および実行中のバックグラウンドスクリプト", "develop_mode_guide": "現在「デベロッパーモード」が有効ではないため、スクリプトは正常に動作しません。👉有効化の方法はこちら", "lower_version_browser_guide": "ご使用のブラウザは古すぎるため、スクリプトは正常に動作しません。👉詳しくはこちら", diff --git a/src/locales/ru-RU/common.json b/src/locales/ru-RU/common.json index 745001a42..26b170f51 100644 --- a/src/locales/ru-RU/common.json +++ b/src/locales/ru-RU/common.json @@ -60,6 +60,7 @@ "copy": "Копировать", "exclude_on": "Восстановить в $0 выполнении", "exclude_off": "Исключить в $0 выполнении", + "include_on": "Включить выполнение на $0", "confirm_error": "Ошибка подтверждения", "import": "Импорт", "error": "Ошибка", diff --git a/src/locales/ru-RU/popup.json b/src/locales/ru-RU/popup.json index b74dae3b6..a5fa4a861 100644 --- a/src/locales/ru-RU/popup.json +++ b/src/locales/ru-RU/popup.json @@ -1,6 +1,7 @@ { "new_version_available": "Доступна новая версия", "current_page_scripts": "Скрипты, выполняющиеся на текущей странице", + "opt_in_scripts": "Скрипты с включением по запросу", "enabled_background_scripts": "Включенные и работающие фоновые скрипты", "develop_mode_guide": "«Режим разработчика» сейчас отключён, поэтому скрипты не могут работать корректно. 👉Нажмите, чтобы узнать, как включить", "lower_version_browser_guide": "Ваш браузер слишком устарел, поэтому скрипты не могут работать корректно. 👉Нажмите, чтобы узнать подробнее", diff --git a/src/locales/tr-TR/common.json b/src/locales/tr-TR/common.json index 0f93f6400..e8fa7facb 100644 --- a/src/locales/tr-TR/common.json +++ b/src/locales/tr-TR/common.json @@ -60,6 +60,7 @@ "copy": "Kopyala", "exclude_on": "$0 yürütmesini yeniden etkinleştir", "exclude_off": "$0 yürütmesini dışla", + "include_on": "$0 yürütmesini dahil et", "confirm_error": "Onay başarısız", "import": "İçe Aktar", "error": "Hata", diff --git a/src/locales/tr-TR/popup.json b/src/locales/tr-TR/popup.json index e9d8c84ca..ce20b6372 100644 --- a/src/locales/tr-TR/popup.json +++ b/src/locales/tr-TR/popup.json @@ -1,6 +1,7 @@ { "new_version_available": "Yeni Sürüm Mevcut", "current_page_scripts": "Geçerli Sayfada Çalışan Betikler", + "opt_in_scripts": "İsteğe Bağlı Betikler", "enabled_background_scripts": "Etkin ve Çalışan Arka Plan Betikleri", "develop_mode_guide": "\"Geliştirici Modu\" şu anda etkin değil, bu nedenle betikler düzgün çalışamaz. 👉Nasıl etkinleştireceğinizi öğrenmek için tıklayın", "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", diff --git a/src/locales/vi-VN/common.json b/src/locales/vi-VN/common.json index 7f6077493..3b516e76a 100644 --- a/src/locales/vi-VN/common.json +++ b/src/locales/vi-VN/common.json @@ -60,6 +60,7 @@ "copy": "Sao chép", "exclude_on": "Cho phép chạy lại $0", "exclude_off": "Loại trừ chạy $0", + "include_on": "Cho phép chạy trên $0", "confirm_error": "Xác nhận thất bại", "import": "Nhập", "error": "Lỗi", diff --git a/src/locales/vi-VN/popup.json b/src/locales/vi-VN/popup.json index 94f65427d..e90f16e3a 100644 --- a/src/locales/vi-VN/popup.json +++ b/src/locales/vi-VN/popup.json @@ -1,6 +1,7 @@ { "new_version_available": "Phiên bản mới có sẵn", "current_page_scripts": "Script đang chạy trên trang hiện tại", + "opt_in_scripts": "Script chỉ chạy trên trang được chọn", "enabled_background_scripts": "Script nền đã bật và đang chạy", "develop_mode_guide": "'Chế độ nhà phát triển' hiện chưa được bật, nên các script không thể hoạt động đúng cách. 👉Nhấn để xem cách bật", "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", diff --git a/src/locales/zh-CN/common.json b/src/locales/zh-CN/common.json index 1d6fce2ac..f086d0250 100644 --- a/src/locales/zh-CN/common.json +++ b/src/locales/zh-CN/common.json @@ -60,6 +60,7 @@ "copy": "复制", "exclude_on": "恢复在 $0 上执行", "exclude_off": "排除在 $0 上执行", + "include_on": "允许在 $0 上执行", "confirm_error": "确认失败", "import": "导入", "error": "错误", diff --git a/src/locales/zh-CN/popup.json b/src/locales/zh-CN/popup.json index ef6dc1e14..84eddf2ca 100644 --- a/src/locales/zh-CN/popup.json +++ b/src/locales/zh-CN/popup.json @@ -1,6 +1,7 @@ { "new_version_available": "有新版本可用", "current_page_scripts": "当前页运行脚本", + "opt_in_scripts": "选择启用脚本", "enabled_background_scripts": "开启和运行的后台脚本", "develop_mode_guide": "当前未启用“开发者模式”,脚本无法正常运行。👉点击查看启用方法", "lower_version_browser_guide": "您的浏览器版本过低,脚本无法正常运行。👉点击了解更多", diff --git a/src/locales/zh-TW/common.json b/src/locales/zh-TW/common.json index 9d6e0c2fd..0edb0c6fd 100644 --- a/src/locales/zh-TW/common.json +++ b/src/locales/zh-TW/common.json @@ -60,6 +60,7 @@ "copy": "複製", "exclude_on": "恢復 $0 的執行", "exclude_off": "排除 $0 的執行", + "include_on": "允許在 $0 執行", "confirm_error": "確認失敗", "import": "匯入", "error": "錯誤", diff --git a/src/locales/zh-TW/popup.json b/src/locales/zh-TW/popup.json index 755b1f76e..ba4fb4e2a 100644 --- a/src/locales/zh-TW/popup.json +++ b/src/locales/zh-TW/popup.json @@ -1,6 +1,7 @@ { "new_version_available": "有新版本可用", "current_page_scripts": "目前頁面執行腳本", + "opt_in_scripts": "選擇啟用腳本", "enabled_background_scripts": "開啟和執行的背景腳本", "develop_mode_guide": "目前尚未啟用「開發者模式」,腳本無法正常執行。👉點此查看啟用方式", "lower_version_browser_guide": "您的瀏覽器版本過舊,腳本無法正常執行。👉點擊了解更多", diff --git a/src/pages/popup/App.test.tsx b/src/pages/popup/App.test.tsx index 083e40cf8..d9d69f7cf 100644 --- a/src/pages/popup/App.test.tsx +++ b/src/pages/popup/App.test.tsx @@ -23,9 +23,11 @@ function makeData(overrides: Record = {}) { isBlacklist: false, host: "example.com", scriptList: [], + optInScriptList: [], backScriptList: [], allScripts: [], fullScriptCount: 0, + fullOptInScriptCount: 0, fullBackScriptCount: 0, remainingCurrentCount: 0, remainingBackCount: 0, @@ -36,6 +38,7 @@ function makeData(overrides: Record = {}) { totalScriptCount: 0, backRunningCount: 0, enabledScriptCount: 0, + enabledOptInScriptCount: 0, enabledBackScriptCount: 0, errorMessage: "", showSearch: false, @@ -122,6 +125,37 @@ describe("Popup 紧凑布局", () => { }); }); +describe("Popup opt-in 脚本分组", () => { + it("有匹配的 opt-in 脚本时,应按当前页、opt-in、后台的顺序显示分组", () => { + const script = makeScriptMenu({ uuid: "opt-in", name: "Opt-in Script", siteAccess: "opt-in" }); + mockData = makeData({ + optInScriptList: [script], + allScripts: [script], + fullOptInScriptCount: 1, + enabledOptInScriptCount: 1, + }); + + const { container } = render(); + const sectionTitles = Array.from(container.querySelectorAll("h3 > button")).map((button) => button.textContent); + + expect(sectionTitles.findIndex((title) => title?.includes(t("popup:current_page_scripts")))).toBeLessThan( + sectionTitles.findIndex((title) => title?.includes(t("popup:opt_in_scripts"))) + ); + expect(sectionTitles.findIndex((title) => title?.includes(t("popup:opt_in_scripts")))).toBeLessThan( + sectionTitles.findIndex((title) => title?.includes(t("popup:enabled_background_scripts"))) + ); + expect(screen.getByText("Opt-in Script")).toBeInTheDocument(); + }); + + it("没有匹配的 opt-in 脚本时,不应显示空的 opt-in 分组", () => { + mockData = makeData(); + + render(); + + expect(screen.queryByText(new RegExp(t("popup:opt_in_scripts")))).not.toBeInTheDocument(); + }); +}); + describe("Popup 脚本列表展开/收起", () => { it("当前页脚本超过展示上限且已展开时,应显示「收起」按钮并可再次折叠", () => { const handleToggleExpand = vi.fn(); diff --git a/src/pages/popup/App.tsx b/src/pages/popup/App.tsx index 6507dc88d..6d9cb4fb5 100644 --- a/src/pages/popup/App.tsx +++ b/src/pages/popup/App.tsx @@ -141,7 +141,11 @@ export default function App() {
0 ? ["current", "background"] : ["current"]} + defaultValue={[ + "current", + ...(data.fullOptInScriptCount > 0 ? ["opt-in"] : []), + ...(data.fullBackScriptCount > 0 ? ["background"] : []), + ]} >
{t("no_data")}}
+ {data.fullOptInScriptCount > 0 && ( + <> + +
+ {data.optInScriptList.map((script) => ( + + ))} + {data.canExpandOptIn && ( + data.handleToggleExpand("optIn")} + /> + )} +
+ + )}
void; @@ -453,6 +495,7 @@ interface ScriptRowProps { onOpenEditor: (uuid: string) => void; onOpenUserConfig: (uuid: string) => void; onExcludeUrl?: (uuid: string, isEffective: boolean) => void; + onIncludeUrl?: (uuid: string) => void; onMenuClick: (uuid: string, menus: ScriptMenuItem[], inputValue?: any) => void; onRun?: (uuid: string) => void; onStop?: (uuid: string) => void; @@ -462,6 +505,7 @@ function ScriptRow({ script, host, isPageScript = true, + isOptInScript = false, menuExpandNum = 5, compact = false, onToggle, @@ -469,6 +513,7 @@ function ScriptRow({ onOpenEditor, onOpenUserConfig, onExcludeUrl, + onIncludeUrl, onMenuClick, onRun, onStop, @@ -547,7 +592,11 @@ function ScriptRow({ {t("edit")} {/* 排除/取消排除 host(无二次确认,与旧版一致) */} - {isPageScript && host && onExcludeUrl && script.isEffective !== null && ( + {isPageScript && isOptInScript && host && onIncludeUrl ? ( + } success onClick={() => onIncludeUrl(script.uuid)}> + {t("include_on").replace("$0", host)} + + ) : isPageScript && host && onExcludeUrl && script.isEffective !== null ? ( : @@ -558,7 +607,7 @@ function ScriptRow({ > {script.isEffective ? t("exclude_off").replace("$0", host) : t("exclude_on").replace("$0", host)} - )} + ) : null} {/* 删除(AlertDialog 二次确认) */} { mocks.getPopupData.mockResolvedValue({ isBlacklist: true, scriptList: [script("disabled", false), script("enabled", true)], + optInScriptList: [], backScriptList: [], }); diff --git a/src/pages/popup/preload.ts b/src/pages/popup/preload.ts index f53fae882..c2deef6e2 100644 --- a/src/pages/popup/preload.ts +++ b/src/pages/popup/preload.ts @@ -19,6 +19,7 @@ export type PopupInitialData = { defaultScriptProvider: ScriptProvider; isBlacklist: boolean; scriptList: ScriptMenu[]; + optInScriptList: ScriptMenu[]; backScriptList: ScriptMenu[]; }; @@ -48,7 +49,7 @@ const popupDataQuery = createPreloadableQuery<"popup", PopupInitialData>({ const popupData = tabId >= 0 && url ? await popupClient.getPopupData({ tabId, url }) - : { isBlacklist: false, scriptList: [], backScriptList: [] }; + : { isBlacklist: false, scriptList: [], optInScriptList: [], backScriptList: [] }; if (signal.aborted) throw new DOMException("Popup preload aborted", "AbortError"); @@ -62,6 +63,7 @@ const popupDataQuery = createPreloadableQuery<"popup", PopupInitialData>({ defaultScriptProvider: provider ?? "scriptcat", isBlacklist: popupData.isBlacklist, scriptList: popupData.scriptList.sort(scriptListSorter), + optInScriptList: popupData.optInScriptList.sort(scriptListSorter), backScriptList: popupData.backScriptList, }; }, diff --git a/src/pages/popup/usePopupData.test.ts b/src/pages/popup/usePopupData.test.ts index 42868eb7b..ef45721d2 100644 --- a/src/pages/popup/usePopupData.test.ts +++ b/src/pages/popup/usePopupData.test.ts @@ -19,6 +19,7 @@ const popupInitialData = vi.hoisted(() => ({ updatetime: 0, }, ], + optInScriptList: [], backScriptList: [], })); diff --git a/src/pages/popup/usePopupData.ts b/src/pages/popup/usePopupData.ts index 68bf8d018..0571d79a1 100644 --- a/src/pages/popup/usePopupData.ts +++ b/src/pages/popup/usePopupData.ts @@ -84,12 +84,13 @@ export function usePopupData() { const initialData = popupData.data; const [initialized, setInitialized] = useState(!!initialData); const [scriptList, setScriptList] = useState(initialData?.scriptList ?? []); + const [optInScriptList, setOptInScriptList] = useState(initialData?.optInScriptList ?? []); const [backScriptList, setBackScriptList] = useState(initialData?.backScriptList ?? []); const [isBlacklist, setIsBlacklist] = useState(initialData?.isBlacklist ?? false); const [currentUrl, setCurrentUrl] = useState(initialData?.url ?? ""); const [currentTabId, setCurrentTabId] = useState(initialData?.tabId ?? -1); const [searchQuery, setSearchQuery] = useState(""); - const [expandedSections, setExpandedSections] = useState({ current: false, background: false }); + const [expandedSections, setExpandedSections] = useState({ current: false, optIn: false, background: false }); const [isEnableScript, setIsEnableScript] = useState(initialData?.isEnableScript ?? true); const [errorMessage, setErrorMessage] = useState(""); const [checkUpdate, setCheckUpdate] = useState<{ notice: string; version: string; isRead: boolean }>( @@ -120,7 +121,9 @@ export function usePopupData() { try { const res = await popupClient.getPopupData({ tabId, url }); res.scriptList.sort(scriptListSorter); + res.optInScriptList.sort(scriptListSorter); setScriptList(res.scriptList); + setOptInScriptList(res.optInScriptList); setBackScriptList(res.backScriptList); setIsBlacklist(res.isBlacklist); } catch (e) { @@ -132,6 +135,7 @@ export function usePopupData() { // 在渲染期把快照一次性同步进本地 state(React 推荐的「渲染期同步外部数据」写法,避免 effect 级联渲染)。 if (initialData && !initialized) { setScriptList(initialData.scriptList); + setOptInScriptList(initialData.optInScriptList); setBackScriptList(initialData.backScriptList); setIsBlacklist(initialData.isBlacklist); setCurrentUrl(initialData.url); @@ -166,6 +170,7 @@ export function usePopupData() { const patch = (prev: ScriptMenu[]) => prev.map((s) => (map.has(s.uuid) ? { ...s, enable: map.get(s.uuid)! } : s)); setScriptList(patch); + setOptInScriptList(patch); setBackScriptList(patch); }), // 脚本被删除(= 移入回收站,广播 trashScripts);彻底删除时脚本早已不在列表里,无事可做 @@ -173,6 +178,7 @@ export function usePopupData() { if (!Array.isArray(data)) return; const uuids = new Set(data.map((d) => d.uuid)); setScriptList((prev) => prev.filter((s) => !uuids.has(s.uuid))); + setOptInScriptList((prev) => prev.filter((s) => !uuids.has(s.uuid))); setBackScriptList((prev) => prev.filter((s) => !uuids.has(s.uuid))); }), // 后台脚本运行状态变更 @@ -224,6 +230,7 @@ export function usePopupData() { async (uuid: string, enable: boolean) => { const patch = (prev: ScriptMenu[]) => prev.map((s) => (s.uuid === uuid ? { ...s, enable } : s)); setScriptList(patch); + setOptInScriptList(patch); setBackScriptList(patch); try { await scriptClient.enable(uuid, enable); @@ -231,6 +238,7 @@ export function usePopupData() { // 回滚 const revert = (prev: ScriptMenu[]) => prev.map((s) => (s.uuid === uuid ? { ...s, enable: !enable } : s)); setScriptList(revert); + setOptInScriptList(revert); setBackScriptList(revert); showError(String(e)); } @@ -273,6 +281,20 @@ export function usePopupData() { } }, []); + const handleIncludeUrl = useCallback( + async (uuid: string) => { + const host = extractHost(stateRef.current.currentUrl); + if (!host) return; + try { + await scriptClient.includeUrl(uuid, `*://${host}/*`); + await fetchData(stateRef.current.currentTabId, stateRef.current.currentUrl); + } catch (e) { + showError(String(e)); + } + }, + [fetchData, showError] + ); + /** 调用方需从 script.menus 中按 groupKey 过滤出所有匹配项传入 */ const handleMenuClick = useCallback(async (uuid: string, menus: ScriptMenuItem[], inputValue?: any) => { try { @@ -351,7 +373,7 @@ export function usePopupData() { setSearchQuery(query); }, []); - const handleToggleExpand = useCallback((section: "current" | "background") => { + const handleToggleExpand = useCallback((section: "current" | "optIn" | "background") => { setExpandedSections((prev) => ({ ...prev, [section]: !prev[section] })); }, []); @@ -359,8 +381,9 @@ export function usePopupData() { const host = extractHost(currentUrl); const filteredScriptList = filterScripts(scriptList, searchQuery); + const filteredOptInScriptList = filterScripts(optInScriptList, searchQuery); const filteredBackScriptList = filterScripts(backScriptList, searchQuery); - const totalScriptCount = scriptList.length + backScriptList.length; + const totalScriptCount = scriptList.length + optInScriptList.length + backScriptList.length; const showSearch = totalScriptCount > EXPAND_LIMIT; const displayScriptList = expandedSections.current ? filteredScriptList : filteredScriptList.slice(0, EXPAND_LIMIT); @@ -368,6 +391,12 @@ export function usePopupData() { // 超过展示上限即可展开/收起:折叠时显示「显示更多」,展开时显示「收起」 const canExpandCurrent = filteredScriptList.length > EXPAND_LIMIT; + const displayOptInScriptList = expandedSections.optIn + ? filteredOptInScriptList + : filteredOptInScriptList.slice(0, EXPAND_LIMIT); + const remainingOptInCount = filteredOptInScriptList.length - displayOptInScriptList.length; + const canExpandOptIn = filteredOptInScriptList.length > EXPAND_LIMIT; + const displayBackScriptList = expandedSections.background ? filteredBackScriptList : filteredBackScriptList.slice(0, EXPAND_LIMIT); @@ -376,29 +405,39 @@ export function usePopupData() { const backRunningCount = backScriptList.filter((s) => s.runStatus === SCRIPT_RUN_STATUS_RUNNING).length; const enabledScriptCount = scriptList.filter((s) => s.enable).length; + const enabledOptInScriptCount = optInScriptList.filter((s) => s.enable).length; const enabledBackScriptCount = backScriptList.filter((s) => s.enable).length; // 全量脚本(未经搜索过滤/分段截断):用于 accessKey 快捷键注册,确保对所有脚本生效 - const allScripts = useMemo(() => [...scriptList, ...backScriptList], [scriptList, backScriptList]); + const allScripts = useMemo( + () => [...scriptList, ...optInScriptList, ...backScriptList], + [scriptList, optInScriptList, backScriptList] + ); return { loading: !initialized && !popupData.isError, isBlacklist, host, scriptList: displayScriptList, + optInScriptList: displayOptInScriptList, backScriptList: displayBackScriptList, allScripts, fullScriptCount: filteredScriptList.length, + fullOptInScriptCount: filteredOptInScriptList.length, fullBackScriptCount: filteredBackScriptList.length, remainingCurrentCount, + remainingOptInCount, remainingBackCount, canExpandCurrent, + canExpandOptIn, canExpandBack, isCurrentExpanded: expandedSections.current, + isOptInExpanded: expandedSections.optIn, isBackExpanded: expandedSections.background, totalScriptCount, backRunningCount, enabledScriptCount, + enabledOptInScriptCount, enabledBackScriptCount, errorMessage, showSearch, @@ -408,6 +447,7 @@ export function usePopupData() { handleOpenEditor, handleOpenUserConfig, handleExcludeUrl, + handleIncludeUrl, handleMenuClick, handleRunScript, handleStopScript, From baae5cf6e82aa09b261249de5a403b4e5a7a0e33 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Fri, 17 Jul 2026 18:23:02 +0900 Subject: [PATCH 2/4] =?UTF-8?q?=E2=9C=A8=20use=20additive=20site-access=20?= =?UTF-8?q?metadata=20for=20opt-in=20scripts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/popup.test.ts | 24 +++ .../service/service_worker/runtime.test.ts | 6 +- src/app/service/service_worker/script.test.ts | 9 +- src/app/service/service_worker/script.ts | 8 +- src/app/service/service_worker/utils.test.ts | 35 ++++- src/app/service/service_worker/utils.ts | 23 ++- src/locales/de-DE/editor.json | 6 + src/locales/en-US/editor.json | 6 + src/locales/ja-JP/editor.json | 6 + src/locales/ru-RU/editor.json | 6 + src/locales/tr-TR/editor.json | 6 + src/locales/vi-VN/editor.json | 6 + src/locales/zh-CN/editor.json | 6 + src/locales/zh-TW/editor.json | 6 + .../ScriptEditor/tabs/SettingsPane.test.tsx | 72 ++++++++- .../routes/ScriptEditor/tabs/SettingsPane.tsx | 141 +++++++++++++----- 16 files changed, 295 insertions(+), 71 deletions(-) diff --git a/src/app/service/service_worker/popup.test.ts b/src/app/service/service_worker/popup.test.ts index 207d82f1d..0b429454e 100644 --- a/src/app/service/service_worker/popup.test.ts +++ b/src/app/service/service_worker/popup.test.ts @@ -327,6 +327,30 @@ describe("PopupService getPopupData Popup 数据获取与合并", () => { expect(result.optInScriptList[0]?.isEffective).toBe(false); }); + it("作者声明的 site-access 默认站点应进入当前页脚本列表", async () => { + const uuid = "opt-in-default-uuid"; + const matchMap = new Map([[uuid, { uuid, effective: true }]]); + + const { service } = createService({ + runtime: { + getPopupPageScriptMatchingResultByUrl: vi.fn().mockResolvedValue(matchMap), + isUrlBlacklist: vi.fn().mockReturnValue(false), + }, + scriptDAO: { + gets: vi.fn().mockResolvedValue([ + createScript(uuid, { + metadata: { match: ["*://*/*"], "site-access": ["opt-in", "+*://example.com/*"] }, + }), + ]), + }, + }); + + const result = await service.getPopupData({ tabId: 1, url: "https://example.com/" }); + + expect(result.scriptList).toHaveLength(1); + expect(result.optInScriptList).toEqual([]); + }); + it("脚本同时在匹配结果与运行缓存中,应复用缓存记录(保留 runNum)并更新 enable/isEffective/hasUserConfig", async () => { const uuid = "run-uuid"; const matchMap = new Map([[uuid, { uuid, effective: false }]]); diff --git a/src/app/service/service_worker/runtime.test.ts b/src/app/service/service_worker/runtime.test.ts index b3e3fd2ec..06013ba93 100644 --- a/src/app/service/service_worker/runtime.test.ts +++ b/src/app/service/service_worker/runtime.test.ts @@ -824,7 +824,7 @@ describe("getScriptsForTab 附加边界场景", () => { expect(result).toBeNull(); }); - it("没有用户自定义 match 时,opt-in 脚本不应执行", async () => { + it("没有 site-access 加号规则时,opt-in 脚本不应执行", async () => { const { runtime, script, scriptRes } = createFullContext({ metadata: { match: ["https://www.example.com/*"], "site-access": ["opt-in"] }, }); @@ -836,10 +836,10 @@ describe("getScriptsForTab 附加边界场景", () => { expect((runtime as any).pageLoadCaches.has(script.uuid)).toBe(false); }); - it("用户自定义 match 命中时,opt-in 脚本应正常执行", async () => { + it("作者或用户的 site-access 加号规则命中时,opt-in 脚本应正常执行", async () => { const { runtime, scriptRes } = createFullContext({ metadata: { match: ["https://www.example.com/*"], "site-access": ["opt-in"] }, - selfMetadata: { match: ["*://www.example.com/*"] }, + selfMetadata: { "site-access": ["+*://www.example.com/*"] }, }); await runtime.applyScriptMatchInfo(scriptRes); diff --git a/src/app/service/service_worker/script.test.ts b/src/app/service/service_worker/script.test.ts index 8f1d4a770..e22953d1e 100644 --- a/src/app/service/service_worker/script.test.ts +++ b/src/app/service/service_worker/script.test.ts @@ -659,13 +659,16 @@ describe("ScriptService selfMetadata 用户覆盖", () => { }); describe("includeUrl - popup 加入 opt-in 白名单", () => { - it("应追加当前网址到用户自定义 match 覆盖中", async () => { - const script = createMockScript({ selfMetadata: { match: ["*://existing.com/*"] } }); + it("应追加当前网址到用户自定义 site-access 覆盖中", async () => { + const script = createMockScript({ + metadata: { match: ["*://script.com/*"], "site-access": ["opt-in", "+*://default.com/*"] }, + selfMetadata: { "site-access": ["+*://existing.com/*"] }, + }); vi.mocked(mockScriptDAO.get).mockResolvedValue(script); await scriptService.includeUrl({ uuid: script.uuid, includePattern: "*://user.com/*" }); - expect(savedSelfMetadata()).toEqual({ match: ["*://existing.com/*", "*://user.com/*"] }); + expect(savedSelfMetadata()).toEqual({ "site-access": ["+*://existing.com/*", "+*://user.com/*"] }); expect(mockMessageQueue.publish).toHaveBeenCalled(); }); }); diff --git a/src/app/service/service_worker/script.ts b/src/app/service/service_worker/script.ts index c510d1729..00a531b74 100644 --- a/src/app/service/service_worker/script.ts +++ b/src/app/service/service_worker/script.ts @@ -919,15 +919,15 @@ export class ScriptService { }); } - // 将 opt-in 脚本的当前网址加入用户自定义 match 白名单。 + // 将 opt-in 脚本的当前网址加入用户自定义 site-access 白名单。 async includeUrl({ uuid, includePattern }: { uuid: string; includePattern: string }) { let script = await this.scriptDAO.get(uuid); if (!script) { throw new Error("script not found"); } - const matchSet = new Set(script.selfMetadata?.match || []); - matchSet.add(includePattern); - script = selfMetadataUpdate(script, "match", matchSet); + const siteAccessSet = new Set(script.selfMetadata?.["site-access"] || []); + siteAccessSet.add(`+${includePattern}`); + script = selfMetadataUpdate(script, "site-access", siteAccessSet); return this.scriptDAO .update(uuid, script) .then(() => { diff --git a/src/app/service/service_worker/utils.test.ts b/src/app/service/service_worker/utils.test.ts index a50bca99a..acd6c4b96 100644 --- a/src/app/service/service_worker/utils.test.ts +++ b/src/app/service/service_worker/utils.test.ts @@ -5,6 +5,7 @@ import { getCombinedMeta, selfMetadataUpdate, isSiteAccessOptIn, + getSiteAccessPatterns, isSiteAccessAllowed, getUserScriptRegister, compileInjectionCode, @@ -166,6 +167,15 @@ describe.concurrent("getCombinedMeta", () => { expect(result.grant).toEqual([]); expect(result.exclude).toEqual(["https://admin.com/*"]); }); + + it.concurrent("应该合并 site-access 的脚本默认值与用户自定义值", () => { + const result = getCombinedMeta( + { "site-access": ["opt-in", "+https://script.com/*"] }, + { "site-access": ["+https://user.com/*"] } + ); + + expect(result["site-access"]).toEqual(["opt-in", "+https://script.com/*", "+https://user.com/*"]); + }); }); describe.concurrent("selfMetadataUpdate", () => { @@ -259,20 +269,31 @@ describe.concurrent("@site-access opt-in", () => { it.concurrent("应识别 opt-in 声明而忽略其他 site-access 值", () => { expect(isSiteAccessOptIn({ "site-access": ["opt-in"] })).toBe(true); expect(isSiteAccessOptIn({ "site-access": ["opt-out"] })).toBe(false); + expect(isSiteAccessOptIn({ "site-access": ["+*://example.com/*"] })).toBe(false); + }); + + it.concurrent("应提取作者默认与用户自定义的加号站点规则", () => { + expect(getSiteAccessPatterns({ "site-access": ["opt-in", " +*://example.com/*", "invalid"] })).toEqual([ + "+*://example.com/*", + ]); }); - it.concurrent("只有当前网址命中用户自定义 match/include 时才允许 opt-in 脚本", () => { - const script = { selfMetadata: { match: ["*://example.com/*"] } }; + it.concurrent("只有当前网址命中 site-access 加号规则时才允许 opt-in 脚本", () => { + const script = { + metadata: { "site-access": ["opt-in", "+*://example.com/*"] }, + selfMetadata: { "site-access": ["+*://user.com/*"] }, + }; expect(isSiteAccessAllowed(script, "https://example.com/page")).toBe(true); + expect(isSiteAccessAllowed(script, "https://user.com/page")).toBe(true); expect(isSiteAccessAllowed(script, "https://other.com/page")).toBe(false); expect( - isSiteAccessAllowed({ selfMetadata: { include: ["*://example.com/tools/*"] } }, "https://example.com/tools/a") - ).toBe(true); - expect( - isSiteAccessAllowed({ selfMetadata: { include: ["*example.com/tools*"] } }, "https://example.com/tools/a") + isSiteAccessAllowed( + { metadata: { "site-access": ["opt-in", "+*example.com/tools*"] } }, + "https://example.com/tools/a" + ) ).toBe(true); - expect(isSiteAccessAllowed({ selfMetadata: {} }, "https://example.com/page")).toBe(false); + expect(isSiteAccessAllowed({ metadata: { "site-access": ["opt-in"] } }, "https://example.com/page")).toBe(false); }); }); diff --git a/src/app/service/service_worker/utils.ts b/src/app/service/service_worker/utils.ts index d86ff99f8..6524a31ac 100644 --- a/src/app/service/service_worker/utils.ts +++ b/src/app/service/service_worker/utils.ts @@ -28,14 +28,17 @@ export type RegisteredUserScriptWithJsCode = RequireField metadata?.["site-access"]?.some((value) => value.trim().toLowerCase() === "opt-in") ?? false; -export const isSiteAccessAllowed = (script: Pick, url: string): boolean => { - const metadata = script.selfMetadata; - if (!metadata) return false; +export const getSiteAccessPatterns = (metadata: SCMetadata | undefined): string[] => + (metadata?.["site-access"] ?? []) + .map((value) => value.trim()) + .filter((value) => value.startsWith("+") && value.length > 1); + +const getSiteAccessRules = (metadata: SCMetadata | undefined): URLRuleEntry[] => + extractUrlPatterns(getSiteAccessPatterns(metadata).map((value) => `@include ${value.slice(1)}`)); + +export const isSiteAccessAllowed = (script: Pick, url: string): boolean => { + const patterns = [...getSiteAccessRules(script.metadata), ...getSiteAccessRules(script.selfMetadata)]; - const patterns = extractUrlPatterns([ - ...(metadata.match || []).map((value) => `@match ${value}`), - ...(metadata.include || []).map((value) => `@include ${value}`), - ]); return patterns.some((pattern) => pattern.ruleType & RuleTypeBit.INCLUSION && isUrlMatch(url, pattern)); }; @@ -159,7 +162,11 @@ export function getCombinedMeta(metaBase: SCMetadata, metaCustom: SCMetadata): S } for (const key of Object.keys(metaCustom)) { const v = metaCustom[key]; - metaRet[key] = v ? [...v] : undefined; + if (key === "site-access" && v) { + metaRet[key] = [...new Set([...(metaRet[key] || []), ...v])]; + } else { + metaRet[key] = v ? [...v] : undefined; + } } return metaRet; } diff --git a/src/locales/de-DE/editor.json b/src/locales/de-DE/editor.json index ceb847936..2a40b6513 100644 --- a/src/locales/de-DE/editor.json +++ b/src/locales/de-DE/editor.json @@ -53,7 +53,9 @@ "match": "Übereinstimmung", "add_match": "Übereinstimmung hinzufügen", "add_exclude": "Ausnahme hinzufügen", + "add_site_access": "Site-Zugriff hinzufügen", "bulk_match_desc": "Fügen Sie ein Muster pro Zeile ein. Leerzeichen am Rand werden entfernt, leere Zeilen und Duplikate werden übersprungen.", + "site_access_bulk_desc": "Fügen Sie pro Zeile ein Site-Muster ein. Das Präfix + kennzeichnet eine Opt-in-Site.", "bulk_permission_desc": "Wählen Sie einen Berechtigungstyp und den Erlaubnisstatus aus, und fügen Sie dann einen Berechtigungswert pro Zeile ein.", "bulk_values": "Mehrfachwerte", "bulk_empty_preview": "Fügen Sie Werte ein, um das Ergebnis der Analyse in der Vorschau zu sehen", @@ -61,13 +63,17 @@ "bulk_status_duplicate": "Duplikat", "preview": "Vorschau", "status": "Status", + "site_access": "Site-Zugriff", "website_match": "Website-Übereinstimmung (@match)", "website_exclude": "Website-Ausnahme (@exclude)", + "website_site_access": "Website-Site-Zugriff (@site-access)", "confirm_reset": "Zurücksetzen bestätigen?", "confirm_delete_match": "Diese Übereinstimmung löschen bestätigen?", "confirm_delete_exclude": "Diese Ausnahme löschen bestätigen?", + "confirm_delete_site_access": "Löschung dieser Site-Zugriffsregel bestätigen?", "after_deleting_match_item": "Nach dem Bearbeiten werden benutzerdefinierte Übereinstimmungsregeln verwendet; klicken Sie auf „Zurücksetzen“, um die vom Skript definierten Übereinstimmungsregeln wiederherzustellen.", "after_deleting_exclude_item": "Nach dem Bearbeiten werden benutzerdefinierte Ausnahmeregeln verwendet; klicken Sie auf „Zurücksetzen“, um die vom Skript definierten Ausnahmeregeln wiederherzustellen.", + "after_deleting_site_access_item": "Von Benutzern hinzugefügte Site-Zugriffsregeln werden getrennt von den Skriptstandards gespeichert; klicken Sie auf „Zurücksetzen“, um Ihre benutzerdefinierten Regeln zu entfernen.", "undo": "Rückgängig", "redo": "Wiederherstellen", "cut": "Ausschneiden", diff --git a/src/locales/en-US/editor.json b/src/locales/en-US/editor.json index 9103211ff..1f3c41d8b 100644 --- a/src/locales/en-US/editor.json +++ b/src/locales/en-US/editor.json @@ -53,7 +53,9 @@ "match": "Match", "add_match": "Add Match", "add_exclude": "Add Exclude", + "add_site_access": "Add Site Access", "bulk_match_desc": "Paste one pattern per line. Whitespace is trimmed, and blank or duplicate entries are skipped.", + "site_access_bulk_desc": "Paste one site pattern per line. The + prefix marks an opt-in site.", "bulk_permission_desc": "Choose one permission type and allow state, then paste one permission value per line.", "bulk_values": "Bulk values", "bulk_empty_preview": "Paste values to preview the parsed result", @@ -61,13 +63,17 @@ "bulk_status_duplicate": "Duplicate", "preview": "Preview", "status": "Status", + "site_access": "Site Access", "website_match": "Website Match (@match)", "website_exclude": "Website Exclude (@exclude)", + "website_site_access": "Website Site Access (@site-access)", "confirm_reset": "Confirm reset?", "confirm_delete_match": "Confirm deletion of this match?", "confirm_delete_exclude": "Confirm deletion of this exclusion?", + "confirm_delete_site_access": "Confirm deletion of this site-access rule?", "after_deleting_match_item": "After editing, your custom match rules are used; click Reset to restore the script's built-in match rules.", "after_deleting_exclude_item": "After editing, your custom exclude rules are used; click Reset to restore the script's built-in exclude rules.", + "after_deleting_site_access_item": "User-added site-access rules are kept separately from the script defaults; click Reset to remove your custom site access rules.", "undo": "Undo", "redo": "Redo", "cut": "Cut", diff --git a/src/locales/ja-JP/editor.json b/src/locales/ja-JP/editor.json index 052de9fa2..d36ae087a 100644 --- a/src/locales/ja-JP/editor.json +++ b/src/locales/ja-JP/editor.json @@ -53,7 +53,9 @@ "match": "マッチ", "add_match": "マッチを追加", "add_exclude": "除外を追加", + "add_site_access": "サイトアクセスを追加", "bulk_match_desc": "1 行に 1 つのパターンを貼り付けます。前後の空白を削除し、空行と重複項目をスキップします。", + "site_access_bulk_desc": "1行に1つのサイトパターンを貼り付けてください。+接頭辞はオプトインサイトを示します。", "bulk_permission_desc": "権限タイプと許可状態を選択し、1 行に 1 つの権限値を貼り付けます。", "bulk_values": "一括値", "bulk_empty_preview": "値を貼り付けると解析結果をプレビューできます", @@ -61,13 +63,17 @@ "bulk_status_duplicate": "重複", "preview": "プレビュー", "status": "状態", + "site_access": "サイトアクセス", "website_match": "ウェブサイトマッチ(@match)", "website_exclude": "ウェブサイト除外(@exclude)", + "website_site_access": "ウェブサイトアクセス(@site-access)", "confirm_reset": "リセットしてもよろしいですか?", "confirm_delete_match": "このマッチを削除してもよろしいですか?", "confirm_delete_exclude": "この除外を削除してもよろしいですか?", + "confirm_delete_site_access": "このサイトアクセスルールを削除してもよろしいですか?", "after_deleting_match_item": "編集するとカスタムのマッチルールが使用されます。「リセット」をクリックするとスクリプト本来のマッチルールに戻ります。", "after_deleting_exclude_item": "編集するとカスタムの除外ルールが使用されます。「リセット」をクリックするとスクリプト本来の除外ルールに戻ります。", + "after_deleting_site_access_item": "ユーザーが追加したサイトアクセスルールはスクリプトのデフォルトと分けて保存されます。「リセット」でカスタムルールを削除できます。", "undo": "元に戻す", "redo": "やり直し", "cut": "切り取り", diff --git a/src/locales/ru-RU/editor.json b/src/locales/ru-RU/editor.json index 15748a3ff..830be35aa 100644 --- a/src/locales/ru-RU/editor.json +++ b/src/locales/ru-RU/editor.json @@ -53,7 +53,9 @@ "match": "Совпадение", "add_match": "Добавить совпадение", "add_exclude": "Добавить исключение", + "add_site_access": "Добавить доступ к сайту", "bulk_match_desc": "Вставьте по одному шаблону в строке. Пробелы по краям будут удалены, пустые строки и дубликаты будут пропущены.", + "site_access_bulk_desc": "Вставьте по одному шаблону сайта в строке. Префикс + обозначает сайт с включением по запросу.", "bulk_permission_desc": "Выберите один тип разрешения и состояние доступа, затем вставьте по одному значению разрешения в строке.", "bulk_values": "Значения для добавления", "bulk_empty_preview": "Вставьте значения, чтобы посмотреть результат разбора", @@ -61,13 +63,17 @@ "bulk_status_duplicate": "Дубликат", "preview": "Предпросмотр", "status": "Статус", + "site_access": "Доступ к сайту", "website_match": "Совпадение сайта (@match)", "website_exclude": "Исключение сайта (@exclude)", + "website_site_access": "Доступ к сайту (@site-access)", "confirm_reset": "Подтвердить сброс?", "confirm_delete_match": "Подтвердить удаление этого совпадения?", "confirm_delete_exclude": "Подтвердить удаление этого исключения?", + "confirm_delete_site_access": "Подтвердить удаление этого правила доступа к сайту?", "after_deleting_match_item": "После редактирования используются пользовательские правила совпадения; нажмите «Сброс», чтобы восстановить встроенные правила совпадения скрипта.", "after_deleting_exclude_item": "После редактирования используются пользовательские правила исключения; нажмите «Сброс», чтобы восстановить встроенные правила исключения скрипта.", + "after_deleting_site_access_item": "Добавленные пользователем правила доступа к сайту хранятся отдельно от настроек скрипта; нажмите «Сброс», чтобы удалить пользовательские правила.", "undo": "Отменить", "redo": "Повторить", "cut": "Вырезать", diff --git a/src/locales/tr-TR/editor.json b/src/locales/tr-TR/editor.json index a96b7097f..192f690e7 100644 --- a/src/locales/tr-TR/editor.json +++ b/src/locales/tr-TR/editor.json @@ -53,7 +53,9 @@ "match": "Eşleşme", "add_match": "Eşleşme Ekle", "add_exclude": "Dışlama Ekle", + "add_site_access": "Site Erişimi Ekle", "bulk_match_desc": "Her satıra bir desen yapıştırın. Baştaki ve sondaki boşluklar kaldırılır; boş ve yinelenen girdiler atlanır.", + "site_access_bulk_desc": "Her satıra bir site deseni yapıştırın. + ön eki isteğe bağlı siteyi belirtir.", "bulk_permission_desc": "Bir izin türü ve izin durumu seçin, ardından her satıra bir izin değeri yapıştırın.", "bulk_values": "Toplu Değerler", "bulk_empty_preview": "Ayrıştırılan sonucu önizlemek için değerleri yapıştırın", @@ -61,13 +63,17 @@ "bulk_status_duplicate": "Yinelenen", "preview": "Önizleme", "status": "Durum", + "site_access": "Site Erişimi", "website_match": "Web Sitesi Eşleşmesi (@match)", "website_exclude": "Web Sitesi Dışlaması (@exclude)", + "website_site_access": "Web Sitesi Erişimi (@site-access)", "confirm_reset": "Sıfırlansın mı?", "confirm_delete_match": "Bu eşleşme silinsin mi?", "confirm_delete_exclude": "Bu dışlama silinsin mi?", + "confirm_delete_site_access": "Bu site erişim kuralı silinsin mi?", "after_deleting_match_item": "Düzenledikten sonra özel eşleşme kuralları kullanılır; betiğin yerleşik eşleşme kurallarını geri yüklemek için «Sıfırla»ya tıklayın.", "after_deleting_exclude_item": "Düzenledikten sonra özel dışlama kuralları kullanılır; betiğin yerleşik dışlama kurallarını geri yüklemek için «Sıfırla»ya tıklayın.", + "after_deleting_site_access_item": "Kullanıcı tarafından eklenen site erişim kuralları betiğin varsayılanlarından ayrı tutulur; özel kuralları kaldırmak için «Sıfırla»ya tıklayın.", "undo": "Geri Al", "redo": "Yinele", "cut": "Kes", diff --git a/src/locales/vi-VN/editor.json b/src/locales/vi-VN/editor.json index 7db46d63a..e059a6133 100644 --- a/src/locales/vi-VN/editor.json +++ b/src/locales/vi-VN/editor.json @@ -53,7 +53,9 @@ "match": "Khớp", "add_match": "Thêm khớp", "add_exclude": "Thêm loại trừ", + "add_site_access": "Thêm quyền truy cập trang", "bulk_match_desc": "Dán một mẫu trên mỗi dòng. Khoảng trắng ở đầu và cuối sẽ được cắt bỏ, dòng trống và mục trùng lặp sẽ bị bỏ qua.", + "site_access_bulk_desc": "Dán một mẫu trang trên mỗi dòng. Tiền tố + biểu thị trang chỉ chạy khi được chọn.", "bulk_permission_desc": "Chọn một loại quyền và trạng thái cho phép, rồi dán một giá trị quyền trên mỗi dòng.", "bulk_values": "Giá trị hàng loạt", "bulk_empty_preview": "Dán giá trị để xem trước kết quả phân tích", @@ -61,13 +63,17 @@ "bulk_status_duplicate": "Trùng lặp", "preview": "Xem trước", "status": "Trạng thái", + "site_access": "Quyền truy cập trang", "website_match": "Khớp trang web (@match)", "website_exclude": "Loại trừ trang web (@exclude)", + "website_site_access": "Quyền truy cập trang web (@site-access)", "confirm_reset": "Xác nhận đặt lại?", "confirm_delete_match": "Xác nhận xóa khớp này?", "confirm_delete_exclude": "Xác nhận xóa loại trừ này?", + "confirm_delete_site_access": "Xác nhận xóa quy tắc truy cập trang này?", "after_deleting_match_item": "Sau khi chỉnh sửa, các quy tắc khớp tùy chỉnh sẽ được sử dụng; nhấp «Đặt lại» để khôi phục các quy tắc khớp có sẵn của script.", "after_deleting_exclude_item": "Sau khi chỉnh sửa, các quy tắc loại trừ tùy chỉnh sẽ được sử dụng; nhấp «Đặt lại» để khôi phục các quy tắc loại trừ có sẵn của script.", + "after_deleting_site_access_item": "Các quy tắc truy cập trang do người dùng thêm được lưu riêng với mặc định của script; nhấp «Đặt lại» để xóa quy tắc tùy chỉnh.", "undo": "Hoàn tác", "redo": "Làm lại", "cut": "Cắt", diff --git a/src/locales/zh-CN/editor.json b/src/locales/zh-CN/editor.json index 5f65cc175..e2b8dd9dc 100644 --- a/src/locales/zh-CN/editor.json +++ b/src/locales/zh-CN/editor.json @@ -53,7 +53,9 @@ "match": "匹配", "add_match": "添加匹配", "add_exclude": "添加排除", + "add_site_access": "添加站点访问", "bulk_match_desc": "每行粘贴一个规则。保存前会去除首尾空白,并跳过空行和重复项。", + "site_access_bulk_desc": "每行粘贴一个站点规则。使用 + 前缀表示仅在选择的站点启用。", "bulk_permission_desc": "选择一种权限类型和允许状态,然后每行粘贴一个授权值。", "bulk_values": "批量值", "bulk_empty_preview": "粘贴内容后可预览解析结果", @@ -61,13 +63,17 @@ "bulk_status_duplicate": "重复", "preview": "预览", "status": "状态", + "site_access": "站点访问", "website_match": "网站匹配(@match)", "website_exclude": "网站排除(@exclude)", + "website_site_access": "网站访问(@site-access)", "confirm_reset": "确定重置?", "confirm_delete_match": "确认删除该匹配?", "confirm_delete_exclude": "确认删除该排除?", + "confirm_delete_site_access": "确认删除该站点访问规则?", "after_deleting_match_item": "编辑后将使用自定义的匹配规则;点击「重置」可恢复脚本自带的匹配规则。", "after_deleting_exclude_item": "编辑后将使用自定义的排除规则;点击「重置」可恢复脚本自带的排除规则。", + "after_deleting_site_access_item": "用户添加的站点访问规则与脚本默认值分开保存;点击「重置」可移除自定义站点访问规则。", "undo": "撤销", "redo": "重做", "cut": "剪切", diff --git a/src/locales/zh-TW/editor.json b/src/locales/zh-TW/editor.json index ed19c7fbe..13e4c693c 100644 --- a/src/locales/zh-TW/editor.json +++ b/src/locales/zh-TW/editor.json @@ -53,7 +53,9 @@ "match": "符合", "add_match": "新增符合項目", "add_exclude": "新增排除項目", + "add_site_access": "新增網站存取", "bulk_match_desc": "每行貼上一個規則。儲存前會移除前後空白,並略過空行與重複項目。", + "site_access_bulk_desc": "每行貼上一個網站規則。使用 + 前綴表示僅在選擇的網站啟用。", "bulk_permission_desc": "選擇一種權限類型與允許狀態,然後每行貼上一個權限值。", "bulk_values": "批次值", "bulk_empty_preview": "貼上內容後可預覽解析結果", @@ -61,13 +63,17 @@ "bulk_status_duplicate": "重複", "preview": "預覽", "status": "狀態", + "site_access": "網站存取", "website_match": "網站符合(@match)", "website_exclude": "網站排除(@exclude)", + "website_site_access": "網站存取(@site-access)", "confirm_reset": "確定要重設嗎?", "confirm_delete_match": "確定要刪除此符合項目嗎?", "confirm_delete_exclude": "確定要刪除此排除項目嗎?", + "confirm_delete_site_access": "確定要刪除此網站存取規則嗎?", "after_deleting_match_item": "編輯後將使用自訂的符合規則;點擊「重設」可恢復腳本自帶的符合規則。", "after_deleting_exclude_item": "編輯後將使用自訂的排除規則;點擊「重設」可恢復腳本自帶的排除規則。", + "after_deleting_site_access_item": "使用者新增的網站存取規則會與腳本預設值分開保存;點擊「重設」可移除自訂網站存取規則。", "undo": "復原", "redo": "重做", "cut": "剪下", diff --git a/src/pages/options/routes/ScriptEditor/tabs/SettingsPane.test.tsx b/src/pages/options/routes/ScriptEditor/tabs/SettingsPane.test.tsx index 731bd3790..d7296258c 100644 --- a/src/pages/options/routes/ScriptEditor/tabs/SettingsPane.test.tsx +++ b/src/pages/options/routes/ScriptEditor/tabs/SettingsPane.test.tsx @@ -37,8 +37,18 @@ const sampleScript = () => ({ downloadUrl: "https://example.com/a.user.js", updatetime: 1_700_000_000_000, createtime: 1_690_000_000_000, - metadata: { version: ["1.0.0"], match: ["*://script.com/*"], exclude: [], tag: ["alpha,beta"] }, - selfMetadata: { match: ["*://script.com/*", "*://user.com/*"], exclude: ["*://exclude.com/*"] }, + metadata: { + version: ["1.0.0"], + match: ["*://script.com/*"], + exclude: [], + "site-access": ["opt-in", "+*://script.com/*"], + tag: ["alpha,beta"], + }, + selfMetadata: { + match: ["*://script.com/*", "*://user.com/*"], + exclude: ["*://exclude.com/*"], + "site-access": ["+*://user.com/*"], + }, }); const samplePermissions = () => [ @@ -209,9 +219,9 @@ describe("SettingsPane 网站匹配/排除", () => { expect(await screen.findByText("*://script.com/*")).toBeInTheDocument(); expect(screen.getByText("*://user.com/*")).toBeInTheDocument(); expect(screen.getByText("*://exclude.com/*")).toBeInTheDocument(); - // script.com 来自脚本元数据(脚本),user.com / exclude.com 为用户添加(用户) - expect(screen.getByText(t("editor:from_script"))).toBeInTheDocument(); - expect(screen.getAllByText(t("editor:from_user")).length).toBe(2); + // script.com 来自脚本元数据(脚本),user.com / exclude.com / site-access user.com 为用户添加(用户) + expect(screen.getAllByText(t("editor:from_script")).length).toBeGreaterThanOrEqual(2); + expect(screen.getAllByText(t("editor:from_user")).length).toBe(3); }); it("删除用户匹配应以剩余规则调用 resetMatch", async () => { @@ -299,7 +309,7 @@ describe("SettingsPane 网站匹配/排除", () => { // 用户添加的 user.com 被清除,脚本自带的 script.com 应保留并标记为「脚本」 expect(screen.getByText("*://script.com/*")).toBeInTheDocument(); expect(screen.queryByText("*://user.com/*")).toBeNull(); - expect(screen.getByText(t("editor:from_script"))).toBeInTheDocument(); + expect(screen.getAllByText(t("editor:from_script")).length).toBeGreaterThanOrEqual(2); }); it("重置排除后应恢复脚本自带规则而非清空列表", async () => { @@ -333,6 +343,52 @@ describe("SettingsPane 网站匹配/排除", () => { }); }); +describe("SettingsPane site-access", () => { + it("应展示作者默认与用户自定义的 opt-in 站点", async () => { + render(); + + expect(await screen.findByText("+*://script.com/*")).toBeInTheDocument(); + expect(screen.getByText("+*://user.com/*")).toBeInTheDocument(); + expect(screen.getAllByText(t("editor:from_script")).length).toBeGreaterThanOrEqual(2); + expect(screen.getAllByText(t("editor:from_user")).length).toBeGreaterThanOrEqual(3); + }); + + it("删除用户自定义 site-access 应更新自定义元数据", async () => { + render(); + await screen.findByText("+*://user.com/*"); + + fireEvent.click(screen.getByLabelText(`${t("delete")} +*://user.com/*`)); + fireEvent.click(screen.getByText(t("confirm"), { selector: "button" })); + + expect(updateMetadata).toHaveBeenCalledWith("u1", "site-access", undefined); + }); + + it("添加 site-access 应只写入用户自定义的加号规则", async () => { + render(); + await screen.findByText("+*://user.com/*"); + + fireEvent.click(screen.getByText(t("editor:add_site_access"), { selector: "button" })); + fireEvent.change(screen.getByLabelText(t("editor:bulk_values")), { + target: { value: "https://new.example.com/*" }, + }); + fireEvent.click(screen.getByText(t("confirm"), { selector: "button" })); + + expect(updateMetadata).toHaveBeenCalledWith("u1", "site-access", ["+*://user.com/*", "+https://new.example.com/*"]); + }); + + it("重置 site-access 应恢复作者默认站点", async () => { + render(); + await screen.findByText("+*://user.com/*"); + + fireEvent.click(screen.getAllByText(t("reset"), { selector: "button" })[2]); + fireEvent.click(screen.getByText(t("confirm"), { selector: "button" })); + + expect(updateMetadata).toHaveBeenCalledWith("u1", "site-access", undefined); + expect(screen.getByText("+*://script.com/*")).toBeInTheDocument(); + expect(screen.queryByText("+*://user.com/*")).toBeNull(); + }); +}); + describe("SettingsPane 授权管理(CORS)", () => { it("应以徽标展示 CORS/Cookie 授权", async () => { render(); @@ -392,8 +448,8 @@ describe("SettingsPane 授权管理(CORS)", () => { it("重置授权应调用 resetPermission 并清空列表", async () => { render(); await screen.findByText("a.com"); - // 第三个重置按钮为授权管理 - fireEvent.click(screen.getAllByText(t("reset"), { selector: "button" })[2]); + // 第四个重置按钮为授权管理 + fireEvent.click(screen.getAllByText(t("reset"), { selector: "button" })[3]); await act(async () => fireEvent.click(screen.getByText(t("confirm"), { selector: "button" }))); expect(resetPermission).toHaveBeenCalledWith("u1"); expect(screen.queryByText("a.com")).toBeNull(); diff --git a/src/pages/options/routes/ScriptEditor/tabs/SettingsPane.tsx b/src/pages/options/routes/ScriptEditor/tabs/SettingsPane.tsx index f413fc271..b42ad4ec4 100644 --- a/src/pages/options/routes/ScriptEditor/tabs/SettingsPane.tsx +++ b/src/pages/options/routes/ScriptEditor/tabs/SettingsPane.tsx @@ -26,6 +26,7 @@ import { } from "@App/pages/components/ui/dialog"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@App/pages/components/ui/select"; import { createPreloadableQuery } from "@App/pages/preloadable-query"; +import { getSiteAccessPatterns, isSiteAccessOptIn } from "@App/app/service/service_worker/utils"; const RUN_IN_OPTIONS = ["default", "all", "normal-tabs", "incognito-tabs"]; const RUN_AT_OPTIONS = ["default", "document-start", "document-body", "document-end", "document-idle", "early-start"]; @@ -173,7 +174,12 @@ function SettingsPaneContent({ uuid, data }: SettingsPaneProps & { data: Setting return self.exclude ?? meta.exclude ?? []; }); const [permissions, setPermissions] = useState(data.permissions); - const [bulkMatchKind, setBulkMatchKind] = useState<"match" | "exclude" | null>(null); + const [siteAccess, setSiteAccess] = useState(() => { + const meta = data.script.metadata || {}; + const self = data.script.selfMetadata || {}; + return [...new Set([...getSiteAccessPatterns(meta), ...getSiteAccessPatterns(self)])]; + }); + const [bulkMatchKind, setBulkMatchKind] = useState<"match" | "exclude" | "site-access" | null>(null); const [bulkMatchValue, setBulkMatchValue] = useState(""); const [bulkPermOpen, setBulkPermOpen] = useState(false); const [bulkPermDraft, setBulkPermDraft] = useState<{ permission: string; allow: boolean; values: string }>({ @@ -186,6 +192,8 @@ function SettingsPaneContent({ uuid, data }: SettingsPaneProps & { data: Setting const self = script.selfMetadata || {}; const metaMatch = meta.match ?? []; const metaExclude = meta.exclude ?? []; + const metaSiteAccess = getSiteAccessPatterns(meta); + const selfSiteAccess = getSiteAccessPatterns(self); const runIn = self["run-in"]?.[0] ?? meta["run-in"]?.[0] ?? "default"; const runAt = self["early-start"] ? "early-start" : (self["run-at"]?.[0] ?? meta["run-at"]?.[0] ?? "default"); @@ -260,18 +268,35 @@ function SettingsPaneContent({ uuid, data }: SettingsPaneProps & { data: Setting } patchSelf({ [kind]: next }); }; - const openBulkMatch = (kind: "match" | "exclude") => { + const setSiteAccessList = (next: string[] | undefined) => { + const custom = next?.length ? [...new Set(next)] : undefined; + void scriptClient.updateMetadata(uuid, "site-access", custom); + setSiteAccess(custom ? [...new Set([...metaSiteAccess, ...custom])] : metaSiteAccess); + patchSelf({ "site-access": custom }); + }; + const openBulkMatch = (kind: "match" | "exclude" | "site-access") => { setBulkMatchValue(""); setBulkMatchKind(kind); }; const bulkMatchParsed = parseBulkValues( bulkMatchValue, - bulkMatchKind === "match" ? matches : bulkMatchKind === "exclude" ? excludes : [] + bulkMatchKind === "match" + ? matches + : bulkMatchKind === "exclude" + ? excludes + : bulkMatchKind === "site-access" + ? siteAccess + : [] ); const submitBulkMatch = () => { if (!bulkMatchKind || bulkMatchParsed.entries.length === 0) return; - const list = bulkMatchKind === "match" ? matches : excludes; - setMatchList(bulkMatchKind, [...list, ...bulkMatchParsed.entries]); + if (bulkMatchKind === "site-access") { + const entries = bulkMatchParsed.entries.map((value) => (value.startsWith("+") ? value : `+${value}`)); + setSiteAccessList([...selfSiteAccess, ...entries]); + } else { + const list = bulkMatchKind === "match" ? matches : excludes; + setMatchList(bulkMatchKind, [...list, ...bulkMatchParsed.entries]); + } setBulkMatchKind(null); setBulkMatchValue(""); }; @@ -352,31 +377,46 @@ function SettingsPaneContent({ uuid, data }: SettingsPaneProps & { data: Setting
); - const matchTable = (kind: "match" | "exclude") => { - const list = kind === "match" ? matches : excludes; - const metaList = kind === "match" ? metaMatch : metaExclude; + const matchTable = (kind: "match" | "exclude" | "site-access") => { + const isSiteAccess = kind === "site-access"; + const list = kind === "match" ? matches : kind === "exclude" ? excludes : siteAccess; + const metaList = kind === "match" ? metaMatch : kind === "exclude" ? metaExclude : metaSiteAccess; return (
- {t(kind === "match" ? "editor:website_match" : "editor:website_exclude")} + + {t( + kind === "match" + ? "editor:website_match" + : kind === "exclude" + ? "editor:website_exclude" + : "editor:website_site_access" + )} +
setMatchList(kind, undefined)} + onConfirm={() => (isSiteAccess ? setSiteAccessList(undefined) : setMatchList(kind, undefined))} >

- {t(kind === "match" ? "editor:after_deleting_match_item" : "editor:after_deleting_exclude_item")} + {t( + kind === "match" + ? "editor:after_deleting_match_item" + : kind === "exclude" + ? "editor:after_deleting_exclude_item" + : "editor:after_deleting_site_access_item" + )}

- {t("editor:match")} + {t(isSiteAccess ? "editor:site_access" : "editor:match")} {t("editor:source")} {t("action")} @@ -411,28 +457,36 @@ function SettingsPaneContent({ uuid, data }: SettingsPaneProps & { data: Setting
- - setMatchList( - kind, - list.filter((x) => x !== m) - ) - } - > - - + + + )}
); @@ -550,6 +604,7 @@ function SettingsPaneContent({ uuid, data }: SettingsPaneProps & { data: Setting {/* 网站匹配 / 网站排除 */} {matchTable("match")} {matchTable("exclude")} + {isSiteAccessOptIn(meta) && matchTable("site-access")} {/* 授权管理 */}
@@ -635,12 +690,22 @@ function SettingsPaneContent({ uuid, data }: SettingsPaneProps & { data: Setting
- {/* 添加匹配 / 排除弹窗 */} + {/* 添加匹配 / 排除 / site-access 弹窗 */} !open && setBulkMatchKind(null)}> - {t(bulkMatchKind === "exclude" ? "editor:add_exclude" : "editor:add_match")} - {t("editor:bulk_match_desc")} + + {t( + bulkMatchKind === "exclude" + ? "editor:add_exclude" + : bulkMatchKind === "site-access" + ? "editor:add_site_access" + : "editor:add_match" + )} + + + {t(bulkMatchKind === "site-access" ? "editor:site_access_bulk_desc" : "editor:bulk_match_desc")} +