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/create_context.test.ts b/src/app/service/content/create_context.test.ts index 1966ddf08..d5de4c455 100644 --- a/src/app/service/content/create_context.test.ts +++ b/src/app/service/content/create_context.test.ts @@ -1,7 +1,8 @@ import { afterEach, describe, it, expect, vi } from "vitest"; -import type { TScriptInfo } from "@App/app/repo/scripts"; +import type { ScriptLoadInfo, TScriptInfo } from "@App/app/repo/scripts"; import { encodeRValue } from "@App/pkg/utils/message_value"; import { createContext, createProxyContext, shouldFnBind } from "./create_context"; +import { trimScriptInfo } from "./utils"; const createScriptInfo = (metadata: Record = {}): TScriptInfo => ({ @@ -79,6 +80,48 @@ describe.concurrent("shouldFnBind", () => { }); describe.concurrent("createContext", () => { + const resourceGrantChecks: Array<{ + grant: string; + read: (context: ReturnType) => unknown; + }> = [ + { grant: "GM_getResourceText", read: (context) => context.GM_getResourceText("asset") }, + { grant: "GM.getResourceText", read: (context) => context.GM.getResourceText("asset") }, + { grant: "GM_getResourceURL", read: (context) => context.GM_getResourceURL("asset") }, + { grant: "GM.getResourceUrl", read: (context) => context.GM.getResourceUrl("asset") }, + { grant: "GM.getResourceURL", read: (context) => context.GM.getResourceURL("asset") }, + { grant: "GM_getResourceUrl", read: (context) => context.GM.getResourceUrl("asset") }, + ]; + + it.concurrent.each(resourceGrantChecks)( + "injects a resource API for $grant after page trimming", + async ({ grant, read }) => { + const scriptInfo = createScriptInfo({ grant: [grant], resource: ["asset https://example.com/asset.txt"] }); + scriptInfo.resource = { + asset: { + base64: "", + content: "resource content", + contentType: "text/plain", + }, + }; + const trimmed = trimScriptInfo(scriptInfo as unknown as ScriptLoadInfo); + const context = createContext( + trimmed, + { script: { name: "create-context-test" }, scriptMetaStr: "" }, + "vitest", + undefined as any, + undefined as any, + new Set([grant]) + ); + + const value = await read(context); + if (grant.includes("ResourceText")) { + expect(value).toBe("resource content"); + } else { + expect(value).toMatch(/^data:text\/plain;base64,/); + } + } + ); + it.concurrent("按 @grant 注入 GM_ 与 GM.* 双命名空间,并忽略未知 grant", async () => { const context = createTestContext(["GM_getValue", "GM_setValue", "GM.cookie", "not_exist"]); diff --git a/src/app/service/content/create_context.ts b/src/app/service/content/create_context.ts index 57cd30365..764acfbfd 100644 --- a/src/app/service/content/create_context.ts +++ b/src/app/service/content/create_context.ts @@ -4,6 +4,7 @@ import type { Message } from "@Packages/message/types"; import EventEmitter from "eventemitter3"; import { GMContextApiGet } from "./gm_api/gm_context"; import { protect } from "./gm_api/gm_context"; +import { getGrantCandidates } from "./gm_api/grant"; import { isEarlyStartScript } from "./utils"; import { ListenerManager } from "./listener_manager"; import { createGMBase } from "./gm_api/gm_api"; @@ -84,12 +85,8 @@ export const createContext = ( return true; }; for (const grant of scriptGrants) { - // GM. 与 GM_ 都需要注入 - __methodInject__(grant); - if (grant.startsWith("GM.")) { - __methodInject__(grant.replace("GM.", "GM_")); - } else if (grant.startsWith("GM_")) { - __methodInject__(grant.replace("GM_", "GM.")); + for (const candidate of getGrantCandidates(grant)) { + __methodInject__(candidate); } } // 兼容GM.Cookie.* 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/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index 2ea570d90..d9c52c8e4 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) { @@ -1590,6 +1590,14 @@ export default class GMApi extends GM_Base { return undefined; } + @GMContext.API({ depend: ["GM_getResourceURL"] }) + public "GM.getResourceURL"(name: string, isBlobUrl?: boolean): Promise { + return new Promise((resolve) => { + const ret = this.GM_getResourceURL(name, isBlobUrl); + resolve(ret); + }); + } + // GM_getResourceURL的异步版本,用来兼容GM.getResourceUrl @GMContext.API({ depend: ["GM_getResourceURL"] }) public "GM.getResourceUrl"(name: string, isBlobUrl?: boolean): Promise { diff --git a/src/app/service/content/gm_api/grant.ts b/src/app/service/content/gm_api/grant.ts new file mode 100644 index 000000000..ab1e91c39 --- /dev/null +++ b/src/app/service/content/gm_api/grant.ts @@ -0,0 +1,9 @@ +export function getGrantCandidates(grant: string): string[] { + if (grant.startsWith("GM.")) { + return [grant, `GM_${grant.slice(3)}`]; + } + if (grant.startsWith("GM_")) { + return [grant, `GM.${grant.slice(3)}`]; + } + return [grant]; +} 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..a4fe6c02a --- /dev/null +++ b/src/app/service/content/script_executor.test.ts @@ -0,0 +1,132 @@ +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 { + 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, + }; +} + +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, 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; }", + 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(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/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.test.ts b/src/app/service/content/utils.test.ts index 468ef94fe..837728eec 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"; @@ -117,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 资源", () => { @@ -237,6 +291,198 @@ 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 resourceGrants = [ + "GM_getResourceText", + "GM.getResourceText", + "GM_getResourceURL", + "GM.getResourceUrl", + "GM.getResourceURL", + "GM_getResourceUrl", + ]; + + 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, resourceKeys: string[]) => + ({ + 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: 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; + + it.each([ + { + name: "drops @resource without a resource grant", + metadata: { resource: [assetDeclaration] }, + 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] }, + resourceKeys: [libraryUrl], + expected: [], + }, + { + name: "keeps @require-css independently of resource grants", + metadata: { "require-css": [styleUrl] }, + resourceKeys: [styleUrl], + expected: [], + expectedCss: [styleUrl], + }, + { + name: "keeps CSS and granted @resource but not compiled @require", + metadata: { + grant: ["GM_getResourceText"], + resource: [assetDeclaration], + require: [libraryUrl], + "require-css": [styleUrl], + }, + resourceKeys: [assetName, libraryUrl, styleUrl], + expected: [assetName], + expectedCss: [styleUrl], + }, + { + name: "keeps CSS when @grant none disables resource APIs", + metadata: { + grant: ["none", "GM_getResourceText"], + resource: [assetDeclaration], + "require-css": [styleUrl], + }, + resourceKeys: [assetName, styleUrl], + expected: [], + expectedCss: [styleUrl], + }, + ])("$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) => { + const script = createScript({ grant: [grant], resource: [assetDeclaration] }, [assetName]); + script.resourceByType = { + require: {}, + "require-css": {}, + resource: { [assetName]: script.resource[assetName] }, + }; + const trimmed = trimScriptInfo(script); + + 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("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( + { + 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); + + const trimmed = trimScriptInfo(script); + + expect(Object.keys(script.resource)).toEqual(originalResourceKeys); + expect(trimmed.resource[assetName]).toEqual({ + base64: "", + content: "asset content", + contentType: "text/plain", + }); + }); + }); + describe("compileScript", () => { it.concurrent("应该返回一个函数", () => { const code = "return 'test result';"; diff --git a/src/app/service/content/utils.ts b/src/app/service/content/utils.ts index 8f464773d..64181c489 100644 --- a/src/app/service/content/utils.ts +++ b/src/app/service/content/utils.ts @@ -5,6 +5,8 @@ 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"; +import { getGrantCandidates } from "./gm_api/grant"; export type CompileScriptCodeResource = { name: string; @@ -57,9 +59,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 }); } @@ -193,14 +196,27 @@ export const trimScriptInfo = (script: ScriptLoadInfo): TScriptInfo => { // --- 处理 resource --- // 由于不需要 complie code, resource 只用在 GM_getResourceURL 和 GM_getResourceText const resource = {} as Record; - if (script.resource) { - for (const [url, { base64, content, contentType }] of Object.entries(script.resource || {})) { - resource[url] = { base64, content, contentType }; + 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 (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; @@ -209,6 +225,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; // 脚本来源 @@ -286,6 +303,32 @@ 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 declaration = parseResourceDeclaration(value); + if (declaration) { + names.add(declaration.name); + } + } + return names; +}; + +const hasResourceGrant = (metadata: SCMetadata): boolean => { + const grants = new Set(metadata.grant || []); + if (grants.has("none") && !isContextMenuScript(metadata)) { + return false; + } + return [...grants].some((grant) => getGrantCandidates(grant).some((candidate) => resourceGrantNames.has(candidate))); +}; + export const getScriptFlag = (uuid: string) => { // scriptFlag 对同一脚本永远一致。重新开启浏览器也不会变。 // 实作内容有待检讨 diff --git a/src/app/service/service_worker/resource.test.ts b/src/app/service/service_worker/resource.test.ts index de3ad2dd8..c9fc1f176 100644 --- a/src/app/service/service_worker/resource.test.ts +++ b/src/app/service/service_worker/resource.test.ts @@ -372,6 +372,67 @@ 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 - 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", () => { diff --git a/src/app/service/service_worker/resource.ts b/src/app/service/service_worker/resource.ts index c55461339..5b1110e07 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; @@ -189,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) { // 检查资源是否存在,如果不存在则重新加载 @@ -455,7 +459,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.test.ts b/src/app/service/service_worker/runtime.test.ts index bb1137d44..fc272def4 100644 --- a/src/app/service/service_worker/runtime.test.ts +++ b/src/app/service/service_worker/runtime.test.ts @@ -21,7 +21,7 @@ import type { ScriptDAO } from "@App/app/repo/scripts"; import { LocalStorageDAO } from "@App/app/repo/localStorage"; import type { MessageConnect, TMessage } from "@Packages/message/types"; import { obtainBlackList } from "@App/pkg/utils/utils"; -import type { CompiledResource } from "@App/app/repo/resource"; +import type { CompiledResource, Resource } from "@App/app/repo/resource"; initTestEnv(); @@ -313,7 +313,9 @@ describe.concurrent("RuntimeService - getPageScriptMatchingResultByUrl 脚本匹 it.concurrent("match 覆盖清空的脚本不应被注册(空规则会被 UserScripts API 退回成全站匹配)", async () => { const { runtime } = createRuntimeTestContext(); - (runtime as any).resource = { getScriptResourceValue: vi.fn().mockResolvedValue({}) }; + (runtime as any).resource = { + getScriptResourceValueByType: vi.fn().mockResolvedValue({ require: {}, "require-css": {}, resource: {} }), + }; const script = createMockScript({ metadata: { match: ["https://www.example.com/*"] }, selfMetadata: { match: [] }, @@ -532,6 +534,7 @@ describe.concurrent("RuntimeService - getPageScriptMatchingResultByUrl 脚本匹 const mockResourceService = { getScriptResourceValue: vi.fn().mockResolvedValue({}), + getScriptResourceValueByType: vi.fn().mockResolvedValue({ require: {}, "require-css": {}, resource: {} }), }; const mockValueService = { @@ -831,6 +834,48 @@ describe("getPageLoadScriptCacheKey 页面加载缓存键生成", () => { // ───────────────────────────────────────────────────────────────────────────── +describe("page-load resource cache", () => { + it("保留分类资源并在重建页面 payload 时保持 legacy resource 视图", () => { + const { runtime } = _createRuntimeContext(); + const sharedKey = "https://example.com/shared"; + const makeResource = (type: Resource["type"], content: string): Resource => ({ + url: sharedKey, + content, + base64: "", + hash: { md5: "", sha1: "", sha256: "", sha384: "", sha512: "" }, + type, + link: {}, + contentType: "text/plain", + createtime: Date.now(), + }); + const scriptRes = _createScriptRunResource(_createMockScript()); + const cache = { + scriptCacheKey: "cache-key", + code: "console.log(1)", + scriptUrlPatterns: [], + originalUrlPatterns: null, + metadataStr: "", + userConfigStr: "", + userConfig: undefined, + resourceByType: { + require: { [sharedKey]: makeResource("require", "require content") }, + "require-css": { [sharedKey]: makeResource("require-css", "css content") }, + resource: { [sharedKey]: makeResource("resource", "resource content") }, + }, + localResources: [], + }; + + const pageInfo = (runtime as any).createPageLoadScriptInfo(scriptRes, cache); + + expect(pageInfo.resourceByType.require[sharedKey].content).toBe("require content"); + expect(pageInfo.resourceByType["require-css"][sharedKey].content).toBe("css content"); + expect(pageInfo.resourceByType.resource[sharedKey].content).toBe("resource content"); + expect(pageInfo.resource[sharedKey].content).toBe("resource content"); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── + describe("getScriptsForTab 附加边界场景", () => { const pageUrl = "https://www.example.com/path"; @@ -870,7 +915,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]); diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index 4b6a2fb28..d44ef0215 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -80,6 +80,7 @@ type TCodeCache = { }; type TRuntimeResource = { base64?: string } & Omit; +type TRuntimeResourceByType = Record>; type TLocalResourceCache = { resourceKey: string; @@ -96,7 +97,7 @@ type TPageLoadScriptCache = { metadataStr: string; userConfigStr: string; userConfig: UserConfig | undefined; - resource: Record; + resourceByType: TRuntimeResourceByType; localResources: TLocalResourceCache[]; }; @@ -855,7 +856,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; @@ -1378,16 +1381,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; @@ -1398,8 +1419,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; @@ -1417,19 +1438,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, @@ -1450,7 +1473,7 @@ export class RuntimeService { } // 每次页面加载都重新拉取 file:/// 本地资源;sha512 未变则跳过。 - // 注意:发现变化时会就地更新共享的 pageLoadCaches 缓存对象(cache.resource / localResource.sha512), + // 注意:发现变化时会就地更新共享的 pageLoadCaches 缓存对象(cache.resourceByType / localResource.sha512), // 使后续加载直接复用最新内容。重复写入的是同一次拉取的结果,幂等。 private async refreshLocalResourcesForPageLoad( enableScriptList: (ScriptLoadInfo & { scriptUrlPatterns: URLRuleEntry[] })[], @@ -1480,7 +1503,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 375ed1d07..613f3d87e 100644 --- a/src/app/service/service_worker/script.ts +++ b/src/app/service/service_worker/script.ts @@ -897,14 +897,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() }; +}