Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/app/repo/resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ export class ResourceDAO extends Repo<Resource> {
}

// CompiledResource结构或 matches 计算规则变更时,建议修改 CompiledResourceNamespace 以删除旧Cache
export const CompiledResourceNamespace = "a8d3d2a3-db3a-4e87-ab6f-9817fe6bd942";
export const CompiledResourceNamespace = "39a5eaa9-1cd2-446b-82b8-dd7bd3719f52";

export class CompiledResourceDAO extends Repo<CompiledResource> {
constructor() {
Expand Down
2 changes: 1 addition & 1 deletion src/app/repo/scripts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ export interface ScriptRunResource extends Script {
resource: ScriptResource; // 资源列表,包含脚本需要的资源
resourceByType?: ScriptResourceByType;
metadata: SCMetadata; // 经自定义覆盖的 Metadata
originalMetadata: SCMetadata; // 原本的 Metadata (目前只需要 match, include, exclude)
originalMetadata: SCMetadata; // 原本的 Metadata (只需要 match, include, exclude, exclude-match)
}

/**
Expand Down
11 changes: 11 additions & 0 deletions src/app/service/service_worker/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -847,6 +847,17 @@ describe("getPageLoadScriptCacheKey 页面加载缓存键生成", () => {
expect(k1).not.toBe(k2);
});

it("@exclude-match 规则变化 → 缓存键不同", () => {
const s1 = _createMockScript({ metadata: { match: ["https://a.com/*"], "exclude-match": ["https://a.com/x/*"] } });
const s2 = _createMockScript({
...s1,
metadata: { match: ["https://a.com/*"], "exclude-match": ["https://a.com/y/*"] },
});
const k1 = (runtime as any).getPageLoadScriptCacheKey(buildScriptRunResourceBasic(s1));
const k2 = (runtime as any).getPageLoadScriptCacheKey(buildScriptRunResourceBasic(s2));
expect(k1).not.toBe(k2);
});

it("status 不同 → 缓存键不同(禁用脚本 key 与启用脚本 key 区分)", () => {
const enabled = _createMockScript({ updatetime: 50, status: SCRIPT_STATUS_ENABLE });
const disabled = { ...enabled, status: SCRIPT_STATUS_DISABLE };
Expand Down
4 changes: 2 additions & 2 deletions src/app/service/service_worker/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1354,9 +1354,9 @@ export class RuntimeService {
private getPageLoadScriptCacheKey(scriptRes: ScriptRunResource) {
const { status, type, updatetime, metadata } = scriptRes;
// 代码/原始 metadata/userConfig/资源变化时,主要靠事件处理器(enable/install/delete)失效缓存。
// 此缓存键只是低成本的兜底:用于 selfMetadata 修改 match/include/exclude 这类「合并后 metadata 变了
// 此缓存键只是低成本的兜底:用于 selfMetadata 修改 match/include/exclude/exclude-match 这类「合并后 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["exclude-match"]])}`;
}

private getCodeCacheKey(script: Script) {
Expand Down
9 changes: 9 additions & 0 deletions src/app/service/service_worker/script.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1156,6 +1156,15 @@ describe("ScriptService selfMetadata 用户覆盖", () => {
});
});

it("作者 @exclude-match 已排除当前网址时不应再写入排除", async () => {
const script = createMockScript({ metadata: { match: ["*://*/*"], "exclude-match": ["*://current.example/*"] } });
vi.mocked(mockScriptDAO.get).mockResolvedValue(script);

await scriptService.excludeFromMatch({ uuid: script.uuid, host, url });

expect(mockScriptDAO.update).not.toHaveBeenCalled();
});

