diff --git a/package.json b/package.json index 61052f25d..2441fc4dd 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "scriptcat", - "version": "1.5.0-beta.1", + "version": "1.5.0-beta.2", "description": "脚本猫,一个可以执行用户脚本的浏览器扩展,万物皆可脚本化,让你的浏览器可以做更多的事情!", "author": "CodFrm", "license": "GPLv3", diff --git a/scripts/build-config.js b/scripts/build-config.js index 610b6cfe3..680587f00 100644 --- a/scripts/build-config.js +++ b/scripts/build-config.js @@ -100,7 +100,7 @@ export function createFirefoxManifest(manifest, agentEnabled, firefoxId) { browser_specific_settings: { gecko: { id: firefoxId, - strict_min_version: "154.0a1", + strict_min_version: "153", data_collection_permissions: { required: ["none"], optional: ["authenticationInfo", "personallyIdentifyingInfo"], diff --git a/scripts/build-config.test.js b/scripts/build-config.test.js index 128f59e46..c6795714e 100644 --- a/scripts/build-config.test.js +++ b/scripts/build-config.test.js @@ -136,7 +136,7 @@ describe("构建配置 - 浏览器专用 manifest", () => { expect(result.incognito).toBe("spanning"); expect(result).not.toHaveProperty("message_serialization"); expect(result.browser_specific_settings.gecko.id).toBe("{firefox-id}"); - expect(result.browser_specific_settings.gecko.strict_min_version).toBe("154.0a1"); + expect(result.browser_specific_settings.gecko.strict_min_version).toBe("153"); expect(result.commands).toEqual({ _execute_action: {} }); expect(source.optional_permissions).toEqual(["background", "userScripts"]); expect(source.background).toHaveProperty("service_worker"); diff --git a/scripts/check-issue-templates.test.mjs b/scripts/check-issue-templates.test.mjs index b2b1f5145..d599cff63 100644 --- a/scripts/check-issue-templates.test.mjs +++ b/scripts/check-issue-templates.test.mjs @@ -1,4 +1,4 @@ -import { describe, it, expect, afterEach } from "vitest"; +import { describe, it, expect, beforeAll, afterEach } from "vitest"; import { mkdirSync, writeFileSync, rmSync } from "node:fs"; import path from "node:path"; import os from "node:os"; @@ -81,10 +81,6 @@ describe("issue 模板机械检查", () => { expect(problemsOf(makeFixtureRoot())).toEqual([]); }); - it("仓库现有的 issue 模板应全部通过检查", () => { - expect(problemsOf(REPO_ROOT)).toEqual([]); - }); - describe("YAML 与 issue-form schema", () => { it("未加引号导致的 YAML 解析失败应报错,而不是被跳过", () => { // description 里裸露的 `: ` 会让 YAML 认为这里又开了一个映射键。 @@ -224,3 +220,22 @@ describe("issue 模板机械检查", () => { }); }); }); + +// 真实仓库模板检查是 I/O 密集的集成检查,不是微单元测试:它要读取全部 12 个模板并做 YAML 解析, +// 再对全量 src 做 issues/new 预填契约的 TS AST 扫描,还要加载 yaml / typescript 两个重型依赖 +// (冷启动实测 ~200ms,CI 并行负载下还会成倍放大)。它不该套用 fast 项目的 340ms 单元预算—— +// 那正是它偶发超时(CI 偶发 Test timed out in 340ms)的根因,与上面的 fixture 测试成本不同。 +// +// 因此把「计算」放进 beforeAll:vitest 的 hook 走默认 10s 预算,与 testTimeout 无关,只算一次 +// 并缓存;「断言」阶段的 it 只对已算好的结果做快照校验,微秒级完成,也不会把仓库扫描重复执行。 +// 以后若要加更多真实仓库断言,直接读下面的缓存即可。 +describe("仓库现有模板(集成检查)", () => { + let repoCheck; + beforeAll(() => { + repoCheck = runCheck(REPO_ROOT); + }); + + it("全部通过机械检查", () => { + expect(repoCheck.problems).toEqual([]); + }); +}); diff --git a/src/app/repo/metadata.ts b/src/app/repo/metadata.ts index 8a3502559..4c4844de5 100644 --- a/src/app/repo/metadata.ts +++ b/src/app/repo/metadata.ts @@ -1,5 +1,8 @@ export type SCMetadata = Partial>; +// OnlyRunOnUrl writes an empty include override; retain its provenance so resetMatch can distinguish it from a user override. +export const SELF_METADATA_ONLY_RUN_ON_URL = "__scriptcat_only_run_on_url"; + // 解析标签 允许使用逗号和空格分隔 export function parseTags(metadata: SCMetadata): string[] { const tags = new Set(); diff --git a/src/app/service/service_worker/client.ts b/src/app/service/service_worker/client.ts index 37946ed63..f770d7e0d 100644 --- a/src/app/service/service_worker/client.ts +++ b/src/app/service/service_worker/client.ts @@ -108,6 +108,10 @@ export class ScriptClient extends Client { return this.do("allowUrl", { uuid, matchPattern, excludePattern }); } + excludeFromMatch(uuid: string, matchPattern: string) { + return this.do("excludeFromMatch", { uuid, matchPattern }); + } + // 重置匹配项 resetMatch(uuid: string, match: string[] | undefined) { return this.do("resetMatch", { uuid, match }); diff --git a/src/app/service/service_worker/popup.test.ts b/src/app/service/service_worker/popup.test.ts index 85b3b1721..5134ae579 100644 --- a/src/app/service/service_worker/popup.test.ts +++ b/src/app/service/service_worker/popup.test.ts @@ -37,6 +37,7 @@ const createMenu = (uuid: string, overrides: Partial = {}): ScriptMe runNumByIframe: 0, menus: [], isEffective: true, + hasMatchOverride: false, ...overrides, }); @@ -104,23 +105,17 @@ describe("PopupService 删除脚本后 Popup 菜单残留清理", () => { await cacheInstance.clear(); }); - it("getPopupData 读取 Popup 数据时,应过滤掉 runScripts 缓存里已删除脚本的残留记录", async () => { + it("getPopupData 读取 Popup 数据时,不应显示 runScripts 缓存中的未匹配脚本", async () => { const deletedUuid = "deleted-script"; const liveUuid = "live-script"; await cacheInstance.set(`${CACHE_KEY_TAB_SCRIPT}${1}`, [createMenu(deletedUuid), createMenu(liveUuid)]); - const { service, scriptDAO } = createService({ - scriptDAO: { - gets: vi.fn(async (uuids: string[]) => - uuids.map((uuid) => (uuid === liveUuid ? createScript(uuid) : undefined)) - ), - }, - }); + const { service, scriptDAO } = createService(); const result = await service.getPopupData({ tabId: 1, url: "https://example.com/" }); - expect(result.scriptList.map((script) => script.uuid)).toEqual([liveUuid]); - expect(scriptDAO.gets).toHaveBeenCalledWith([deletedUuid, liveUuid]); + expect(result.scriptList).toEqual([]); + expect(scriptDAO.gets).toHaveBeenCalledWith([]); }); it("updateRegisterMenuCommand 应忽略已删除脚本发来的迟到 GM_registerMenuCommand", async () => { @@ -288,7 +283,7 @@ describe("PopupService getPopupData Popup 数据获取与合并", () => { isUrlBlacklist: vi.fn().mockReturnValue(false), }, scriptDAO: { - gets: vi.fn().mockResolvedValue([createScript(uuid)]), + gets: vi.fn().mockResolvedValue([createScript(uuid, { selfMetadata: { match: ["*://example.com/*"] } })]), }, }); @@ -298,9 +293,29 @@ describe("PopupService getPopupData Popup 数据获取与合并", () => { expect(result.scriptList[0].uuid).toBe(uuid); expect(result.scriptList[0].isEffective).toBe(true); expect(result.scriptList[0].enable).toBe(true); + expect(result.scriptList[0].hasMatchOverride).toBe(true); expect(result.isBlacklist).toBe(false); }); + it("无 match 覆盖的脚本(无运行缓存)hasMatchOverride 应为 false", async () => { + const uuid = "no-match-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)]), + }, + }); + + const result = await service.getPopupData({ tabId: 1, url: "https://example.com/" }); + + expect(result.scriptList[0].hasMatchOverride).toBe(false); + }); + it("脚本同时在匹配结果与运行缓存中,应复用缓存记录(保留 runNum)并更新 enable/isEffective/hasUserConfig", async () => { const uuid = "run-uuid"; const matchMap = new Map([[uuid, { uuid, effective: false }]]); @@ -326,6 +341,28 @@ describe("PopupService getPopupData Popup 数据获取与合并", () => { expect(result.scriptList[0].hasUserConfig).toBe(true); // script.config 存在 }); + it("合并 run 记录时应按当前脚本同步 hasMatchOverride(match 覆盖存在性)", async () => { + const uuid = "run-uuid-2"; + const matchMap = new Map([[uuid, { uuid, effective: false }]]); + // 运行缓存中存有旧的 hasMatchOverride 记录(模拟缓存来自更早的一次合并) + await cacheInstance.set(`${CACHE_KEY_TAB_SCRIPT}${1}`, [createMenu(uuid, { runNum: 3, hasMatchOverride: false })]); + + const { service } = createService({ + runtime: { + getPopupPageScriptMatchingResultByUrl: vi.fn().mockResolvedValue(matchMap), + isUrlBlacklist: vi.fn().mockReturnValue(false), + }, + scriptDAO: { + gets: vi.fn().mockResolvedValue([createScript(uuid, { selfMetadata: { match: ["*://example.com/*"] } })]), + }, + }); + + const result = await service.getPopupData({ tabId: 1, url: "https://example.com/" }); + + expect(result.scriptList[0].runNum).toBe(3); // 保留缓存中的执行次数 + expect(result.scriptList[0].hasMatchOverride).toBe(true); // 按当前脚本的 match 覆盖同步 + }); + it("URL 无任何匹配脚本时,scriptList 为空;后台脚本应出现在 backScriptList", async () => { const bgUuid = "bg-uuid"; // 预置后台菜单缓存(tabId = -1) diff --git a/src/app/service/service_worker/popup.ts b/src/app/service/service_worker/popup.ts index 0cc63b20a..7026ac36b 100644 --- a/src/app/service/service_worker/popup.ts +++ b/src/app/service/service_worker/popup.ts @@ -395,6 +395,7 @@ export class PopupService { // 如果脚本已经存在,则不添加,更新信息 run.enable = script.status === SCRIPT_STATUS_ENABLE; run.isEffective = o.effective!; + run.hasMatchOverride = script.selfMetadata?.match !== undefined; run.hasUserConfig = !!script.config; } else { // 由于目前没有在 Popup 显示 @match @include @exclude, 所以以下代码暂不需要 @@ -407,19 +408,6 @@ export class PopupService { scriptMenuMap.set(uuid, run); } - // 将未匹配当前 url 但仍在运行的脚本,附加到清单末端,避免使用者找不到其菜单。 - // 这些记录来自 tabScript: session cache;脚本删除事件与 Popup 读取可能交错, - // 因此这里必须用 DAO 结果做读侧防护,避免已删除脚本残留在 Popup 清单。 - const unmatchedRunScripts = runScripts.filter((script) => !scriptMenuMap.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++) { - const script = unmatchedRunScripts[idx]; - if (existingRunScripts[idx]) { - scriptMenuMap.set(script.uuid, script); - } - } - } const scriptMenu = [...scriptMenuMap.values()]; // 检查是否在黑名单中 const isBlacklist = this.runtime.isUrlBlacklist(url); diff --git a/src/app/service/service_worker/popup_scriptmenu.test.ts b/src/app/service/service_worker/popup_scriptmenu.test.ts index cebe95c48..3d211d5aa 100644 --- a/src/app/service/service_worker/popup_scriptmenu.test.ts +++ b/src/app/service/service_worker/popup_scriptmenu.test.ts @@ -3,7 +3,7 @@ import { changeLanguage } from "@App/locales/locales"; import { initTestLanguage } from "@Tests/initTestLanguage"; import type { Script } from "@App/app/repo/scripts"; import type { ScriptMenu } from "./types"; -import { applyScriptDisplayInfo } from "./popup_scriptmenu"; +import { applyScriptDisplayInfo, scriptToMenu } from "./popup_scriptmenu"; const makeMenu = (over: Partial = {}): ScriptMenu => ({ uuid: "u1", @@ -16,6 +16,7 @@ const makeMenu = (over: Partial = {}): ScriptMenu => ({ runNumByIframe: 0, menus: [], isEffective: null, + hasMatchOverride: false, ...over, }); @@ -24,6 +25,24 @@ const makeMenu = (over: Partial = {}): ScriptMenu => ({ const makeScript = (metadata: Record, name = "Raw Name"): Script => ({ name, metadata }) as unknown as Script; +describe("scriptToMenu Script 转 ScriptMenu", () => { + it("无 selfMetadata.match 覆盖时 hasMatchOverride 应为 false", () => { + expect(scriptToMenu(makeScript({})).hasMatchOverride).toBe(false); + }); + + it("存在 selfMetadata.match 覆盖时 hasMatchOverride 应为 true", () => { + const script = makeScript({}); + script.selfMetadata = { match: ["*://example.com/*"] }; + expect(scriptToMenu(script).hasMatchOverride).toBe(true); + }); + + it("selfMetadata.match 为空数组时(显式空覆盖)hasMatchOverride 应为 true", () => { + const script = makeScript({}); + script.selfMetadata = { match: [] }; + expect(scriptToMenu(script).hasMatchOverride).toBe(true); + }); +}); + describe("applyScriptDisplayInfo Popup 展示信息补充", () => { beforeAll(() => initTestLanguage("zh-CN")); diff --git a/src/app/service/service_worker/popup_scriptmenu.ts b/src/app/service/service_worker/popup_scriptmenu.ts index dafa06f3f..fe3391c55 100644 --- a/src/app/service/service_worker/popup_scriptmenu.ts +++ b/src/app/service/service_worker/popup_scriptmenu.ts @@ -23,6 +23,7 @@ export const scriptToMenu = (script: Script): ScriptMenu => { runNumByIframe: 0, menus: [], isEffective: null, + hasMatchOverride: script.selfMetadata?.match !== undefined, }; }; diff --git a/src/app/service/service_worker/runtime.test.ts b/src/app/service/service_worker/runtime.test.ts index fe9e84481..4fc2fcd4f 100644 --- a/src/app/service/service_worker/runtime.test.ts +++ b/src/app/service/service_worker/runtime.test.ts @@ -288,6 +288,45 @@ describe.concurrent("RuntimeService - getPageScriptMatchingResultByUrl 脚本匹 }); }); + it.concurrent("match 覆盖清空后不再保留此前的匹配规则", async () => { + const { runtime } = createRuntimeTestContext(); + const script = createMockScript({ + metadata: { match: ["https://www.example.com/*"] }, + selfMetadata: { match: ["https://www.example.com/*"] }, + }); + + await runtime.applyScriptMatchInfo(createScriptRunResource(script)); + expect(runtime.getPageScriptMatchingResultByUrl("https://www.example.com/").has(script.uuid)).toBe(true); + + const emptyMatchOverride = createScriptRunResource({ + ...script, + selfMetadata: { match: [] }, + }); + expect(await runtime.applyScriptMatchInfo(emptyMatchOverride)).toBeUndefined(); + + expect(runtime.getPageScriptMatchingResultByUrl("https://www.example.com/", true).has(script.uuid)).toBe(false); + }); + + it.concurrent("空匹配覆盖时应删除持久化 CompiledResource 并注销旧注册", async () => { + const { runtime, mockScriptService } = createRuntimeTestContext(); + const script = createMockScript({ + metadata: { match: ["https://www.example.com/*"] }, + selfMetadata: { match: [] }, + status: SCRIPT_STATUS_ENABLE, + }); + const scriptRunResource = createScriptRunResource(script); + mockScriptService.buildScriptRunResource.mockResolvedValue(scriptRunResource); + + const deleteSpy = vi.spyOn(runtime.compiledResourceDAO, "delete").mockResolvedValue(undefined); + const unregisterSpy = vi.spyOn(runtime, "unregistryPageScripts").mockResolvedValue(undefined); + + await runtime.updateResourceOnScriptChange(script); + + // 空覆盖 = 全站不匹配:旧 CompiledResource 与浏览器注册必须被清掉,否则 SW 重启后旧范围复活 + expect(deleteSpy).toHaveBeenCalledWith(script.uuid); + expect(unregisterSpy).toHaveBeenCalledWith([script.uuid]); + }); + describe.concurrent("includeDisabled 选项", () => { it.concurrent("当 includeDisabled=false 时不返回禁用脚本;当 includeDisabled=true 时返回禁用脚本", async () => { // Arrange diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index f9c670fbb..88ed68dea 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -455,7 +455,14 @@ export class RuntimeService { } // 安装,启用,或earlyStartScript的value更新 const ret = await this.buildAndSaveCompiledResourceFromScript(script, true); - if (!ret) return; + if (!ret) { + // 空匹配覆盖(match 与 include 均为空)时脚本不再匹配任何站点。 + // 内存 matcher 已由 applyScriptMatchInfo 清空,这里再清掉持久化的 CompiledResource + // 并注销浏览器旧注册,否则 SW 重启后 waitInit 会信任旧资源、让旧范围复活。 + await this.compiledResourceDAO.delete(script.uuid); + await this.unregistryPageScripts([script.uuid]); + return; + } const { apiScript } = ret; await this.loadPageScript(script, apiScript!); } @@ -1692,6 +1699,10 @@ export class RuntimeService { async applyScriptMatchInfo(scriptRes: ScriptRunResource) { const o = scriptURLPatternResults(scriptRes); if (!o) { + const { uuid } = scriptRes; + this.scriptMatchEnable.clearRules(uuid); + this.scriptMatchEnable.clearRules(this.getOriginalMatchUuid(uuid)); + this.cachedPatterns.delete(uuid); return undefined; } // 构建脚本匹配信息 diff --git a/src/app/service/service_worker/script.test.ts b/src/app/service/service_worker/script.test.ts index e35c2ba6c..4330ce12e 100644 --- a/src/app/service/service_worker/script.test.ts +++ b/src/app/service/service_worker/script.test.ts @@ -17,6 +17,9 @@ import type { TDeleteScript, TInstallScript } from "@App/app/service/queue"; import { createMockOPFS } from "@App/app/repo/test-helpers"; import type { Group } from "@Packages/message/server"; import type { IMessageQueue } from "@Packages/message/message_queue"; +import type { MessageSend } from "@Packages/message/types"; +import { ScriptClient } from "./client"; +import { SELF_METADATA_ONLY_RUN_ON_URL } from "@App/app/repo/metadata"; initTestEnv(); @@ -578,6 +581,20 @@ describe("ScriptService.deleteScripts —— 回收站关闭时直接销毁", () * selfMetadata 是「用户对脚本自带 @metadata 的覆盖」。 * undefined 表示撤销覆盖(生效值回落脚本自带 metadata),空数组表示用户显式清空,两者语义不同。 */ +describe("ScriptClient 站点范围消息", () => { + it("排除已匹配站点时应发送对应操作与站点规则", async () => { + const sendMessage = vi.fn().mockResolvedValue({ data: true }); + const client = new ScriptClient({ sendMessage } as unknown as MessageSend); + + await client.excludeFromMatch("script-uuid", "*://current.example/*"); + + expect(sendMessage).toHaveBeenCalledWith({ + action: "serviceWorker/script/excludeFromMatch", + data: { uuid: "script-uuid", matchPattern: "*://current.example/*" }, + }); + }); +}); + describe("ScriptService selfMetadata 用户覆盖", () => { let scriptService: ScriptService; let mockScriptDAO: ScriptDAO; @@ -617,8 +634,13 @@ describe("ScriptService selfMetadata 用户覆盖", () => { update: vi.fn().mockResolvedValue(true), } as any; + const mockSystemConfig = { + getCheckScriptUpdateCycle: vi.fn().mockResolvedValue(0), + addListener: vi.fn(), + } as unknown as SystemConfig; + scriptService = new ScriptService( - {} as SystemConfig, + mockSystemConfig, mockGroup, mockMessageQueue, {} as ValueService, @@ -656,16 +678,131 @@ describe("ScriptService selfMetadata 用户覆盖", () => { expect(savedSelfMetadata()).toEqual({ exclude: ["*://ads.script.com/*", "*://user.com/*"] }); }); + + it("已有用户排除覆盖时新增排除应同时保留作者与用户规则", async () => { + const script = createMockScript({ + selfMetadata: { exclude: ["*://user-blocked.example/*"] }, + }); + vi.mocked(mockScriptDAO.get).mockResolvedValue(script); + + await scriptService.excludeUrl({ uuid: script.uuid, excludePattern: "*://new.example/*", remove: false }); + + expect(savedSelfMetadata()).toEqual({ + exclude: ["*://ads.script.com/*", "*://user-blocked.example/*", "*://new.example/*"], + }); + }); }); describe("popup 站点范围快捷操作", () => { - it("仅在当前站点执行应以当前站点替换用户匹配列表", async () => { - const script = createMockScript({ selfMetadata: { match: ["*://old.example/*"] } }); + it("初始化时应注册排除已匹配站点操作", async () => { + const alarmsDescriptor = Object.getOwnPropertyDescriptor(chrome, "alarms"); + Object.defineProperty(chrome, "alarms", { + configurable: true, + value: { clear: vi.fn().mockResolvedValue(false) }, + }); + const on = vi.spyOn(mockGroup, "on"); + vi.spyOn(scriptService, "listenerScriptInstall").mockImplementation(() => {}); + + try { + scriptService.init(); + await vi.waitFor(() => expect(chrome.alarms.clear).toHaveBeenCalledWith("checkScriptUpdate")); + + expect(on).toHaveBeenCalledWith("excludeFromMatch", expect.any(Function)); + } finally { + if (alarmsDescriptor) { + Object.defineProperty(chrome, "alarms", alarmsDescriptor); + } else { + Reflect.deleteProperty(chrome, "alarms"); + } + } + }); + + it("仅在当前站点执行应以当前站点替换匹配列表并清空作者 include", async () => { + const script = createMockScript({ + metadata: { include: ["*://included.example/*"] }, + selfMetadata: { match: ["*://old.example/*"] }, + }); vi.mocked(mockScriptDAO.get).mockResolvedValue(script); await scriptService.onlyRunOnUrl({ uuid: script.uuid, matchPattern: "*://current.example/*" }); - expect(savedSelfMetadata()).toEqual({ match: ["*://current.example/*"] }); + expect(savedSelfMetadata()).toEqual({ + match: ["*://current.example/*"], + include: [], + [SELF_METADATA_ONLY_RUN_ON_URL]: ["*://current.example/*"], + }); + }); + + it("并发站点范围操作应保留两个操作的覆盖变更", async () => { + let stored = createMockScript(); + let signalFirstUpdate!: () => void; + let releaseFirstUpdate!: () => void; + const firstUpdateStarted = new Promise((resolve) => { + signalFirstUpdate = resolve; + }); + const firstUpdateRelease = new Promise((resolve) => { + releaseFirstUpdate = resolve; + }); + let updateCount = 0; + vi.mocked(mockScriptDAO.get).mockImplementation(async () => stored); + vi.mocked(mockScriptDAO.update).mockImplementation(async (_uuid, next) => { + updateCount += 1; + if (updateCount === 1) { + signalFirstUpdate(); + await firstUpdateRelease; + } + stored = { ...stored, ...next }; + return stored; + }); + + const onlyRun = scriptService.onlyRunOnUrl({ uuid: stored.uuid, matchPattern: "*://current.example/*" }); + await firstUpdateStarted; + const exclude = scriptService.excludeFromMatch({ uuid: stored.uuid, matchPattern: "*://current.example/*" }); + await Promise.resolve(); + await Promise.resolve(); + releaseFirstUpdate(); + await Promise.all([onlyRun, exclude]); + + expect(stored.selfMetadata).toEqual({ + match: [], + include: [], + exclude: ["*://ads.script.com/*", "*://current.example/*"], + }); + }); + + it("并发 onlyRunOnUrl 与 resetMatch 应串行执行并保留两次读改写", async () => { + let stored = createMockScript(); + let signalFirstUpdate!: () => void; + let releaseFirstUpdate!: () => void; + const firstUpdateStarted = new Promise((resolve) => { + signalFirstUpdate = resolve; + }); + const firstUpdateRelease = new Promise((resolve) => { + releaseFirstUpdate = resolve; + }); + let updateCount = 0; + vi.mocked(mockScriptDAO.get).mockImplementation(async () => stored); + vi.mocked(mockScriptDAO.update).mockImplementation(async (_uuid, next) => { + updateCount += 1; + if (updateCount === 1) { + signalFirstUpdate(); + await firstUpdateRelease; + } + stored = { ...stored, ...next }; + return stored; + }); + + const onlyRun = scriptService.onlyRunOnUrl({ uuid: stored.uuid, matchPattern: "*://current.example/*" }); + await firstUpdateStarted; + const reset = scriptService.resetMatch({ uuid: stored.uuid, match: ["*://edited.example/*"] }); + await Promise.resolve(); + await Promise.resolve(); + releaseFirstUpdate(); + await Promise.all([onlyRun, reset]); + + // popup 的 onlyRunOnUrl 与设置页的 resetMatch 并发读改写须串行:resetMatch 后写,其 + // match 覆盖生效,同时不覆盖 onlyRunOnUrl 写入的 include 覆盖 + expect(stored.selfMetadata).toEqual({ match: ["*://edited.example/*"], include: [] }); }); it("自定义匹配未覆盖当前站点时应把当前站点加入允许列表", async () => { @@ -680,9 +817,10 @@ describe("ScriptService selfMetadata 用户覆盖", () => { excludePattern: "*://current.example/*", }); + // 作者 @exclude(ads.script.com)并入用户覆盖,避免移除当前项时丢作者规则 expect(savedSelfMetadata()).toEqual({ match: ["*://allowed.example/*", "*://current.example/*"], - exclude: [], + exclude: ["*://ads.script.com/*"], }); }); @@ -696,7 +834,126 @@ describe("ScriptService selfMetadata 用户覆盖", () => { excludePattern: "*://current.example/*", }); - expect(savedSelfMetadata()).toEqual({ exclude: [] }); + // 移除当前站点时并入作者 @exclude,避免用户覆盖整体替换丢作者规则 + expect(savedSelfMetadata()).toEqual({ exclude: ["*://ads.script.com/*"] }); + }); + + it("只有匹配覆盖而没有排除覆盖时应保留作者排除", async () => { + const script = createMockScript({ + metadata: { exclude: ["*://current.example/*"] }, + selfMetadata: { match: ["*://allowed.example/*"] }, + }); + vi.mocked(mockScriptDAO.get).mockResolvedValue(script); + + await scriptService.allowUrl({ + uuid: script.uuid, + matchPattern: "*://current.example/*", + excludePattern: "*://current.example/*", + }); + + expect(savedSelfMetadata()).toEqual({ + match: ["*://allowed.example/*", "*://current.example/*"], + }); + }); + + it("已有用户排除覆盖时 allowUrl 移除当前站点应保留作者与用户排除规则", async () => { + const script = createMockScript({ + metadata: { exclude: ["*://author-blocked.example/*"] }, + selfMetadata: { exclude: ["*://current.example/*", "*://user-blocked.example/*"] }, + }); + vi.mocked(mockScriptDAO.get).mockResolvedValue(script); + + await scriptService.allowUrl({ + uuid: script.uuid, + matchPattern: "*://current.example/*", + excludePattern: "*://current.example/*", + }); + + // 用户覆盖整体替换作者规则,allowUrl 移除当前项时须并入作者 @exclude,避免丢作者规则 + expect(savedSelfMetadata()).toEqual({ + exclude: ["*://author-blocked.example/*", "*://user-blocked.example/*"], + }); + }); + + it("排除已包含站点时应移出用户匹配并加入用户排除", async () => { + const script = createMockScript({ + selfMetadata: { + match: ["*://allowed.example/*", "*://current.example/*"], + exclude: ["*://blocked.example/*"], + }, + }); + vi.mocked(mockScriptDAO.get).mockResolvedValue(script); + + await scriptService.excludeFromMatch({ uuid: script.uuid, matchPattern: "*://current.example/*" }); + + // 作者 @exclude(ads.script.com)并入用户覆盖,避免用户覆盖整体替换作者规则 + expect(savedSelfMetadata()).toEqual({ + match: ["*://allowed.example/*"], + exclude: ["*://ads.script.com/*", "*://blocked.example/*", "*://current.example/*"], + }); + }); + + it("排除最后一个用户匹配时应保留显式空匹配覆盖", async () => { + const script = createMockScript({ selfMetadata: { match: ["*://current.example/*"] } }); + vi.mocked(mockScriptDAO.get).mockResolvedValue(script); + + await scriptService.excludeFromMatch({ uuid: script.uuid, matchPattern: "*://current.example/*" }); + + expect(savedSelfMetadata()).toEqual({ + match: [], + exclude: ["*://ads.script.com/*", "*://current.example/*"], + }); + }); + + it("没有用户匹配覆盖时排除站点不应创建匹配覆盖", async () => { + const script = createMockScript(); + vi.mocked(mockScriptDAO.get).mockResolvedValue(script); + + await scriptService.excludeFromMatch({ uuid: script.uuid, matchPattern: "*://current.example/*" }); + + expect(savedSelfMetadata()).toEqual({ exclude: ["*://ads.script.com/*", "*://current.example/*"] }); + }); + + it("已有空匹配覆盖时排除站点应保留空覆盖", async () => { + const script = createMockScript({ selfMetadata: { match: [] } }); + vi.mocked(mockScriptDAO.get).mockResolvedValue(script); + + await scriptService.excludeFromMatch({ uuid: script.uuid, matchPattern: "*://current.example/*" }); + + expect(savedSelfMetadata()).toEqual({ + match: [], + exclude: ["*://ads.script.com/*", "*://current.example/*"], + }); + }); + + it("新增排除覆盖时应保留作者已有的排除规则", async () => { + const script = createMockScript({ + metadata: { exclude: ["*://author-blocked.example/*"] }, + selfMetadata: { match: ["*://current.example/*"] }, + }); + vi.mocked(mockScriptDAO.get).mockResolvedValue(script); + + await scriptService.excludeFromMatch({ uuid: script.uuid, matchPattern: "*://current.example/*" }); + + expect(savedSelfMetadata()).toEqual({ + match: [], + exclude: ["*://author-blocked.example/*", "*://current.example/*"], + }); + }); + + it("已有用户排除覆盖时排除站点应同时保留作者与用户排除规则", async () => { + const script = createMockScript({ + metadata: { exclude: ["*://author-blocked.example/*"] }, + selfMetadata: { match: ["*://current.example/*"], exclude: ["*://user-blocked.example/*"] }, + }); + vi.mocked(mockScriptDAO.get).mockResolvedValue(script); + + await scriptService.excludeFromMatch({ uuid: script.uuid, matchPattern: "*://current.example/*" }); + + expect(savedSelfMetadata()).toEqual({ + match: [], + exclude: ["*://author-blocked.example/*", "*://user-blocked.example/*", "*://current.example/*"], + }); }); }); @@ -719,6 +976,43 @@ describe("ScriptService selfMetadata 用户覆盖", () => { expect(savedSelfMetadata()).toEqual({ match: [] }); }); + it("重置时应同时撤销 onlyRunOnUrl 写入的 include 覆盖,恢复作者 include", async () => { + const script = createMockScript({ + metadata: { include: ["*://included.example/*"] }, + selfMetadata: { + match: ["*://current.example/*"], + include: [], + [SELF_METADATA_ONLY_RUN_ON_URL]: ["*://current.example/*"], + }, + }); + vi.mocked(mockScriptDAO.get).mockResolvedValue(script); + + await scriptService.resetMatch({ uuid: script.uuid, match: undefined }); + + // match 与 include 覆盖是 onlyRunOnUrl 一并写入的 URL 范围单元,重置须一并撤销 + expect(savedSelfMetadata()).toBeUndefined(); + }); + + it("重置 match 时应保留独立的 include 覆盖", async () => { + const script = createMockScript({ selfMetadata: { include: ["*://user.example/*"] } }); + vi.mocked(mockScriptDAO.get).mockResolvedValue(script); + + await scriptService.resetMatch({ uuid: script.uuid, match: undefined }); + + expect(savedSelfMetadata()).toEqual({ include: ["*://user.example/*"] }); + }); + + it("传入空数组(删除最后一项)时应保留 include 覆盖", async () => { + const script = createMockScript({ + selfMetadata: { match: ["*://current.example/*"], include: [] }, + }); + vi.mocked(mockScriptDAO.get).mockResolvedValue(script); + + await scriptService.resetMatch({ uuid: script.uuid, match: [] }); + + expect(savedSelfMetadata()).toEqual({ match: [], include: [] }); + }); + it("resetExclude 传入 undefined(重置)应删除用户覆盖", async () => { const script = createMockScript({ selfMetadata: { exclude: ["*://user.com/*"] } }); vi.mocked(mockScriptDAO.get).mockResolvedValue(script); @@ -756,5 +1050,23 @@ describe("ScriptService selfMetadata 用户覆盖", () => { expect(savedSelfMetadata()).toBeUndefined(); }); + + it("通用 metadata 更新可保存 include 覆盖并清除 onlyRunOnUrl provenance", async () => { + const script = createMockScript({ + selfMetadata: { + match: ["*://current.example/*"], + include: [], + [SELF_METADATA_ONLY_RUN_ON_URL]: ["*://current.example/*"], + }, + }); + vi.mocked(mockScriptDAO.get).mockResolvedValue(script); + + await scriptService.updateMetadata({ uuid: script.uuid, key: "include", value: ["*://user.example/*"] }); + + expect(savedSelfMetadata()).toEqual({ + match: ["*://current.example/*"], + include: ["*://user.example/*"], + }); + }); }); }); diff --git a/src/app/service/service_worker/script.ts b/src/app/service/service_worker/script.ts index 726157eea..985067df2 100644 --- a/src/app/service/service_worker/script.ts +++ b/src/app/service/service_worker/script.ts @@ -19,6 +19,7 @@ import type { ScriptRunResource, ScriptSite, } from "@App/app/repo/scripts"; +import { SELF_METADATA_ONLY_RUN_ON_URL } from "@App/app/repo/metadata"; import { SCRIPT_STATUS_DISABLE, SCRIPT_STATUS_ENABLE, ScriptCodeDAO } from "@App/app/repo/scripts"; import { type IMessageQueue } from "@Packages/message/message_queue"; import { type ScriptInfo, type InstallSource, createTempCodeEntry } from "@App/pkg/utils/scriptInstall"; @@ -46,6 +47,7 @@ import { LocalStorageDAO } from "@App/app/repo/localStorage"; import { CompiledResourceDAO } from "@App/app/repo/resource"; import { initRegularUpdateCheck, watchRegularUpdateCheck } from "./regular_updatecheck"; import { parseSkillScriptMetadata } from "@App/pkg/utils/skill_script"; +import { stackAsyncTask } from "@App/pkg/utils/async_queue"; import { TempStorageDAO, TempStorageItemType } from "@App/app/repo/tempStorage"; import { EnableAgent } from "@App/app/const"; import { TrashScriptDAO } from "@App/app/repo/trash_script"; @@ -896,37 +898,47 @@ export class ScriptService { // ScriptMenuList 的 excludeUrl - 排除或回复 async excludeUrl({ uuid, excludePattern, remove }: { uuid: string; excludePattern: string; remove: boolean }) { - let script = await this.scriptDAO.get(uuid); - if (!script) { - throw new Error("script not found"); - } - // 建立Set去掉重复(如有) - const excludeSet = new Set(script.selfMetadata?.exclude || script.metadata?.exclude || []); - if (remove) { - const deleted = excludeSet.delete(excludePattern); - if (!deleted) { - return; // scriptDAO 不用更新 + return stackAsyncTask("script-site-scope", async () => { + let script = await this.scriptDAO.get(uuid); + if (!script) { + throw new Error("script not found"); } - } else { - excludeSet.add(excludePattern); - } - // 更新 script.selfMetadata.exclude - script = selfMetadataUpdate(script, "exclude", excludeSet); - return this.scriptDAO - .update(uuid, script) - .then(() => { + // 建立Set去掉重复(如有);用户覆盖整体替换作者规则,因此须把作者 @exclude 一并并入, + // 否则用户已有排除覆盖时会丢作者规则 + const excludeSet = new Set([...(script.metadata?.exclude || []), ...(script.selfMetadata?.exclude || [])]); + if (remove) { + const deleted = excludeSet.delete(excludePattern); + if (!deleted) { + return; // scriptDAO 不用更新 + } + } else { + excludeSet.add(excludePattern); + } + // 更新 script.selfMetadata.exclude + script = selfMetadataUpdate(script, "exclude", excludeSet); + try { + await this.scriptDAO.update(uuid, script); // 广播一下 this.publishInstallScript(script, { update: true }); return true; - }) - .catch((e) => { + } catch (e) { this.logger.error("exclude url error", Logger.E(e)); throw e; - }); + } + }); } async onlyRunOnUrl({ uuid, matchPattern }: { uuid: string; matchPattern: string }) { - return this.resetMatch({ uuid, match: [matchPattern] }); + return stackAsyncTask("script-site-scope", async () => { + let script = await this.scriptDAO.get(uuid); + if (!script) throw new Error("script not found"); + script = selfMetadataUpdate(script, "match", new Set([matchPattern])); + script = selfMetadataUpdate(script, "include", new Set()); + script = selfMetadataUpdate(script, SELF_METADATA_ONLY_RUN_ON_URL, new Set([matchPattern])); + await this.scriptDAO.update(uuid, script); + this.publishInstallScript(script, { update: true }); + return true; + }); } async allowUrl({ @@ -938,62 +950,92 @@ export class ScriptService { matchPattern: string; excludePattern: string; }) { - let script = await this.scriptDAO.get(uuid); - if (!script) throw new Error("script not found"); - if (script.selfMetadata?.match !== undefined) { - script = selfMetadataUpdate(script, "match", new Set([...script.selfMetadata.match, matchPattern])); - } - const excludeSet = new Set(script.selfMetadata?.exclude || script.metadata?.exclude || []); - excludeSet.delete(excludePattern); - script = selfMetadataUpdate(script, "exclude", excludeSet); - return this.scriptDAO.update(uuid, script).then(() => { + return stackAsyncTask("script-site-scope", async () => { + let script = await this.scriptDAO.get(uuid); + if (!script) throw new Error("script not found"); + if (script.selfMetadata?.match !== undefined) { + script = selfMetadataUpdate(script, "match", new Set([...script.selfMetadata.match, matchPattern])); + script = selfMetadataUpdate(script, SELF_METADATA_ONLY_RUN_ON_URL, undefined); + } + if (script.selfMetadata?.exclude !== undefined) { + // 用户覆盖整体替换作者规则,因此移除当前项时把作者 @exclude 一并并入,避免丢作者规则 + const excludeSet = new Set([...(script.metadata?.exclude || []), ...script.selfMetadata.exclude]); + excludeSet.delete(excludePattern); + script = selfMetadataUpdate(script, "exclude", excludeSet); + } + await this.scriptDAO.update(uuid, script); + this.publishInstallScript(script, { update: true }); + return true; + }); + } + + async excludeFromMatch({ uuid, matchPattern }: { uuid: string; matchPattern: string }) { + return stackAsyncTask("script-site-scope", async () => { + let script = await this.scriptDAO.get(uuid); + if (!script) throw new Error("script not found"); + if (script.selfMetadata?.match !== undefined) { + const matchSet = new Set(script.selfMetadata.match); + matchSet.delete(matchPattern); + script = selfMetadataUpdate(script, "match", matchSet); + script = selfMetadataUpdate(script, SELF_METADATA_ONLY_RUN_ON_URL, undefined); + } + // 用户覆盖整体替换作者规则,因此把作者 @exclude 一并并入用户覆盖,避免丢作者规则 + const excludeSet = new Set([...(script.metadata?.exclude || []), ...(script.selfMetadata?.exclude || [])]); + excludeSet.add(matchPattern); + script = selfMetadataUpdate(script, "exclude", excludeSet); + await this.scriptDAO.update(uuid, script); this.publishInstallScript(script, { update: true }); return true; }); } async resetExclude({ uuid, exclude }: { uuid: string; exclude: string[] | undefined }) { - let script = await this.scriptDAO.get(uuid); - if (!script) { - throw new Error("script not found"); - } - // 建立Set去掉重复(如有);exclude 为 undefined 表示重置,即撤销用户覆盖 - const excludeSet = exclude === undefined ? undefined : new Set(exclude); - // 更新 script.selfMetadata.exclude - script = selfMetadataUpdate(script, "exclude", excludeSet); - return this.scriptDAO - .update(uuid, script) - .then(() => { + return stackAsyncTask("script-site-scope", async () => { + let script = await this.scriptDAO.get(uuid); + if (!script) { + throw new Error("script not found"); + } + // 建立Set去掉重复(如有);exclude 为 undefined 表示重置,即撤销用户覆盖 + const excludeSet = exclude === undefined ? undefined : new Set(exclude); + // 更新 script.selfMetadata.exclude + script = selfMetadataUpdate(script, "exclude", excludeSet); + try { + await this.scriptDAO.update(uuid, script); // 广播一下 this.publishInstallScript(script, { update: true }); return true; - }) - .catch((e) => { + } catch (e) { this.logger.error("reset exclude error", Logger.E(e)); throw e; - }); + } + }); } async resetMatch({ uuid, match }: { uuid: string; match: string[] | undefined }) { - let script = await this.scriptDAO.get(uuid); - if (!script) { - throw new Error("script not found"); - } - // 建立Set去掉重复(如有);match 为 undefined 表示重置,即撤销用户覆盖 - const matchSet = match === undefined ? undefined : new Set(match); - // 更新 script.selfMetadata.match - script = selfMetadataUpdate(script, "match", matchSet); - return this.scriptDAO - .update(uuid, script) - .then(() => { + return stackAsyncTask("script-site-scope", async () => { + let script = await this.scriptDAO.get(uuid); + if (!script) { + throw new Error("script not found"); + } + // 建立Set去掉重复(如有);match 为 undefined 表示重置,即撤销用户覆盖 + const matchSet = match === undefined ? undefined : new Set(match); + // 更新 script.selfMetadata.match + script = selfMetadataUpdate(script, "match", matchSet); + if (script.selfMetadata?.[SELF_METADATA_ONLY_RUN_ON_URL] !== undefined && match === undefined) { + // onlyRunOnUrl owns the empty include override; an imported or explicit include override does not. + script = selfMetadataUpdate(script, "include", undefined); + } + script = selfMetadataUpdate(script, SELF_METADATA_ONLY_RUN_ON_URL, undefined); + try { + await this.scriptDAO.update(uuid, script); // 广播一下 this.publishInstallScript(script, { update: true }); return true; - }) - .catch((e) => { + } catch (e) { this.logger.error("reset match error", Logger.E(e)); throw e; - }); + } + }); } async checkUpdatesAvailable( @@ -1573,23 +1615,26 @@ export class ScriptService { // 更新脚本元数据;value 为 undefined 表示撤销用户覆盖,生效值回落脚本自带 metadata async updateMetadata({ uuid, key, value }: { uuid: string; key: string; value: string[] | undefined }) { - let script = await this.scriptDAO.get(uuid); - if (!script) { - throw new Error("script not found"); - } - const valueSet = value === undefined ? undefined : new Set(value); - script = selfMetadataUpdate(script, key, valueSet); - return this.scriptDAO - .update(uuid, script) - .then(() => { + return stackAsyncTask("script-site-scope", async () => { + let script = await this.scriptDAO.get(uuid); + if (!script) { + throw new Error("script not found"); + } + const valueSet = value === undefined ? undefined : new Set(value); + script = selfMetadataUpdate(script, key, valueSet); + if (key === "match" || key === "include") { + script = selfMetadataUpdate(script, SELF_METADATA_ONLY_RUN_ON_URL, undefined); + } + try { + await this.scriptDAO.update(uuid, script); // 广播一下 this.mq.publish("installScript", { script, update: true }); return true; - }) - .catch((e) => { + } catch (e) { this.logger.error("reset exclude error", Logger.E(e)); throw e; - }); + } + }); } async getBatchUpdateRecordLite(i: number) { @@ -1701,6 +1746,7 @@ export class ScriptService { this.group.on("excludeUrl", this.excludeUrl.bind(this)); this.group.on("onlyRunOnUrl", this.onlyRunOnUrl.bind(this)); this.group.on("allowUrl", this.allowUrl.bind(this)); + this.group.on("excludeFromMatch", this.excludeFromMatch.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 c4b0bef0b..e8ecafe62 100644 --- a/src/app/service/service_worker/types.ts +++ b/src/app/service/service_worker/types.ts @@ -200,6 +200,7 @@ export type ScriptMenu = { runNumByIframe: number; // iframe运行次数 menus: ScriptMenuItem[]; // 脚本菜单 isEffective: boolean | null; // 是否在当前网址启动 + hasMatchOverride: boolean; // 是否存在 match 覆盖(selfMetadata.match !== undefined),用于区分 S1/S3 与 S2/S4 }; /** 批量更新记录 */ diff --git a/src/app/service/service_worker/utils.test.ts b/src/app/service/service_worker/utils.test.ts index 5f935d6e6..cd39524fe 100644 --- a/src/app/service/service_worker/utils.test.ts +++ b/src/app/service/service_worker/utils.test.ts @@ -9,6 +9,7 @@ import { shouldAutoOpenChangelog, } from "./utils"; import type { SCMetadata, Script, ScriptRunResource } from "@App/app/repo/scripts"; +import { SELF_METADATA_ONLY_RUN_ON_URL } from "@App/app/repo/metadata"; import { SCRIPT_TYPE_NORMAL, SCRIPT_STATUS_ENABLE, SCRIPT_RUN_STATUS_COMPLETE } from "@App/app/repo/scripts"; import type { ScriptMatchInfo } from "./types"; import { extractUrlPatterns } from "@App/pkg/utils/url_matcher"; @@ -164,6 +165,14 @@ describe.concurrent("getCombinedMeta", () => { expect(result.grant).toEqual([]); expect(result.exclude).toEqual(["https://admin.com/*"]); }); + + it.concurrent("不应把 onlyRunOnUrl provenance 合并为有效 metadata", () => { + const result = getCombinedMeta(baseMetadata, { + [SELF_METADATA_ONLY_RUN_ON_URL]: ["*://current.example/*"], + }); + + expect(result).toEqual(baseMetadata); + }); }); describe.concurrent("selfMetadataUpdate", () => { diff --git a/src/app/service/service_worker/utils.ts b/src/app/service/service_worker/utils.ts index 2f141befb..4dbc3ae28 100644 --- a/src/app/service/service_worker/utils.ts +++ b/src/app/service/service_worker/utils.ts @@ -1,5 +1,6 @@ export const BrowserNoSupport = new Error("browserNoSupport"); import type { SCMetadata, Script, ScriptLoadInfo, ScriptRunResource } from "@App/app/repo/scripts"; +import { SELF_METADATA_ONLY_RUN_ON_URL } from "@App/app/repo/metadata"; import { getMetadataStr, getUserConfigStr } from "@App/pkg/utils/utils"; import type { ScriptMatchInfo } from "./types"; import { @@ -142,6 +143,9 @@ export function getCombinedMeta(metaBase: SCMetadata, metaCustom: SCMetadata): S return metaRet; } for (const key of Object.keys(metaCustom)) { + if (key === SELF_METADATA_ONLY_RUN_ON_URL) { + continue; + } const v = metaCustom[key]; metaRet[key] = v ? [...v] : undefined; } diff --git a/src/locales/de-DE/common.json b/src/locales/de-DE/common.json index 3c7a448fe..730106c29 100644 --- a/src/locales/de-DE/common.json +++ b/src/locales/de-DE/common.json @@ -61,6 +61,7 @@ "exclude_on": "Wiederherstellen auf $0 zur Ausführung", "exclude_off": "Ausschließen auf $0 zur Ausführung", "only_on_site": "Nur auf $0 ausführen", + "confirm_only_run_on_site": "Die bestehenden Website-Abgleichsregeln des Skripts werden gelöscht. Nur auf dieser Website ausführen?", "allow_on_site": "Ausführung auf $0 zulassen", "confirm_error": "Bestätigung fehlgeschlagen", "import": "Importieren", diff --git a/src/locales/en-US/common.json b/src/locales/en-US/common.json index b3599e882..3a27ae70f 100644 --- a/src/locales/en-US/common.json +++ b/src/locales/en-US/common.json @@ -61,6 +61,7 @@ "exclude_on": "Reinstate $0's execution", "exclude_off": "Exclude $0's execution", "only_on_site": "Run only on $0", + "confirm_only_run_on_site": "This will clear the script's existing site match rules. Run only on this site?", "allow_on_site": "Allow execution on $0", "confirm_error": "Confirmation Failed", "import": "Import", diff --git a/src/locales/ja-JP/common.json b/src/locales/ja-JP/common.json index 72d09484d..782c6860d 100644 --- a/src/locales/ja-JP/common.json +++ b/src/locales/ja-JP/common.json @@ -61,6 +61,7 @@ "exclude_on": "$0の実行を復元", "exclude_off": "$0の実行を除外", "only_on_site": "$0 でのみ実行", + "confirm_only_run_on_site": "スクリプトの既存のサイト対象ルールが消去されます。このサイトでのみ実行しますか?", "allow_on_site": "$0 での実行を許可", "confirm_error": "確認に失敗しました", "import": "インポート", diff --git a/src/locales/ko-KR/common.json b/src/locales/ko-KR/common.json index 90254478a..6bea732aa 100644 --- a/src/locales/ko-KR/common.json +++ b/src/locales/ko-KR/common.json @@ -61,6 +61,7 @@ "exclude_on": "$0에서 다시 실행", "exclude_off": "$0에서 실행 제외", "only_on_site": "$0에서만 실행", + "confirm_only_run_on_site": "스크립트의 기존 사이트 일치 규칙이 모두 삭제됩니다. 이 사이트에서만 실행하시겠습니까?", "allow_on_site": "$0에서 실행 허용", "confirm_error": "확인 실패", "import": "가져오기", diff --git a/src/locales/pt-BR/common.json b/src/locales/pt-BR/common.json index e8714ad99..6c7605b1a 100644 --- a/src/locales/pt-BR/common.json +++ b/src/locales/pt-BR/common.json @@ -61,6 +61,7 @@ "exclude_on": "Restaurar a execução de $0", "exclude_off": "Impedir a execução em $0", "only_on_site": "Executar somente em $0", + "confirm_only_run_on_site": "Isso limpará as regras de correspondência de sites existentes do script. Executar somente neste site?", "allow_on_site": "Permitir execução em $0", "confirm_error": "Falha na confirmação", "import": "Importar", diff --git a/src/locales/ru-RU/common.json b/src/locales/ru-RU/common.json index 06c75092d..80b612b4c 100644 --- a/src/locales/ru-RU/common.json +++ b/src/locales/ru-RU/common.json @@ -61,6 +61,7 @@ "exclude_on": "Восстановить в $0 выполнении", "exclude_off": "Исключить в $0 выполнении", "only_on_site": "Выполнять только на $0", + "confirm_only_run_on_site": "Существующие правила соответствия сайтов для скрипта будут очищены. Выполнять только на этом сайте?", "allow_on_site": "Разрешить выполнение на $0", "confirm_error": "Ошибка подтверждения", "import": "Импорт", diff --git a/src/locales/tr-TR/common.json b/src/locales/tr-TR/common.json index e6d804e88..93b11c7f0 100644 --- a/src/locales/tr-TR/common.json +++ b/src/locales/tr-TR/common.json @@ -61,6 +61,7 @@ "exclude_on": "$0 yürütmesini yeniden etkinleştir", "exclude_off": "$0 yürütmesini dışla", "only_on_site": "Yalnızca $0 üzerinde çalıştır", + "confirm_only_run_on_site": "Betiğin mevcut site eşleştirme kuralları temizlenecek. Yalnızca bu sitede çalıştırılsın mı?", "allow_on_site": "$0 üzerinde çalışmasına izin ver", "confirm_error": "Onay başarısız", "import": "İçe Aktar", diff --git a/src/locales/vi-VN/common.json b/src/locales/vi-VN/common.json index b214d9e76..82fecd08a 100644 --- a/src/locales/vi-VN/common.json +++ b/src/locales/vi-VN/common.json @@ -61,6 +61,7 @@ "exclude_on": "Cho phép chạy lại $0", "exclude_off": "Loại trừ chạy $0", "only_on_site": "Chỉ chạy trên $0", + "confirm_only_run_on_site": "Thao tác này sẽ xóa các quy tắc khớp trang web hiện có của script. Chỉ chạy trên trang web này?", "allow_on_site": "Cho phép chạy trên $0", "confirm_error": "Xác nhận thất bại", "import": "Nhập", diff --git a/src/locales/zh-CN/common.json b/src/locales/zh-CN/common.json index 1394b0048..7764553c0 100644 --- a/src/locales/zh-CN/common.json +++ b/src/locales/zh-CN/common.json @@ -61,6 +61,7 @@ "exclude_on": "恢复在 $0 上执行", "exclude_off": "排除在 $0 上执行", "only_on_site": "仅在 $0 执行", + "confirm_only_run_on_site": "将清空脚本原有的网站匹配规则,确认仅在此网站运行?", "allow_on_site": "允许在 $0 执行", "confirm_error": "确认失败", "import": "导入", diff --git a/src/locales/zh-TW/common.json b/src/locales/zh-TW/common.json index e16ac471a..4ca26967d 100644 --- a/src/locales/zh-TW/common.json +++ b/src/locales/zh-TW/common.json @@ -61,6 +61,7 @@ "exclude_on": "恢復 $0 的執行", "exclude_off": "排除 $0 的執行", "only_on_site": "僅在 $0 執行", + "confirm_only_run_on_site": "這將清除腳本原有的網站匹配規則,確定只在此網站執行嗎?", "allow_on_site": "允許在 $0 執行", "confirm_error": "確認失敗", "import": "匯入", diff --git a/src/manifest.json b/src/manifest.json index 41ebd002a..547a241cf 100644 --- a/src/manifest.json +++ b/src/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "__MSG_scriptcat__", - "version": "1.5.0.1100", + "version": "1.5.0.1300", "author": "CodFrm", "description": "__MSG_scriptcat_description__", "options_ui": { diff --git a/src/pages/options/routes/ScriptEditor/tabs/SettingsPane.test.tsx b/src/pages/options/routes/ScriptEditor/tabs/SettingsPane.test.tsx index 3b98ef8b0..3ca32afc7 100644 --- a/src/pages/options/routes/ScriptEditor/tabs/SettingsPane.test.tsx +++ b/src/pages/options/routes/ScriptEditor/tabs/SettingsPane.test.tsx @@ -324,6 +324,27 @@ describe("SettingsPane 网站匹配/排除", () => { expect(screen.queryByText("*://exclude.com/*")).toBeNull(); }); + it("清空匹配规则后重置按钮应可用并可恢复脚本自带规则", async () => { + // 脚本自带 @match 但无用户覆盖;删除最后一项后是显式空覆盖,重置应可撤销它并恢复自带规则 + fetchScript.mockResolvedValue({ + ...sampleScript(), + selfMetadata: { exclude: ["*://exclude.com/*"] }, + }); + render(); + await screen.findByText("*://script.com/*"); + fireEvent.click(screen.getByLabelText(`${t("delete")} *://script.com/*`)); + fireEvent.click(screen.getByText(t("confirm"), { selector: "button" })); + expect(screen.queryByText("*://script.com/*")).toBeNull(); + // 存在可撤销的空覆盖,重置按钮不应被禁用 + const resetBtn = screen.getAllByText(t("reset"), { selector: "button" })[0]; + expect(resetBtn).not.toBeDisabled(); + // 点击重置应撤销空覆盖、恢复脚本自带规则 + fireEvent.click(resetBtn); + fireEvent.click(screen.getByText(t("confirm"), { selector: "button" })); + expect(resetMatch).toHaveBeenCalledWith("u1", undefined); + expect(screen.getByText("*://script.com/*")).toBeInTheDocument(); + }); + it("删除最后一项匹配应显式清空覆盖而非复活脚本自带规则", async () => { fetchScript.mockResolvedValue({ ...sampleScript(), diff --git a/src/pages/options/routes/ScriptEditor/tabs/SettingsPane.tsx b/src/pages/options/routes/ScriptEditor/tabs/SettingsPane.tsx index ef3bbe9eb..7e1195b1b 100644 --- a/src/pages/options/routes/ScriptEditor/tabs/SettingsPane.tsx +++ b/src/pages/options/routes/ScriptEditor/tabs/SettingsPane.tsx @@ -380,11 +380,13 @@ function SettingsPaneContent({ uuid, data }: SettingsPaneProps & { data: Setting cancelText={t("editor:cancel")} onConfirm={() => setMatchList(kind, undefined)} > + {/* 重置即撤销用户覆盖(回落脚本自带 metadata);无覆盖时禁用,而非按列表为空判断—— + 用户删空匹配/排除后是显式空覆盖,仍应能重置恢复自带规则 */}