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/packages/message/message_queue.test.ts b/packages/message/message_queue.test.ts index 67669fb55..5fdbde58d 100644 --- a/packages/message/message_queue.test.ts +++ b/packages/message/message_queue.test.ts @@ -186,6 +186,51 @@ describe("MessageQueueGroup", () => { }); describe("发布方法测试", () => { + it("publishAndWait 应等待本地异步订阅者完成", async () => { + const group = messageQueue.group("api-publishAndWait"); + let resolveRegistration!: () => void; + const registration = new Promise((resolve) => { + resolveRegistration = resolve; + }); + const handler = vi.fn(async () => registration); + group.subscribe("test-await", handler); + + let settled = false; + const publishPromise = group.publishAndWait("test-await", { data: "test-await" }).then(() => { + settled = true; + }); + + await nextTick(); + expect(handler).toHaveBeenCalledWith({ data: "test-await" }); + expect(settled).toBe(false); + + resolveRegistration(); + await publishPromise; + expect(settled).toBe(true); + }); + + it("publishAndWait 应等待带中间件的异步订阅者完成", async () => { + const group = messageQueue.group("api-publishAndWaitMiddleware", (_topic, _message, next) => next()); + let resolveRegistration!: () => void; + const registration = new Promise((resolve) => { + resolveRegistration = resolve; + }); + group.subscribe("test-await", async () => registration); + + const publishPromise = group.publishAndWait("test-await", { data: "test-await" }); + await nextTick(); + let settled = false; + void publishPromise.then(() => { + settled = true; + }); + await nextTick(); + expect(settled).toBe(false); + + resolveRegistration(); + await publishPromise; + expect(settled).toBe(true); + }); + it("publish 方法应该使用 chrome.runtime.sendMessage", () => { const group = messageQueue.group("api-sendChromeMessage"); diff --git a/packages/message/message_queue.ts b/packages/message/message_queue.ts index 9864c2523..d012d225a 100644 --- a/packages/message/message_queue.ts +++ b/packages/message/message_queue.ts @@ -13,7 +13,7 @@ export type TKeyValue = { type MiddlewareFunction = (topic: string, message: T, next: () => void) => void | Promise; // 消息处理函数类型 -type MessageHandler = (message: T) => void; +type MessageHandler = (message: T) => void | Promise; export interface IMessageQueue { // 创建子分组 @@ -25,6 +25,9 @@ export interface IMessageQueue { // 发布消息 publish(topic: string, message: NonNullable): void; + // 发布消息并等待当前环境的订阅者完成 + publishAndWait(topic: string, message: NonNullable): Promise; + // 只发布给当前环境 emit(topic: string, message: NonNullable): void; } @@ -82,6 +85,16 @@ export class MessageQueue implements IMessageQueue { LoggerCore.getInstance().logger({ service: "messageQueue" }).trace("publish", { topic, message }); } + async publishAndWait(topic: string, message: NonNullable) { + chrome.runtime.sendMessage({ + msgQueue: topic, + data: { action: "message", message }, + }); + await Promise.all(this.EE.listeners(topic).map((handler) => Promise.resolve(handler(message)))); + //@ts-ignore + LoggerCore.getInstance().logger({ service: "messageQueue" }).trace("publishAndWait", { topic, message }); + } + // 只发布给当前环境 emit(topic: string, message: NonNullable) { this.EE.emit(topic, message); @@ -144,8 +157,8 @@ export class MessageQueueGroup implements IMessageQueue { await result; } } else { - // 所有中间件都执行完毕,执行最终的处理函数 - handler(message); + // 所有中间件都执行完毕,等待最终处理函数完成 + await handler(message); } }; @@ -162,6 +175,12 @@ export class MessageQueueGroup implements IMessageQueue { this.messageQueue.publish(fullTopic, message); } + // 发布消息并等待当前环境的订阅者完成 + publishAndWait(topic: string, message: NonNullable) { + const fullTopic = `${this.name}${topic}`; + return this.messageQueue.publishAndWait(fullTopic, message); + } + // 只发布给当前环境 emit(topic: string, message: NonNullable) { const fullTopic = `${this.name}${topic}`; diff --git a/src/app/repo/resource.ts b/src/app/repo/resource.ts index c705d35c8..dd06e17b1 100644 --- a/src/app/repo/resource.ts +++ b/src/app/repo/resource.ts @@ -64,7 +64,7 @@ export class ResourceDAO extends Repo { } // CompiledResource结构变更时,建议修改 CompiledResourceNamespace 以删除旧Cache -export const CompiledResourceNamespace = "57d79c56-231a-42d3-b6e3-d2004ba0866f"; +export const CompiledResourceNamespace = "a8c4e0e7-fb12-4a33-a9d6-4f7a0aa2d0f1"; export class CompiledResourceDAO extends Repo { constructor() { diff --git a/src/app/service/content/utils.ts b/src/app/service/content/utils.ts index bc7905a0b..4ce198ec6 100644 --- a/src/app/service/content/utils.ts +++ b/src/app/service/content/utils.ts @@ -78,7 +78,8 @@ export function getScriptRequire(scriptRes: ScriptRunResource): CompileScriptCod export function compileScriptletCode( scriptRes: ScriptRunResource, scriptCode: string, - scriptUrlPatterns: URLRuleEntry[] + scriptUrlPatterns: URLRuleEntry[], + executionCondition?: string ): string { scriptCode = scriptCode ?? scriptRes.code; const requireArray = getScriptRequire(scriptRes); @@ -89,7 +90,8 @@ export function compileScriptletCode( ruleContent, })) satisfies EmbeddedURLRuleEntry[]; const urlCondition = embeddedPatternCheckerString("location.href", JSON.stringify(reducedPatterns)); - const codeBody = `if(${urlCondition}){\n${requireCode}\n${scriptCode}\nwindow['${scriptRes.flag}']=function(){};\n}`; + const condition = executionCondition ? `&&(${executionCondition})` : ""; + const codeBody = `if(${urlCondition}${condition}){\n${requireCode}\n${scriptCode}\nwindow['${scriptRes.flag}']=function(){};\n}`; return `${codeBody}${sourceMapTo(`${scriptRes.name}.user.js`)}\n`; } @@ -171,18 +173,21 @@ export function compileScript(code: string): ScriptFunc { export function compileInjectScript( script: ScriptRunResource, scriptCode: string, - autoDeleteMountFunction: boolean = false + autoDeleteMountFunction: boolean = false, + executionCondition?: string ): string { - return compileInjectScriptByFlag(script.flag, scriptCode, autoDeleteMountFunction); + return compileInjectScriptByFlag(script.flag, scriptCode, autoDeleteMountFunction, executionCondition); } export function compileInjectScriptByFlag( flag: string, scriptCode: string, - autoDeleteMountFunction: boolean = false + autoDeleteMountFunction: boolean = false, + executionCondition?: string ): string { const autoDeleteMountCode = autoDeleteMountFunction ? `try{delete window['${flag}']}catch(e){}` : ""; - return `window['${flag}'] = function(){${autoDeleteMountCode}${scriptCode}}`; + const code = `window['${flag}'] = function(){${autoDeleteMountCode}${scriptCode}}`; + return executionCondition ? `if(${executionCondition}){${code}}` : code; } /** @@ -224,7 +229,8 @@ export const trimScriptInfo = (script: ScriptLoadInfo): TScriptInfo => { export function compilePreInjectScript( script: ScriptLoadInfo, scriptCode: string, - autoDeleteMountFunction: boolean = false + autoDeleteMountFunction: boolean = false, + executionCondition?: string ): string { const scriptEnvTag = isInjectIntoContent(script.metadata) ? ScriptEnvTag.content : ScriptEnvTag.inject; const eventNamePrefix = `evt${process.env.SC_RANDOM_KEY}.${scriptEnvTag}`; // 仅用于early-start初始化 @@ -234,15 +240,15 @@ export function compilePreInjectScript( const autoDeleteMountCode = autoDeleteMountFunction ? `try{delete window['${flag}']}catch(e){}` : ""; const evScriptLoad = `${eventNamePrefix}${DefinedFlags.scriptLoadComplete}`; const evEnvLoad = `${eventNamePrefix}${DefinedFlags.envLoadComplete}`; - return `window['${flag}'] = function(){${autoDeleteMountCode}${scriptCode}}; + const code = `window['${flag}'] = function(){${autoDeleteMountCode}${scriptCode}}; { let o = { cancelable: true, detail: { scriptFlag: '${flag}', scriptInfo: (${scriptInfoJSON}) } }, c = typeof cloneInto === "function" ? cloneInto(o, performance) : o, f = () => performance.dispatchEvent(new CustomEvent('${evScriptLoad}', c)), needWait = f(); if (needWait) performance.addEventListener('${evEnvLoad}', f, { once: true }); -} -`; +}`; + return executionCondition ? `if(${executionCondition}){\n${code}\n}\n` : `${code}\n`; } export function addStyle(css: string): HTMLStyleElement { diff --git a/src/app/service/service_worker/client.ts b/src/app/service/service_worker/client.ts index 720564940..a72c98f5e 100644 --- a/src/app/service/service_worker/client.ts +++ b/src/app/service/service_worker/client.ts @@ -98,6 +98,14 @@ export class ScriptClient extends Client { return this.do("excludeUrl", { uuid, excludePattern, remove }); } + includeUrl(uuid: string, includePattern: string) { + return this.do("includeUrl", { uuid, includePattern }); + } + + excludeSiteAccessUrl(uuid: string, includePattern: string) { + return this.do("excludeSiteAccessUrl", { uuid, includePattern }); + } + // 重置匹配项 resetMatch(uuid: string, match: string[] | undefined) { return this.do("resetMatch", { uuid, match }); @@ -229,6 +237,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..2eeaacfce 100644 --- a/src/app/service/service_worker/popup.test.ts +++ b/src/app/service/service_worker/popup.test.ts @@ -301,6 +301,104 @@ 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("作者声明的 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("宽泛的用户 site-access 规则不应显示精确当前站点的移除操作", async () => { + const uuid = "opt-in-broad-user-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"] }, + selfMetadata: { "site-access": ["+*://*.example.com/*"] }, + }), + ]), + }, + }); + + const result = await service.getPopupData({ tabId: 1, url: "https://www.example.com/" }); + + expect(result.scriptList[0]?.siteAccessUser).toBe(false); + }); + + it("作者默认允许当前站点时不应显示移除用户规则的操作", async () => { + const uuid = "opt-in-author-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/*"] }, + selfMetadata: { "site-access": ["+*://example.com/*"] }, + }), + ]), + }, + }); + + const result = await service.getPopupData({ tabId: 1, url: "https://example.com/" }); + + expect(result.scriptList[0]?.siteAccessUser).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..79c35435e 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 { hasExactUserSiteAccess, isSiteAccessAllowed, isSiteAccessAllowedByAuthor, 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,25 @@ 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; + run.siteAccessUser = + siteAccessOptIn && !isSiteAccessAllowedByAuthor(script, url) && hasExactUserSiteAccess(script, url); + 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 +439,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..06013ba93 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("没有 site-access 加号规则时,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("作者或用户的 site-access 加号规则命中时,opt-in 脚本应正常执行", async () => { + const { runtime, scriptRes } = createFullContext({ + metadata: { match: ["https://www.example.com/*"], "site-access": ["opt-in"] }, + selfMetadata: { "site-access": ["+*://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 55b7e0be6..747bf648d 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -13,7 +13,11 @@ import { runScript, stopScript } from "../offscreen/client"; import { buildScriptRunResourceBasic, compileInjectionCode, + getCombinedMeta, + getOptInExecutionCondition, getUserScriptRegister, + isSiteAccessAllowed, + isSiteAccessOptIn, parseUrlSRI, scriptURLPatternResults, type RegisteredUserScriptWithJsCode, @@ -32,16 +36,15 @@ import { UrlMatch } from "@App/pkg/utils/match"; import { stackAsyncTask } from "@App/pkg/utils/async_queue"; import { ExtensionContentMessageSend } from "@Packages/message/extension_message"; import { sendMessage } from "@Packages/message/client"; -import type { CompileScriptCodeResource } from "../content/utils"; import { compileInjectScriptByFlag, compileScriptCodeByResource, - compileScriptletCode, isContextMenuScript, isEarlyStartScript, isInjectIntoContent, isScriptletUnwrap, trimScriptInfo, + type CompileScriptCodeResource, } from "../content/utils"; import LoggerCore from "@App/app/logger/core"; import PermissionVerify from "./permission_verify"; @@ -912,15 +915,8 @@ export class RuntimeService { // 从CompiledResource中还原脚本代码 async restoreJSCodeFromCompiledResource(script: Script, result: CompiledResource) { - // 如果是 Scriptlet (unwrap) 脚本,需要另外的处理方式 - if (isScriptletUnwrap(script.metadata)) { - const scriptRes = await this.script.buildScriptRunResource(script); - if (!scriptRes) return ""; - return compileScriptletCode(scriptRes, scriptRes.code, result.scriptUrlPatterns); - } - - // 如果是预加载脚本,需要另外的处理方式 - if (isEarlyStartScript(script.metadata)) { + // 如果是 Scriptlet (unwrap) 或预加载脚本,需要用完整脚本资源编译专用加载代码。 + if (isScriptletUnwrap(script.metadata) || isEarlyStartScript(script.metadata)) { const scriptRes = await this.script.buildScriptRunResource(script); if (!scriptRes) return ""; return compileInjectionCode(scriptRes, scriptRes.code, result.scriptUrlPatterns); @@ -934,15 +930,17 @@ export class RuntimeService { require.push({ url: res.url, content: res.content }); } } - + const metadata = getCombinedMeta(script.metadata, script.selfMetadata || {}); return compileInjectScriptByFlag( result.flag, compileScriptCodeByResource({ name: result.name, code: originalCode?.code || "", require, - isContextMenu: isContextMenuScript(script.metadata), - }) + isContextMenu: isContextMenuScript(metadata), + }), + false, + getOptInExecutionCondition(metadata, result.scriptUrlPatterns) ); } @@ -1322,7 +1320,12 @@ export class RuntimeService { // 代码/原始 metadata/userConfig/资源变化时,主要靠事件处理器(enable/install/delete)失效缓存。 // 此缓存键只是低成本的兜底:用于 selfMetadata 修改 match/include/exclude 这类「合并后 metadata 变了 // 但不一定 bump updatetime」的情况。 - return `${status}:${type}:${updatetime || 0}~${JSON.stringify([metadata.match, metadata.include, metadata.exclude])}`; + return `${status}:${type}:${updatetime || 0}~${JSON.stringify([ + metadata.match, + metadata.include, + metadata.exclude, + metadata["site-access"], + ])}`; } private getCodeCacheKey(script: Script) { @@ -1512,6 +1515,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..198578280 100644 --- a/src/app/service/service_worker/script.test.ts +++ b/src/app/service/service_worker/script.test.ts @@ -611,6 +611,7 @@ describe("ScriptService selfMetadata 用户覆盖", () => { mockGroup = server.group("script"); mockMessageQueue = new MessageQueue(); mockMessageQueue.publish = vi.fn(); + mockMessageQueue.publishAndWait = vi.fn().mockResolvedValue(undefined); mockScriptDAO = { get: vi.fn(), @@ -658,6 +659,78 @@ describe("ScriptService selfMetadata 用户覆盖", () => { }); }); + describe("includeUrl - popup 加入 opt-in 白名单", () => { + it("应等待 installScript 处理器完成注册后才返回", async () => { + const script = createMockScript({ + metadata: { match: ["*://script.com/*"], "site-access": ["opt-in"] }, + }); + vi.mocked(mockScriptDAO.get).mockResolvedValue(script); + let resolveRegistration!: () => void; + const registration = new Promise((resolve) => { + resolveRegistration = resolve; + }); + vi.mocked(mockMessageQueue.publishAndWait).mockReturnValue(registration); + + let settled = false; + const includePromise = scriptService.includeUrl({ uuid: script.uuid, includePattern: "*://user.com/*" }); + void includePromise.then(() => { + settled = true; + }); + await vi.waitFor(() => expect(mockMessageQueue.publishAndWait).toHaveBeenCalled()); + + expect(settled).toBe(false); + expect(mockMessageQueue.publishAndWait).toHaveBeenCalledWith( + "installScript", + expect.objectContaining({ script: expect.objectContaining({ uuid: script.uuid }), update: true }) + ); + + resolveRegistration(); + await expect(includePromise).resolves.toBe(true); + expect(settled).toBe(true); + }); + + 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({ "site-access": ["+*://existing.com/*", "+*://user.com/*"] }); + expect(mockMessageQueue.publishAndWait).toHaveBeenCalled(); + }); + + it("移除不存在的精确用户规则时应返回 false 且不广播更新", async () => { + const script = createMockScript({ + metadata: { match: ["*://script.com/*"], "site-access": ["opt-in"] }, + selfMetadata: { "site-access": ["+*://*.example.com/*"] }, + }); + vi.mocked(mockScriptDAO.get).mockResolvedValue(script); + + await expect( + scriptService.excludeSiteAccessUrl({ uuid: script.uuid, includePattern: "*://example.com/*" }) + ).resolves.toBe(false); + + expect(mockScriptDAO.update).not.toHaveBeenCalled(); + expect(mockMessageQueue.publishAndWait).not.toHaveBeenCalled(); + }); + + it("应从用户自定义 site-access 覆盖中移除当前网址并保留作者默认值", async () => { + const script = makeScript({ + metadata: { match: ["*://script.com/*"], "site-access": ["opt-in", "+*://default.com/*"] }, + selfMetadata: { "site-access": ["+*://existing.com/*", "+*://user.com/*"] }, + }); + vi.mocked(mockScriptDAO.get).mockResolvedValue(script); + + await scriptService.excludeSiteAccessUrl({ uuid: script.uuid, includePattern: "*://user.com/*" }); + + expect(savedSelfMetadata()).toEqual({ "site-access": ["+*://existing.com/*"] }); + expect(mockMessageQueue.publishAndWait).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 8281645f5..afb0896c7 100644 --- a/src/app/service/service_worker/script.ts +++ b/src/app/service/service_worker/script.ts @@ -412,10 +412,14 @@ export class ScriptService { return <[boolean, ScriptInfo, Record]>entry?.value; } - publishInstallScript(scriptFull: Script, options: any) { + publishInstallScript(scriptFull: Script, options: any, waitForCompletion = false) { const { uuid, type, status, name, namespace, origin, checkUpdateUrl, downloadUrl } = scriptFull; const script = { uuid, type, status, name, namespace, origin, checkUpdateUrl, downloadUrl } as TInstallScriptParams; - return this.mq.publish("installScript", { script, ...options }); + const message = { script, ...options } as TInstallScript; + if (waitForCompletion) { + return this.mq.publishAndWait("installScript", message); + } + this.mq.publish("installScript", message); } // 安装脚本 / 更新脚本 @@ -919,6 +923,46 @@ export class ScriptService { }); } + // 将 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 siteAccessSet = new Set(script.selfMetadata?.["site-access"] || []); + siteAccessSet.add(`+${includePattern}`); + script = selfMetadataUpdate(script, "site-access", siteAccessSet); + try { + await this.scriptDAO.update(uuid, script); + await this.publishInstallScript(script, { update: true }, true); + return true; + } catch (e) { + this.logger.error("include url error", Logger.E(e)); + throw e; + } + } + + // 将 opt-in 脚本的当前网址从用户自定义 site-access 白名单移除。 + async excludeSiteAccessUrl({ uuid, includePattern }: { uuid: string; includePattern: string }) { + let script = await this.scriptDAO.get(uuid); + if (!script) { + throw new Error("script not found"); + } + const siteAccessSet = new Set(script.selfMetadata?.["site-access"] || []); + if (!siteAccessSet.delete(`+${includePattern}`)) { + return false; + } + script = selfMetadataUpdate(script, "site-access", siteAccessSet.size ? siteAccessSet : undefined); + try { + await this.scriptDAO.update(uuid, script); + await this.publishInstallScript(script, { update: true }, true); + return true; + } catch (e) { + this.logger.error("exclude site-access 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 +1706,8 @@ 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("excludeSiteAccessUrl", this.excludeSiteAccessUrl.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..32b5f22ae 100644 --- a/src/app/service/service_worker/types.ts +++ b/src/app/service/service_worker/types.ts @@ -192,6 +192,8 @@ export type ScriptMenu = { updatetime: number; // 脚本更新时间 hasUserConfig: boolean; // 是否有用户配置 icon?: string; // 脚本图标 URL(由 getPopupData 即时附加在响应中,不写回 session 缓存) + siteAccess?: "opt-in"; // 脚本声明了 @site-access opt-in + siteAccessUser?: boolean; // 当前网址是否由用户自定义 site-access 规则允许 // 不需要完整 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..f0ecad1e0 100644 --- a/src/app/service/service_worker/utils.test.ts +++ b/src/app/service/service_worker/utils.test.ts @@ -1,9 +1,12 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { isBase64, parseUrlSRI, getCombinedMeta, selfMetadataUpdate, + isSiteAccessOptIn, + getSiteAccessPatterns, + isSiteAccessAllowed, getUserScriptRegister, compileInjectionCode, shouldAutoOpenChangelog, @@ -164,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", () => { @@ -253,6 +265,38 @@ 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); + expect(isSiteAccessOptIn({ "site-access": ["+*://example.com/*"] })).toBe(false); + }); + + it.concurrent("应提取作者默认与用户自定义的加号站点规则", () => { + expect(getSiteAccessPatterns({ "site-access": ["opt-in", " +*://example.com/*", "invalid"] })).toEqual([ + "+*://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( + { metadata: { "site-access": ["opt-in", "+*example.com/tools*"] } }, + "https://example.com/tools/a" + ) + ).toBe(true); + expect(isSiteAccessAllowed({ metadata: { "site-access": ["opt-in"] } }, "https://example.com/page")).toBe(false); + }); +}); + describe.concurrent("getUserScriptRegister", () => { it.concurrent("should return a valid RegisteredUserScript object", () => { const mockScriptMatchInfo: ScriptMatchInfo = { @@ -298,6 +342,31 @@ describe.concurrent("getUserScriptRegister", () => { expect(result.registerScript.world).toBe("MAIN"); expect(result.registerScript.runAt).toBe("document_end"); }); + + it.concurrent("opt-in 注册范围应先收窄到 site-access 规则", () => { + const mockScriptMatchInfo: ScriptMatchInfo = { + uuid: "opt-in-uuid", + name: "Opt-in Script", + metadata: { + match: ["*://*/*"], + "site-access": ["opt-in", "+https://allowed.com/*"], + }, + scriptUrlPatterns: extractUrlPatterns(["@match *://*/*"]), + originalUrlPatterns: [], + namespace: "test", + type: SCRIPT_TYPE_NORMAL, + status: SCRIPT_STATUS_ENABLE, + sort: 0, + runStatus: SCRIPT_RUN_STATUS_COMPLETE, + createtime: Date.now(), + checktime: Date.now(), + }; + + const result = getUserScriptRegister(mockScriptMatchInfo); + + expect(result.registerScript.matches).toEqual(["https://allowed.com/*"]); + expect(result.registerScript.includeGlobs).toEqual([]); + }); }); describe.concurrent("compileInjectionCode", () => { @@ -348,4 +417,69 @@ describe.concurrent("compileInjectionCode", () => { // 使用 compileInjectScript 包裹(window[flag] = function(){...}) expect(result).toContain("window['#-test-uuid']"); }); + + const createOptInScriptRes = (metadata: SCMetadata): ScriptRunResource => + createMockScriptRes({ + code: "void 0;", + metadata, + }); + + const executeInjection = (code: string, href: string) => { + const fakeWindow: Record = {}; + const fakePerformance = { + dispatchEvent: vi.fn(() => false), + addEventListener: vi.fn(), + }; + class FakeCustomEvent { + constructor( + readonly type: string, + readonly init?: unknown + ) {} + } + new Function("window", "location", "performance", "CustomEvent", code)( + fakeWindow, + { href }, + fakePerformance, + FakeCustomEvent + ); + return fakeWindow; + }; + + it.concurrent("普通 opt-in 脚本在拒绝网址不应暴露可调用函数", () => { + const scriptRes = createOptInScriptRes({ + match: ["*://*/*"], + "site-access": ["opt-in", "+https://allowed.com/*"], + }); + const result = compileInjectionCode(scriptRes, scriptRes.code, extractUrlPatterns(["@match *://*/*"])); + + expect(executeInjection(result, "https://denied.com/page")["#-test-uuid"]).toBeUndefined(); + expect(typeof executeInjection(result, "https://allowed.com/page")["#-test-uuid"]).toBe("function"); + }); + + it.concurrent("early-start opt-in 脚本在拒绝网址不应注册函数或发出加载事件", () => { + const scriptRes = createOptInScriptRes({ + match: ["*://*/*"], + "site-access": ["opt-in", "+https://allowed.com/*"], + "early-start": [""], + "run-at": ["document-start"], + }); + const result = compileInjectionCode(scriptRes, scriptRes.code, extractUrlPatterns(["@match *://*/*"])); + + const deniedWindow = executeInjection(result, "https://denied.com/page"); + const allowedWindow = executeInjection(result, "https://allowed.com/page"); + expect(deniedWindow["#-test-uuid"]).toBeUndefined(); + expect(typeof allowedWindow["#-test-uuid"]).toBe("function"); + }); + + it.concurrent("unwrap opt-in 脚本在拒绝网址不应执行或注册标记函数", () => { + const scriptRes = createOptInScriptRes({ + match: ["*://*/*"], + "site-access": ["opt-in", "+https://allowed.com/*"], + unwrap: [""], + }); + const result = compileInjectionCode(scriptRes, scriptRes.code, extractUrlPatterns(["@match *://*/*"])); + + expect(executeInjection(result, "https://denied.com/page")["#-test-uuid"]).toBeUndefined(); + expect(typeof executeInjection(result, "https://allowed.com/page")["#-test-uuid"]).toBe("function"); + }); }); diff --git a/src/app/service/service_worker/utils.ts b/src/app/service/service_worker/utils.ts index 2f141befb..bbe56472e 100644 --- a/src/app/service/service_worker/utils.ts +++ b/src/app/service/service_worker/utils.ts @@ -15,14 +15,80 @@ import { import { extractUrlPatterns, getApiMatchesAndGlobs, + isUrlMatch, RuleType, + RuleTypeBit, toUniquePatternStrings, type URLRuleEntry, + embeddedPatternCheckerString, } from "@App/pkg/utils/url_matcher"; 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 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)}`)); + +const getEmbeddedPatternCondition = (patterns: URLRuleEntry[]): string => + embeddedPatternCheckerString( + "location.href", + JSON.stringify(patterns.map(({ ruleType, ruleContent }) => ({ ruleType, ruleContent }))) + ); + +const getSiteAccessCondition = (metadata: SCMetadata | undefined): string | undefined => { + if (!isSiteAccessOptIn(metadata)) return undefined; + const rules = getSiteAccessRules(metadata); + return rules.length ? getEmbeddedPatternCondition(rules) : "false"; +}; + +export const getOptInExecutionCondition = ( + metadata: SCMetadata | undefined, + scriptUrlPatterns: URLRuleEntry[] +): string | undefined => { + const siteAccessCondition = getSiteAccessCondition(metadata); + return siteAccessCondition + ? `(${getEmbeddedPatternCondition(scriptUrlPatterns)})&&(${siteAccessCondition})` + : undefined; +}; + +export const isSiteAccessAllowed = (script: Pick, url: string): boolean => { + const patterns = [...getSiteAccessRules(script.metadata), ...getSiteAccessRules(script.selfMetadata)]; + + return patterns.some((pattern) => pattern.ruleType & RuleTypeBit.INCLUSION && isUrlMatch(url, pattern)); +}; + +export const isSiteAccessAllowedByAuthor = (script: Pick, url: string): boolean => + getSiteAccessRules(script.metadata).some( + (pattern) => pattern.ruleType & RuleTypeBit.INCLUSION && isUrlMatch(url, pattern) + ); + +export const isSiteAccessAllowedByUser = (script: Pick, url: string): boolean => + getSiteAccessRules(script.selfMetadata).some( + (pattern) => pattern.ruleType & RuleTypeBit.INCLUSION && isUrlMatch(url, pattern) + ); + +export const getSiteAccessPatternForUrl = (url: string): string | undefined => { + try { + const host = new URL(url).host; + return host ? `+*://${host}/*` : undefined; + } catch { + return undefined; + } +}; + +export const hasExactUserSiteAccess = (script: Pick, url: string): boolean => { + const pattern = getSiteAccessPatternForUrl(url); + return pattern ? getSiteAccessPatterns(script.selfMetadata).includes(pattern) : false; +}; + export function getRunAt(runAts: string[]): chrome.extensionTypes.RunAt { // 没有 run-at 时为 undefined. Fallback 至 document_idle const runAt = runAts[0] as string | undefined; @@ -143,7 +209,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; } @@ -191,15 +261,22 @@ export function compileInjectionCode( scriptUrlPatterns: URLRuleEntry[] ): string { // 注意! restoreJSCodeFromCompiledResource 跟 compileInjectionCode 的处理是不同的! + const siteAccessCondition = getSiteAccessCondition(scriptRes.metadata); + const executionCondition = getOptInExecutionCondition(scriptRes.metadata, scriptUrlPatterns); let scriptInjectCode; if (isScriptletUnwrap(scriptRes.metadata)) { - scriptInjectCode = compileScriptletCode(scriptRes, scriptCode, scriptUrlPatterns); + scriptInjectCode = compileScriptletCode(scriptRes, scriptCode, scriptUrlPatterns, siteAccessCondition); } else { scriptCode = compileScriptCode(scriptRes, scriptCode); if (isEarlyStartScript(scriptRes.metadata)) { - scriptInjectCode = compilePreInjectScript(parseScriptLoadInfo(scriptRes, scriptUrlPatterns), scriptCode); + scriptInjectCode = compilePreInjectScript( + parseScriptLoadInfo(scriptRes, scriptUrlPatterns), + scriptCode, + false, + executionCondition + ); } else { - scriptInjectCode = compileInjectScript(scriptRes, scriptCode); + scriptInjectCode = compileInjectScript(scriptRes, scriptCode, false, executionCondition); } } return scriptInjectCode; @@ -207,7 +284,12 @@ export function compileInjectionCode( // 构建userScript注册信息(忽略代码部份) export function getUserScriptRegister(scriptMatchInfo: ScriptMatchInfo) { - const { matches, includeGlobs } = getApiMatchesAndGlobs(scriptMatchInfo.scriptUrlPatterns); + const siteAccessRules = getSiteAccessRules(scriptMatchInfo.metadata); + const registrationPatterns = + isSiteAccessOptIn(scriptMatchInfo.metadata) && siteAccessRules.length + ? siteAccessRules + : scriptMatchInfo.scriptUrlPatterns; + const { matches, includeGlobs } = getApiMatchesAndGlobs(registrationPatterns); const excludeMatches = toUniquePatternStrings( scriptMatchInfo.scriptUrlPatterns.filter((e) => e.ruleType === RuleType.MATCH_EXCLUDE) diff --git a/src/locales/de-DE/common.json b/src/locales/de-DE/common.json index df1003539..ff7734b5b 100644 --- a/src/locales/de-DE/common.json +++ b/src/locales/de-DE/common.json @@ -60,6 +60,8 @@ "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", + "include_off": "$0 von den erlaubten Websites entfernen", "confirm_error": "Bestätigung fehlgeschlagen", "import": "Importieren", "error": "Fehler", 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/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..2ece30063 100644 --- a/src/locales/en-US/common.json +++ b/src/locales/en-US/common.json @@ -60,6 +60,8 @@ "copy": "Copy", "exclude_on": "Reinstate $0's execution", "exclude_off": "Exclude $0's execution", + "include_on": "Include $0's execution", + "include_off": "Remove $0 from allowed sites", "confirm_error": "Confirmation Failed", "import": "Import", "error": "Error", 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/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..bbefa9665 100644 --- a/src/locales/ja-JP/common.json +++ b/src/locales/ja-JP/common.json @@ -60,6 +60,8 @@ "copy": "コピー", "exclude_on": "$0の実行を復元", "exclude_off": "$0の実行を除外", + "include_on": "$0での実行を含める", + "include_off": "$0での実行を許可対象から外す", "confirm_error": "確認に失敗しました", "import": "インポート", "error": "エラー", 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/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..9222bdbe8 100644 --- a/src/locales/ru-RU/common.json +++ b/src/locales/ru-RU/common.json @@ -60,6 +60,8 @@ "copy": "Копировать", "exclude_on": "Восстановить в $0 выполнении", "exclude_off": "Исключить в $0 выполнении", + "include_on": "Включить выполнение на $0", + "include_off": "Убрать $0 из разрешённых сайтов", "confirm_error": "Ошибка подтверждения", "import": "Импорт", "error": "Ошибка", 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/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..4668704dc 100644 --- a/src/locales/tr-TR/common.json +++ b/src/locales/tr-TR/common.json @@ -60,6 +60,8 @@ "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", + "include_off": "$0 adresini izin verilen sitelerden kaldır", "confirm_error": "Onay başarısız", "import": "İçe Aktar", "error": "Hata", 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/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..b61d51e6b 100644 --- a/src/locales/vi-VN/common.json +++ b/src/locales/vi-VN/common.json @@ -60,6 +60,8 @@ "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", + "include_off": "Xóa $0 khỏi các trang web được phép", "confirm_error": "Xác nhận thất bại", "import": "Nhập", "error": "Lỗi", 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/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..c198bef2b 100644 --- a/src/locales/zh-CN/common.json +++ b/src/locales/zh-CN/common.json @@ -60,6 +60,8 @@ "copy": "复制", "exclude_on": "恢复在 $0 上执行", "exclude_off": "排除在 $0 上执行", + "include_on": "允许在 $0 上执行", + "include_off": "取消允许在 $0 上执行", "confirm_error": "确认失败", "import": "导入", "error": "错误", 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-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..b6735bdb0 100644 --- a/src/locales/zh-TW/common.json +++ b/src/locales/zh-TW/common.json @@ -60,6 +60,8 @@ "copy": "複製", "exclude_on": "恢復 $0 的執行", "exclude_off": "排除 $0 的執行", + "include_on": "允許在 $0 執行", + "include_off": "取消允許在 $0 執行", "confirm_error": "確認失敗", "import": "匯入", "error": "錯誤", 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/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/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")} +