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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions src/app/repo/scripts.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -95,12 +95,19 @@ export type ScriptAndCode = Script & ScriptCode;

export type ValueStore = { [key: string]: any };

export type ScriptResource = { [key: string]: { base64?: string } & Omit<Resource, "base64"> };

export type ScriptResourceByType = Record<ResourceType, ScriptResource>;

export type PageScriptResource = Record<string, { base64?: string; content: string; contentType: string }>;

// 脚本运行时的资源,包含已经编译好的脚本与脚本需要的资源
export interface ScriptRunResource extends Script {
code: string; // 原始代码
value: ValueStore;
flag: string;
resource: { [key: string]: { base64?: string } & Omit<Resource, "base64"> }; // 资源列表,包含脚本需要的资源
resource: ScriptResource; // 资源列表,包含脚本需要的资源
resourceByType?: ScriptResourceByType;
metadata: SCMetadata; // 经自定义覆盖的 Metadata
originalMetadata: SCMetadata; // 原本的 Metadata (目前只需要 match, include, exclude)
}
Expand All @@ -127,7 +134,8 @@ export type TScriptInfo = Override<
ScriptLoadInfo,
{
originalMetadata?: Partial<Record<string, string[]>>;
resource: Record<string, { base64?: string; content: string; contentType: string }>;
resource: PageScriptResource;
requireCssResource?: PageScriptResource;
code: "" | string;
sort?: number;
flag: string;
Expand Down
45 changes: 44 additions & 1 deletion src/app/service/content/create_context.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string[]> = {}): TScriptInfo =>
({
Expand Down Expand Up @@ -79,6 +80,48 @@ describe.concurrent("shouldFnBind", () => {
});

describe.concurrent("createContext", () => {
const resourceGrantChecks: Array<{
grant: string;
read: (context: ReturnType<typeof createContext>) => 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"]);

Expand Down
9 changes: 3 additions & 6 deletions src/app/service/content/create_context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.*
Expand Down
45 changes: 45 additions & 0 deletions src/app/service/content/gm_api/gm_api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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;
Expand Down
12 changes: 10 additions & 2 deletions src/app/service/content/gm_api/gm_api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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) {
Expand All @@ -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<string | undefined> {
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<string | undefined> {
Expand Down
9 changes: 9 additions & 0 deletions src/app/service/content/gm_api/grant.ts
Original file line number Diff line number Diff line change
@@ -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];
}
132 changes: 132 additions & 0 deletions src/app/service/content/script_executor.test.ts
Original file line number Diff line number Diff line change
@@ -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 & Pick<TScriptInfo, "requireCssResource">> = {}): 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; }");
});
});
});
2 changes: 1 addition & 1 deletion src/app/service/content/script_executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]) {
Expand Down
Loading
Loading