it("当前站点本就不在匹配范围内时不应写入任何覆盖", async () => {
const script = createMockScript({ metadata: { match: ["*://other.example/*"] } });
vi.mocked(mockScriptDAO.get).mockResolvedValue(script);
Expand Down
2 changes: 2 additions & 0 deletions src/app/service/service_worker/script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -971,6 +971,7 @@ export class ScriptService {
const match = script.selfMetadata?.match ?? script.metadata?.match ?? [];
const include = script.selfMetadata?.include ?? script.metadata?.include ?? [];
const exclude = script.selfMetadata?.exclude ?? script.metadata?.exclude ?? [];
const excludeMatch = script.selfMetadata?.["exclude-match"] ?? script.metadata?.["exclude-match"] ?? [];
// 通配网域(`*://*/*`、`*://*.example.com/*`)不只服务于当前站点,移除会连带关掉其他站点
const keptMatch = match.filter((pattern) => getMatchPatternHost(pattern) !== host);
const removedFromMatch = keptMatch.length !== match.length;
Expand All @@ -982,6 +983,7 @@ export class ScriptService {
...keptMatch.map((e) => `@match ${e}`),
...include.map((e) => `@include ${e}`),
...exclude.map((e) => `@exclude ${e}`),
...excludeMatch.map((e) => `@exclude-match ${e}`),
]);
if (isUrlIncluded(url, rules)) {
// 用户覆盖整体替换作者规则,因此把作者 @exclude 一并并入用户覆盖,避免丢作者规则
Expand Down
74 changes: 74 additions & 0 deletions src/app/service/service_worker/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,15 @@ import {
shouldAutoOpenChangelog,
scriptURLPatternResults,
openNotificationUrl,
buildScriptRunResourceBasic,
parseScriptLoadInfo,
} 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, RuleTypeBit } from "@App/pkg/utils/url_matcher";
import { isUrlExcluded, isUrlIncluded } from "@App/pkg/utils/match";

describe.concurrent("parseUrlSRI", () => {
it.concurrent("should parse URL SRI", () => {
Expand Down Expand Up @@ -500,3 +503,74 @@ describe.concurrent("scriptURLPatternResults", () => {
expect(result!.originalUrlPatterns.map((rule) => rule.patternString)).toEqual(["*://example.com/*"]);
});
});

describe.concurrent("@exclude-match 进入生效规则与注册", () => {
const createScript = (metadata: SCMetadata, selfMetadata?: SCMetadata): Script => ({
uuid: "exclude-match-uuid",
name: "exclude-match",
namespace: "test",
type: SCRIPT_TYPE_NORMAL,
status: SCRIPT_STATUS_ENABLE,
sort: 0,
runStatus: SCRIPT_RUN_STATUS_COMPLETE,
createtime: 0,
checktime: 0,
metadata,
selfMetadata,
});
const toMatchInfo = (script: Script) => {
const scriptRes = buildScriptRunResourceBasic(script);
const patterns = scriptURLPatternResults(scriptRes)!;
return { scriptRes, patterns, matchInfo: { ...scriptRes, ...patterns } as ScriptMatchInfo };
};

it.concurrent("作者的 @exclude-match 进入 excludeMatches,不产生 excludeGlobs", () => {
const { matchInfo } = toMatchInfo(
createScript({
match: ["*://example.com/*"],
"exclude-match": ["*://example.com/admin/*", "https://example.com/private/*"],
})
);

const { registerScript } = getUserScriptRegister(matchInfo);

expect(registerScript.matches).toEqual(["*://example.com/*"]);
expect(registerScript.excludeMatches).toEqual(["*://example.com/admin/*", "https://example.com/private/*"]);
expect(registerScript.excludeGlobs).toEqual([]);
});

it.concurrent("用户覆盖匹配后原始规则仍带 @exclude-match,Popup 判断不会把被排除的站点算作原本匹配", () => {
const { patterns } = toMatchInfo(
createScript(
{ match: ["*://example.com/*"], "exclude-match": ["*://example.com/admin/*"] },
{ match: ["*://other.example/*"] }
)
);

expect(isUrlIncluded("https://example.com/admin/x", patterns.originalUrlPatterns)).toBe(false);
expect(isUrlIncluded("https://example.com/home", patterns.originalUrlPatterns)).toBe(true);
});

it.concurrent("用户以空列表覆盖 @exclude-match 时生效规则不再排除,原始规则仍排除", () => {
const { patterns } = toMatchInfo(
createScript(
{ match: ["*://example.com/*"], "exclude-match": ["*://example.com/admin/*"] },
{ "exclude-match": [] }
)
);

expect(isUrlIncluded("https://example.com/admin/x", patterns.scriptUrlPatterns)).toBe(true);
expect(isUrlIncluded("https://example.com/admin/x", patterns.originalUrlPatterns)).toBe(false);
});

it.concurrent("early-start 含正则时注入代码携带的规则同样排除 @exclude-match", () => {
const { scriptRes, patterns } = toMatchInfo(
createScript({ include: ["/example\\.com/"], "exclude-match": ["*://example.com/admin/*"] })
);

const loadInfo = parseScriptLoadInfo(scriptRes, patterns.scriptUrlPatterns);

expect(isUrlExcluded("https://example.com/admin/x", loadInfo.scriptUrlPatterns!)).toBe(true);
expect(isUrlExcluded("https://example.com/home", loadInfo.scriptUrlPatterns!)).toBe(false);
});
});
9 changes: 6 additions & 3 deletions src/app/service/service_worker/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,8 +249,8 @@ export function getUserScriptRegister(scriptMatchInfo: ScriptMatchInfo) {
export function buildScriptRunResourceBasic(script: Script): ScriptRunResource {
const ret: ScriptRunResource = { ...script } as ScriptRunResource;
// 自定义配置
const { match, include, exclude } = ret.metadata;
ret.originalMetadata = { match, include, exclude }; // 目前只需要 match, include, exclude
const { match, include, exclude, "exclude-match": excludeMatch } = ret.metadata;
ret.originalMetadata = { match, include, exclude, "exclude-match": excludeMatch }; // 只需要 URL 匹配规则
if (ret.selfMetadata) {
ret.metadata = getCombinedMeta(ret.metadata, ret.selfMetadata);
}
Expand All @@ -274,6 +274,7 @@ export function scriptURLPatternResults(scriptRes: {
const metaMatch = metadata.match;
const metaInclude = metadata.include;
const metaExclude = metadata.exclude;
const metaExcludeMatch = metadata["exclude-match"];
// 生效规则为空时仍要解析原始规则:用户把当前站点从匹配中移除后可能一条不剩,
// 此时脚本不匹配任何站点,但 Popup 要靠原始规则继续列出它(未生效),用户才有恢复入口。
// 只有连原始规则都没有的脚本才真的无从匹配。
Expand All @@ -289,18 +290,20 @@ export function scriptURLPatternResults(scriptRes: {
...(metaMatch || []).map((e) => `@match ${e}`),
...(metaInclude || []).map((e) => `@include ${e}`),
...(metaExclude || []).map((e) => `@exclude ${e}`),
...(metaExcludeMatch || []).map((e) => `@exclude-match ${e}`),
]);

// 如果使用了自定义排除,无法在脚本原有的网域看到匹配情况
// 所有统一把原本的pattern都解析一下

const selfMetadata = scriptRes.selfMetadata;
const originalUrlPatterns: URLRuleEntry[] | null =
selfMetadata?.match || selfMetadata?.include || selfMetadata?.exclude
selfMetadata?.match || selfMetadata?.include || selfMetadata?.exclude || selfMetadata?.["exclude-match"]
? extractUrlPatterns([
...(originalMetadata.match || []).map((e) => `@match ${e}`),
...(originalMetadata.include || []).map((e) => `@include ${e}`),
...(originalMetadata.exclude || []).map((e) => `@exclude ${e}`),
...(originalMetadata["exclude-match"] || []).map((e) => `@exclude-match ${e}`),
])
: scriptUrlPatterns;

Expand Down
14 changes: 12 additions & 2 deletions src/pages/install/compat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,9 @@ describe("安装页兼容性标记", () => {
});

it("标记按作者的原始写法呈现", () => {
const { code, metadata } = build(`// @name X\n// @Exclude-Match *://b.com/*\n// @storageName s\n`);
const { code, metadata } = build(`// @name X\n// @SandBox raw\n// @storageName s\n`);
const marks = deriveCompatMarks(metadata, code);
expect(marks.tags).toEqual([{ tag: "Exclude-Match", line: 3 }]);
expect(marks.tags).toEqual([{ tag: "SandBox", line: 3 }]);
expect(marks.scriptcatOnlyTags).toEqual([{ tag: "storageName", line: 4 }]);
});

Expand Down Expand Up @@ -97,6 +97,16 @@ describe("安装页兼容性标记", () => {
expect(marks.tags).toEqual([]);
});

it("@exclude-match 受支持不标记;解析不出规则的取值连同取值标出并定位,不混进运行网站行", () => {
const { code, metadata } = build(
`// @name X\n// @match *://a.com/*\n// @exclude-match *://a.com/admin/*\n// @exclude-match *admin*\n`
);
const marks = deriveCompatMarks(metadata, code);
expect(marks.tags).toEqual([{ tag: "exclude-match", value: "*admin*", line: 5 }]);
expect(marks.matches).toEqual(new Map());
expect(marks.scriptcatOnlyTags).toEqual([]);
});

it("脚本猫独有的指令写了也不生效时只按不生效标一次", () => {
const { code, metadata } = build(`// @name X\n// @early-start\n`);
const marks = deriveCompatMarks(metadata, code);
Expand Down
6 changes: 3 additions & 3 deletions src/pages/install/useInstallData.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,10 +118,10 @@ describe("assembleInstallView 组装安装视图", () => {
});

it("组装时派生不生效标记,行号取自待安装代码", () => {
const metadata = { name: ["示例脚本"], version: ["2.3.1"], "exclude-match": ["*://a.com/*"], grant: ["GM_audio"] };
const metadata = { name: ["示例脚本"], version: ["2.3.1"], sandbox: ["JavaScript"], grant: ["GM_audio"] };
const code = `// ==UserScript==
// @name 示例脚本
// @exclude-match *://a.com/*
// @sandbox JavaScript
// @grant GM_audio
// ==/UserScript==`;
const view = assembleInstallView({
Expand All @@ -132,7 +132,7 @@ describe("assembleInstallView 组装安装视图", () => {
oldVersion: null,
});
expect(view.compat.grants).toEqual(new Map([["GM_audio", 4]]));
expect(view.compat.tags).toEqual([{ tag: "exclude-match", line: 3 }]);
expect(view.compat.tags).toEqual([{ tag: "sandbox", line: 3 }]);
expect(view.compat.scriptcatOnlyTags).toEqual([]);
});

Expand Down
14 changes: 11 additions & 3 deletions src/pkg/backup/self_metadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,18 @@ describe("vmCustomToOverride", () => {
expect(ov.merge_excludes).toBe(true);
expect(ov.run_at).toBe("document-start");
});
it("excludeMatch 折入 exclude", () => {
it("excludeMatch/origExcludeMatch 与 exclude/origExclude 各自独立归一", () => {
const ov = vmCustomToOverride({ exclude: ["a"], excludeMatch: ["b"], origExclude: false, origExcludeMatch: true });
expect(ov.use_excludes).toEqual(["a", "b"]);
expect(ov.merge_excludes).toBe(false); // origExclude=false → 不再合并
expect(ov.use_excludes).toEqual(["a"]);
expect(ov.merge_excludes).toBe(false);
expect(ov.use_exclude_matches).toEqual(["b"]);
expect(ov.merge_exclude_matches).toBe(true);
});
it("VM 关掉脚本自带 @exclude-match 时导入后以用户列表替换,不复活作者规则", () => {
const m: SCMetadata = { exclude: ["https://a.com/x"], "exclude-match": ["*://a.com/admin/*"] };
const self = overrideToSelfMetadata(vmCustomToOverride({ excludeMatch: [], origExcludeMatch: false }), m);
expect(self["exclude-match"]).toEqual([]);
expect(self.exclude).toBeUndefined();
});
it("把 VM custom 的 tag/origTag 归一到 override", () => {
const ov = vmCustomToOverride({ tag: ["te", "tag"], origTag: true });
Expand Down
10 changes: 7 additions & 3 deletions src/pkg/backup/self_metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ export type OverrideInput = {
use_matches?: string[];
use_includes?: string[];
use_excludes?: string[];
use_exclude_matches?: string[];
use_tags?: string[];
merge_matches?: boolean;
merge_includes?: boolean;
merge_excludes?: boolean;
merge_exclude_matches?: boolean;
merge_tags?: boolean;
run_at?: string | null;
noframes?: boolean | null;
Expand All @@ -18,6 +20,7 @@ const LIST_MAP = [
["match", "use_matches", "merge_matches"],
["include", "use_includes", "merge_includes"],
["exclude", "use_excludes", "merge_excludes"],
["exclude-match", "use_exclude_matches", "merge_exclude_matches"],
["tag", "use_tags", "merge_tags"],
] as const;

Expand Down Expand Up @@ -65,15 +68,16 @@ type VMCustom = {
/** Violentmonkey custom → 归一化 override */
export function vmCustomToOverride(custom: VMCustom | undefined): OverrideInput {
const c = custom || {};
const hasExclude = c.exclude !== undefined || c.excludeMatch !== undefined;
return {
use_matches: c.match,
use_includes: c.include,
use_excludes: hasExclude ? [...(c.exclude || []), ...(c.excludeMatch || [])] : undefined,
use_excludes: c.exclude,
use_exclude_matches: c.excludeMatch,
use_tags: c.tag,
merge_matches: c.origMatch !== false,
merge_includes: c.origInclude !== false,
merge_excludes: c.origExclude !== false && c.origExcludeMatch !== false,
merge_excludes: c.origExclude !== false,
merge_exclude_matches: c.origExcludeMatch !== false,
merge_tags: c.origTag !== false,
run_at: c.runAt,
noframes: c.noframes == null ? null : !!c.noframes,
Expand Down
26 changes: 23 additions & 3 deletions src/pkg/utils/script_compat.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,17 @@ import {

describe("元数据指令支持判定", () => {
it("运行时会消费的指令视为支持", () => {
for (const tag of ["match", "include", "exclude", "grant", "run-at", "run-in", "noframes", "connect"]) {
for (const tag of [
"match",
"include",
"exclude",
"exclude-match",
"grant",
"run-at",
"run-in",
"noframes",
"connect",
]) {
expect(isSupportedMetadataTag(tag), tag).toBe(true);
}
});
Expand All @@ -32,7 +42,7 @@ describe("元数据指令支持判定", () => {
});

it("别家管理器会执行、脚本猫未实现的指令视为不支持", () => {
for (const tag of ["exclude-match", "top-level-await", "sandbox", "webRequest", "allFrames", "user-agent"]) {
for (const tag of ["top-level-await", "sandbox", "webRequest", "allFrames", "user-agent"]) {
expect(isSupportedMetadataTag(tag), tag).toBe(false);
}
});
Expand All @@ -46,7 +56,8 @@ describe("元数据指令支持判定", () => {
it("指令名大小写不敏感", () => {
expect(isSupportedMetadataTag("MATCH")).toBe(true);
expect(isSupportedMetadataTag("Run-At")).toBe(true);
expect(isSupportedMetadataTag("EXCLUDE-MATCH")).toBe(false);
expect(isSupportedMetadataTag("EXCLUDE-MATCH")).toBe(true);
expect(isSupportedMetadataTag("TOP-LEVEL-AWAIT")).toBe(false);
});

it("name/description 的语言后缀归到同一指令", () => {
Expand Down Expand Up @@ -149,6 +160,15 @@ describe("元数据取值支持判定", () => {
]);
});

it("解析不出 match pattern 的 @exclude-match 报为不生效,不会退化成 @exclude 的 glob/正则", () => {
expect(
ineffectiveMetadataValues({ "exclude-match": ["*://a.com/admin/*", "*admin*", "/admin/", "www.a.com/*"] })
).toEqual([
{ tag: "exclude-match", index: 1, value: "*admin*" },
{ tag: "exclude-match", index: 2, value: "/admin/" },
]);
});

it("不限取值的指令不做取值判定", () => {
expect(ineffectiveMetadataValues({ include: ["anything"], namespace: ["x"], noframes: ["whatever"] })).toEqual([]);
});
Expand Down
Loading
Loading