From c93926f03e227cbe9a5a8060b5582644760c40ca Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 22 Aug 2026 08:49:16 +0900 Subject: [PATCH 01/10] =?UTF-8?q?=E2=9C=85=20add=20verification=20tests=20?= =?UTF-8?q?for=20script=20execution=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/content/create_context.test.ts | 15 ++ .../service/content/script_executor.test.ts | 140 ++++++++++++++++++ src/app/service/content/utils.test.ts | 81 +++++++++- 3 files changed, 235 insertions(+), 1 deletion(-) create mode 100644 src/app/service/content/script_executor.test.ts diff --git a/src/app/service/content/create_context.test.ts b/src/app/service/content/create_context.test.ts index 54961d107..092bbfefb 100644 --- a/src/app/service/content/create_context.test.ts +++ b/src/app/service/content/create_context.test.ts @@ -237,4 +237,19 @@ describe.concurrent("createProxyContext", () => { const sandbox = createProxyContext(createTestContext([])); expect(Object.hasOwn(sandbox, "addEventListener")).toBe(true); }); + + it.concurrent("多个沙盒之间不共享自有属性,且各自保留原生事件方法", () => { + const first = createProxyContext(createTestContext([])); + const second = createProxyContext(createTestContext([])); + + first.sandboxOnly = "first"; + second.sandboxOnly = "second"; + + expect(first.sandboxOnly).toBe("first"); + expect(second.sandboxOnly).toBe("second"); + expect(Object.hasOwn(first, "addEventListener")).toBe(true); + expect(Object.hasOwn(second, "addEventListener")).toBe(true); + expect(first.window).toBe(first); + expect(second.window).toBe(second); + }); }); diff --git a/src/app/service/content/script_executor.test.ts b/src/app/service/content/script_executor.test.ts new file mode 100644 index 000000000..657ec4188 --- /dev/null +++ b/src/app/service/content/script_executor.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; +import type { Message } from "@Packages/message/types"; +import type { ScriptLoadInfo } from "../service_worker/types"; +import { initEnvInfo, ScriptExecutor } from "./script_executor"; + +const styleUrl = "https://example.com/style.css"; + +function makeScript(overrides: Partial = {}): ScriptLoadInfo { + return { + uuid: "executor-test-uuid", + name: "Executor test", + namespace: "executor.test", + type: 1, + status: 1, + sort: 0, + runStatus: "complete", + createtime: Date.now(), + checktime: Date.now(), + code: "", + value: {}, + flag: "executor-test-flag", + resource: {}, + metadata: {}, + originalMetadata: {}, + metadataStr: "", + userConfigStr: "", + ...overrides, + }; +} + +function makeValueUpdate(overrides: Partial[0]> = {}) { + return { + id: "value-update-id", + entries: [], + uuid: "missing-uuid", + storageName: "missing-storage", + sender: { runFlag: "remote-run" }, + valueUpdated: true, + ...overrides, + }; +} + +describe("ScriptExecutor", () => { + describe("resource execution", () => { + let adoptedSheets: CSSStyleSheet[]; + + beforeEach(() => { + class MockCSSStyleSheet { + cssText = ""; + + replaceSync(css: string) { + this.cssText = css; + } + } + + vi.stubGlobal("CSSStyleSheet", MockCSSStyleSheet); + adoptedSheets = []; + vi.spyOn(document, "adoptedStyleSheets", "get").mockImplementation(() => [...adoptedSheets]); + vi.spyOn(document, "adoptedStyleSheets", "set").mockImplementation((value: CSSStyleSheet[]) => { + adoptedSheets = [...value]; + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("injects every resolved @require-css resource in declaration order", () => { + const script = makeScript({ + metadata: { "require-css": [styleUrl] }, + resource: { + [styleUrl]: { + url: styleUrl, + content: "body { color: red; }", + base64: "", + hash: { md5: "test", sha1: "test", sha256: "test", sha384: "test", sha512: "test" }, + type: "require-css", + link: {}, + contentType: "text/css", + createtime: Date.now(), + }, + }, + }); + + const executor = new ScriptExecutor({} as Message, {} as Message); + executor.execScriptEntry({ + scriptLoadInfo: script, + scriptFlag: script.flag, + envInfo: initEnvInfo, + scriptFunc: () => undefined, + }); + + expect(adoptedSheets).toHaveLength(1); + expect((adoptedSheets[0] as CSSStyleSheet & { cssText: string }).cssText).toBe("body { color: red; }"); + }); + }); + + describe("value update routing", () => { + it("delivers UUID and shared-storage matches once, preserving registration order", () => { + const executor = new ScriptExecutor({} as Message, {} as Message); + const delivered: string[] = []; + const add = (uuid: string, storageName: string) => { + executor.execScriptMap.set(uuid, { + scriptRes: { uuid, metadata: storageName ? { storagename: [storageName] } : {} }, + valueUpdate: vi.fn(() => delivered.push(uuid)), + } as never); + }; + + add("first", "shared-storage"); + add("second", "shared-storage"); + add("third", "other-storage"); + + executor.valueUpdate(makeValueUpdate({ uuid: "first", storageName: "shared-storage" })); + + expect(delivered).toEqual(["first", "second"]); + expect(executor.execScriptMap.get("first")?.valueUpdate).toHaveBeenCalledTimes(1); + expect(executor.execScriptMap.get("second")?.valueUpdate).toHaveBeenCalledTimes(1); + expect(executor.execScriptMap.get("third")?.valueUpdate).not.toHaveBeenCalled(); + }); + + it("delivers only the UUID match when no shared storage overlaps", () => { + const executor = new ScriptExecutor({} as Message, {} as Message); + const first = vi.fn(); + const second = vi.fn(); + executor.execScriptMap.set("first", { + scriptRes: { uuid: "first", metadata: { storagename: ["first-storage"] } }, + valueUpdate: first, + } as never); + executor.execScriptMap.set("second", { + scriptRes: { uuid: "second", metadata: { storagename: ["second-storage"] } }, + valueUpdate: second, + } as never); + + executor.valueUpdate(makeValueUpdate({ uuid: "second", storageName: "unknown-storage" })); + + expect(first).not.toHaveBeenCalled(); + expect(second).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/app/service/content/utils.test.ts b/src/app/service/content/utils.test.ts index e4bf62ed9..de0532358 100644 --- a/src/app/service/content/utils.test.ts +++ b/src/app/service/content/utils.test.ts @@ -7,8 +7,9 @@ import { isScriptletUnwrap, addStyle, addStyleSheet, + trimScriptInfo, } from "./utils"; -import type { ScriptRunResource } from "@App/app/repo/scripts"; +import type { SCMetadata, ScriptLoadInfo, ScriptRunResource } from "@App/app/repo/scripts"; import type { ScriptFunc } from "./types"; import { RuleType, type URLRuleEntry } from "@App/pkg/utils/url_matcher"; @@ -219,6 +220,84 @@ describe("utils", () => { }); }); + describe("trimScriptInfo resource selection", () => { + const resource = (url: string, content: string) => ({ + url, + content, + base64: "", + hash: { md5: "test", sha1: "test", sha256: "test", sha384: "test", sha512: "test" }, + type: "resource" as const, + link: {}, + contentType: "text/plain", + createtime: Date.now(), + }); + + const createScript = (metadata: SCMetadata) => + ({ + uuid: "trim-test-uuid", + name: "Trim test", + namespace: "trim.test", + type: 1, + status: 1, + sort: 0, + runStatus: "complete", + createtime: Date.now(), + checktime: Date.now(), + code: "", + value: {}, + flag: "trim-test-flag", + resource: { + asset: resource("asset", "asset content"), + library: resource("library", "library content"), + style: resource("style", "body { color: red; }"), + }, + metadata, + originalMetadata: {}, + }) as unknown as ScriptLoadInfo; + + it.each([ + { + name: "drops @resource without a resource grant", + metadata: { resource: ["asset"] }, + expected: [], + }, + { + name: "keeps @resource for GM_getResourceText", + metadata: { grant: ["GM_getResourceText"], resource: ["asset"] }, + expected: ["asset"], + }, + { + name: "keeps @resource for GM_getResourceURL", + metadata: { grant: ["GM_getResourceURL"], resource: ["asset"] }, + expected: ["asset"], + }, + { + name: "keeps @require independently of resource grants", + metadata: { require: ["library"] }, + expected: ["library"], + }, + { + name: "keeps @require-css independently of resource grants", + metadata: { "require-css": ["style"] }, + expected: ["style"], + }, + { + name: "keeps all resources when each consumer is present", + metadata: { + grant: ["GM_getResourceText"], + resource: ["asset"], + require: ["library"], + "require-css": ["style"], + }, + expected: ["asset", "library", "style"], + }, + ])("$name", ({ metadata, expected }) => { + const trimmed = trimScriptInfo(createScript(metadata)); + + expect(Object.keys(trimmed.resource)).toEqual(expected); + }); + }); + describe("compileScript", () => { it.concurrent("应该返回一个函数", () => { const code = "return 'test result';"; From 023d3b182f36a236132e928dc8eb488a75873bca Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 22 Aug 2026 08:06:46 +0900 Subject: [PATCH 02/10] =?UTF-8?q?=E2=9C=85=20improve=20Vitest=20CI=20failu?= =?UTF-8?q?re=20output?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/test.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 376f26399..586cbafe9 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -97,7 +97,7 @@ jobs: run: > pnpm exec vitest run --coverage - --silent + --silent=passed-only --reporter=blob --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} @@ -158,6 +158,7 @@ jobs: pnpm exec vitest --merge-reports --coverage + --silent=passed-only --reporter=default --reporter.default.summary=false From 7ab7c4858b4b5d0a7bb884c5c148e5d56fdd81a8 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 22 Aug 2026 09:32:28 +0900 Subject: [PATCH 03/10] =?UTF-8?q?=E2=9C=85=20correct=20resource=20verifica?= =?UTF-8?q?tion=20fixtures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/utils.test.ts | 73 ++++++++++++++++++--------- 1 file changed, 49 insertions(+), 24 deletions(-) diff --git a/src/app/service/content/utils.test.ts b/src/app/service/content/utils.test.ts index de0532358..2f3853b0b 100644 --- a/src/app/service/content/utils.test.ts +++ b/src/app/service/content/utils.test.ts @@ -221,6 +221,11 @@ describe("utils", () => { }); describe("trimScriptInfo resource selection", () => { + const assetName = "asset"; + const assetDeclaration = `${assetName} https://example.com/asset.bin`; + const libraryUrl = "https://example.com/library.js"; + const styleUrl = "https://example.com/style.css"; + const resource = (url: string, content: string) => ({ url, content, @@ -232,7 +237,7 @@ describe("utils", () => { createtime: Date.now(), }); - const createScript = (metadata: SCMetadata) => + const createScript = (metadata: SCMetadata, resourceKeys: string[]) => ({ uuid: "trim-test-uuid", name: "Trim test", @@ -246,11 +251,15 @@ describe("utils", () => { code: "", value: {}, flag: "trim-test-flag", - resource: { - asset: resource("asset", "asset content"), - library: resource("library", "library content"), - style: resource("style", "body { color: red; }"), - }, + resource: Object.fromEntries( + resourceKeys.map((key) => [ + key, + resource( + key === assetName ? "https://example.com/asset.bin" : key, + key === libraryUrl ? "library content" : key === styleUrl ? "body { color: red; }" : "asset content" + ), + ]) + ), metadata, originalMetadata: {}, }) as unknown as ScriptLoadInfo; @@ -258,43 +267,59 @@ describe("utils", () => { it.each([ { name: "drops @resource without a resource grant", - metadata: { resource: ["asset"] }, + metadata: { resource: [assetDeclaration] }, + resourceKeys: [assetName], expected: [], }, { name: "keeps @resource for GM_getResourceText", - metadata: { grant: ["GM_getResourceText"], resource: ["asset"] }, - expected: ["asset"], + metadata: { grant: ["GM_getResourceText"], resource: [assetDeclaration] }, + resourceKeys: [assetName], + expected: [assetName], }, { name: "keeps @resource for GM_getResourceURL", - metadata: { grant: ["GM_getResourceURL"], resource: ["asset"] }, - expected: ["asset"], + metadata: { grant: ["GM_getResourceURL"], resource: [assetDeclaration] }, + resourceKeys: [assetName], + expected: [assetName], }, { - name: "keeps @require independently of resource grants", - metadata: { require: ["library"] }, - expected: ["library"], + name: "does not forward @require after Service Worker compilation", + metadata: { require: [libraryUrl] }, + resourceKeys: [libraryUrl], + expected: [], }, { name: "keeps @require-css independently of resource grants", - metadata: { "require-css": ["style"] }, - expected: ["style"], + metadata: { "require-css": [styleUrl] }, + resourceKeys: [styleUrl], + expected: [styleUrl], }, { - name: "keeps all resources when each consumer is present", + name: "keeps CSS and granted @resource but not compiled @require", metadata: { grant: ["GM_getResourceText"], - resource: ["asset"], - require: ["library"], - "require-css": ["style"], + resource: [assetDeclaration], + require: [libraryUrl], + "require-css": [styleUrl], + }, + resourceKeys: [assetName, libraryUrl, styleUrl], + expected: [assetName, styleUrl], + }, + { + name: "keeps CSS when @grant none disables resource APIs", + metadata: { + grant: ["none", "GM_getResourceText"], + resource: [assetDeclaration], + "require-css": [styleUrl], }, - expected: ["asset", "library", "style"], + resourceKeys: [assetName, styleUrl], + expected: [styleUrl], }, - ])("$name", ({ metadata, expected }) => { - const trimmed = trimScriptInfo(createScript(metadata)); + ])("$name", ({ metadata, resourceKeys, expected }) => { + const trimmed = trimScriptInfo(createScript(metadata, resourceKeys)); - expect(Object.keys(trimmed.resource)).toEqual(expected); + expect(Object.keys(trimmed.resource).sort()).toEqual(expected.sort()); }); }); From dd24ac170815338a8d2c7c780b35f635a7e3007a Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 22 Aug 2026 09:49:47 +0900 Subject: [PATCH 04/10] =?UTF-8?q?=F0=9F=90=9B=20filter=20page=20resource?= =?UTF-8?q?=20payloads=20by=20consumer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/utils.ts | 39 +++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/src/app/service/content/utils.ts b/src/app/service/content/utils.ts index bc7905a0b..b1305158e 100644 --- a/src/app/service/content/utils.ts +++ b/src/app/service/content/utils.ts @@ -192,9 +192,17 @@ export const trimScriptInfo = (script: ScriptLoadInfo): TScriptInfo => { // --- 处理 resource --- // 由于不需要 complie code, resource 只用在 GM_getResourceURL 和 GM_getResourceText const resource = {} as Record; + const resourceNames = new Set(script.metadata["require-css"] || []); + if (hasResourceGrant(script.metadata)) { + for (const name of getDeclaredResourceNames(script.metadata)) { + resourceNames.add(name); + } + } if (script.resource) { for (const [url, { base64, content, contentType }] of Object.entries(script.resource || {})) { - resource[url] = { base64, content, contentType }; + if (resourceNames.has(url)) { + resource[url] = { base64, content, contentType }; + } } } // --- 处理 resource --- @@ -285,6 +293,35 @@ export function isInjectIntoContent(metadata: SCMetadata): boolean { return metadata["inject-into"]?.[0] === "content"; } +const resourceGrantNames = new Set([ + "GM_getResourceText", + "GM_getResourceURL", + "GM.getResourceText", + "GM.getResourceUrl", +]); + +const getDeclaredResourceNames = (metadata: SCMetadata): Set => { + const names = new Set(); + for (const value of metadata.resource || []) { + const split = value.split(/\s+/); + if (split.length === 2 && split[0] && split[1].trim()) { + names.add(split[0]); + } + } + return names; +}; + +const hasResourceGrant = (metadata: SCMetadata): boolean => { + const grants = new Set(metadata.grant || []); + if (grants.has("none") && !isContextMenuScript(metadata)) { + return false; + } + if (isContextMenuScript(metadata)) { + grants.delete("none"); + } + return [...grants].some((grant) => resourceGrantNames.has(grant)); +}; + export const getScriptFlag = (uuid: string) => { // scriptFlag 对同一脚本永远一致。重新开启浏览器也不会变。 // 实作内容有待检讨 From 9efb062b782f7a4818f06c36192500c6b4a36358 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 22 Aug 2026 09:53:27 +0900 Subject: [PATCH 05/10] =?UTF-8?q?=E2=9C=85=20cover=20resource=20grant=20bo?= =?UTF-8?q?undary=20cases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/utils.test.ts | 63 ++++++++++++++++++++++----- 1 file changed, 51 insertions(+), 12 deletions(-) diff --git a/src/app/service/content/utils.test.ts b/src/app/service/content/utils.test.ts index 2f3853b0b..997573324 100644 --- a/src/app/service/content/utils.test.ts +++ b/src/app/service/content/utils.test.ts @@ -225,6 +225,7 @@ describe("utils", () => { const assetDeclaration = `${assetName} https://example.com/asset.bin`; const libraryUrl = "https://example.com/library.js"; const styleUrl = "https://example.com/style.css"; + const resourceGrants = ["GM_getResourceText", "GM_getResourceURL", "GM.getResourceText", "GM.getResourceUrl"]; const resource = (url: string, content: string) => ({ url, @@ -271,18 +272,6 @@ describe("utils", () => { resourceKeys: [assetName], expected: [], }, - { - name: "keeps @resource for GM_getResourceText", - metadata: { grant: ["GM_getResourceText"], resource: [assetDeclaration] }, - resourceKeys: [assetName], - expected: [assetName], - }, - { - name: "keeps @resource for GM_getResourceURL", - metadata: { grant: ["GM_getResourceURL"], resource: [assetDeclaration] }, - resourceKeys: [assetName], - expected: [assetName], - }, { name: "does not forward @require after Service Worker compilation", metadata: { require: [libraryUrl] }, @@ -321,6 +310,56 @@ describe("utils", () => { expect(Object.keys(trimmed.resource).sort()).toEqual(expected.sort()); }); + + it.each(resourceGrants)("keeps @resource for %s", (grant) => { + const trimmed = trimScriptInfo(createScript({ grant: [grant], resource: [assetDeclaration] }, [assetName])); + + expect(Object.keys(trimmed.resource)).toEqual([assetName]); + }); + + it("keeps a resource grant in context-menu scripts after removing none", () => { + const trimmed = trimScriptInfo( + createScript( + { + grant: ["none", "GM_getResourceText"], + resource: [assetDeclaration], + "run-at": ["context-menu"], + }, + [assetName] + ) + ); + + expect(Object.keys(trimmed.resource)).toEqual([assetName]); + }); + + it("does not expose malformed @resource declarations", () => { + const script = createScript( + { + grant: ["GM_getResourceText"], + resource: [assetName, `${assetName} https://example.com/asset.bin extra`], + }, + [assetName] + ); + + expect(Object.keys(trimScriptInfo(script).resource)).toEqual([]); + }); + + it("does not mutate the full resource map while trimming", () => { + const script = createScript( + { + grant: ["GM_getResourceText"], + resource: [assetDeclaration], + require: [libraryUrl], + "require-css": [styleUrl], + }, + [assetName, libraryUrl, styleUrl] + ); + const originalResourceKeys = Object.keys(script.resource); + + trimScriptInfo(script); + + expect(Object.keys(script.resource)).toEqual(originalResourceKeys); + }); }); describe("compileScript", () => { From 3a04d08b856cf46c9890f0c30a579173036ae184 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 22 Aug 2026 10:07:46 +0900 Subject: [PATCH 06/10] =?UTF-8?q?=F0=9F=94=92=20preserve=20resource=20cate?= =?UTF-8?q?gories=20across=20execution=20contexts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/repo/scripts.ts | 14 +++-- src/app/service/content/script_executor.ts | 2 +- src/app/service/content/utils.ts | 34 +++++++----- src/app/service/service_worker/resource.ts | 39 ++++++++------ src/app/service/service_worker/runtime.ts | 62 +++++++++++++++------- src/app/service/service_worker/script.ts | 11 ++-- src/pkg/utils/resource.ts | 10 ++++ 7 files changed, 118 insertions(+), 54 deletions(-) create mode 100644 src/pkg/utils/resource.ts diff --git a/src/app/repo/scripts.ts b/src/app/repo/scripts.ts index 6fcc2b86f..cf99f6de4 100644 --- a/src/app/repo/scripts.ts +++ b/src/app/repo/scripts.ts @@ -1,5 +1,5 @@ import { Repo } from "./repo"; -import type { Resource } from "./resource"; +import type { Resource, ResourceType } from "./resource"; import type { SCMetadata } from "./metadata"; import type { GMInfoEnv } from "../service/content/types"; import type { URLRuleEntry } from "@App/pkg/utils/url_matcher"; @@ -95,12 +95,19 @@ export type ScriptAndCode = Script & ScriptCode; export type ValueStore = { [key: string]: any }; +export type ScriptResource = { [key: string]: { base64?: string } & Omit }; + +export type ScriptResourceByType = Record; + +export type PageScriptResource = Record; + // 脚本运行时的资源,包含已经编译好的脚本与脚本需要的资源 export interface ScriptRunResource extends Script { code: string; // 原始代码 value: ValueStore; flag: string; - resource: { [key: string]: { base64?: string } & Omit }; // 资源列表,包含脚本需要的资源 + resource: ScriptResource; // 资源列表,包含脚本需要的资源 + resourceByType?: ScriptResourceByType; metadata: SCMetadata; // 经自定义覆盖的 Metadata originalMetadata: SCMetadata; // 原本的 Metadata (目前只需要 match, include, exclude) } @@ -127,7 +134,8 @@ export type TScriptInfo = Override< ScriptLoadInfo, { originalMetadata?: Partial>; - resource: Record; + resource: PageScriptResource; + requireCssResource?: PageScriptResource; code: "" | string; sort?: number; flag: string; diff --git a/src/app/service/content/script_executor.ts b/src/app/service/content/script_executor.ts index 75eb90bec..7bbcd9bb3 100644 --- a/src/app/service/content/script_executor.ts +++ b/src/app/service/content/script_executor.ts @@ -152,7 +152,7 @@ export class ScriptExecutor { }); this.execScriptMap.set(scriptLoadInfo.uuid, execScript); const metadata = scriptLoadInfo.metadata || {}; - const resource = scriptLoadInfo.resource; + const resource = scriptLoadInfo.requireCssResource ?? scriptLoadInfo.resource; // 注入css if (metadata["require-css"] && resource) { for (const val of metadata["require-css"]) { diff --git a/src/app/service/content/utils.ts b/src/app/service/content/utils.ts index b1305158e..3aa2e5755 100644 --- a/src/app/service/content/utils.ts +++ b/src/app/service/content/utils.ts @@ -5,6 +5,7 @@ import { DefinedFlags } from "../service_worker/runtime.consts"; import { sourceMapTo } from "@App/pkg/utils/utils"; import { ScriptEnvTag } from "@Packages/message/consts"; import { embeddedPatternCheckerString, type EmbeddedURLRuleEntry, type URLRuleEntry } from "@App/pkg/utils/url_matcher"; +import { parseResourceDeclaration } from "@App/pkg/utils/resource"; export type CompileScriptCodeResource = { name: string; @@ -57,9 +58,10 @@ export const waitBody = (callback: () => void) => { // 根据ScriptRunResource获取require的资源 export function getScriptRequire(scriptRes: ScriptRunResource): CompileScriptCodeResource["require"] { const resourceArray = new Array<{ url: string; content: string }>(); + const resource = scriptRes.resourceByType?.require || scriptRes.resource; if (Array.isArray(scriptRes.metadata.require)) { for (const val of scriptRes.metadata.require) { - const res = scriptRes.resource[val]; + const res = resource[val]; if (res) { resourceArray.push({ url: res.url, content: res.content }); } @@ -192,22 +194,27 @@ export const trimScriptInfo = (script: ScriptLoadInfo): TScriptInfo => { // --- 处理 resource --- // 由于不需要 complie code, resource 只用在 GM_getResourceURL 和 GM_getResourceText const resource = {} as Record; - const resourceNames = new Set(script.metadata["require-css"] || []); - if (hasResourceGrant(script.metadata)) { - for (const name of getDeclaredResourceNames(script.metadata)) { - resourceNames.add(name); + const requireCssResource = {} as Record; + const resourceByType = script.resourceByType; + const resourceResources = resourceByType?.resource || script.resource; + const requireCssResources = resourceByType?.["require-css"] || script.resource; + for (const name of script.metadata["require-css"] || []) { + const res = requireCssResources[name]; + if (res) { + requireCssResource[name] = { base64: res.base64, content: res.content, contentType: res.contentType }; } } - if (script.resource) { - for (const [url, { base64, content, contentType }] of Object.entries(script.resource || {})) { - if (resourceNames.has(url)) { - resource[url] = { base64, content, contentType }; + if (hasResourceGrant(script.metadata)) { + for (const name of getDeclaredResourceNames(script.metadata)) { + const res = resourceResources[name]; + if (res) { + resource[name] = { base64: res.base64, content: res.content, contentType: res.contentType }; } } } // --- 处理 resource --- // --- 处理 scriptInfo --- - const scriptInfo = { ...script, resource, code: "" } as TScriptInfo; + const scriptInfo = { ...script, resource, requireCssResource, code: "" } as TScriptInfo; // 删除其他不需要注入的 script 信息 delete scriptInfo.originalMetadata; delete scriptInfo.selfMetadata; @@ -216,6 +223,7 @@ export const trimScriptInfo = (script: ScriptLoadInfo): TScriptInfo => { delete scriptInfo.ignoreVersion; // UserScript 里面不需要知道用户有没有在更新时忽略 delete scriptInfo.sort; // UserScript 里面不需要知道用户如何 sort delete scriptInfo.error; + delete scriptInfo.resourceByType; delete scriptInfo.subscribeUrl; // UserScript 里面不需要知道用户从何处订阅 delete scriptInfo.originDomain; // 脚本来源域名 delete scriptInfo.origin; // 脚本来源 @@ -303,9 +311,9 @@ const resourceGrantNames = new Set([ const getDeclaredResourceNames = (metadata: SCMetadata): Set => { const names = new Set(); for (const value of metadata.resource || []) { - const split = value.split(/\s+/); - if (split.length === 2 && split[0] && split[1].trim()) { - names.add(split[0]); + const declaration = parseResourceDeclaration(value); + if (declaration) { + names.add(declaration.name); } } return names; diff --git a/src/app/service/service_worker/resource.ts b/src/app/service/service_worker/resource.ts index c55461339..211bef4f1 100644 --- a/src/app/service/service_worker/resource.ts +++ b/src/app/service/service_worker/resource.ts @@ -2,7 +2,7 @@ import LoggerCore from "@App/app/logger/core"; import Logger from "@App/app/logger/logger"; import type { Resource, ResourceHash, ResourceType } from "@App/app/repo/resource"; import { ResourceDAO } from "@App/app/repo/resource"; -import type { Script } from "@App/app/repo/scripts"; +import type { Script, ScriptResource, ScriptResourceByType } from "@App/app/repo/scripts"; import { type IMessageQueue } from "@Packages/message/message_queue"; import { type Group } from "@Packages/message/server"; import type { ResourceBackup } from "@App/pkg/backup/struct"; @@ -15,6 +15,7 @@ import { stackAsyncTask } from "@App/pkg/utils/async_queue"; import { blobToUint8Array } from "@App/pkg/utils/datatype"; import { readRawContent } from "@App/pkg/utils/encoding"; import { Semaphore, withTimeoutNotify } from "@App/pkg/utils/concurrency-control"; +import { parseResourceDeclaration } from "@App/pkg/utils/resource"; /** * 滑动窗口并发上限:同时"已启动、尚未归还槽位"的 fetch 数量。 @@ -105,12 +106,9 @@ export class ResourceService { return oldResource; } - public async getScriptResourceValue(script: Script): Promise<{ [key: string]: Resource }> { - const [require, require_css, resource] = await this.getResourceByTypes(script, [ - "require", - "require-css", - "resource", - ]); + public async getScriptResourceValue(script: Script): Promise { + const resourceByType = await this.getScriptResourceValueByType(script); + const { require, "require-css": require_css, resource } = resourceByType; const ret = { ...require, ...require_css, @@ -123,11 +121,20 @@ export class ResourceService { this.logger.warn("One or more properties are merged in ResourceService.getScriptResourceValue"); } + return ret; + } + + public async getScriptResourceValueByType(script: Script): Promise { + const [require, require_css, resource] = await this.getResourceByTypes(script, [ + "require", + "require-css", + "resource", + ]); return { - ...require, - ...require_css, - ...resource, - }; + require, + "require-css": require_css, + resource, + } as ScriptResourceByType; } public getResourceByTypes(script: Script, types: ResourceType[]): Promise[]> { @@ -144,10 +151,10 @@ export class ResourceService { let resourcePath: string; if (type === "resource") { // @resource xxx https://... - const split = mdValue.split(/\s+/); - if (split.length !== 2) return; // @resource 必须有 key 和 path. "xxx yyy zzz" 也不符合格式要求 - resourceKey = split[0]; - resourcePath = split[1].trim(); + const declaration = parseResourceDeclaration(mdValue); + if (!declaration) return; + resourceKey = declaration.name; + resourcePath = declaration.url; } else { // require / require-css 的话,使用 url 作为 resourceKey resourceKey = mdValue; @@ -455,7 +462,7 @@ export class ResourceService { return await this.resourceDAO.save(res); } - requestGetScriptResources(script: Script): Promise<{ [key: string]: Resource }> { + requestGetScriptResources(script: Script): Promise { return this.getScriptResourceValue(script); } diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index 88ed68dea..32683869f 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -79,6 +79,7 @@ type TCodeCache = { }; type TRuntimeResource = { base64?: string } & Omit; +type TRuntimeResourceByType = Record>; type TLocalResourceCache = { resourceKey: string; @@ -95,7 +96,7 @@ type TPageLoadScriptCache = { metadataStr: string; userConfigStr: string; userConfig: UserConfig | undefined; - resource: Record; + resourceByType: TRuntimeResourceByType; localResources: TLocalResourceCache[]; }; @@ -850,7 +851,9 @@ export class RuntimeService { async buildAndSaveCompiledResourceFromScript(script: Script, withCode: boolean = false) { const scriptRes = withCode ? await this.script.buildScriptRunResource(script) : buildScriptRunResourceBasic(script); - const resources = withCode ? scriptRes.resource : await this.resource.getScriptResourceValue(scriptRes); + const resources = withCode + ? scriptRes.resourceByType?.require || scriptRes.resource + : (await this.resource.getScriptResourceValueByType(scriptRes)).require; const resourceUrls = (script.metadata["require"] || []).map((res) => resources[res]?.url).filter((res) => res); const scriptMatchInfo = await this.applyScriptMatchInfo(scriptRes); if (!scriptMatchInfo) return undefined; @@ -1348,16 +1351,34 @@ export class RuntimeService { return ret; } - private getLocalResourceCacheList(resource: Record) { + private cloneRuntimeResourceByType(resourceByType: TRuntimeResourceByType): TRuntimeResourceByType { + return { + require: this.cloneRuntimeResource(resourceByType.require), + "require-css": this.cloneRuntimeResource(resourceByType["require-css"]), + resource: this.cloneRuntimeResource(resourceByType.resource), + }; + } + + private mergeRuntimeResourceByType(resourceByType: TRuntimeResourceByType) { + return { + ...resourceByType.require, + ...resourceByType["require-css"], + ...resourceByType.resource, + }; + } + + private getLocalResourceCacheList(resourceByType: TRuntimeResourceByType) { const localResources: TLocalResourceCache[] = []; - for (const [resourceKey, res] of Object.entries(resource)) { - if (res.url.startsWith("file:///")) { - localResources.push({ - resourceKey, - url: res.url, - type: res.type, - sha512: res.hash?.sha512, - }); + for (const type of ["require", "require-css", "resource"] as const) { + for (const [resourceKey, res] of Object.entries(resourceByType[type])) { + if (res.url.startsWith("file:///")) { + localResources.push({ + resourceKey, + url: res.url, + type, + sha512: res.hash?.sha512, + }); + } } } return localResources; @@ -1368,8 +1389,8 @@ export class RuntimeService { compiledResource: CompiledResource, scriptCacheKey: string ): Promise { - const [resource, codeInfo] = await Promise.all([ - this.resource.getScriptResourceValue(scriptRes) as Promise>, + const [resourceByType, codeInfo] = await Promise.all([ + this.resource.getScriptResourceValueByType(scriptRes) as Promise, this.getScriptInfoForCode(scriptRes), ]); if (!codeInfo) return undefined; @@ -1387,19 +1408,21 @@ export class RuntimeService { metadataStr: codeInfo.metadataStr, userConfigStr: codeInfo.userConfigStr, userConfig: codeInfo.userConfig, - resource: this.cloneRuntimeResource(resource), - localResources: this.getLocalResourceCacheList(resource), + resourceByType: this.cloneRuntimeResourceByType(resourceByType), + localResources: this.getLocalResourceCacheList(resourceByType), }; } private createPageLoadScriptInfo(scriptRes: ScriptRunResource, cache: TPageLoadScriptCache) { + const resourceByType = this.cloneRuntimeResourceByType(cache.resourceByType); return { ...scriptRes, scriptUrlPatterns: cache.scriptUrlPatterns, originalUrlPatterns: cache.originalUrlPatterns === null ? cache.scriptUrlPatterns : cache.originalUrlPatterns, code: cache.code, value: {}, - resource: this.cloneRuntimeResource(cache.resource), + resource: this.mergeRuntimeResourceByType(resourceByType), + resourceByType, metadataStr: cache.metadataStr, userConfigStr: cache.userConfigStr, userConfig: cache.userConfig, @@ -1420,7 +1443,7 @@ export class RuntimeService { } // 每次页面加载都重新拉取 file:/// 本地资源;sha512 未变则跳过。 - // 注意:发现变化时会就地更新共享的 pageLoadCaches 缓存对象(cache.resource / localResource.sha512), + // 注意:发现变化时会就地更新共享的 pageLoadCaches 缓存对象(cache.resourceByType / localResource.sha512), // 使后续加载直接复用最新内容。重复写入的是同一次拉取的结果,幂等。 private async refreshLocalResourcesForPageLoad( enableScriptList: (ScriptLoadInfo & { scriptUrlPatterns: URLRuleEntry[] })[], @@ -1450,7 +1473,10 @@ export class RuntimeService { nextResource.base64 = undefined; } localResource.sha512 = updatedResource.hash?.sha512; - cache.resource[localResource.resourceKey] = nextResource; + cache.resourceByType[localResource.type][localResource.resourceKey] = nextResource; + if (scriptRes.resourceByType) { + scriptRes.resourceByType[localResource.type][localResource.resourceKey] = { ...nextResource }; + } scriptRes.resource[localResource.resourceKey] = { ...nextResource }; resourceUpdated = true; } catch (e) { diff --git a/src/app/service/service_worker/script.ts b/src/app/service/service_worker/script.ts index 64bc34431..8282f98fb 100644 --- a/src/app/service/service_worker/script.ts +++ b/src/app/service/service_worker/script.ts @@ -887,14 +887,19 @@ export class ScriptService { const ret = buildScriptRunResourceBasic(script); return Promise.all([ this.valueService.getScriptValue(ret), - this.resourceService.getScriptResourceValue(ret), + this.resourceService.getScriptResourceValueByType(ret), this.scriptCodeDAO.get(script.uuid), - ]).then(([value, resource, code]) => { + ]).then(([value, resourceByType, code]) => { if (!code) { throw new Error("code is null"); } ret.value = value; - ret.resource = resource; + ret.resourceByType = resourceByType; + ret.resource = { + ...resourceByType.require, + ...resourceByType["require-css"], + ...resourceByType.resource, + }; ret.code = code.code; return ret; }); diff --git a/src/pkg/utils/resource.ts b/src/pkg/utils/resource.ts new file mode 100644 index 000000000..ad3c2ef9a --- /dev/null +++ b/src/pkg/utils/resource.ts @@ -0,0 +1,10 @@ +export type ResourceDeclaration = { + name: string; + url: string; +}; + +export function parseResourceDeclaration(value: string): ResourceDeclaration | undefined { + const split = value.split(/\s+/); + if (split.length !== 2 || !split[0] || !split[1].trim()) return undefined; + return { name: split[0], url: split[1].trim() }; +} From bed1598b52caff69bec7c212d4f7af7a1d9821e6 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 22 Aug 2026 10:08:20 +0900 Subject: [PATCH 07/10] =?UTF-8?q?=E2=9C=85=20verify=20category-preserving?= =?UTF-8?q?=20resource=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/content/script_executor.test.ts | 53 +++++++++- src/app/service/content/utils.test.ts | 100 +++++++++++++++++- .../service/service_worker/resource.test.ts | 38 +++++++ .../service/service_worker/runtime.test.ts | 6 +- 4 files changed, 188 insertions(+), 9 deletions(-) diff --git a/src/app/service/content/script_executor.test.ts b/src/app/service/content/script_executor.test.ts index 657ec4188..1d0d17a63 100644 --- a/src/app/service/content/script_executor.test.ts +++ b/src/app/service/content/script_executor.test.ts @@ -1,11 +1,13 @@ import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; import type { Message } from "@Packages/message/types"; import type { ScriptLoadInfo } from "../service_worker/types"; +import type { TScriptInfo } from "@App/app/repo/scripts"; import { initEnvInfo, ScriptExecutor } from "./script_executor"; const styleUrl = "https://example.com/style.css"; +const secondStyleUrl = "https://example.com/second-style.css"; -function makeScript(overrides: Partial = {}): ScriptLoadInfo { +function makeScript(overrides: Partial> = {}): ScriptLoadInfo { return { uuid: "executor-test-uuid", name: "Executor test", @@ -67,8 +69,18 @@ describe("ScriptExecutor", () => { it("injects every resolved @require-css resource in declaration order", () => { const script = makeScript({ - metadata: { "require-css": [styleUrl] }, + metadata: { "require-css": [styleUrl, secondStyleUrl] }, resource: { + [secondStyleUrl]: { + url: secondStyleUrl, + content: "body { color: blue; }", + base64: "", + hash: { md5: "test", sha1: "test", sha256: "test", sha384: "test", sha512: "test" }, + type: "require-css", + link: {}, + contentType: "text/css", + createtime: Date.now(), + }, [styleUrl]: { url: styleUrl, content: "body { color: red; }", @@ -90,8 +102,43 @@ describe("ScriptExecutor", () => { scriptFunc: () => undefined, }); - expect(adoptedSheets).toHaveLength(1); + expect(adoptedSheets).toHaveLength(2); expect((adoptedSheets[0] as CSSStyleSheet & { cssText: string }).cssText).toBe("body { color: red; }"); + expect((adoptedSheets[1] as CSSStyleSheet & { cssText: string }).cssText).toBe("body { color: blue; }"); + }); + + it("uses the category-specific CSS resource when a key collides", () => { + const script = makeScript({ + metadata: { "require-css": [styleUrl] }, + resource: { + [styleUrl]: { + url: styleUrl, + content: "not css", + base64: "", + hash: { md5: "test", sha1: "test", sha256: "test", sha384: "test", sha512: "test" }, + type: "resource", + link: {}, + contentType: "text/plain", + createtime: Date.now(), + }, + }, + requireCssResource: { + [styleUrl]: { + content: "body { color: green; }", + contentType: "text/css", + }, + }, + }); + + const executor = new ScriptExecutor({} as Message, {} as Message); + executor.execScriptEntry({ + scriptLoadInfo: script, + scriptFlag: script.flag, + envInfo: initEnvInfo, + scriptFunc: () => undefined, + }); + + expect((adoptedSheets[0] as CSSStyleSheet & { cssText: string }).cssText).toBe("body { color: green; }"); }); }); diff --git a/src/app/service/content/utils.test.ts b/src/app/service/content/utils.test.ts index 997573324..64093d94c 100644 --- a/src/app/service/content/utils.test.ts +++ b/src/app/service/content/utils.test.ts @@ -118,6 +118,59 @@ describe("utils", () => { expect(result).toContain("// Library 1 content"); expect(result).toContain("// Library 2 content"); + expect(result.indexOf("// Library 1 content")).toBeLessThan(result.indexOf("// Library 2 content")); + }); + + it.concurrent("应该从 category-specific resource map 编译冲突 key 的 require", () => { + const sharedUrl = "https://example.com/shared.js"; + const scriptRes = createMockScriptRes({ + metadata: { require: [sharedUrl] }, + resource: { + [sharedUrl]: { + url: "https://example.com/wrong-resource.txt", + content: "wrong resource content", + base64: "", + hash: { + md5: "test", + sha1: "test", + sha256: "test", + sha384: "test", + sha512: "test", + }, + type: "resource", + link: {}, + contentType: "text/plain", + createtime: Date.now(), + }, + }, + resourceByType: { + require: { + [sharedUrl]: { + url: sharedUrl, + content: "correct library content", + base64: "", + hash: { + md5: "test", + sha1: "test", + sha256: "test", + sha384: "test", + sha512: "test", + }, + type: "require", + link: {}, + contentType: "text/javascript", + createtime: Date.now(), + }, + }, + "require-css": {}, + resource: {}, + }, + }); + + const result = compileScriptCode(scriptRes); + + expect(result).toContain("correct library content"); + expect(result).not.toContain("wrong resource content"); }); it.concurrent("应该忽略不存在的 require 资源", () => { @@ -272,6 +325,12 @@ describe("utils", () => { resourceKeys: [assetName], expected: [], }, + { + name: "drops @resource for ordinary @grant none", + metadata: { grant: ["none"], resource: [assetDeclaration] }, + resourceKeys: [assetName], + expected: [], + }, { name: "does not forward @require after Service Worker compilation", metadata: { require: [libraryUrl] }, @@ -282,7 +341,8 @@ describe("utils", () => { name: "keeps @require-css independently of resource grants", metadata: { "require-css": [styleUrl] }, resourceKeys: [styleUrl], - expected: [styleUrl], + expected: [], + expectedCss: [styleUrl], }, { name: "keeps CSS and granted @resource but not compiled @require", @@ -293,7 +353,8 @@ describe("utils", () => { "require-css": [styleUrl], }, resourceKeys: [assetName, libraryUrl, styleUrl], - expected: [assetName, styleUrl], + expected: [assetName], + expectedCss: [styleUrl], }, { name: "keeps CSS when @grant none disables resource APIs", @@ -303,12 +364,14 @@ describe("utils", () => { "require-css": [styleUrl], }, resourceKeys: [assetName, styleUrl], - expected: [styleUrl], + expected: [], + expectedCss: [styleUrl], }, - ])("$name", ({ metadata, resourceKeys, expected }) => { + ])("$name", ({ metadata, resourceKeys, expected, expectedCss }) => { const trimmed = trimScriptInfo(createScript(metadata, resourceKeys)); expect(Object.keys(trimmed.resource).sort()).toEqual(expected.sort()); + expect(Object.keys(trimmed.requireCssResource || {}).sort()).toEqual((expectedCss || []).sort()); }); it.each(resourceGrants)("keeps @resource for %s", (grant) => { @@ -332,6 +395,28 @@ describe("utils", () => { expect(Object.keys(trimmed.resource)).toEqual([assetName]); }); + it("keeps category-specific values when resource keys collide", () => { + const sharedKey = "https://example.com/shared"; + const script = createScript( + { + grant: ["GM_getResourceText"], + resource: [`${sharedKey} https://example.com/data.txt`], + "require-css": [sharedKey], + }, + [sharedKey] + ); + script.resourceByType = { + require: { [sharedKey]: resource(sharedKey, "library content") }, + "require-css": { [sharedKey]: resource(sharedKey, "body { color: red; }") }, + resource: { [sharedKey]: resource("https://example.com/data.txt", "resource content") }, + }; + + const trimmed = trimScriptInfo(script); + + expect(trimmed.resource[sharedKey]?.content).toBe("resource content"); + expect(trimmed.requireCssResource?.[sharedKey]?.content).toBe("body { color: red; }"); + }); + it("does not expose malformed @resource declarations", () => { const script = createScript( { @@ -356,9 +441,14 @@ describe("utils", () => { ); const originalResourceKeys = Object.keys(script.resource); - trimScriptInfo(script); + const trimmed = trimScriptInfo(script); expect(Object.keys(script.resource)).toEqual(originalResourceKeys); + expect(trimmed.resource[assetName]).toEqual({ + base64: "", + content: "asset content", + contentType: "text/plain", + }); }); }); diff --git a/src/app/service/service_worker/resource.test.ts b/src/app/service/service_worker/resource.test.ts index de3ad2dd8..290ed3e3d 100644 --- a/src/app/service/service_worker/resource.test.ts +++ b/src/app/service/service_worker/resource.test.ts @@ -372,6 +372,44 @@ describe("ResourceService - getResourceByTypes", () => { expect(updateSpy).toHaveBeenCalledWith("script-1", expect.objectContaining({ url }), "resource", oldResource); expect(res.data).toBe(freshResource); }); + + it.each(["data", "data https://example.com/data.txt extra", " data https://example.com/data.txt"])( + "非法 @resource 声明 %j 不应产生资源或下载", + async (metadataValue) => { + const updateSpy = vi.spyOn(service, "updateResource"); + + const [res] = await service.getResourceByTypes(normalScript("script-invalid", { resource: [metadataValue] }), [ + "resource", + ]); + + expect(res).toEqual({}); + expect(updateSpy).not.toHaveBeenCalled(); + } + ); + + it("按资源类别保留相同 key 的独立来源", async () => { + const sharedKey = "https://example.com/shared"; + const requireResource = resourceModel(sharedKey, "library content"); + const cssResource = resourceModel(sharedKey, "body { color: red; }"); + const namedResource = resourceModel("https://example.com/data.txt", "resource content"); + vi.spyOn(service, "getResourceByTypes").mockResolvedValue([ + { [sharedKey]: requireResource }, + { [sharedKey]: cssResource }, + { [sharedKey]: namedResource }, + ]); + + const resourceByType = await service.getScriptResourceValueByType( + normalScript("script-collision", { + require: [sharedKey], + "require-css": [sharedKey], + resource: [`${sharedKey} https://example.com/data.txt`], + }) + ); + + expect(resourceByType.require[sharedKey]).toBe(requireResource); + expect(resourceByType["require-css"][sharedKey]).toBe(cssResource); + expect(resourceByType.resource[sharedKey]).toBe(namedResource); + }); }); describe("ResourceService - importResource", () => { diff --git a/src/app/service/service_worker/runtime.test.ts b/src/app/service/service_worker/runtime.test.ts index 4fc2fcd4f..2ea2b9301 100644 --- a/src/app/service/service_worker/runtime.test.ts +++ b/src/app/service/service_worker/runtime.test.ts @@ -517,6 +517,7 @@ describe.concurrent("RuntimeService - getPageScriptMatchingResultByUrl 脚本匹 const mockResourceService = { getScriptResourceValue: vi.fn().mockResolvedValue({}), + getScriptResourceValueByType: vi.fn().mockResolvedValue({ require: {}, "require-css": {}, resource: {} }), }; const mockValueService = { @@ -855,7 +856,10 @@ describe("getScriptsForTab 附加边界场景", () => { save: vi.fn().mockResolvedValue(undefined), }; const mockScriptCodeDAO = { get: vi.fn().mockResolvedValue({ code: "// test" }) }; - const mockResourceService = { getScriptResourceValue: vi.fn().mockResolvedValue({}) }; + const mockResourceService = { + getScriptResourceValue: vi.fn().mockResolvedValue({}), + getScriptResourceValueByType: vi.fn().mockResolvedValue({ require: {}, "require-css": {}, resource: {} }), + }; const mockValueService = { getScriptValue: vi.fn().mockResolvedValue({}) }; (mockScriptDAO as any).gets = vi.fn().mockResolvedValue([script]); From 2619e4d131bff36c329f37f9b29ab17aa2e53f0c Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 23 Aug 2026 19:58:00 +0900 Subject: [PATCH 08/10] =?UTF-8?q?=E2=9C=85=20add=20resource=20boundary=20r?= =?UTF-8?q?egression=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/gm_api/gm_api.test.ts | 45 +++++++++++++++++++ .../service/service_worker/resource.test.ts | 23 ++++++++++ 2 files changed, 68 insertions(+) diff --git a/src/app/service/content/gm_api/gm_api.test.ts b/src/app/service/content/gm_api/gm_api.test.ts index 60ec1b1a8..cf926aed6 100644 --- a/src/app/service/content/gm_api/gm_api.test.ts +++ b/src/app/service/content/gm_api/gm_api.test.ts @@ -6,6 +6,8 @@ import { compileScript, compileScriptCode } from "../utils"; import type { Message } from "@Packages/message/types"; import { encodeRValue } from "@App/pkg/utils/message_value"; import { uuidv4 } from "@App/pkg/utils/uuid"; +import type { ScriptRunResource } from "@App/app/repo/scripts"; +import GMApi from "./gm_api"; const nilFn: ScriptFunc = () => {}; const scriptRes = { @@ -30,6 +32,49 @@ const envInfo: GMInfoEnv = { isIncognito: false, }; +const makeResource = (url: string, content: string, type: "require" | "require-css" | "resource") => ({ + url, + content, + base64: "", + hash: { md5: "", sha1: "", sha256: "", sha384: "", sha512: "" }, + type, + link: {}, + contentType: "text/plain", + createtime: Date.now(), +}); + +describe("GM Resource API", () => { + it("只从 resourceByType.resource 读取资源,并保留旧 payload fallback", async () => { + const name = "shared-name"; + const script = { + ...scriptRes, + uuid: "gm-resource-category-test", + value: {}, + resource: { [name]: makeResource("https://example.com/lib.js", "require content", "require") }, + resourceByType: { + require: { [name]: makeResource("https://example.com/lib.js", "require content", "require") }, + "require-css": {}, + resource: { [name]: makeResource("https://example.com/data.txt", "declared resource", "resource") }, + }, + } as unknown as ScriptRunResource; + const api = new GMApi("test", {} as Message, {} as Message, script); + + expect(api.GM_getResourceText(name)).toBe("declared resource"); + expect(api.GM_getResourceURL(name)).toContain("ZGVjbGFyZWQgcmVzb3VyY2U="); + expect(await api["GM.getResourceText"](name)).toBe("declared resource"); + expect(await api["GM.getResourceUrl"](name)).toContain("ZGVjbGFyZWQgcmVzb3VyY2U="); + + const legacyScript = { + ...script, + resourceByType: undefined, + resource: { [name]: makeResource("https://example.com/data.txt", "legacy resource", "resource") }, + } as unknown as ScriptRunResource; + const legacyApi = new GMApi("test", {} as Message, {} as Message, legacyScript); + + expect(legacyApi.GM_getResourceText(name)).toBe("legacy resource"); + }); +}); + describe.concurrent("@grant GM", () => { it.concurrent("GM_", async () => { const script = Object.assign({}, scriptRes) as ScriptLoadInfo; diff --git a/src/app/service/service_worker/resource.test.ts b/src/app/service/service_worker/resource.test.ts index 290ed3e3d..c9fc1f176 100644 --- a/src/app/service/service_worker/resource.test.ts +++ b/src/app/service/service_worker/resource.test.ts @@ -412,6 +412,29 @@ describe("ResourceService - getResourceByTypes", () => { }); }); +describe("ResourceService - updateResourceByTypes", () => { + let service: ResourceService; + + beforeEach(() => { + vi.clearAllMocks(); + service = new ResourceService({} as Group, {} as IMessageQueue); + }); + + it.each([" https://example.com/payload", "data", "data https://example.com/data.txt extra"])( + "非法 @resource 声明 %j 不应触发安装下载", + async (metadataValue) => { + const updateSpy = vi.spyOn(service, "updateResource"); + + await service.updateResourceByTypes(normalScript("invalid-install-resource", { resource: [metadataValue] }), [ + "resource", + ]); + + expect(updateSpy).not.toHaveBeenCalled(); + expect(mockFetch).not.toHaveBeenCalled(); + } + ); +}); + describe("ResourceService - importResource", () => { let service: ResourceService; From c79d8509964a316f13e455ae5d4f5850085da283 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 23 Aug 2026 19:58:41 +0900 Subject: [PATCH 09/10] =?UTF-8?q?=F0=9F=90=9B=20reuse=20resource=20parser?= =?UTF-8?q?=20during=20installation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/service_worker/resource.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/app/service/service_worker/resource.ts b/src/app/service/service_worker/resource.ts index 211bef4f1..5b1110e07 100644 --- a/src/app/service/service_worker/resource.ts +++ b/src/app/service/service_worker/resource.ts @@ -196,15 +196,12 @@ export class ResourceService { const uuid = script.uuid; const metadata = script.metadata; const promises = types.map((type) => { - const promises = metadata[type]?.map(async (u) => { + const promises = metadata[type]?.map(async (value) => { let url = ""; if (type === "resource") { - const split = u.split(/\s+/); - if (split.length === 2) { - url = split[1]; - } + url = parseResourceDeclaration(value)?.url || ""; } else { - url = u; + url = value; } if (url) { // 检查资源是否存在,如果不存在则重新加载 From 762795d9767a458c40d2b1f21945db207425fb4f Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 23 Aug 2026 19:59:20 +0900 Subject: [PATCH 10/10] =?UTF-8?q?=F0=9F=90=9B=20isolate=20GM=20resources?= =?UTF-8?q?=20from=20compiled=20dependencies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/service/content/gm_api/gm_api.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index 2ea570d90..8aa26007c 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -1557,7 +1557,7 @@ export default class GMApi extends GM_Base { @GMContext.API() public GM_getResourceText(name: string): string | undefined { - const r = this.scriptRes?.resource?.[name]; + const r = (this.scriptRes?.resourceByType?.resource ?? this.scriptRes?.resource)?.[name]; if (r) { return r.content; } @@ -1575,7 +1575,7 @@ export default class GMApi extends GM_Base { @GMContext.API() public GM_getResourceURL(name: string, isBlobUrl?: boolean): string | undefined { - const r = this.scriptRes?.resource?.[name]; + const r = (this.scriptRes?.resourceByType?.resource ?? this.scriptRes?.resource)?.[name]; if (r) { let base64 = r.base64; if (!base64) {